Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20b26b4c7 | ||
|
|
f1cdb7ac68 | ||
|
|
3f0774813d | ||
|
|
5615d989fa | ||
|
|
e2e70a5aad | ||
|
|
dd98421b08 | ||
|
|
69312c9080 | ||
|
|
c8697532f9 | ||
|
|
be8122f9e5 | ||
|
|
0a8463d8f4 | ||
|
|
41cca86e6e | ||
|
|
71cc705cd2 | ||
|
|
811890443b | ||
|
|
7456dece00 | ||
|
|
beb7e22652 | ||
|
|
72feb39d56 | ||
|
|
1b08979699 | ||
|
|
527f9ac869 | ||
|
|
f9f21052d9 | ||
|
|
db884808f2 | ||
|
|
368a2ca365 | ||
|
|
96c4cc7b47 | ||
|
|
3d25d8a614 | ||
|
|
9985ff3440 | ||
|
|
e84432a492 | ||
|
|
210fdb2244 | ||
|
|
721f15e900 | ||
|
|
877ea9e3f8 | ||
|
|
20654b0682 | ||
|
|
14a08c700c |
@@ -1,5 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
# Instance data (SQLite, live workflows, control-state, backups)
|
||||||
data/
|
data/
|
||||||
|
# Process logs
|
||||||
logs/
|
logs/
|
||||||
*.db
|
*.db
|
||||||
*.db-*
|
*.db-*
|
||||||
@@ -9,6 +12,11 @@ packages/web/dist
|
|||||||
# Personal/local scripts and workflows (not for the repo)
|
# Personal/local scripts and workflows (not for the repo)
|
||||||
debug-*.js
|
debug-*.js
|
||||||
|
|
||||||
|
# Legacy live workflow trees
|
||||||
|
packages/server/workflows/
|
||||||
|
|
||||||
# Plugin install staging and per-plugin deps
|
# Plugin install staging and per-plugin deps
|
||||||
plugins/.staging-*
|
plugins/.staging-*
|
||||||
plugins/*/node_modules/
|
plugins/*/node_modules/
|
||||||
|
|
||||||
|
.gitea/workflows/
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
|
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 `data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
|
||||||
|
|
||||||
|
| Kind | In git? | Path |
|
||||||
|
|---|---|---|
|
||||||
|
| Live / personal YAML | No | `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 `data/workflows/`. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
| Kind | YAML `script` | Editable | Path |
|
| Kind | YAML `script` | Editable | Path |
|
||||||
@@ -109,7 +120,7 @@ Return `{ output, context?, skipRemaining? }`. Do not return `ctx`. Mutations of
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
|
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
|
||||||
| `ctx.context` | Run clipboard (plain object) |
|
| `ctx.context` | Run clipboard (plain object) |
|
||||||
| `ctx.config` | YAML `config` (secrets already unwrapped from `$SECRET_name`) |
|
| `ctx.config` | YAML `config` (mustache refs like `{{ secrets.name }}` already resolved) |
|
||||||
| `output` | Next step’s `data` |
|
| `output` | Next step’s `data` |
|
||||||
| `context` | Next clipboard. Omit to keep incoming |
|
| `context` | Next clipboard. Omit to keep incoming |
|
||||||
|
|
||||||
@@ -127,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scripts:
|
scripts:
|
||||||
- script: plugin/my-plugin
|
- name: Notify to channel
|
||||||
|
script: plugin/my-plugin
|
||||||
config:
|
config:
|
||||||
url: http://10.8.0.6:3030/notes
|
url: http://10.8.0.6:3030/notes
|
||||||
token: $SECRET_joplin_api_token
|
token: "{{ secrets.joplin_api_token }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
@@ -141,4 +153,4 @@ Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
|||||||
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
|
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
|
||||||
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
|
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
|
||||||
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
|
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
|
||||||
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`.
|
- Put secrets in `script.js`; use YAML `{{ secrets.name }}` / `{{ vars.name }}` (quote if the value starts with `{`).
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ pnpm dev
|
|||||||
- UI (dev): http://localhost:8500
|
- UI (dev): http://localhost:8500
|
||||||
- API: http://localhost:8700
|
- API: http://localhost:8700
|
||||||
|
|
||||||
The first account created becomes **admin**. Later accounts are created from Users.
|
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
|
||||||
|
|
||||||
|
Reset or create the admin login from the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||||
|
```
|
||||||
|
|
||||||
### Process modes
|
### Process modes
|
||||||
|
|
||||||
@@ -36,16 +42,29 @@ The first account created becomes **admin**. Later accounts are created from Use
|
|||||||
| Kind | Name in YAML | Editable | Location |
|
| Kind | Name in YAML | Editable | Location |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||||
| **Plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
| **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"`.
|
- 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 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`.
|
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
|
||||||
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
- `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 | `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).
|
||||||
|
- 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
|
```bash
|
||||||
# Smoke
|
# Smoke (isolated under packages/server/data — not the live instance tree)
|
||||||
|
JFLOW_DATA_DIR=packages/server/data \
|
||||||
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||||
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||||
node packages/server/test/plugins-smoke.js
|
node packages/server/test/plugins-smoke.js
|
||||||
@@ -64,13 +83,34 @@ Each script is `async function main(ctx)` and **must** return:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
|
| `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
|
||||||
| `ctx.context` | Run clipboard (plain object, default `{}`) |
|
| `ctx.context` | Run clipboard (plain object, default `{}`) |
|
||||||
| `ctx.config` | This step’s YAML config |
|
| `ctx.config` | This step’s YAML config (mustache refs already resolved) |
|
||||||
| `output` | Becomes the **next** step’s `data` |
|
| `output` | Becomes the **next** step’s `data` |
|
||||||
| `context` | Next snapshot of the bag. Omitted → keep incoming |
|
| `context` | Next snapshot of the bag. Omitted → keep incoming |
|
||||||
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` |
|
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` |
|
||||||
|
|
||||||
Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned.
|
Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned.
|
||||||
|
|
||||||
|
### Config interpolation
|
||||||
|
|
||||||
|
YAML `config` strings may use mustache paths. Quote values that start with `{`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: "{{ vars.ntfy_channel }}"
|
||||||
|
token: "{{ secrets.joplin_api_token }}"
|
||||||
|
id: "{{ context.user.id }}"
|
||||||
|
title: "{{ data.httpResponse.data.date }}"
|
||||||
|
topic: "{{ vars.ntfy_prefix }}/{{ data.channel }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Root | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `vars` | Owner variable; remaining segments are the flat name (`{{ vars.foo.bar }}` → variable `foo.bar`) |
|
||||||
|
| `secrets` | Same for secrets |
|
||||||
|
| `context` | Run clipboard (nested) |
|
||||||
|
| `data` | This step’s input (nested; numeric segments index arrays) |
|
||||||
|
|
||||||
|
A string that is exactly one `{{ path }}` keeps the native type (object/array/number/boolean). Mixed strings concatenate as text. Bare name fields such as `passwordSecret: gmail_app_password` stay names for `$secrets.get` — do not wrap them in `{{ secrets.… }}`. Script APIs `$vars.get` / `$secrets.get` are unchanged.
|
||||||
|
|
||||||
YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same.
|
YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same.
|
||||||
|
|
||||||
DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run.
|
DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run.
|
||||||
@@ -86,8 +126,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
| `pnpm dev:server` | Monolith API/runner only |
|
| `pnpm dev:server` | Monolith API/runner only |
|
||||||
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||||
| `pnpm build` | Production UI build |
|
| `pnpm build` | Production UI build |
|
||||||
| `pnpm start` | Monolith: API + worker + built UI |
|
| `pnpm start` | Monolith: API + worker + built UI (serves `dist` on :8700) |
|
||||||
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
|
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
|
||||||
|
| `pnpm start:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) |
|
||||||
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||||
| `pnpm start:worker` | BullMQ worker only |
|
| `pnpm start:worker` | BullMQ worker only |
|
||||||
| `pnpm migrate` | Apply SQLite migrations |
|
| `pnpm migrate` | Apply SQLite migrations |
|
||||||
@@ -104,7 +145,7 @@ Admin UI route **Ops** (`/ops`) talks to the control process.
|
|||||||
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
|
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
|
||||||
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) |
|
| 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.
|
Desired state is stored in `data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -112,7 +153,10 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
|
| `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_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_DATA_DIR` | `data/` | Instance data root (SQLite, workflows, control-state, backups, trash). Falls back to `packages/server/data` if that tree still has the db or workflows. |
|
||||||
|
| `JFLOW_DB_PATH` | `data/jerapah-flow.db` | SQLite file. |
|
||||||
|
| `JFLOW_WORKFLOWS_DIR` | `data/workflows` | Live workflow YAML (instance data). |
|
||||||
|
| `JFLOW_LOGS_DIR` | `logs/` | Rolling process logs. |
|
||||||
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
| `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. |
|
| `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_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||||
@@ -120,10 +164,12 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
|||||||
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. |
|
| `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_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||||
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
||||||
|
| `JFLOW_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. |
|
||||||
|
| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). |
|
||||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
|
||||||
| `PORT` | `8700` | HTTP API port |
|
| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
|
||||||
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
|
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
|
||||||
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
|
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
|
||||||
|
|
||||||
@@ -131,14 +177,32 @@ Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 {
|
|||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
|
Control-plane topology (same ports as `pnpm dev:pm2`):
|
||||||
|
|
||||||
|
| Process | Port | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 |
|
||||||
|
| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers |
|
||||||
|
| `jflow-http` | **8700** | API + cron enqueue (managed by control) |
|
||||||
|
| `jflow-worker` | — | BullMQ workers (managed by control) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||||
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
|
||||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
# Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
|
||||||
# Or monolith (dev-style):
|
# against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
|
||||||
# ... pnpm start
|
pnpm start:pm2
|
||||||
|
# UI: http://localhost:8500
|
||||||
|
# If you already mixed versions: pnpm pm2 -- kill && pnpm start:pm2
|
||||||
```
|
```
|
||||||
|
|
||||||
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process.
|
Or without the ecosystem file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_ENV=production pnpm start:control # :8600 + PM2 children
|
||||||
|
NODE_ENV=production pnpm start:web # :8500
|
||||||
|
```
|
||||||
|
|
||||||
|
Monolith (no Ops stop/scale): `pnpm build && pnpm start` serves the UI from the API process on :8700. Optional `JFLOW_SERVE_UI=1` on `start:api` does the same when you run HTTP alone — do **not** use that under control-plane mode (stopping HTTP would take down the UI).
|
||||||
|
|||||||
+37
-5
@@ -1,3 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Production PM2 ecosystem (control plane + UI).
|
||||||
|
* Starts always-on processes only; HTTP (:8700) and workers are owned by
|
||||||
|
* control.js via PM2 (same as `pnpm dev:pm2`).
|
||||||
|
*
|
||||||
|
* Use `pnpm start:pm2` (in-tree PM2 6.x). Do not use a global `pm2` 7.x —
|
||||||
|
* a CLI/daemon version mismatch pegs CPU even with instances: 1.
|
||||||
|
*
|
||||||
|
* Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets.
|
||||||
|
*/
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
@@ -23,20 +33,42 @@ function loadEnv(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = __dirname;
|
const root = __dirname;
|
||||||
|
const env = {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...loadEnv(path.join(root, ".env")),
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "jerapah-flow",
|
name: "jflow-control",
|
||||||
cwd: root,
|
cwd: root,
|
||||||
script: "packages/server/runner.js",
|
script: "packages/server/control.js",
|
||||||
interpreter: "node",
|
interpreter: process.execPath,
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 8,
|
||||||
|
restart_delay: 2000,
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jflow-web",
|
||||||
|
cwd: root,
|
||||||
|
script: "packages/server/web-server.js",
|
||||||
|
interpreter: process.execPath,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_restarts: 20,
|
max_restarts: 20,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
...env,
|
||||||
...loadEnv(path.join(root, ".env")),
|
JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500",
|
||||||
|
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||||
|
JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Comic - monkeyuser to ntfy
|
||||||
|
description: |
|
||||||
|
Scrape the latest MonkeyUser comic and send it to ntfy (requires {{ vars.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: "{{ vars.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
|
||||||
+7
-11
@@ -14,16 +14,12 @@
|
|||||||
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||||
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
"start:web": "pnpm --filter @jerapah-flow/server start:web",
|
||||||
|
"pm2": "node scripts/pm2.mjs",
|
||||||
|
"start:pm2": "node scripts/pm2.mjs start ecosystem.config.cjs",
|
||||||
|
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
||||||
|
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
||||||
|
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.25.0",
|
"packageManager": "pnpm@11.22.0"
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"better-sqlite3",
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"rss-parser": "^3.13.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { REPO_ROOT } from "./paths.js";
|
||||||
import { SERVER_ROOT } from "./paths.js";
|
|
||||||
|
|
||||||
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
const ROOT_PKG = path.join(REPO_ROOT, "package.json");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JerapahFlow app version from the monorepo root package.json.
|
* JerapahFlow app version from the monorepo root package.json.
|
||||||
@@ -75,5 +74,3 @@ export function satisfiesRange(version, range) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fileURLToPath;
|
|
||||||
|
|||||||
+156
-72
@@ -3,36 +3,24 @@ import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
|
|||||||
import { isSecret } from "./secret-value.js";
|
import { isSecret } from "./secret-value.js";
|
||||||
import { assertVariableName, getVariablePlain } from "./variables-store.js";
|
import { assertVariableName, getVariablePlain } from "./variables-store.js";
|
||||||
|
|
||||||
const PREFIXES = [
|
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
||||||
{ kind: "context", prefix: "$CONTEXT_" },
|
const MUSTACHE_TOKEN_RE =
|
||||||
{ kind: "secret", prefix: "$SECRET_" },
|
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
|
||||||
{ kind: "var", prefix: "$VAR_" },
|
const WHOLE_MUSTACHE_RE =
|
||||||
];
|
/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef
|
* @typedef {{
|
||||||
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
|
* owner: string,
|
||||||
|
* workflowKey?: string,
|
||||||
|
* context?: unknown,
|
||||||
|
* data?: unknown,
|
||||||
|
* }} ConfigRefCtx
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a whole-value config placeholder. Returns null for literals.
|
* Walk config (objects/arrays) and interpolate `{{ path }}` strings.
|
||||||
* @param {unknown} value
|
* Does not walk trigger data.
|
||||||
* @returns {ConfigRef | null}
|
|
||||||
*/
|
|
||||||
export function parseConfigRef(value) {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
for (const { kind, prefix } of PREFIXES) {
|
|
||||||
if (trimmed.startsWith(prefix)) {
|
|
||||||
return { kind, name: trimmed.slice(prefix.length), raw: trimmed };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_`
|
|
||||||
* strings. Does not walk trigger data.
|
|
||||||
*
|
*
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
* @param {ConfigRefCtx} ctx
|
* @param {ConfigRefCtx} ctx
|
||||||
@@ -68,83 +56,179 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
|
|||||||
/**
|
/**
|
||||||
* @param {string} value
|
* @param {string} value
|
||||||
* @param {ConfigRefCtx} ctx
|
* @param {ConfigRefCtx} ctx
|
||||||
* @returns {Promise<string | number | boolean>}
|
* @returns {Promise<unknown>}
|
||||||
*/
|
*/
|
||||||
async function resolveStringRef(value, ctx) {
|
async function resolveStringRef(value, ctx) {
|
||||||
const ref = parseConfigRef(value);
|
const whole = WHOLE_MUSTACHE_RE.exec(value);
|
||||||
if (!ref) return value;
|
if (whole && whole[0] === value) {
|
||||||
|
return resolvePath(whole[1], ctx, { raw: value, allowObject: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (ref.kind === "secret") {
|
if (!value.includes("{{")) {
|
||||||
return resolveSecretRef(ref, ctx);
|
return value;
|
||||||
}
|
}
|
||||||
if (ref.kind === "var") {
|
|
||||||
return resolveVarRef(ref, ctx);
|
MUSTACHE_TOKEN_RE.lastIndex = 0;
|
||||||
|
let out = "";
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = MUSTACHE_TOKEN_RE.exec(value)) != null) {
|
||||||
|
out += value.slice(lastIndex, match.index);
|
||||||
|
const resolved = await resolvePath(match[1], ctx, {
|
||||||
|
raw: match[0],
|
||||||
|
allowObject: false,
|
||||||
|
});
|
||||||
|
out += stringifyScalar(resolved, match[0]);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
return resolveContextRef(ref, ctx);
|
out += value.slice(lastIndex);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ConfigRef} ref
|
* @param {string} pathExpr
|
||||||
* @param {ConfigRefCtx} ctx
|
* @param {ConfigRefCtx} ctx
|
||||||
* @returns {Promise<string>}
|
* @param {{ raw: string, allowObject: boolean }} opts
|
||||||
*/
|
*/
|
||||||
async function resolveSecretRef(ref, ctx) {
|
async function resolvePath(pathExpr, ctx, opts) {
|
||||||
try {
|
const segments = pathExpr.split(".");
|
||||||
assertSecretName(ref.name);
|
if (segments.length === 0 || segments.some((s) => !s)) {
|
||||||
} catch {
|
throw new Error(`config ref ${opts.raw}: empty path`);
|
||||||
throw new Error(`config ref ${ref.raw}: invalid secret name`);
|
|
||||||
}
|
}
|
||||||
const plaintext = await getSecretPlaintext(ctx.owner, ref.name);
|
for (const seg of segments) {
|
||||||
if (plaintext == null) {
|
if (FORBIDDEN_SEGMENTS.has(seg)) {
|
||||||
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`);
|
throw new Error(`config ref ${opts.raw}: forbidden path segment "${seg}"`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return plaintext;
|
|
||||||
|
const root = segments[0];
|
||||||
|
const rest = segments.slice(1);
|
||||||
|
|
||||||
|
if (root === "vars") {
|
||||||
|
return resolveNamedStore("var", rest, ctx, opts);
|
||||||
|
}
|
||||||
|
if (root === "secrets") {
|
||||||
|
return resolveNamedStore("secret", rest, ctx, opts);
|
||||||
|
}
|
||||||
|
if (root === "context") {
|
||||||
|
return walkObject(ctx.context, rest, opts);
|
||||||
|
}
|
||||||
|
if (root === "data") {
|
||||||
|
return walkObject(ctx.data, rest, opts);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`config ref ${opts.raw}: unknown root "${root}" (use vars, secrets, context, or data)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ConfigRef} ref
|
* @param {"var" | "secret"} kind
|
||||||
|
* @param {string[]} rest
|
||||||
* @param {ConfigRefCtx} ctx
|
* @param {ConfigRefCtx} ctx
|
||||||
* @returns {Promise<string | number | boolean>}
|
* @param {{ raw: string, allowObject: boolean }} opts
|
||||||
*/
|
*/
|
||||||
async function resolveVarRef(ref, ctx) {
|
async function resolveNamedStore(kind, rest, ctx, opts) {
|
||||||
if (ref.name.length === 0) {
|
if (rest.length === 0) {
|
||||||
throw new Error(`config ref ${ref.raw}: empty variable name`);
|
throw new Error(`config ref ${opts.raw}: empty ${kind} name`);
|
||||||
}
|
}
|
||||||
|
const name = rest.join(".");
|
||||||
try {
|
try {
|
||||||
assertVariableName(ref.name);
|
if (kind === "secret") assertSecretName(name);
|
||||||
|
else assertVariableName(name);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`config ref ${ref.raw}: invalid variable name`);
|
throw new Error(`config ref ${opts.raw}: invalid ${kind} name`);
|
||||||
}
|
}
|
||||||
const value = await getVariablePlain(ctx.owner, ref.name);
|
|
||||||
|
if (kind === "secret") {
|
||||||
|
const plaintext = await getSecretPlaintext(ctx.owner, name);
|
||||||
|
if (plaintext == null) {
|
||||||
|
throw new Error(`config ref ${opts.raw}: secret "${name}" not found`);
|
||||||
|
}
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await getVariablePlain(ctx.owner, name);
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`);
|
throw new Error(`config ref ${opts.raw}: variable "${name}" not found`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ConfigRef} ref
|
* @param {unknown} root
|
||||||
* @param {ConfigRefCtx} ctx
|
* @param {string[]} rest
|
||||||
* @returns {string}
|
* @param {{ raw: string, allowObject: boolean }} opts
|
||||||
*/
|
*/
|
||||||
function resolveContextRef(ref, ctx) {
|
function walkObject(root, rest, opts) {
|
||||||
if (ref.name.length === 0) {
|
if (rest.length === 0) {
|
||||||
throw new Error(`config ref ${ref.raw}: empty context key`);
|
return unwrapValue(root, opts);
|
||||||
}
|
}
|
||||||
const bag =
|
|
||||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
let cur = root;
|
||||||
? /** @type {Record<string, unknown>} */ (ctx.context)
|
for (const seg of rest) {
|
||||||
: {};
|
if (cur == null || typeof cur !== "object") {
|
||||||
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) {
|
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
|
}
|
||||||
|
if (Array.isArray(cur)) {
|
||||||
|
if (!/^\d+$/.test(seg)) {
|
||||||
|
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||||
|
}
|
||||||
|
const idx = Number(seg);
|
||||||
|
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
|
||||||
|
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||||
|
}
|
||||||
|
cur = cur[idx];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bag = /** @type {Record<string, unknown>} */ (cur);
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(bag, seg)) {
|
||||||
|
throw new Error(`config ref ${opts.raw}: path not found`);
|
||||||
|
}
|
||||||
|
cur = bag[seg];
|
||||||
}
|
}
|
||||||
const raw = bag[ref.name];
|
return unwrapValue(cur, opts);
|
||||||
if (isSecret(raw)) {
|
}
|
||||||
return raw.reveal();
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {{ raw: string, allowObject: boolean }} opts
|
||||||
|
*/
|
||||||
|
function unwrapValue(value, opts) {
|
||||||
|
if (isSecret(value)) {
|
||||||
|
return value.reveal();
|
||||||
}
|
}
|
||||||
const coerced = coerceCredentialString(raw);
|
if (!opts.allowObject && value != null && typeof value === "object") {
|
||||||
if (coerced == null) {
|
throw new Error(`config ref ${opts.raw}: value is not a scalar`);
|
||||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`);
|
|
||||||
}
|
}
|
||||||
return coerced;
|
// Whole-value context/data may be any JSON type; mixed strings need scalars only.
|
||||||
|
if (opts.allowObject) {
|
||||||
|
if (value != null && typeof value === "object") return value;
|
||||||
|
if (
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
// Prefer credential coercion for odd primitives (e.g. bigint) when whole-value.
|
||||||
|
const coerced = coerceCredentialString(value);
|
||||||
|
if (coerced != null) return coerced;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {string} raw
|
||||||
|
*/
|
||||||
|
function stringifyScalar(value, raw) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new Error(`config ref ${raw}: value is null`);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
throw new Error(`config ref ${raw}: value is not a scalar`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** @returns {ControlState} */
|
/** @returns {ControlState} */
|
||||||
export function defaultControlState() {
|
function defaultControlState() {
|
||||||
return {
|
return {
|
||||||
http: "running",
|
http: "running",
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
+21
-18
@@ -64,13 +64,6 @@ try {
|
|||||||
process.exit(1);
|
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() {
|
async function applyDesiredState() {
|
||||||
const state = readControlState();
|
const state = readControlState();
|
||||||
await ensureHttp({
|
await ensureHttp({
|
||||||
@@ -98,8 +91,6 @@ async function applyDesiredState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await applyDesiredState();
|
|
||||||
|
|
||||||
const server = fastify({ loggerInstance: log });
|
const server = fastify({ loggerInstance: log });
|
||||||
await server.register(cookie);
|
await server.register(cookie);
|
||||||
await server.register(jwt, {
|
await server.register(jwt, {
|
||||||
@@ -484,12 +475,24 @@ process.on("SIGINT", shutdown);
|
|||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
||||||
server
|
try {
|
||||||
.listen({ host: "0.0.0.0", port })
|
await server.listen({ host: "0.0.0.0", port });
|
||||||
.then(() => {
|
log.info(`Control is running on port ${port}`);
|
||||||
log.info(`Control is running on port ${port}`);
|
} catch (err) {
|
||||||
})
|
log.error({ err }, "failed to start control");
|
||||||
.catch((err) => {
|
process.exit(1);
|
||||||
log.error({ err }, "failed to start control");
|
}
|
||||||
process.exit(1);
|
|
||||||
});
|
try {
|
||||||
|
await connectPm2();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyDesiredState();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to apply desired state");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Dev ecosystem for `pnpm dev:pm2`.
|
* Local multi-process Ops UI ecosystem (`pnpm dev:pm2`).
|
||||||
* Prefer starting children via control.js (desired state) rather than this file.
|
* Prefer starting children via control.js (desired state) rather than this file.
|
||||||
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
|
* 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 path = require("path");
|
||||||
|
|
||||||
|
|||||||
+27
-32
@@ -49,14 +49,8 @@ export function readScript(name) {
|
|||||||
return fs.readFileSync(filePath, "utf8");
|
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}
|
* @returns {{ filePath: string, contentType: string } | null}
|
||||||
*/
|
*/
|
||||||
export function resolveScriptIcon(name) {
|
export function resolveScriptIcon(name) {
|
||||||
@@ -65,6 +59,7 @@ export function resolveScriptIcon(name) {
|
|||||||
for (const { ext, contentType } of [
|
for (const { ext, contentType } of [
|
||||||
{ ext: "png", contentType: "image/png" },
|
{ ext: "png", contentType: "image/png" },
|
||||||
{ ext: "jpg", contentType: "image/jpeg" },
|
{ ext: "jpg", contentType: "image/jpeg" },
|
||||||
|
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||||
]) {
|
]) {
|
||||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
@@ -78,22 +73,6 @@ export function scriptHasIcon(name) {
|
|||||||
return resolveScriptIcon(name) != null;
|
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() {
|
export function listOwners() {
|
||||||
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
||||||
return fs
|
return fs
|
||||||
@@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) {
|
|||||||
return fs.readFileSync(filePath, "utf8");
|
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) {
|
export function writeWorkflowYaml(owner, file, content) {
|
||||||
assertOwner(owner);
|
assertOwner(owner);
|
||||||
assertWorkflowFile(file);
|
assertWorkflowFile(file);
|
||||||
@@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) {
|
|||||||
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
|
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) {
|
export function listOwnerYamlFiles(owner) {
|
||||||
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
||||||
if (!fs.existsSync(ownerDir)) return [];
|
if (!fs.existsSync(ownerDir)) return [];
|
||||||
|
|||||||
@@ -1,390 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/http-auths-store.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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).
|
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
||||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||||
*/
|
*/
|
||||||
export function summarizeBinary(value) {
|
export function summarizeBinary(value) {
|
||||||
const buf = Buffer.isBuffer(value)
|
const buf = toBuffer(value);
|
||||||
? value
|
|
||||||
: value instanceof ArrayBuffer
|
|
||||||
? Buffer.from(value)
|
|
||||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
||||||
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
||||||
return {
|
return {
|
||||||
type: "Buffer",
|
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:
|
* 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.
|
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
||||||
|
|||||||
+1
-399
@@ -1,399 +1 @@
|
|||||||
import { db } from "./db.js";
|
export * from "./src/stores/kv-store.js";
|
||||||
|
|
||||||
const MAX_KEY_LENGTH = 512;
|
|
||||||
const MAX_NAMESPACE_LENGTH = 512;
|
|
||||||
const MAX_VALUE_BYTES = 256 * 1024;
|
|
||||||
const DEFAULT_LIST_LIMIT = 100;
|
|
||||||
const MAX_LIST_LIMIT = 500;
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function valuesEqual(a, b) {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a == null || b == null) return a === b;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function assertString(label, value) {
|
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
|
||||||
throw new Error(`${label} must be a non-empty string`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {string} value
|
|
||||||
* @param {number} max
|
|
||||||
*/
|
|
||||||
function assertMaxLength(label, value, max) {
|
|
||||||
if (value.length > max) {
|
|
||||||
throw new Error(`${label} must be at most ${max} characters`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
*/
|
|
||||||
function assertNamespace(namespace) {
|
|
||||||
assertString("namespace", namespace);
|
|
||||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
*/
|
|
||||||
function assertKey(key) {
|
|
||||||
assertString("key", key);
|
|
||||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function serializeKvValue(value) {
|
|
||||||
let json;
|
|
||||||
try {
|
|
||||||
json = JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
throw new Error("value must be JSON-serializable");
|
|
||||||
}
|
|
||||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
|
||||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null | undefined} value
|
|
||||||
* @returns {unknown}
|
|
||||||
*/
|
|
||||||
function deserializeKvValue(value) {
|
|
||||||
if (value == null) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ expires_at?: string | null }} row
|
|
||||||
*/
|
|
||||||
function isExpired(row) {
|
|
||||||
if (!row.expires_at) return false;
|
|
||||||
return Date.parse(row.expires_at) <= Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
export async function kvGet(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const row = await db("script_state").where({ namespace, key }).first();
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key }).del();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return deserializeKvValue(row.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvSet(namespace, key, value, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const json = serializeKvValue(value);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db("script_state")
|
|
||||||
.insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
})
|
|
||||||
.onConflict(["namespace", "key"])
|
|
||||||
.merge({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function kvDelete(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
|
||||||
return deleted > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
|
||||||
*/
|
|
||||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
return db.transaction(async (trx) => {
|
|
||||||
const row = await trx("script_state").where({ namespace, key }).first();
|
|
||||||
|
|
||||||
if (row && isExpired(row)) {
|
|
||||||
await trx("script_state").where({ namespace, key }).del();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRow =
|
|
||||||
row && !isExpired(row)
|
|
||||||
? row
|
|
||||||
: await trx("script_state").where({ namespace, key }).first();
|
|
||||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
|
||||||
|
|
||||||
if (!valuesEqual(previous, expected)) {
|
|
||||||
return { ok: false, previous };
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = serializeKvValue(next);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRow) {
|
|
||||||
await trx("script_state").where({ namespace, key }).update({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await trx("script_state").insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, previous };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {{ limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvList(namespace, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
|
||||||
MAX_LIST_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = await db("script_state")
|
|
||||||
.where({ namespace })
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key: row.key }).del();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items.push({
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeLike(value) {
|
|
||||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneExpiredKv() {
|
|
||||||
await db("script_state")
|
|
||||||
.whereNotNull("expires_at")
|
|
||||||
.andWhere("expires_at", "<=", nowIso())
|
|
||||||
.del();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{
|
|
||||||
* namespace?: string,
|
|
||||||
* q?: string,
|
|
||||||
* limit?: number,
|
|
||||||
* offset?: number,
|
|
||||||
* }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvQuery(opts = {}) {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
|
|
||||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
|
||||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
|
||||||
|
|
||||||
let q = db("script_state");
|
|
||||||
if (opts.namespace) {
|
|
||||||
assertNamespace(opts.namespace);
|
|
||||||
q = q.where({ namespace: opts.namespace });
|
|
||||||
}
|
|
||||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
|
||||||
const like = `%${escapeLike(opts.q)}%`;
|
|
||||||
q = q.where(function likeSearch() {
|
|
||||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
|
||||||
"value LIKE ? ESCAPE '\\'",
|
|
||||||
[like],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const countRow = await q.clone().count({ count: "*" }).first();
|
|
||||||
const total = Number(countRow?.count ?? 0);
|
|
||||||
|
|
||||||
const rows = await q
|
|
||||||
.clone()
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.orderBy("namespace", "asc")
|
|
||||||
.orderBy("key", "asc")
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: rows.map((row) => ({
|
|
||||||
namespace: row.namespace,
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<string[]>}
|
|
||||||
*/
|
|
||||||
export async function kvNamespaces() {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
|
||||||
return rows.map((row) => row.namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} defaultNamespace
|
|
||||||
*/
|
|
||||||
export function createKvApi(defaultNamespace) {
|
|
||||||
assertNamespace(defaultNamespace);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
function resolveNamespace(opts = {}) {
|
|
||||||
const namespace = opts.namespace ?? defaultNamespace;
|
|
||||||
assertNamespace(namespace);
|
|
||||||
return namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
namespace: defaultNamespace,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
get(key, opts) {
|
|
||||||
return kvGet(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
set(key, value, opts) {
|
|
||||||
const { namespace, expiresAt } = opts ?? {};
|
|
||||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
delete(key, opts) {
|
|
||||||
return kvDelete(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
compareAndSet(key, expected, next, opts) {
|
|
||||||
const { expiresAt } = opts ?? {};
|
|
||||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
list(opts) {
|
|
||||||
const { namespace, limit } = opts ?? {};
|
|
||||||
return kvList(resolveNamespace(opts), { limit });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+5
-38
@@ -17,6 +17,9 @@ export async function up(knex) {
|
|||||||
t.text("output");
|
t.text("output");
|
||||||
t.text("error");
|
t.text("error");
|
||||||
t.text("parent_run_id").references("id").inTable("workflow_runs");
|
t.text("parent_run_id").references("id").inTable("workflow_runs");
|
||||||
|
t.text("job_id");
|
||||||
|
t.text("queued_at");
|
||||||
|
t.integer("workflow_revision");
|
||||||
});
|
});
|
||||||
|
|
||||||
await knex.schema.raw(
|
await knex.schema.raw(
|
||||||
@@ -28,45 +31,11 @@ export async function up(knex) {
|
|||||||
await knex.schema.raw(
|
await knex.schema.raw(
|
||||||
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)",
|
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)",
|
||||||
);
|
);
|
||||||
|
|
||||||
await knex.schema.createTable("step_runs", (t) => {
|
|
||||||
t.text("id").primary();
|
|
||||||
t.text("run_id")
|
|
||||||
.notNullable()
|
|
||||||
.references("id")
|
|
||||||
.inTable("workflow_runs")
|
|
||||||
.onDelete("CASCADE");
|
|
||||||
t.integer("step_index").notNullable();
|
|
||||||
t.text("script").notNullable();
|
|
||||||
t.text("config");
|
|
||||||
t.text("status").notNullable();
|
|
||||||
t.text("started_at").notNullable();
|
|
||||||
t.text("finished_at");
|
|
||||||
t.integer("duration_ms");
|
|
||||||
t.text("output");
|
|
||||||
t.text("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.raw(
|
await knex.schema.raw(
|
||||||
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
|
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
|
||||||
);
|
);
|
||||||
|
|
||||||
await knex.schema.createTable("logs", (t) => {
|
|
||||||
t.increments("id").primary();
|
|
||||||
t.text("run_id")
|
|
||||||
.notNullable()
|
|
||||||
.references("id")
|
|
||||||
.inTable("workflow_runs")
|
|
||||||
.onDelete("CASCADE");
|
|
||||||
t.text("step_id");
|
|
||||||
t.text("ts").notNullable();
|
|
||||||
t.integer("level").notNullable();
|
|
||||||
t.text("msg");
|
|
||||||
t.text("payload");
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.raw(
|
await knex.schema.raw(
|
||||||
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
|
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +43,5 @@ export async function up(knex) {
|
|||||||
* @param {import("knex").Knex} knex
|
* @param {import("knex").Knex} knex
|
||||||
*/
|
*/
|
||||||
export async function down(knex) {
|
export async function down(knex) {
|
||||||
await knex.schema.dropTableIfExists("logs");
|
|
||||||
await knex.schema.dropTableIfExists("step_runs");
|
|
||||||
await knex.schema.dropTableIfExists("workflow_runs");
|
await knex.schema.dropTableIfExists("workflow_runs");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function up(knex) {
|
||||||
|
await knex.schema.createTable("step_runs", (t) => {
|
||||||
|
t.text("id").primary();
|
||||||
|
t.text("run_id")
|
||||||
|
.notNullable()
|
||||||
|
.references("id")
|
||||||
|
.inTable("workflow_runs")
|
||||||
|
.onDelete("CASCADE");
|
||||||
|
t.integer("step_index").notNullable();
|
||||||
|
t.text("script").notNullable();
|
||||||
|
t.text("config");
|
||||||
|
t.text("status").notNullable();
|
||||||
|
t.text("started_at").notNullable();
|
||||||
|
t.text("finished_at");
|
||||||
|
t.integer("duration_ms");
|
||||||
|
t.text("output");
|
||||||
|
t.text("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.raw(
|
||||||
|
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function down(knex) {
|
||||||
|
await knex.schema.dropTableIfExists("step_runs");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function up(knex) {
|
||||||
|
await knex.schema.createTable("logs", (t) => {
|
||||||
|
t.increments("id").primary();
|
||||||
|
t.text("run_id")
|
||||||
|
.notNullable()
|
||||||
|
.references("id")
|
||||||
|
.inTable("workflow_runs")
|
||||||
|
.onDelete("CASCADE");
|
||||||
|
t.text("step_id");
|
||||||
|
t.text("ts").notNullable();
|
||||||
|
t.integer("level").notNullable();
|
||||||
|
t.text("msg");
|
||||||
|
t.text("payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.raw(
|
||||||
|
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function down(knex) {
|
||||||
|
await knex.schema.dropTableIfExists("logs");
|
||||||
|
}
|
||||||
+20
-20
@@ -29,35 +29,35 @@ const DEFAULT_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
|||||||
* @param {import("knex").Knex} knex
|
* @param {import("knex").Knex} knex
|
||||||
*/
|
*/
|
||||||
export async function up(knex) {
|
export async function up(knex) {
|
||||||
await knex.schema.alterTable("http_pages", (t) => {
|
await knex.schema.createTable("http_pages", (t) => {
|
||||||
|
t.text("id").primary();
|
||||||
|
t.text("name").notNullable().unique();
|
||||||
|
t.text("content").notNullable();
|
||||||
|
t.text("mime").notNullable();
|
||||||
|
t.integer("status").notNullable().defaultTo(200);
|
||||||
t.text("kind").notNullable().defaultTo("response");
|
t.text("kind").notNullable().defaultTo("response");
|
||||||
t.integer("system").notNullable().defaultTo(0);
|
t.integer("system").notNullable().defaultTo(0);
|
||||||
|
t.text("created_at").notNullable();
|
||||||
|
t.text("updated_at").notNullable();
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const existing = await knex("http_pages").where({ name: "email-default" }).first();
|
await knex("http_pages").insert({
|
||||||
if (!existing) {
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
await knex("http_pages").insert({
|
name: "email-default",
|
||||||
id: "00000000-0000-4000-8000-000000000001",
|
content: DEFAULT_EMAIL_TEMPLATE,
|
||||||
name: "email-default",
|
mime: "html",
|
||||||
content: DEFAULT_EMAIL_TEMPLATE,
|
status: 200,
|
||||||
mime: "html",
|
kind: "template",
|
||||||
status: 200,
|
system: 1,
|
||||||
kind: "template",
|
created_at: now,
|
||||||
system: 1,
|
updated_at: now,
|
||||||
created_at: now,
|
});
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("knex").Knex} knex
|
* @param {import("knex").Knex} knex
|
||||||
*/
|
*/
|
||||||
export async function down(knex) {
|
export async function down(knex) {
|
||||||
await knex("http_pages").where({ name: "email-default", system: 1 }).del();
|
await knex.schema.dropTableIfExists("http_pages");
|
||||||
await knex.schema.alterTable("http_pages", (t) => {
|
|
||||||
t.dropColumn("kind");
|
|
||||||
t.dropColumn("system");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
+2
-13
@@ -2,21 +2,11 @@
|
|||||||
* @param {import("knex").Knex} knex
|
* @param {import("knex").Knex} knex
|
||||||
*/
|
*/
|
||||||
export async function up(knex) {
|
export async function up(knex) {
|
||||||
await knex.schema.createTable("http_pages", (t) => {
|
|
||||||
t.text("id").primary();
|
|
||||||
t.text("name").notNullable().unique();
|
|
||||||
t.text("content").notNullable();
|
|
||||||
t.text("mime").notNullable(); // html | json
|
|
||||||
t.integer("status").notNullable().defaultTo(200);
|
|
||||||
t.text("created_at").notNullable();
|
|
||||||
t.text("updated_at").notNullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.createTable("http_auths", (t) => {
|
await knex.schema.createTable("http_auths", (t) => {
|
||||||
t.text("id").primary();
|
t.text("id").primary();
|
||||||
t.text("name").notNullable().unique();
|
t.text("name").notNullable().unique();
|
||||||
t.text("type").notNullable(); // bearer | basic | header
|
t.text("type").notNullable();
|
||||||
t.text("config").notNullable(); // JSON
|
t.text("config").notNullable();
|
||||||
t.integer("unauthorized_status").nullable();
|
t.integer("unauthorized_status").nullable();
|
||||||
t.text("unauthorized_response").nullable();
|
t.text("unauthorized_response").nullable();
|
||||||
t.text("created_at").notNullable();
|
t.text("created_at").notNullable();
|
||||||
@@ -29,5 +19,4 @@ export async function up(knex) {
|
|||||||
*/
|
*/
|
||||||
export async function down(knex) {
|
export async function down(knex) {
|
||||||
await knex.schema.dropTableIfExists("http_auths");
|
await knex.schema.dropTableIfExists("http_auths");
|
||||||
await knex.schema.dropTableIfExists("http_pages");
|
|
||||||
}
|
}
|
||||||
-18
@@ -21,29 +21,11 @@ export async function up(knex) {
|
|||||||
await knex.schema.raw(
|
await knex.schema.raw(
|
||||||
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)",
|
"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
|
* @param {import("knex").Knex} knex
|
||||||
*/
|
*/
|
||||||
export async function down(knex) {
|
export async function down(knex) {
|
||||||
await knex.schema.dropTableIfExists("workflow_trash");
|
|
||||||
await knex.schema.dropTableIfExists("workflow_revisions");
|
await knex.schema.dropTableIfExists("workflow_revisions");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function up(knex) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @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,97 @@
|
|||||||
|
const HOP_BY_HOP = new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REPLY_SKIP = new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"content-encoding",
|
||||||
|
"content-length",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control origin used when the UI or HTTP process proxies to control.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function controlOrigin() {
|
||||||
|
const explicit = process.env.JFLOW_CONTROL_URL?.trim();
|
||||||
|
if (explicit) return explicit.replace(/\/$/, "");
|
||||||
|
const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600);
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API origin (workflow triggers + REST).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function httpOrigin() {
|
||||||
|
const explicit = process.env.JFLOW_HTTP_URL?.trim();
|
||||||
|
if (explicit) return explicit.replace(/\/$/, "");
|
||||||
|
const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700);
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward the incoming request to `origin`, preserving path + query.
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
* @param {string} origin
|
||||||
|
* @param {{ unreachableMessage?: string }} [opts]
|
||||||
|
*/
|
||||||
|
export async function proxyToOrigin(req, reply, origin, opts = {}) {
|
||||||
|
const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`;
|
||||||
|
const headers = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue;
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method.toUpperCase();
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
let body;
|
||||||
|
if (hasBody) {
|
||||||
|
if (Buffer.isBuffer(req.body)) body = req.body;
|
||||||
|
else if (typeof req.body === "string") body = req.body;
|
||||||
|
else if (req.body != null) {
|
||||||
|
body = JSON.stringify(req.body);
|
||||||
|
if (!headers["content-type"]) headers["content-type"] = "application/json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(target, { method, headers, body });
|
||||||
|
} catch (err) {
|
||||||
|
const message = opts.unreachableMessage ?? "upstream unreachable";
|
||||||
|
req.log.warn({ err, target }, `proxy: ${message}`);
|
||||||
|
return reply.code(502).send({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(res.status);
|
||||||
|
res.headers.forEach((value, key) => {
|
||||||
|
if (REPLY_SKIP.has(key.toLowerCase())) return;
|
||||||
|
reply.header(key, value);
|
||||||
|
});
|
||||||
|
return reply.send(Buffer.from(await res.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward `/ops/*` to the control plane (same-origin UI in production).
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
export async function proxyOpsToControl(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, controlOrigin(), {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,11 +11,17 @@
|
|||||||
"start:api": "node server.js",
|
"start:api": "node server.js",
|
||||||
"start:worker": "node worker.js",
|
"start:worker": "node worker.js",
|
||||||
"start:control": "node control.js",
|
"start:control": "node control.js",
|
||||||
|
"start:web": "node web-server.js",
|
||||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
"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:plugins": "JFLOW_DATA_DIR=./data JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||||
"test:workflow-history": "node test/workflow-history-smoke.js"
|
"test:workflow-history": "JFLOW_DATA_DIR=./data JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||||
|
"reset-admin": "node reset-admin.js",
|
||||||
|
"test:profiles": "node test/profiles-smoke.js",
|
||||||
|
"test:set-dry-run": "node test/set-dry-run-smoke.js",
|
||||||
|
"test:config-refs": "node test/config-refs-smoke.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jerapah-flow/shared": "workspace:*",
|
||||||
"@aws-sdk/client-s3": "^3.1111.0",
|
"@aws-sdk/client-s3": "^3.1111.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -37,7 +43,8 @@
|
|||||||
"nodemailer": "^9.0.5",
|
"nodemailer": "^9.0.5",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-roll": "^4.0.0",
|
"pino-roll": "^4.0.0",
|
||||||
"pm2": "^6.0.13",
|
"pm2": "6.0.14",
|
||||||
|
"rss-parser": "^3.13.0",
|
||||||
"ssh2-sftp-client": "^12.1.1",
|
"ssh2-sftp-client": "^12.1.1",
|
||||||
"webdav": "^5.10.0",
|
"webdav": "^5.10.0",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
|
|||||||
+43
-12
@@ -1,18 +1,49 @@
|
|||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
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");
|
/** Prefer `preferred` unless only `legacy` already has files. */
|
||||||
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
function existingDir(preferred, legacy, probe) {
|
||||||
export const PLUGINS_DIR =
|
const has = (dir) =>
|
||||||
process.env.JFLOW_PLUGINS_DIR ??
|
probe ? probe(dir) : fs.existsSync(dir);
|
||||||
path.resolve(SERVER_ROOT, "../../plugins");
|
if (has(preferred) || !has(legacy)) return preferred;
|
||||||
/** Example plugin sources shipped with the repo. */
|
return legacy;
|
||||||
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
}
|
||||||
SERVER_ROOT,
|
|
||||||
"../../examples/plugins",
|
function hasInstanceData(dir) {
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(dir, "jerapah-flow.db")) ||
|
||||||
|
fs.existsSync(path.join(dir, "workflows"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Instance data (SQLite, live workflows, control-state). Not product source. */
|
||||||
|
export const DATA_DIR = path.resolve(
|
||||||
|
process.env.JFLOW_DATA_DIR ??
|
||||||
|
existingDir(
|
||||||
|
path.join(REPO_ROOT, "data"),
|
||||||
|
path.join(SERVER_ROOT, "data"),
|
||||||
|
hasInstanceData,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
/** Live instance workflows (not shipped in git). Override for tests. */
|
||||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
export const WORKFLOWS_DIR = path.resolve(
|
||||||
|
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"),
|
||||||
|
);
|
||||||
|
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||||
|
export const PLUGINS_DIR = path.resolve(
|
||||||
|
process.env.JFLOW_PLUGINS_DIR ?? path.join(REPO_ROOT, "plugins"),
|
||||||
|
);
|
||||||
|
/** Example plugin sources shipped with the repo. */
|
||||||
|
export const EXAMPLE_PLUGINS_DIR = path.join(REPO_ROOT, "examples/plugins");
|
||||||
|
/** Example workflow YAML presets (not loaded by the runner). */
|
||||||
|
export const EXAMPLE_WORKFLOWS_DIR = path.join(REPO_ROOT, "examples/workflows");
|
||||||
|
export const LOGS_DIR = path.resolve(
|
||||||
|
process.env.JFLOW_LOGS_DIR ??
|
||||||
|
existingDir(path.join(REPO_ROOT, "logs"), path.join(SERVER_ROOT, "logs")),
|
||||||
|
);
|
||||||
|
export const WEB_DIST = path.join(REPO_ROOT, "packages/web/dist");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
installPluginFromDirectory,
|
installPluginFromDirectory,
|
||||||
pluginDir,
|
pluginDir,
|
||||||
} from "./plugin-store.js";
|
} from "./plugin-store.js";
|
||||||
import { readManifestFile } from "./plugin-manifest.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) {
|
|||||||
});
|
});
|
||||||
return { ...installed, already: false };
|
return { ...installed, already: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
void readManifestFile;
|
|
||||||
|
|||||||
@@ -380,6 +380,96 @@ export function forkCoreScript(coreName, newId, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy an installed plugin to a new plugin id.
|
||||||
|
*
|
||||||
|
* @param {string} sourceId
|
||||||
|
* @param {string} newId
|
||||||
|
* @param {{ description?: string }} [opts]
|
||||||
|
*/
|
||||||
|
export function duplicatePlugin(sourceId, newId, opts = {}) {
|
||||||
|
const fromId = assertPluginId(sourceId);
|
||||||
|
const id = assertPluginId(newId);
|
||||||
|
if (id === fromId) {
|
||||||
|
const err = new Error("cannot duplicate onto itself");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
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 source = getInstalledPlugin(fromId);
|
||||||
|
if (!source) {
|
||||||
|
const err = new Error(`plugin not found: ${fromId}`);
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!source.manifest) {
|
||||||
|
const err = new Error(
|
||||||
|
source.compatError || `plugin has no valid manifest: ${fromId}`,
|
||||||
|
);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staging = path.join(PLUGINS_DIR, `.staging-dup-${id}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
try {
|
||||||
|
fs.cpSync(source.dir, staging, {
|
||||||
|
recursive: true,
|
||||||
|
filter: (src) => {
|
||||||
|
const base = path.basename(src);
|
||||||
|
return base !== "node_modules" && base !== ".disabled";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = buildManifest({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
version: source.manifest.version,
|
||||||
|
jerapah: source.manifest.jerapah,
|
||||||
|
main: source.manifest.main,
|
||||||
|
description:
|
||||||
|
opts.description ?? source.manifest.description ?? null,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(staging, PLUGIN_MANIFEST),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pkgPath = path.join(staging, "package.json");
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
let pkg = {};
|
||||||
|
try {
|
||||||
|
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
pkg = {};
|
||||||
|
}
|
||||||
|
if (pkg == null || typeof pkg !== "object" || Array.isArray(pkg)) {
|
||||||
|
pkg = {};
|
||||||
|
}
|
||||||
|
pkg.name = `jflow-plugin-${id}`;
|
||||||
|
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return installPluginFromDirectory(staging, {
|
||||||
|
overwrite: false,
|
||||||
|
reason: `plugin:${id} duplicated from ${fromId}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} pluginDirectory
|
* @param {string} pluginDirectory
|
||||||
* @returns {((id: string) => unknown) | null}
|
* @returns {((id: string) => unknown) | null}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import pm2 from "pm2";
|
import pm2 from "pm2";
|
||||||
import { SERVER_ROOT } from "./paths.js";
|
import { REPO_ROOT, SERVER_ROOT } from "./paths.js";
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
|
||||||
|
|
||||||
export const PM2_HTTP_NAME = "jflow-http";
|
export const PM2_HTTP_NAME = "jflow-http";
|
||||||
export const PM2_WORKER_NAME = "jflow-worker";
|
export const PM2_WORKER_NAME = "jflow-worker";
|
||||||
@@ -164,13 +162,57 @@ export async function restartPm2Process(pmId) {
|
|||||||
return { name: proc.name, pmId: id };
|
return { name: proc.name, pmId: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PM2 injects these into process.env of a managed app. Spreading them into
|
||||||
|
* `pm2.start({ env })` overwrites `name` / `pm_exec_path` so God restarts
|
||||||
|
* jflow-control instead of launching http/worker (EADDRINUSE :8600 loop).
|
||||||
|
*/
|
||||||
|
const PM2_META_KEYS = new Set([
|
||||||
|
"name",
|
||||||
|
"namespace",
|
||||||
|
"exec_mode",
|
||||||
|
"exec_interpreter",
|
||||||
|
"instances",
|
||||||
|
"instance_var",
|
||||||
|
"node_app_instance",
|
||||||
|
"unique_id",
|
||||||
|
"status",
|
||||||
|
"username",
|
||||||
|
"windowsHide",
|
||||||
|
"merge_logs",
|
||||||
|
"vizion",
|
||||||
|
"vizion_running",
|
||||||
|
"autostart",
|
||||||
|
"autorestart",
|
||||||
|
"automation",
|
||||||
|
"km_link",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NodeJS.ProcessEnv} env
|
||||||
|
* @returns {NodeJS.ProcessEnv}
|
||||||
|
*/
|
||||||
|
export function withoutPm2Meta(env) {
|
||||||
|
/** @type {NodeJS.ProcessEnv} */
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of Object.entries(env)) {
|
||||||
|
if (val == null) continue;
|
||||||
|
if (PM2_META_KEYS.has(key)) continue;
|
||||||
|
if (key.startsWith("pm_") || key.startsWith("axm_") || key.startsWith("PM2_")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared env for child processes.
|
* Shared env for child processes.
|
||||||
* @param {{ generation: number }} opts
|
* @param {{ generation: number }} opts
|
||||||
*/
|
*/
|
||||||
export function childEnv(opts) {
|
export function childEnv(opts) {
|
||||||
return {
|
return {
|
||||||
...process.env,
|
...withoutPm2Meta(process.env),
|
||||||
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
||||||
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||||
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src/stores/profiles-store.js";
|
||||||
+39
-134
@@ -23,14 +23,14 @@ import {
|
|||||||
storedEnvelope,
|
storedEnvelope,
|
||||||
} from "./step-result.js";
|
} from "./step-result.js";
|
||||||
import * as fsStore from "./fs-store.js";
|
import * as fsStore from "./fs-store.js";
|
||||||
import {
|
|
||||||
checkAnyHttpAuth,
|
|
||||||
resolveAuthMechanisms,
|
|
||||||
resolveUnauthorizedSpec,
|
|
||||||
sendHttpPageOrJson,
|
|
||||||
sendSuccessPage,
|
|
||||||
} from "./http-trigger-auth.js";
|
|
||||||
import { resolveConfigRefs } from "./config-refs.js";
|
import { 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 {
|
import {
|
||||||
buildFailureAlertData,
|
buildFailureAlertData,
|
||||||
resolveFailureTriggerConfig,
|
resolveFailureTriggerConfig,
|
||||||
@@ -46,17 +46,6 @@ import { publishReload } from "./control-bus.js";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
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 {import("fastify").FastifyInstance} server
|
||||||
@@ -80,7 +69,14 @@ export function createRegistry(server, opts = {}) {
|
|||||||
let pruneTask = null;
|
let pruneTask = null;
|
||||||
/** @type {Map<string, HttpRouteEntry>} */
|
/** @type {Map<string, HttpRouteEntry>} */
|
||||||
const httpRoutes = new Map();
|
const httpRoutes = new Map();
|
||||||
let httpDispatcherRegistered = false;
|
const httpDispatcherState = { registered: false };
|
||||||
|
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||||
|
httpRoutes,
|
||||||
|
workflows,
|
||||||
|
namespacedPath,
|
||||||
|
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||||
@@ -186,118 +182,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
* Fastify route once so path/method changes apply on reregister without restart.
|
* Fastify route once so path/method changes apply on reregister without restart.
|
||||||
*/
|
*/
|
||||||
function registerHttpTriggers() {
|
function registerHttpTriggers() {
|
||||||
httpRoutes.clear();
|
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||||
|
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||||
for (const [key, { owner, workflow }] of workflows) {
|
log,
|
||||||
if (workflow.enabled === false) {
|
});
|
||||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const trigger of workflow.triggers ?? []) {
|
|
||||||
if (trigger.type !== "HTTP") continue;
|
|
||||||
|
|
||||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
|
||||||
const url = namespacedPath(owner, trigger.path);
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
|
|
||||||
if (httpRoutes.has(routeKey)) {
|
|
||||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
|
||||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpDispatcherRegistered) {
|
|
||||||
httpDispatcherRegistered = true;
|
|
||||||
server.route({
|
|
||||||
method: HTTP_METHODS,
|
|
||||||
url: "/u/*",
|
|
||||||
handler: dispatchHttpTrigger,
|
|
||||||
});
|
|
||||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("fastify").FastifyRequest} req
|
|
||||||
* @param {import("fastify").FastifyReply} reply
|
|
||||||
*/
|
|
||||||
async function dispatchHttpTrigger(req, reply) {
|
|
||||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
|
||||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
|
||||||
const method = String(req.method ?? "GET").toUpperCase();
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
const mapped = httpRoutes.get(routeKey);
|
|
||||||
|
|
||||||
if (!mapped) {
|
|
||||||
return reply.code(404).send({ error: "not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = workflows.get(mapped.key);
|
|
||||||
if (!entry || entry.workflow?.enabled === false) {
|
|
||||||
return reply.code(404).send({ error: "workflow disabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
|
||||||
const liveTrigger =
|
|
||||||
(entry.workflow.triggers ?? []).find((t) => {
|
|
||||||
if (t?.type !== "HTTP") return false;
|
|
||||||
const m = String(t.method ?? "POST").toUpperCase();
|
|
||||||
const p = namespacedPath(entry.owner, t.path);
|
|
||||||
return m === method && p === url;
|
|
||||||
}) ?? mapped.trigger;
|
|
||||||
|
|
||||||
if (
|
|
||||||
liveTrigger.auth != null &&
|
|
||||||
liveTrigger.auth !== false &&
|
|
||||||
!(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0)
|
|
||||||
) {
|
|
||||||
const mechanisms = await resolveAuthMechanisms(liveTrigger.auth);
|
|
||||||
if (mechanisms.length === 0) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ok = await checkAnyHttpAuth(req, mechanisms, {
|
|
||||||
owner: entry.owner,
|
|
||||||
workflowKey: mapped.key,
|
|
||||||
});
|
|
||||||
if (!ok) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(
|
|
||||||
liveTrigger,
|
|
||||||
mechanisms[0],
|
|
||||||
);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await enqueueWorkflow(
|
|
||||||
mapped.key,
|
|
||||||
{ data: req.body },
|
|
||||||
{ type: "http", detail: `${method} ${url}` },
|
|
||||||
);
|
|
||||||
if (result.status === "failed") {
|
|
||||||
return reply.code(result.runId ? 500 : 404).send({
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
error: result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultBody = {
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
};
|
|
||||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
|
||||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
|
||||||
}
|
|
||||||
return reply.code(202).send(defaultBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCronTriggers() {
|
function registerCronTriggers() {
|
||||||
@@ -595,8 +483,21 @@ export function createRegistry(server, opts = {}) {
|
|||||||
owner,
|
owner,
|
||||||
depth,
|
depth,
|
||||||
) {
|
) {
|
||||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||||
const unresolvedConfig = parsed.config;
|
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 incomingContext = normalizeContext(ctx.context);
|
||||||
const step = await store.startStep({
|
const step = await store.startStep({
|
||||||
runId,
|
runId,
|
||||||
@@ -610,6 +511,7 @@ export function createRegistry(server, opts = {}) {
|
|||||||
owner,
|
owner,
|
||||||
workflowKey: key,
|
workflowKey: key,
|
||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
|
data: ctx.data,
|
||||||
});
|
});
|
||||||
const stepCtx = {
|
const stepCtx = {
|
||||||
data: ctx.data,
|
data: ctx.data,
|
||||||
@@ -956,7 +858,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
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 {
|
} catch {
|
||||||
// skip invalid steps
|
// skip invalid steps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
|||||||
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||||
import { getSecretPlaintext } from "./secrets-store.js";
|
import { getSecretPlaintext } from "./secrets-store.js";
|
||||||
import { getVariablePlain } from "./variables-store.js";
|
import { getVariablePlain } from "./variables-store.js";
|
||||||
|
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||||
|
|
||||||
const hostRequire = createRequire(import.meta.url);
|
const hostRequire = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -443,7 +444,7 @@ function createScriptSandbox({
|
|||||||
log,
|
log,
|
||||||
script,
|
script,
|
||||||
workflowName,
|
workflowName,
|
||||||
owner = "default",
|
owner = DEFAULT_OWNER,
|
||||||
$workflows = $workflowsStub,
|
$workflows = $workflowsStub,
|
||||||
pluginDir = null,
|
pluginDir = null,
|
||||||
}) {
|
}) {
|
||||||
@@ -486,7 +487,7 @@ const inspectLog = pino({ level: "silent" });
|
|||||||
* @param {unknown} fn
|
* @param {unknown} fn
|
||||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||||
*/
|
*/
|
||||||
export function extractScriptMeta(fn) {
|
function extractScriptMeta(fn) {
|
||||||
if (typeof fn !== "function") {
|
if (typeof fn !== "function") {
|
||||||
return { meta: null, metaError: "default export must be a function" };
|
return { meta: null, metaError: "default export must be a function" };
|
||||||
}
|
}
|
||||||
@@ -563,7 +564,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
|||||||
log: opts.log ?? inspectLog,
|
log: opts.log ?? inspectLog,
|
||||||
script,
|
script,
|
||||||
workflowName: opts.workflowName ?? "inspect",
|
workflowName: opts.workflowName ?? "inspect",
|
||||||
owner: opts.owner ?? "default",
|
owner: opts.owner ?? DEFAULT_OWNER,
|
||||||
$workflows: opts.$workflows,
|
$workflows: opts.$workflows,
|
||||||
pluginDir: opts.pluginDir ?? null,
|
pluginDir: opts.pluginDir ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
function ensureDataObject(ctx) {
|
function passContext(ctx) {
|
||||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
ctx.data = {};
|
return { ...ctx.context };
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeData(data) {
|
||||||
|
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
return { ...data };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_METHODS = new Set([
|
const ALLOWED_METHODS = new Set([
|
||||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
|||||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
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) {
|
async function detectUrlChanges(ctx) {
|
||||||
const url = ctx.config?.url;
|
const url = ctx.config?.url;
|
||||||
if (typeof url !== "string" || url.length === 0) {
|
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");
|
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||||
const response = await $axios.request({
|
const response = await $axios.request({
|
||||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
|||||||
"detect-url-changes: fetch complete",
|
"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) {
|
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||||
log.info(
|
log.info(
|
||||||
{ jsonata: fingerprintExpr },
|
{ jsonata: fingerprintExpr },
|
||||||
"detect-url-changes: evaluating fingerprint jsonata",
|
"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, {
|
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||||
maxAge: ctx.config?.maxAge,
|
maxAge: ctx.config?.maxAge,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.hasChanges = result.changed;
|
const extra = {
|
||||||
ctx.data.fingerprint = result.hash;
|
hasChanges: result.changed,
|
||||||
ctx.data.fingerprintChanged = result.changed;
|
fingerprint: result.hash,
|
||||||
ctx.data.fingerprintPrevious = result.previous;
|
fingerprintChanged: result.changed,
|
||||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
fingerprintPrevious: result.previous,
|
||||||
ctx.data.fingerprintAge = result.ageMs;
|
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||||
ctx.data.fingerprintExpired = result.expired;
|
fingerprintAge: result.ageMs,
|
||||||
|
fingerprintExpired: result.expired,
|
||||||
|
};
|
||||||
|
Object.assign(data, extra);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
|||||||
{ outputVar, jsonata: transformExpr },
|
{ outputVar, jsonata: transformExpr },
|
||||||
"detect-url-changes: evaluating transform jsonata",
|
"detect-url-changes: evaluating transform jsonata",
|
||||||
);
|
);
|
||||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||||
ctx.data[outputVar] = transformed;
|
data[outputVar] = transformed;
|
||||||
log.info(
|
log.info(
|
||||||
{ outputVar, value: previewValue(transformed) },
|
{ outputVar, value: previewValue(transformed) },
|
||||||
"detect-url-changes: saved transform result",
|
"detect-url-changes: saved transform result",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
data[outputVar] = data.httpResponse;
|
||||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
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) {
|
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||||
ctx.skipRemaining = true;
|
envelope.skipRemaining = true;
|
||||||
}
|
}
|
||||||
|
return envelope;
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
detectUrlChanges.meta = {
|
detectUrlChanges.meta = {
|
||||||
description:
|
description:
|
||||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
"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: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "URL to fetch" },
|
url: { type: "string", required: true, description: "URL to fetch" },
|
||||||
method: {
|
method: {
|
||||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
|||||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||||
fingerprintExpired: { type: "boolean" },
|
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: {
|
example: {
|
||||||
data: {},
|
data: {},
|
||||||
config: {
|
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 |
@@ -15,6 +15,10 @@ function ntfyHeaders(ctx) {
|
|||||||
headers.Title = ctx.data.title;
|
headers.Title = ctx.data.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.config?.markdown === true) {
|
||||||
|
headers.md = "true";
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +153,11 @@ ntfy.meta = {
|
|||||||
default: "https://ntfy.sh/jerapah-flow",
|
default: "https://ntfy.sh/jerapah-flow",
|
||||||
description: "ntfy topic URL",
|
description: "ntfy topic URL",
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Send as Markdown (ntfy md header)",
|
||||||
|
},
|
||||||
fingerprint: {
|
fingerprint: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,144 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/secrets-store.js";
|
||||||
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}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
|||||||
/**
|
/**
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
*/
|
*/
|
||||||
export function getMasterKey() {
|
function getMasterKey() {
|
||||||
if (cachedKey) return cachedKey;
|
if (cachedKey) return cachedKey;
|
||||||
const raw = resolveSecretsKeyMaterial();
|
const raw = resolveSecretsKeyMaterial();
|
||||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { kvNamespaces, kvQuery } from "../../kv-store.js";
|
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
|
|||||||
offset: Number.isFinite(offset) ? offset : undefined,
|
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 * as store from "../../store.js";
|
||||||
|
import { queryRunsFromRequest } from "./run-query.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
export default async function runsPlugin(fastify) {
|
export default async function runsPlugin(fastify) {
|
||||||
fastify.get("/runs", async (req) => {
|
fastify.get("/runs", async (req) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
|
||||||
const runs = await store.listRuns({
|
|
||||||
owner: q.owner,
|
|
||||||
workflow: q.workflow,
|
|
||||||
status: q.status,
|
|
||||||
limit: Number.isFinite(limit) ? limit : undefined,
|
|
||||||
before: q.before,
|
|
||||||
});
|
|
||||||
return { runs };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/consecutive-failures", async (req) => {
|
fastify.get("/consecutive-failures", async (req) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import * as fsStore from "../../fs-store.js";
|
import * as fsStore from "../../fs-store.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
getInstalledPlugin,
|
duplicatePlugin,
|
||||||
listCoreScriptNames,
|
listCoreScriptNames,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
@@ -22,11 +22,17 @@ import {
|
|||||||
installPluginFromZipBuffer,
|
installPluginFromZipBuffer,
|
||||||
} from "../../plugin-install.js";
|
} from "../../plugin-install.js";
|
||||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||||
|
import {
|
||||||
|
encodeBinaryForWire,
|
||||||
|
reviveBinaryFromWire,
|
||||||
|
} from "../../json-preview.js";
|
||||||
import { normalizeStepResult } from "../../step-result.js";
|
import { normalizeStepResult } from "../../step-result.js";
|
||||||
import { resolveConfigRefs } from "../../config-refs.js";
|
import { resolveConfigRefs } from "../../config-refs.js";
|
||||||
import { getAppVersion } from "../../app-version.js";
|
import { getAppVersion } from "../../app-version.js";
|
||||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
import { parsePluginScriptRef, pluginScriptRef } from "../../plugin-manifest.js";
|
||||||
|
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||||
|
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||||
@@ -246,18 +252,49 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post("/scripts/:name/duplicate", 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" });
|
||||||
|
}
|
||||||
|
const parsed = parsePluginScriptRef(rawName);
|
||||||
|
if (!parsed) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: "name must be a plugin ref (plugin/<id>)" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const installed = duplicatePlugin(parsed.id, 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) => {
|
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
||||||
const rawName = decodeURIComponent(
|
const rawName = decodeURIComponent(
|
||||||
/** @type {{ name: string }} */ (req.params).name,
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
);
|
);
|
||||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||||
req.body ?? {}
|
req.body ?? {}
|
||||||
);
|
);
|
||||||
if (typeof body.content !== "string") {
|
|
||||||
return reply.code(400).send({ error: "content is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner = "default";
|
let owner = DEFAULT_OWNER;
|
||||||
if (body.owner != null && body.owner !== "") {
|
if (body.owner != null && body.owner !== "") {
|
||||||
try {
|
try {
|
||||||
owner = fsStore.assertOwner(String(body.owner));
|
owner = fsStore.assertOwner(String(body.owner));
|
||||||
@@ -266,12 +303,93 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingContext =
|
const incomingContext = reviveBinaryFromWire(
|
||||||
body.context != null &&
|
body.context != null &&
|
||||||
typeof body.context === "object" &&
|
typeof body.context === "object" &&
|
||||||
!Array.isArray(body.context)
|
!Array.isArray(body.context)
|
||||||
? 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,
|
||||||
|
data: incomingData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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 resolved = resolveScriptRef(rawName);
|
||||||
const pluginDir =
|
const pluginDir =
|
||||||
@@ -279,17 +397,15 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
? resolved.pluginDir ?? null
|
? resolved.pluginDir ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { log, logs } = createDryRunLogger();
|
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await resolveConfigRefs(body.config ?? null, {
|
const config = await resolveConfigRefs(body.config ?? null, {
|
||||||
owner,
|
owner,
|
||||||
workflowKey: "dry-run",
|
workflowKey: "dry-run",
|
||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
|
data: incomingData,
|
||||||
});
|
});
|
||||||
const ctx = {
|
const ctx = {
|
||||||
data: body.data ?? null,
|
data: incomingData,
|
||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
@@ -313,6 +429,8 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
status: "success",
|
status: "success",
|
||||||
output: safeSerialize(result.output),
|
output: safeSerialize(result.output),
|
||||||
context: safeSerialize(result.context),
|
context: safeSerialize(result.context),
|
||||||
|
wireOutput: encodeBinaryForWire(result.output),
|
||||||
|
wireContext: encodeBinaryForWire(result.context),
|
||||||
skipRemaining: result.skipRemaining,
|
skipRemaining: result.skipRemaining,
|
||||||
error: null,
|
error: null,
|
||||||
logs,
|
logs,
|
||||||
@@ -475,7 +593,5 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
void getInstalledPlugin;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
collectWorkflowWarnings,
|
collectWorkflowWarnings,
|
||||||
parseWorkflowDocument,
|
parseWorkflowDocument,
|
||||||
} from "../../workflow-validate-warnings.js";
|
} from "../../workflow-validate-warnings.js";
|
||||||
|
import { getProfilePlain } from "../../profiles-store.js";
|
||||||
|
import { resolveScriptRef } from "../../plugin-store.js";
|
||||||
import {
|
import {
|
||||||
recordRevision,
|
recordRevision,
|
||||||
listRevisions,
|
listRevisions,
|
||||||
@@ -43,6 +45,11 @@ import {
|
|||||||
createWorkflowBackupBuffer,
|
createWorkflowBackupBuffer,
|
||||||
restoreWorkflowBackup,
|
restoreWorkflowBackup,
|
||||||
} from "../../workflow-backup.js";
|
} from "../../workflow-backup.js";
|
||||||
|
import {
|
||||||
|
listExampleWorkflows,
|
||||||
|
readExampleWorkflow,
|
||||||
|
assertExampleWorkflowId,
|
||||||
|
} from "../../workflow-examples.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload this process and notify other HTTP/worker processes via Redis.
|
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||||
@@ -81,7 +88,9 @@ function scriptNames(workflow) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
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 {
|
} catch {
|
||||||
names.push(null);
|
names.push(null);
|
||||||
}
|
}
|
||||||
@@ -89,6 +98,59 @@ function scriptNames(workflow) {
|
|||||||
return names;
|
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
|
* @param {unknown} parsed
|
||||||
*/
|
*/
|
||||||
@@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) {
|
|||||||
* }} opts
|
* }} opts
|
||||||
*/
|
*/
|
||||||
async function saveWorkflowContent(opts) {
|
async function saveWorkflowContent(opts) {
|
||||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||||
const saveAnyway = Boolean(opts.saveAnyway);
|
if (parsed) {
|
||||||
|
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||||
|
}
|
||||||
|
const saveAnyway = Boolean(opts.saveAnyway);
|
||||||
|
|
||||||
if (!saveAnyway) {
|
if (!saveAnyway) {
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
@@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return { owners: fsStore.listOwners() };
|
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 () => {
|
fastify.get("/workflows/trash", async () => {
|
||||||
return { items: await listTrash() };
|
return { items: await listTrash() };
|
||||||
});
|
});
|
||||||
@@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
enabled: parsed ? parsed.enabled !== false : false,
|
enabled: parsed ? parsed.enabled !== false : false,
|
||||||
registered: registered.includes(file),
|
registered: registered.includes(file),
|
||||||
loadError: loadError ?? (parsed ? null : "unreadable"),
|
loadError: loadError ?? (parsed ? null : "unreadable"),
|
||||||
|
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
|
||||||
lastInvokedAt: st.lastInvokedAt,
|
lastInvokedAt: st.lastInvokedAt,
|
||||||
lastStatus: st.lastStatus ?? null,
|
lastStatus: st.lastStatus ?? null,
|
||||||
invocationCount: st.invocationCount,
|
invocationCount: st.invocationCount,
|
||||||
@@ -315,6 +397,13 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
|
||||||
|
});
|
||||||
return { workflows: items };
|
return { workflows: items };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -617,15 +706,14 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||||
}
|
}
|
||||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||||
if (raw == null) {
|
|
||||||
return reply.code(404).send({ error: "workflow not found" });
|
|
||||||
}
|
|
||||||
let name = null;
|
let name = null;
|
||||||
try {
|
if (raw != null) {
|
||||||
const parsed = yaml.parse(raw);
|
try {
|
||||||
name = parsed?.name ?? null;
|
const parsed = yaml.parse(raw);
|
||||||
} catch {
|
name = parsed?.name ?? null;
|
||||||
// ignore
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const item = await moveWorkflowToTrash({
|
const item = await moveWorkflowToTrash({
|
||||||
|
|||||||
@@ -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 ?? ""));
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ import usersPlugin from "./src/api/users.js";
|
|||||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||||
import runsPlugin from "./src/api/runs.js";
|
import runsPlugin from "./src/api/runs.js";
|
||||||
|
import { queryRunsFromRequest } from "./src/api/run-query.js";
|
||||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||||
import secretsPlugin from "./src/api/secrets.js";
|
import secretsPlugin from "./src/api/secrets.js";
|
||||||
import kvPlugin from "./src/api/kv.js";
|
import kvPlugin from "./src/api/kv.js";
|
||||||
import variablesPlugin from "./src/api/variables.js";
|
import variablesPlugin from "./src/api/variables.js";
|
||||||
|
import profilesPlugin from "./src/api/profiles.js";
|
||||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||||
import { WEB_DIST } from "./paths.js";
|
import { WEB_DIST } from "./paths.js";
|
||||||
@@ -168,6 +170,7 @@ export async function startApp(opts = {}) {
|
|||||||
await api.register(usersPlugin);
|
await api.register(usersPlugin);
|
||||||
await api.register(secretsPlugin);
|
await api.register(secretsPlugin);
|
||||||
await api.register(variablesPlugin);
|
await api.register(variablesPlugin);
|
||||||
|
await api.register(profilesPlugin);
|
||||||
await api.register(kvPlugin);
|
await api.register(kvPlugin);
|
||||||
await api.register(httpPagesPlugin);
|
await api.register(httpPagesPlugin);
|
||||||
await api.register(httpAuthsPlugin);
|
await api.register(httpAuthsPlugin);
|
||||||
@@ -198,16 +201,8 @@ export async function startApp(opts = {}) {
|
|||||||
"/admin/runs",
|
"/admin/runs",
|
||||||
{ onRequest: [server.authenticate] },
|
{ onRequest: [server.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
return reply.send(await queryRunsFromRequest(q));
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
import { isPlainObject } from "@jerapah-flow/shared";
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
export { isPlainObject };
|
||||||
export function isPlainObject(value) {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
|
|||||||
+103
-15
@@ -222,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 {{
|
* @param {{
|
||||||
* owner?: string,
|
* owner?: string,
|
||||||
* workflow?: string,
|
* workflow?: string,
|
||||||
* status?: string | string[],
|
* status?: string | string[],
|
||||||
* limit?: number,
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
* before?: string,
|
* before?: string,
|
||||||
* }} [filters]
|
* }} filters
|
||||||
*/
|
*/
|
||||||
export async function listRuns(filters = {}) {
|
function applyRunFilters(q, filters) {
|
||||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
if (filters.owner) q.where("owner", filters.owner);
|
||||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
|
||||||
if (filters.owner) q = q.where("owner", filters.owner);
|
|
||||||
if (filters.workflow) {
|
if (filters.workflow) {
|
||||||
const key = String(filters.workflow);
|
const key = String(filters.workflow);
|
||||||
if (key.includes("*")) {
|
if (key.includes("*")) {
|
||||||
@@ -243,25 +253,102 @@ export async function listRuns(filters = {}) {
|
|||||||
.replaceAll("%", "\\%")
|
.replaceAll("%", "\\%")
|
||||||
.replaceAll("_", "\\_")
|
.replaceAll("_", "\\_")
|
||||||
.replaceAll("*", "%");
|
.replaceAll("*", "%");
|
||||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("workflow", key);
|
q.where("workflow", key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
if (Array.isArray(filters.status)) {
|
if (Array.isArray(filters.status)) {
|
||||||
q = q.whereIn("status", filters.status);
|
q.whereIn("status", filters.status);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("status", filters.status);
|
q.where("status", filters.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||||
const rows = await q.limit(limit);
|
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||||
return rows.map((row) => ({
|
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,
|
...row,
|
||||||
input: deserialize(row.input),
|
input: deserialize(row.input),
|
||||||
output: deserialize(row.output),
|
output: deserialize(row.output),
|
||||||
}));
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* owner?: string,
|
||||||
|
* workflow?: string,
|
||||||
|
* status?: string | string[],
|
||||||
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
|
* before?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* offset?: number,
|
||||||
|
* sort?: string,
|
||||||
|
* order?: string,
|
||||||
|
* }} [filters]
|
||||||
|
*/
|
||||||
|
export async function queryRuns(filters = {}) {
|
||||||
|
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||||
|
const offset = Math.max(Number(filters.offset) || 0, 0);
|
||||||
|
|
||||||
|
let q = db("workflow_runs");
|
||||||
|
q = applyRunFilters(q, filters);
|
||||||
|
|
||||||
|
const countRow = await q.clone().count({ count: "*" }).first();
|
||||||
|
const total = Number(countRow?.count ?? 0);
|
||||||
|
|
||||||
|
let rowsQ = q.clone().select("*");
|
||||||
|
rowsQ = applyRunSort(rowsQ, filters.sort, filters.order);
|
||||||
|
const rows = await rowsQ.limit(limit).offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs: rows.map(mapRunRow),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* owner?: string,
|
||||||
|
* workflow?: string,
|
||||||
|
* status?: string | string[],
|
||||||
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
|
* before?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* }} [filters]
|
||||||
|
*/
|
||||||
|
export async function listRuns(filters = {}) {
|
||||||
|
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||||
|
let q = db("workflow_runs").select("*");
|
||||||
|
q = applyRunFilters(q, filters);
|
||||||
|
q = applyRunSort(q, "started_at", "desc");
|
||||||
|
const rows = await q.limit(limit);
|
||||||
|
return rows.map(mapRunRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -495,12 +582,13 @@ export async function listUsers() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {{ passwordHash?: string, role?: string }} patch
|
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||||
*/
|
*/
|
||||||
export async function updateUser(id, patch) {
|
export async function updateUser(id, patch) {
|
||||||
const update = { updated_at: nowIso() };
|
const update = { updated_at: nowIso() };
|
||||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||||
if (patch.role) update.role = patch.role;
|
if (patch.role) update.role = patch.role;
|
||||||
|
if (patch.username) update.username = patch.username;
|
||||||
await db("users").where({ id }).update(update);
|
await db("users").where({ id }).update(update);
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { migrate, db } from "../db.js";
|
|||||||
import { upsertSecret, deleteSecret } from "../secrets-store.js";
|
import { upsertSecret, deleteSecret } from "../secrets-store.js";
|
||||||
import { deleteVariable, upsertVariable } from "../variables-store.js";
|
import { deleteVariable, upsertVariable } from "../variables-store.js";
|
||||||
import { Secret } from "../secret-value.js";
|
import { Secret } from "../secret-value.js";
|
||||||
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js";
|
import { resolveConfigRefs } from "../config-refs.js";
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
|
|
||||||
@@ -23,126 +23,138 @@ async function assertRejects(fn, match) {
|
|||||||
throw new Error(`expected to reject (${match ?? "any error"})`);
|
throw new Error(`expected to reject (${match ?? "any error"})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = "default";
|
const owner = "config_refs_smoke_owner";
|
||||||
const workflowKey = "default/config-refs-smoke.yaml";
|
const ctx = { owner, workflowKey: `${owner}/config-refs-smoke.yaml`, context: {}, data: {} };
|
||||||
const ctx = { owner, workflowKey, context: {} };
|
|
||||||
|
|
||||||
function assertParse(value, expected) {
|
|
||||||
const got = parseConfigRef(value);
|
|
||||||
if (expected == null) {
|
|
||||||
assert(got == null, `expected null parse for ${JSON.stringify(value)}, got ${JSON.stringify(got)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assert(got != null, `expected parse for ${JSON.stringify(value)}`);
|
|
||||||
assert(got.kind === expected.kind, `kind ${got.kind} !== ${expected.kind}`);
|
|
||||||
assert(got.name === expected.name, `name ${JSON.stringify(got.name)} !== ${JSON.stringify(expected.name)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
assertParse("password123", null);
|
|
||||||
assertParse("$FOO_bar", null);
|
|
||||||
assertParse("$SECRET", null);
|
|
||||||
assertParse(" password123 ", null);
|
|
||||||
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
|
|
||||||
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
|
|
||||||
assertParse("Bearer $SECRET_x", null);
|
|
||||||
assertParse("$KV_modem password", null);
|
|
||||||
assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" });
|
|
||||||
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
|
|
||||||
assertParse("$SECRET_", { kind: "secret", name: "" });
|
|
||||||
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const literal = await resolveConfigRefs("password123", ctx);
|
const literal = await resolveConfigRefs("password123", ctx);
|
||||||
assert(literal === "password123", "literal passthrough");
|
assert(literal === "password123", "literal passthrough");
|
||||||
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
|
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
|
||||||
assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
|
assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
|
||||||
const kvLiteral = await resolveConfigRefs("$KV_modem_password", ctx);
|
|
||||||
assert(kvLiteral === "$KV_modem_password", "$KV_ stays literal");
|
|
||||||
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
|
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
|
||||||
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
|
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
|
||||||
const number = await resolveConfigRefs(42, ctx);
|
const number = await resolveConfigRefs(42, ctx);
|
||||||
assert(number === 42, "number passthrough");
|
assert(number === 42, "number passthrough");
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await upsertSecret({
|
const created = [];
|
||||||
owner,
|
|
||||||
name: "config_refs_smoke_token",
|
|
||||||
value: "s3cret-ok",
|
|
||||||
});
|
|
||||||
const varUrl = await upsertVariable({
|
|
||||||
owner,
|
|
||||||
name: "config_refs_smoke_url",
|
|
||||||
type: "string",
|
|
||||||
value: "https://example.test",
|
|
||||||
});
|
|
||||||
const varRetry = await upsertVariable({
|
|
||||||
owner,
|
|
||||||
name: "config_refs_smoke_retry",
|
|
||||||
type: "number",
|
|
||||||
value: 3,
|
|
||||||
});
|
|
||||||
const varDebug = await upsertVariable({
|
|
||||||
owner,
|
|
||||||
name: "config_refs_smoke_debug",
|
|
||||||
type: "boolean",
|
|
||||||
value: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const secret = await upsertSecret({
|
||||||
|
owner,
|
||||||
|
name: "config_refs_smoke_token",
|
||||||
|
value: "s3cret-ok",
|
||||||
|
});
|
||||||
|
created.push(["secret", secret.id]);
|
||||||
|
|
||||||
|
const varUrl = await upsertVariable({
|
||||||
|
owner,
|
||||||
|
name: "config_refs_smoke_url",
|
||||||
|
type: "string",
|
||||||
|
value: "https://example.test",
|
||||||
|
});
|
||||||
|
created.push(["var", varUrl.id]);
|
||||||
|
|
||||||
|
const varRetry = await upsertVariable({
|
||||||
|
owner,
|
||||||
|
name: "config_refs_smoke_retry",
|
||||||
|
type: "number",
|
||||||
|
value: 3,
|
||||||
|
});
|
||||||
|
created.push(["var", varRetry.id]);
|
||||||
|
|
||||||
|
const varDebug = await upsertVariable({
|
||||||
|
owner,
|
||||||
|
name: "config_refs_smoke_debug",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
created.push(["var", varDebug.id]);
|
||||||
|
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx);
|
const resolved = await resolveConfigRefs("{{ secrets.config_refs_smoke_token }}", ctx);
|
||||||
assert(resolved === "s3cret-ok", "secret resolve");
|
assert(resolved === "s3cret-ok", "secret whole-value");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx);
|
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_url }}", ctx);
|
||||||
assert(resolved === "s3cret-ok", "secret resolve trimmed");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
|
|
||||||
assert(resolved === "https://example.test", "var string");
|
assert(resolved === "https://example.test", "var string");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx);
|
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_retry }}", ctx);
|
||||||
assert(resolved === 3, "var number stays number");
|
assert(resolved === 3, "var number keeps type");
|
||||||
assert(typeof resolved === "number", "var number type");
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx);
|
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_debug }}", ctx);
|
||||||
assert(resolved === false, "var boolean stays false");
|
assert(resolved === false, "var boolean keeps type");
|
||||||
assert(typeof resolved === "boolean", "var boolean type");
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs("$CONTEXT_token", {
|
const resolved = await resolveConfigRefs("{{ context.token }}", {
|
||||||
...ctx,
|
...ctx,
|
||||||
context: { token: "ctx-token-ok" },
|
context: { token: "ctx-token-ok" },
|
||||||
});
|
});
|
||||||
assert(resolved === "ctx-token-ok", "context string");
|
assert(resolved === "ctx-token-ok", "context string");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const resolved = await resolveConfigRefs("$CONTEXT_n", {
|
const resolved = await resolveConfigRefs("{{ context.n }}", {
|
||||||
...ctx,
|
...ctx,
|
||||||
context: { n: 7 },
|
context: { n: 7 },
|
||||||
});
|
});
|
||||||
assert(resolved === "7", "context number stringify");
|
assert(resolved === 7, "context number keeps type");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const wrapped = new Secret("wrapped-secret-ok");
|
const wrapped = new Secret("wrapped-secret-ok");
|
||||||
const resolved = await resolveConfigRefs("$CONTEXT_tok", {
|
const resolved = await resolveConfigRefs("{{ context.tok }}", {
|
||||||
...ctx,
|
...ctx,
|
||||||
context: { tok: wrapped },
|
context: { tok: wrapped },
|
||||||
});
|
});
|
||||||
assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
|
assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
const resolved = await resolveConfigRefs("{{ context.user }}", {
|
||||||
|
...ctx,
|
||||||
|
context: { user: { id: "u1", role: "admin" } },
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
resolved && typeof resolved === "object" && resolved.id === "u1",
|
||||||
|
"whole-value object pass-through",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const resolved = await resolveConfigRefs("{{ context.user.id }}", {
|
||||||
|
...ctx,
|
||||||
|
context: { user: { id: "nested-id" } },
|
||||||
|
});
|
||||||
|
assert(resolved === "nested-id", "nested context path");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const resolved = await resolveConfigRefs("{{ data.items.0.id }}", {
|
||||||
|
...ctx,
|
||||||
|
data: { items: [{ id: "row-0" }] },
|
||||||
|
});
|
||||||
|
assert(resolved === "row-0", "array index path");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const resolved = await resolveConfigRefs(
|
||||||
|
"{{ vars.config_refs_smoke_url }}/{{ data.channel }}",
|
||||||
|
{ ...ctx, data: { channel: "alerts" } },
|
||||||
|
);
|
||||||
|
assert(resolved === "https://example.test/alerts", "concatenation");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const resolved = await resolveConfigRefs("Bearer {{ context.token }}", {
|
||||||
|
...ctx,
|
||||||
|
context: { token: "abc" },
|
||||||
|
});
|
||||||
|
assert(resolved === "Bearer abc", "mixed string");
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const nested = await resolveConfigRefs(
|
const nested = await resolveConfigRefs(
|
||||||
{
|
{
|
||||||
url: "$VAR_config_refs_smoke_url",
|
url: "{{ vars.config_refs_smoke_url }}",
|
||||||
retry: "$VAR_config_refs_smoke_retry",
|
retry: "{{ vars.config_refs_smoke_retry }}",
|
||||||
debug: "$VAR_config_refs_smoke_debug",
|
debug: "{{ vars.config_refs_smoke_debug }}",
|
||||||
password: "$SECRET_config_refs_smoke_token",
|
password: "{{ secrets.config_refs_smoke_token }}",
|
||||||
headers: { Authorization: "$KV_modem_password" },
|
headers: { Authorization: "$KV_modem_password" },
|
||||||
extra: ["$CONTEXT_token", "plain"],
|
extra: ["{{ context.token }}", "plain"],
|
||||||
},
|
},
|
||||||
{ ...ctx, context: { token: "ctx-token-ok" } },
|
{ ...ctx, context: { token: "ctx-token-ok" } },
|
||||||
);
|
);
|
||||||
@@ -155,59 +167,47 @@ try {
|
|||||||
assert(nested.extra[1] === "plain", "nested array literal");
|
assert(nested.extra[1] === "plain", "nested array literal");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { password: "$SECRET_config_refs_smoke_token" };
|
const data = { password: "{{ secrets.config_refs_smoke_token }}" };
|
||||||
const config = { password: "$SECRET_config_refs_smoke_token" };
|
const config = { password: "{{ secrets.config_refs_smoke_token }}" };
|
||||||
const resolvedConfig = await resolveConfigRefs(config, ctx);
|
const resolvedConfig = await resolveConfigRefs(config, ctx);
|
||||||
assert(resolvedConfig.password === "s3cret-ok", "config resolved");
|
assert(resolvedConfig.password === "s3cret-ok", "config resolved");
|
||||||
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked");
|
assert(data.password === "{{ secrets.config_refs_smoke_token }}", "data not walked");
|
||||||
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated");
|
assert(config.password === "{{ secrets.config_refs_smoke_token }}", "input config not mutated");
|
||||||
|
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx),
|
() => resolveConfigRefs("{{ secrets.does_not_exist_xyz }}", ctx),
|
||||||
'secret "does_not_exist_xyz" not found',
|
'secret "does_not_exist_xyz" not found',
|
||||||
);
|
);
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => resolveConfigRefs("$SECRET_not valid", ctx),
|
() => resolveConfigRefs("{{ context.missing }}", ctx),
|
||||||
"invalid secret name",
|
"path not found",
|
||||||
);
|
|
||||||
await assertRejects(
|
|
||||||
() => resolveConfigRefs("$SECRET_", ctx),
|
|
||||||
"invalid secret name",
|
|
||||||
);
|
|
||||||
await assertRejects(
|
|
||||||
() => resolveConfigRefs("$CONTEXT_missing", ctx),
|
|
||||||
'context "missing" not found',
|
|
||||||
);
|
);
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() =>
|
() =>
|
||||||
resolveConfigRefs("$CONTEXT_obj", {
|
resolveConfigRefs("Bearer {{ context.obj }}", {
|
||||||
...ctx,
|
...ctx,
|
||||||
context: { obj: { a: 1 } },
|
context: { obj: { a: 1 } },
|
||||||
}),
|
}),
|
||||||
'context "obj" is not a scalar',
|
"not a scalar",
|
||||||
);
|
);
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => resolveConfigRefs("$CONTEXT_", ctx),
|
() => resolveConfigRefs("{{ vars }}", ctx),
|
||||||
"empty context key",
|
"empty var name",
|
||||||
);
|
);
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx),
|
() => resolveConfigRefs("{{ title }}", ctx),
|
||||||
'variable "does_not_exist_xyz" not found',
|
"unknown root",
|
||||||
);
|
);
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => resolveConfigRefs("$VAR_not valid", ctx),
|
() => resolveConfigRefs("{{ context.__proto__.x }}", ctx),
|
||||||
"invalid variable name",
|
"forbidden path segment",
|
||||||
);
|
|
||||||
await assertRejects(
|
|
||||||
() => resolveConfigRefs("$VAR_", ctx),
|
|
||||||
"empty variable name",
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await deleteSecret(secret.id);
|
for (const [kind, id] of created.reverse()) {
|
||||||
await deleteVariable(varUrl.id);
|
if (kind === "secret") await deleteSecret(id);
|
||||||
await deleteVariable(varRetry.id);
|
else await deleteVariable(id);
|
||||||
await deleteVariable(varDebug.id);
|
}
|
||||||
|
await db.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("config-refs smoke test passed");
|
console.log("config-refs smoke test passed");
|
||||||
await db.destroy();
|
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>v1</body></html>";
|
state.body = "<html><body>v1</body></html>";
|
||||||
|
|
||||||
const first = await run({ url });
|
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(
|
assert(
|
||||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||||
"httpResponse should hold the raw body",
|
"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 });
|
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>";
|
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||||
const third = await run({ url });
|
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(
|
assert(
|
||||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||||
"fingerprintPrevious should equal the prior hash",
|
"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" };
|
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||||
|
|
||||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
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.
|
// Change only an unwatched field -> no change.
|
||||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(
|
assert(
|
||||||
second.data.hasChanges === false,
|
second.output.hasChanges === false,
|
||||||
"changing an unwatched field should not report a change",
|
"changing an unwatched field should not report a change",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change the watched field -> change.
|
// Change the watched field -> change.
|
||||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
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";
|
state.contentType = "text/html; charset=utf-8";
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
|||||||
transform: '"changed=" & $string(data.hasChanges)',
|
transform: '"changed=" & $string(data.hasChanges)',
|
||||||
});
|
});
|
||||||
assert(
|
assert(
|
||||||
withTransform.data.message === "changed=true",
|
withTransform.output.message === "changed=true",
|
||||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||||
assert(
|
assert(
|
||||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||||
"outputVar without transform should store the raw response",
|
"outputVar without transform should store the raw response",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>stable</body></html>";
|
state.body = "<html><body>stable</body></html>";
|
||||||
|
|
||||||
const first = await run({ url, skipRemaining: true });
|
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");
|
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||||
|
|
||||||
const second = await run({ url, skipRemaining: true });
|
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");
|
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||||
|
|
||||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||||
|
|||||||
@@ -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 { serialize, toDisplayValue } from "../store.js";
|
||||||
import { safeSerialize } from "../src/api/dry-run-logger.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)}`);
|
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");
|
console.log("json-preview-smoke: ok");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
|
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
|
* JFLOW_DATA_DIR=packages/server/data \
|
||||||
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||||
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||||
* node packages/server/test/plugins-smoke.js
|
* node packages/server/test/plugins-smoke.js
|
||||||
@@ -12,6 +13,7 @@ import { migrate, db } from "../db.js";
|
|||||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
|
duplicatePlugin,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
@@ -82,6 +84,26 @@ async function main() {
|
|||||||
);
|
);
|
||||||
assert.equal(blankRun.output.ok, true);
|
assert.equal(blankRun.output.ok, true);
|
||||||
|
|
||||||
|
const duplicated = duplicatePlugin("blank-smoke", "blank-smoke-copy");
|
||||||
|
assert.equal(duplicated.scriptRef, "plugin/blank-smoke-copy");
|
||||||
|
clearScriptCache();
|
||||||
|
assert.equal(resolveScriptRef("plugin/blank-smoke-copy").kind, "plugin");
|
||||||
|
const dupRun = await runScript(
|
||||||
|
"plugin/blank-smoke-copy",
|
||||||
|
{ data: 1, context: {}, config: null },
|
||||||
|
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||||
|
);
|
||||||
|
assert.equal(dupRun.output.ok, true);
|
||||||
|
|
||||||
|
let hitDupSelf = false;
|
||||||
|
try {
|
||||||
|
duplicatePlugin("blank-smoke", "blank-smoke");
|
||||||
|
} catch (err) {
|
||||||
|
hitDupSelf = true;
|
||||||
|
assert.match(String(err.message), /itself/);
|
||||||
|
}
|
||||||
|
assert.equal(hitDupSelf, true);
|
||||||
|
|
||||||
let hit = false;
|
let hit = false;
|
||||||
try {
|
try {
|
||||||
forkCoreScript("ntfy.js", "ntfy");
|
forkCoreScript("ntfy.js", "ntfy");
|
||||||
@@ -101,6 +123,7 @@ async function main() {
|
|||||||
|
|
||||||
uninstallPlugin("jsonata-smoke-fork");
|
uninstallPlugin("jsonata-smoke-fork");
|
||||||
uninstallPlugin("blank-smoke");
|
uninstallPlugin("blank-smoke");
|
||||||
|
uninstallPlugin("blank-smoke-copy");
|
||||||
uninstallPlugin("get-current-time");
|
uninstallPlugin("get-current-time");
|
||||||
|
|
||||||
console.log("plugins-smoke: ok");
|
console.log("plugins-smoke: ok");
|
||||||
|
|||||||
@@ -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: "{{ vars.ntfy_channel }}" },
|
||||||
|
description: "smoke",
|
||||||
|
});
|
||||||
|
assert(created.name === name, "created");
|
||||||
|
assert(created.config.url === "{{ vars.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");
|
||||||
@@ -136,6 +136,18 @@ const warnings = collectWorkflowWarnings(
|
|||||||
);
|
);
|
||||||
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
|
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
|
||||||
|
|
||||||
|
const ghostFile = newWorkflowFilename();
|
||||||
|
fsStore.writeRegisters(owner, [file, ghostFile]);
|
||||||
|
const ghostTrash = await moveWorkflowToTrash({
|
||||||
|
workflowId: workflowIdFromFile(ghostFile),
|
||||||
|
owner,
|
||||||
|
file: ghostFile,
|
||||||
|
name: null,
|
||||||
|
});
|
||||||
|
assert.equal(ghostTrash, null);
|
||||||
|
assert.ok(!fsStore.readRegisters(owner).includes(ghostFile));
|
||||||
|
assert.ok(fsStore.readRegisters(owner).includes(file));
|
||||||
|
|
||||||
const trashed = await moveWorkflowToTrash({
|
const trashed = await moveWorkflowToTrash({
|
||||||
workflowId,
|
workflowId,
|
||||||
owner,
|
owner,
|
||||||
|
|||||||
@@ -1,190 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/variables-store.js";
|
||||||
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,116 @@
|
|||||||
|
/**
|
||||||
|
* Production UI server (:8500).
|
||||||
|
* Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev.
|
||||||
|
* Always-on — survives Ops stop of jflow-http.
|
||||||
|
*/
|
||||||
|
import fs from "fs";
|
||||||
|
import fastify from "fastify";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { log } from "./logger.js";
|
||||||
|
import { WEB_DIST } from "./paths.js";
|
||||||
|
import {
|
||||||
|
controlOrigin,
|
||||||
|
httpOrigin,
|
||||||
|
proxyToOrigin,
|
||||||
|
} from "./ops-proxy.js";
|
||||||
|
|
||||||
|
if (!fs.existsSync(WEB_DIST)) {
|
||||||
|
log.error(
|
||||||
|
{ WEB_DIST },
|
||||||
|
"web dist missing — run `pnpm build` before starting the UI server",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(process.env.JFLOW_UI_PORT ?? 8500);
|
||||||
|
const control = controlOrigin();
|
||||||
|
const http = httpOrigin();
|
||||||
|
|
||||||
|
const server = fastify({ loggerInstance: log });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyApi(req, reply) {
|
||||||
|
const url = req.raw.url ?? "";
|
||||||
|
// Match Vite: /api/auth → control (login works when HTTP is stopped).
|
||||||
|
if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) {
|
||||||
|
return proxyToOrigin(req, reply, control, {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return proxyToOrigin(req, reply, http, {
|
||||||
|
unreachableMessage: "HTTP API unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyOps(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, control, {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyHttp(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, http, {
|
||||||
|
unreachableMessage: "HTTP API unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
server.all("/api", proxyApi);
|
||||||
|
server.all("/api/*", proxyApi);
|
||||||
|
server.all("/ops", proxyOps);
|
||||||
|
server.all("/ops/*", proxyOps);
|
||||||
|
server.all("/admin", proxyHttp);
|
||||||
|
server.all("/admin/*", proxyHttp);
|
||||||
|
server.all("/u", proxyHttp);
|
||||||
|
server.all("/u/*", proxyHttp);
|
||||||
|
|
||||||
|
await server.register(fastifyStatic, {
|
||||||
|
root: WEB_DIST,
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.raw.url ?? "";
|
||||||
|
if (
|
||||||
|
url.startsWith("/api") ||
|
||||||
|
url.startsWith("/u/") ||
|
||||||
|
url.startsWith("/admin") ||
|
||||||
|
url.startsWith("/ops")
|
||||||
|
) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
try {
|
||||||
|
await server.close();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "web-server shutdown error");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.listen({ host: "0.0.0.0", port });
|
||||||
|
log.info(
|
||||||
|
{ port, WEB_DIST, control, http },
|
||||||
|
"UI server listening (static + proxy)",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to start UI server");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -1,36 +1,14 @@
|
|||||||
import yaml from "yaml";
|
import yaml from "yaml";
|
||||||
|
import {
|
||||||
|
ensureWorkflowFilename,
|
||||||
|
suggestCopyFilename,
|
||||||
|
} from "@jerapah-flow/shared";
|
||||||
import {
|
import {
|
||||||
newWorkflowFilename,
|
newWorkflowFilename,
|
||||||
workflowFileStem,
|
workflowFileStem,
|
||||||
} from "./workflow-normalize.js";
|
} from "./workflow-normalize.js";
|
||||||
|
|
||||||
export function ensureWorkflowFilename(file) {
|
export { ensureWorkflowFilename, suggestCopyFilename };
|
||||||
const trimmed = String(file ?? "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy human-readable copy name (kept for UI hints).
|
|
||||||
* @param {string} file
|
|
||||||
* @param {string[]} existingFiles
|
|
||||||
*/
|
|
||||||
export function suggestCopyFilename(file, existingFiles = []) {
|
|
||||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
|
||||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
|
||||||
const base = match ? match[1] : name;
|
|
||||||
const ext = match ? match[2] : ".yaml";
|
|
||||||
const existing = new Set(existingFiles);
|
|
||||||
|
|
||||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
|
||||||
const root = copyMatch ? copyMatch[1] : base;
|
|
||||||
const candidate = (i) =>
|
|
||||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
|
||||||
|
|
||||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
|
||||||
while (existing.has(candidate(n))) n += 1;
|
|
||||||
return candidate(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UUID-based duplicate filename (default for new duplicates).
|
* UUID-based duplicate filename (default for new duplicates).
|
||||||
|
|||||||
@@ -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,165 @@
|
|||||||
|
import { HTTP_METHODS, DEFAULT_OWNER } 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(/^\/+/, "")}`;
|
||||||
|
// Compat: leftover webhooks still hitting /u/default/... after owner rename.
|
||||||
|
const compatUrl = url.startsWith("/u/default/")
|
||||||
|
? `/u/${DEFAULT_OWNER}/${url.slice("/u/default/".length)}`
|
||||||
|
: url === "/u/default"
|
||||||
|
? `/u/${DEFAULT_OWNER}`
|
||||||
|
: url;
|
||||||
|
const method = String(req.method ?? "GET").toUpperCase();
|
||||||
|
const routeKey = `${method} ${compatUrl}`;
|
||||||
|
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 === compatUrl;
|
||||||
|
}) ?? 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,4 +1,5 @@
|
|||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
export { namespacedPath } from "@jerapah-flow/shared";
|
||||||
|
|
||||||
export const SET_STEP_SCRIPT = "set";
|
export const SET_STEP_SCRIPT = "set";
|
||||||
|
|
||||||
@@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "script",
|
* kind: "script",
|
||||||
* script: string,
|
* script: string,
|
||||||
|
* profile: string | null,
|
||||||
* config: unknown | null,
|
* config: unknown | null,
|
||||||
* expression?: undefined,
|
* expression?: undefined,
|
||||||
|
* name: string | null,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
* when: string | null,
|
* when: string | null,
|
||||||
* }} ParsedScriptStep
|
* }} ParsedScriptStep
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "set",
|
* kind: "set",
|
||||||
* script: typeof SET_STEP_SCRIPT,
|
* script: typeof SET_STEP_SCRIPT,
|
||||||
* config: { expression: string },
|
* profile: null,
|
||||||
|
* config: { expression: string },
|
||||||
* expression: string,
|
* expression: string,
|
||||||
|
* name: string | null,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
@@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) {
|
|||||||
return await result;
|
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
|
* @param {unknown} step
|
||||||
* @returns {ParsedStep}
|
* @returns {ParsedStep}
|
||||||
@@ -85,7 +80,9 @@ export function parseScriptStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step,
|
script: step,
|
||||||
|
profile: null,
|
||||||
config: null,
|
config: null,
|
||||||
|
name: null,
|
||||||
id: null,
|
id: null,
|
||||||
needsKind: "none",
|
needsKind: "none",
|
||||||
needs: [],
|
needs: [],
|
||||||
@@ -97,25 +94,35 @@ export function parseScriptStep(step) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasScript = step.script != null && step.script !== "";
|
const hasScript = step.script != null && step.script !== "";
|
||||||
|
const hasProfile = step.profile != null && step.profile !== "";
|
||||||
const hasSet = step.set != null;
|
const hasSet = step.set != null;
|
||||||
|
|
||||||
if (hasScript && hasSet) {
|
if (hasScript && hasSet) {
|
||||||
throw new Error("Step cannot have both script and set");
|
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) {
|
if (hasSet) {
|
||||||
return parseSetStep(step);
|
return parseSetStep(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScript) {
|
if (hasProfile && typeof step.profile !== "string") {
|
||||||
if (typeof step.script !== "string") {
|
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
|
||||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
}
|
||||||
}
|
if (hasScript && typeof step.script !== "string") {
|
||||||
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasScript || hasProfile) {
|
||||||
const { needsKind, needs } = parseNeeds(step.needs);
|
const { needsKind, needs } = parseNeeds(step.needs);
|
||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step.script,
|
script: hasScript ? step.script : "",
|
||||||
|
profile: hasProfile ? step.profile : null,
|
||||||
config: step.config ?? null,
|
config: step.config ?? null,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -265,8 +272,10 @@ function parseSetStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "set",
|
kind: "set",
|
||||||
script: SET_STEP_SCRIPT,
|
script: SET_STEP_SCRIPT,
|
||||||
|
profile: null,
|
||||||
config: { expression },
|
config: { expression },
|
||||||
expression,
|
expression,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -287,6 +296,19 @@ function parseWhen(when) {
|
|||||||
return 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
|
* @param {unknown} id
|
||||||
* @returns {string | null}
|
* @returns {string | null}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
|
|||||||
/**
|
/**
|
||||||
* @param {string} deletedAtIso
|
* @param {string} deletedAtIso
|
||||||
*/
|
*/
|
||||||
export function trashAgeMs(deletedAtIso) {
|
function trashAgeMs(deletedAtIso) {
|
||||||
return Date.now() - Date.parse(deletedAtIso);
|
return Date.now() - Date.parse(deletedAtIso);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,29 +62,49 @@ export async function isInTrash(owner, file) {
|
|||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unregisterWorkflow(owner, file) {
|
||||||
|
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
||||||
|
fsStore.writeRegisters(owner, registered);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
|
* Soft-delete: unregister, move YAML to trash when present, keep revision history.
|
||||||
|
* Missing YAML (ghost register entries) is still unregistered so it leaves the list.
|
||||||
* @param {{
|
* @param {{
|
||||||
* workflowId: string,
|
* workflowId: string,
|
||||||
* owner: string,
|
* owner: string,
|
||||||
* file: string,
|
* file: string,
|
||||||
* name?: string | null,
|
* name?: string | null,
|
||||||
* }} opts
|
* }} opts
|
||||||
|
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
|
||||||
*/
|
*/
|
||||||
export async function moveWorkflowToTrash(opts) {
|
export async function moveWorkflowToTrash(opts) {
|
||||||
|
unregisterWorkflow(opts.owner, opts.file);
|
||||||
|
|
||||||
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
||||||
if (!fs.existsSync(sourcePath)) {
|
if (!fs.existsSync(sourcePath)) {
|
||||||
const err = new Error("workflow not found");
|
const existing = await db("workflow_trash")
|
||||||
err.statusCode = 404;
|
.where({ owner: opts.owner, file: opts.file })
|
||||||
throw err;
|
.first();
|
||||||
|
return existing ? rowToItem(existing) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trashPath = trashFilePath(opts.owner, opts.file);
|
const trashPath = trashFilePath(opts.owner, opts.file);
|
||||||
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
||||||
fs.renameSync(sourcePath, trashPath);
|
fs.renameSync(sourcePath, trashPath);
|
||||||
|
|
||||||
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
|
const existing = await db("workflow_trash")
|
||||||
fsStore.writeRegisters(opts.owner, registered);
|
.where({ owner: opts.owner, file: opts.file })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
await db("workflow_trash").where({ id: existing.id }).update({
|
||||||
|
workflow_id: opts.workflowId,
|
||||||
|
name: opts.name ?? existing.name ?? null,
|
||||||
|
deleted_at: nowIso(),
|
||||||
|
trash_path: trashPath,
|
||||||
|
});
|
||||||
|
return rowToItem(await db("workflow_trash").where({ id: existing.id }).first());
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const deleted_at = nowIso();
|
const deleted_at = nowIso();
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
|||||||
try {
|
try {
|
||||||
const step = parseScriptStep(raw);
|
const step = parseScriptStep(raw);
|
||||||
if (step.kind === "set") continue;
|
if (step.kind === "set") continue;
|
||||||
|
if (step.profile && !step.script) continue;
|
||||||
const resolved = resolveScriptRef(step.script);
|
const resolved = resolveScriptRef(step.script);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ scripts:
|
|||||||
- script: fetch-binary.js
|
- script: fetch-binary.js
|
||||||
- script: ntfy.js
|
- script: ntfy.js
|
||||||
config:
|
config:
|
||||||
url: https://ntfy.sh/jerapah-flow
|
url: $VAR_ntfy_channel
|
||||||
triggers:
|
triggers:
|
||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Joplin nightly daily log
|
|
||||||
description: |
|
|
||||||
POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy.
|
|
||||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3040/api/logs/run
|
|
||||||
method: POST
|
|
||||||
token: $SECRET_joplin_setup_token
|
|
||||||
timeoutMs: 600000
|
|
||||||
- set:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.ok ? "Bullet journal" : "Bullet journal failed",
|
|
||||||
"message": data.ok
|
|
||||||
? "The bullet journal for " & data.httpResponse.date & " has been created."
|
|
||||||
: data.message
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "0 4 * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-daily
|
|
||||||
enabled: false
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: Joplin get note
|
|
||||||
description: |
|
|
||||||
GET a Joplin note by id from joplin-api. Input (data): id (32-char hex).
|
|
||||||
Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3030/notes
|
|
||||||
method: GET
|
|
||||||
token: $SECRET_joplin_api_token
|
|
||||||
timeoutMs: 60000
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-get-note
|
|
||||||
enabled: false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: Joplin nightly sync
|
|
||||||
description: |
|
|
||||||
POST joplin-auto /api/sync at 03:00, then ntfy success or failure.
|
|
||||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3040/api/sync
|
|
||||||
method: POST
|
|
||||||
token: $SECRET_joplin_setup_token
|
|
||||||
timeoutMs: 600000
|
|
||||||
- set:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.ok ? "Joplin sync ok" : "Joplin sync failed",
|
|
||||||
"message": data.message
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "0 3 * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-sync
|
|
||||||
enabled: false
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: dev-zte-sms
|
|
||||||
description: |
|
|
||||||
Send an SMS via a ZTE modem web UI.
|
|
||||||
Input (data): to, message
|
|
||||||
Password is the named secret zte_modem_password ($SECRET_).
|
|
||||||
scripts:
|
|
||||||
- script: plugin/send-sms
|
|
||||||
config:
|
|
||||||
url: http://192.168.5.1/reqproc/proc_post
|
|
||||||
password: $SECRET_sms_secret
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-zte-sms
|
|
||||||
auth:
|
|
||||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
|
||||||
@@ -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:
|
scripts:
|
||||||
- time-to-ntfy-example.yaml
|
|
||||||
- test.yaml
|
|
||||||
- cron-example.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
|
- 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,40 +0,0 @@
|
|||||||
name: send-gmail
|
|
||||||
description: |
|
|
||||||
Send email via Gmail SMTP. Configure user/from and the named secret,
|
|
||||||
then call from other workflows with trigger-workflow.js (name: send-gmail).
|
|
||||||
|
|
||||||
Input (data):
|
|
||||||
to optional recipient(s); defaults to your Gmail below
|
|
||||||
subject required
|
|
||||||
text plain body (aliases: body, message)
|
|
||||||
html optional HTML body
|
|
||||||
from, cc, bcc, replyTo, priority, headers optional
|
|
||||||
scripts:
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
|
|
||||||
"from": data.from,
|
|
||||||
"subject": data.subject,
|
|
||||||
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
|
|
||||||
"html": data.html,
|
|
||||||
"cc": data.cc,
|
|
||||||
"bcc": data.bcc,
|
|
||||||
"replyTo": data.replyTo,
|
|
||||||
"priority": data.priority,
|
|
||||||
"headers": data.headers
|
|
||||||
}
|
|
||||||
- script: send-email.js
|
|
||||||
config:
|
|
||||||
service: Gmail
|
|
||||||
user: elevent16th@gmail.com
|
|
||||||
from: elevent16th@gmail.com
|
|
||||||
passwordSecret: gmail_app_password
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /send-gmail
|
|
||||||
auth:
|
|
||||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
|
||||||
@@ -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:
|
|
||||||
- plugin/get-current-time
|
|
||||||
- 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: plugin/get-current-time
|
|
||||||
|
|
||||||
- id: comic
|
|
||||||
script: fetch-html.js
|
|
||||||
config:
|
|
||||||
url: "https://www.monkeyuser.com/"
|
|
||||||
outputVar: "httpResponse"
|
|
||||||
selector: ".comic img"
|
|
||||||
jsonata: |
|
|
||||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
|
||||||
|
|
||||||
- id: compose
|
|
||||||
script: jsonata.js
|
|
||||||
needs: [time, comic]
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.comic.httpResponse.title,
|
|
||||||
"message": data.time.datetime & " " & data.comic.httpResponse.title,
|
|
||||||
"attach": data.comic.httpResponse.url
|
|
||||||
}
|
|
||||||
|
|
||||||
- id: notify
|
|
||||||
script: ntfy.js
|
|
||||||
needs: [compose]
|
|
||||||
config:
|
|
||||||
url: https://ntfy.sh/jerapah-flow
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /time-and-comic
|
|
||||||
@@ -6,9 +6,7 @@ scripts:
|
|||||||
- script: plugin/get-current-time
|
- script: plugin/get-current-time
|
||||||
config:
|
config:
|
||||||
key: ""
|
key: ""
|
||||||
- script: ntfy.js
|
- profile: ntfy_default
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
triggers:
|
||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Track Gojek Trip
|
|
||||||
scripts:
|
|
||||||
- script: fetch-http.js
|
|
||||||
config:
|
|
||||||
url: https://api.gojekapi.com/live/track/491c3132c2cc7043
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept: application/json
|
|
||||||
Origin: https://track.gojek.com
|
|
||||||
- script: fingerprint.js
|
|
||||||
config:
|
|
||||||
key: gojek-track-status
|
|
||||||
jsonata: data.httpResponse.status
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.httpResponse.customer_name,
|
|
||||||
"message": data.httpResponse.customer_name & " : " & data.httpResponse.status
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "* * * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: GET
|
|
||||||
path: /new
|
|
||||||
enabled: false
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
name: Dab 0dev
|
|
||||||
scripts:
|
|
||||||
- script: list-webdav.js
|
|
||||||
config:
|
|
||||||
url: https://dav.0dev.web.id/books
|
|
||||||
path: /
|
|
||||||
includeDirectories: true
|
|
||||||
recursive: false
|
|
||||||
username: nsrb
|
|
||||||
passwordSecret: dav_0dev_password
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /new
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@jerapah-flow/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user