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/
|
||||
.pnpm-store/
|
||||
# Instance data (SQLite, live workflows, control-state, backups)
|
||||
data/
|
||||
# Process logs
|
||||
logs/
|
||||
*.db
|
||||
*.db-*
|
||||
@@ -9,6 +12,11 @@ packages/web/dist
|
||||
# Personal/local scripts and workflows (not for the repo)
|
||||
debug-*.js
|
||||
|
||||
# Legacy live workflow trees
|
||||
packages/server/workflows/
|
||||
|
||||
# Plugin install staging and per-plugin deps
|
||||
plugins/.staging-*
|
||||
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).
|
||||
|
||||
## 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
|
||||
|
||||
| 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.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` |
|
||||
| `context` | Next clipboard. Omit to keep incoming |
|
||||
|
||||
@@ -127,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
- script: plugin/my-plugin
|
||||
- name: Notify to channel
|
||||
script: plugin/my-plugin
|
||||
config:
|
||||
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
|
||||
|
||||
@@ -141,4 +153,4 @@ Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
|
||||
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
|
||||
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
|
||||
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -36,16 +42,29 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
| Kind | Name in YAML | Editable | Location |
|
||||
|---|---|---|---|
|
||||
| **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"`.
|
||||
- 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`.
|
||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
||||
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
||||
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
|
||||
- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`.
|
||||
|
||||
## Workflows (instance data vs examples)
|
||||
|
||||
| Kind | Loaded by runner? | Location |
|
||||
|---|---|---|
|
||||
| **Live workflows** | Yes | `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
|
||||
# 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_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
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.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` |
|
||||
| `context` | Next snapshot of the bag. Omitted → keep incoming |
|
||||
| `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.
|
||||
|
||||
### 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.
|
||||
|
||||
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:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||
| `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:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) |
|
||||
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||
| `pnpm start:worker` | BullMQ worker only |
|
||||
| `pnpm migrate` | Apply SQLite migrations |
|
||||
@@ -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 |
|
||||
| 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
|
||||
|
||||
@@ -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_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_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. |
|
||||
@@ -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_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
||||
| `JFLOW_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. |
|
||||
| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). |
|
||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
||||
| `PORT` | `8700` | HTTP API port |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
|
||||
| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
|
||||
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
|
||||
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
|
||||
|
||||
@@ -131,14 +177,32 @@ Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 {
|
||||
|
||||
## Production
|
||||
|
||||
Control-plane topology (same ports as `pnpm dev:pm2`):
|
||||
|
||||
| Process | Port | Role |
|
||||
|---|---|---|
|
||||
| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 |
|
||||
| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers |
|
||||
| `jflow-http` | **8700** | API + cron enqueue (managed by control) |
|
||||
| `jflow-worker` | — | BullMQ workers (managed by control) |
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
||||
# Or monolith (dev-style):
|
||||
# ... pnpm start
|
||||
# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
|
||||
# Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
|
||||
# against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
|
||||
pnpm start:pm2
|
||||
# UI: http://localhost:8500
|
||||
# If you already mixed versions: pnpm pm2 -- kill && pnpm start:pm2
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
+38
-6
@@ -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 path = require("path");
|
||||
|
||||
@@ -23,21 +33,43 @@ function loadEnv(file) {
|
||||
}
|
||||
|
||||
const root = __dirname;
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
...loadEnv(path.join(root, ".env")),
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "jerapah-flow",
|
||||
name: "jflow-control",
|
||||
cwd: root,
|
||||
script: "packages/server/runner.js",
|
||||
interpreter: "node",
|
||||
script: "packages/server/control.js",
|
||||
interpreter: process.execPath,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 8,
|
||||
restart_delay: 2000,
|
||||
env: {
|
||||
...env,
|
||||
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "jflow-web",
|
||||
cwd: root,
|
||||
script: "packages/server/web-server.js",
|
||||
interpreter: process.execPath,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 20,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
...loadEnv(path.join(root, ".env")),
|
||||
...env,
|
||||
JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500",
|
||||
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
|
||||
JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||
"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",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"rss-parser": "^3.13.0"
|
||||
}
|
||||
"packageManager": "pnpm@11.22.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
import { REPO_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.
|
||||
@@ -75,5 +74,3 @@ export function satisfiesRange(version, range) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void fileURLToPath;
|
||||
|
||||
+156
-72
@@ -3,36 +3,24 @@ import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
|
||||
import { isSecret } from "./secret-value.js";
|
||||
import { assertVariableName, getVariablePlain } from "./variables-store.js";
|
||||
|
||||
const PREFIXES = [
|
||||
{ kind: "context", prefix: "$CONTEXT_" },
|
||||
{ kind: "secret", prefix: "$SECRET_" },
|
||||
{ kind: "var", prefix: "$VAR_" },
|
||||
];
|
||||
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
const MUSTACHE_TOKEN_RE =
|
||||
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
|
||||
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 {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
|
||||
* @typedef {{
|
||||
* owner: string,
|
||||
* workflowKey?: string,
|
||||
* context?: unknown,
|
||||
* data?: unknown,
|
||||
* }} ConfigRefCtx
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a whole-value config placeholder. Returns null for literals.
|
||||
* @param {unknown} value
|
||||
* @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.
|
||||
* Walk config (objects/arrays) and interpolate `{{ path }}` strings.
|
||||
* Does not walk trigger data.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
@@ -68,83 +56,179 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async function resolveStringRef(value, ctx) {
|
||||
const ref = parseConfigRef(value);
|
||||
if (!ref) return value;
|
||||
const whole = WHOLE_MUSTACHE_RE.exec(value);
|
||||
if (whole && whole[0] === value) {
|
||||
return resolvePath(whole[1], ctx, { raw: value, allowObject: true });
|
||||
}
|
||||
|
||||
if (ref.kind === "secret") {
|
||||
return resolveSecretRef(ref, ctx);
|
||||
if (!value.includes("{{")) {
|
||||
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
|
||||
* @returns {Promise<string>}
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
async function resolveSecretRef(ref, ctx) {
|
||||
try {
|
||||
assertSecretName(ref.name);
|
||||
} catch {
|
||||
throw new Error(`config ref ${ref.raw}: invalid secret name`);
|
||||
async function resolvePath(pathExpr, ctx, opts) {
|
||||
const segments = pathExpr.split(".");
|
||||
if (segments.length === 0 || segments.some((s) => !s)) {
|
||||
throw new Error(`config ref ${opts.raw}: empty path`);
|
||||
}
|
||||
const plaintext = await getSecretPlaintext(ctx.owner, ref.name);
|
||||
if (plaintext == null) {
|
||||
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`);
|
||||
for (const seg of segments) {
|
||||
if (FORBIDDEN_SEGMENTS.has(seg)) {
|
||||
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
|
||||
* @returns {Promise<string | number | boolean>}
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
async function resolveVarRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty variable name`);
|
||||
async function resolveNamedStore(kind, rest, ctx, opts) {
|
||||
if (rest.length === 0) {
|
||||
throw new Error(`config ref ${opts.raw}: empty ${kind} name`);
|
||||
}
|
||||
const name = rest.join(".");
|
||||
try {
|
||||
assertVariableName(ref.name);
|
||||
if (kind === "secret") assertSecretName(name);
|
||||
else assertVariableName(name);
|
||||
} 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConfigRef} ref
|
||||
* @param {ConfigRefCtx} ctx
|
||||
* @returns {string}
|
||||
* @param {unknown} root
|
||||
* @param {string[]} rest
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
function resolveContextRef(ref, ctx) {
|
||||
if (ref.name.length === 0) {
|
||||
throw new Error(`config ref ${ref.raw}: empty context key`);
|
||||
function walkObject(root, rest, opts) {
|
||||
if (rest.length === 0) {
|
||||
return unwrapValue(root, opts);
|
||||
}
|
||||
const bag =
|
||||
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)
|
||||
? /** @type {Record<string, unknown>} */ (ctx.context)
|
||||
: {};
|
||||
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
|
||||
|
||||
let cur = root;
|
||||
for (const seg of rest) {
|
||||
if (cur == null || typeof cur !== "object") {
|
||||
throw new Error(`config ref ${opts.raw}: path 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];
|
||||
if (isSecret(raw)) {
|
||||
return raw.reveal();
|
||||
return unwrapValue(cur, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {{ raw: string, allowObject: boolean }} opts
|
||||
*/
|
||||
function unwrapValue(value, opts) {
|
||||
if (isSecret(value)) {
|
||||
return value.reveal();
|
||||
}
|
||||
const coerced = coerceCredentialString(raw);
|
||||
if (coerced == null) {
|
||||
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`);
|
||||
if (!opts.allowObject && value != null && typeof value === "object") {
|
||||
throw new Error(`config ref ${opts.raw}: value 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} */
|
||||
export function defaultControlState() {
|
||||
function defaultControlState() {
|
||||
return {
|
||||
http: "running",
|
||||
workers: 1,
|
||||
|
||||
+21
-18
@@ -64,13 +64,6 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function applyDesiredState() {
|
||||
const state = readControlState();
|
||||
await ensureHttp({
|
||||
@@ -98,8 +91,6 @@ async function applyDesiredState() {
|
||||
);
|
||||
}
|
||||
|
||||
await applyDesiredState();
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
await server.register(cookie);
|
||||
await server.register(jwt, {
|
||||
@@ -484,12 +475,24 @@ process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
||||
server
|
||||
.listen({ host: "0.0.0.0", port })
|
||||
.then(() => {
|
||||
log.info(`Control is running on port ${port}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "failed to start control");
|
||||
process.exit(1);
|
||||
});
|
||||
try {
|
||||
await server.listen({ host: "0.0.0.0", port });
|
||||
log.info(`Control is running on port ${port}`);
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to start control");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectPm2();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await applyDesiredState();
|
||||
} catch (err) {
|
||||
log.error({ err }, "failed to apply desired state");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* 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");
|
||||
|
||||
|
||||
+27
-32
@@ -49,14 +49,8 @@ export function readScript(name) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
export function writeScript(name, content) {
|
||||
assertScriptName(name);
|
||||
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
|
||||
* @returns {{ filePath: string, contentType: string } | null}
|
||||
*/
|
||||
export function resolveScriptIcon(name) {
|
||||
@@ -65,6 +59,7 @@ export function resolveScriptIcon(name) {
|
||||
for (const { ext, contentType } of [
|
||||
{ ext: "png", contentType: "image/png" },
|
||||
{ ext: "jpg", contentType: "image/jpeg" },
|
||||
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||
]) {
|
||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
@@ -78,22 +73,6 @@ export function scriptHasIcon(name) {
|
||||
return resolveScriptIcon(name) != null;
|
||||
}
|
||||
|
||||
export function deleteScript(name) {
|
||||
assertScriptName(name);
|
||||
const filePath = path.join(SCRIPTS_DIR, name);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const icon = resolveScriptIcon(name);
|
||||
fs.unlinkSync(filePath);
|
||||
if (icon) {
|
||||
try {
|
||||
fs.unlinkSync(icon.filePath);
|
||||
} catch {
|
||||
// ignore missing icon
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listOwners() {
|
||||
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
||||
return fs
|
||||
@@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Last content change time for a workflow YAML file.
|
||||
* Uses birthtime (creation) when the file has not been modified since it was created.
|
||||
* @returns {string | null} ISO timestamp
|
||||
*/
|
||||
export function workflowLastModifiedAt(owner, file) {
|
||||
try {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
||||
try {
|
||||
const st = fs.statSync(filePath);
|
||||
const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
|
||||
const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null;
|
||||
const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000);
|
||||
const ms = unmodified ? birthMs : (mtimeMs ?? birthMs);
|
||||
return ms != null ? new Date(ms).toISOString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowYaml(owner, file, content) {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
@@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) {
|
||||
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
|
||||
}
|
||||
|
||||
export function deleteWorkflowYaml(owner, file) {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listOwnerYamlFiles(owner) {
|
||||
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
||||
if (!fs.existsSync(ownerDir)) return [];
|
||||
|
||||
@@ -1,390 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertHttpStatus } from "./http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const 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;
|
||||
}
|
||||
export * from "./src/stores/http-auths-store.js";
|
||||
|
||||
@@ -11,16 +11,21 @@ export function isBinary(value) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function toBuffer(value) {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function summarizeBinary(value) {
|
||||
const buf = Buffer.isBuffer(value)
|
||||
? value
|
||||
: value instanceof ArrayBuffer
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
const buf = toBuffer(value);
|
||||
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
||||
return {
|
||||
type: "Buffer",
|
||||
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function encodeBinary(value) {
|
||||
const buf = toBuffer(value);
|
||||
return {
|
||||
type: "Buffer",
|
||||
encoding: "base64",
|
||||
data: buf.toString("base64"),
|
||||
length: buf.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isWireBuffer(value) {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
|
||||
if (obj.type !== "Buffer") return false;
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
|
||||
return Array.isArray(obj.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace live Buffers with reconstructable JSON (for dry-run responses).
|
||||
* Display still uses summarizeBinary / safeSerialize.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function encodeBinaryForWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (isBinary(v)) return encodeBinary(v);
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
|
||||
return out;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`.
|
||||
* Preview-only summaries (`preview` / `truncated`, no payload) are left as-is.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function reviveBinaryFromWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (isWireBuffer(v)) {
|
||||
const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v);
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") {
|
||||
return Buffer.from(obj.data, "base64");
|
||||
}
|
||||
return Buffer.from(/** @type {number[]} */ (obj.data));
|
||||
}
|
||||
if (seen.has(v)) return v;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (let i = 0; i < v.length; i++) v[i] = walk(v[i]);
|
||||
return v;
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (v);
|
||||
for (const k of Object.keys(obj)) obj[k] = walk(obj[k]);
|
||||
return obj;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify replacer. Must be a real function so `this` is the holder:
|
||||
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
||||
|
||||
+1
-399
@@ -1,399 +1 @@
|
||||
import { db } from "./db.js";
|
||||
|
||||
const MAX_KEY_LENGTH = 512;
|
||||
const MAX_NAMESPACE_LENGTH = 512;
|
||||
const MAX_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
const MAX_LIST_LIMIT = 500;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function valuesEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function assertString(label, value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {number} max
|
||||
*/
|
||||
function assertMaxLength(label, value, max) {
|
||||
if (value.length > max) {
|
||||
throw new Error(`${label} must be at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
*/
|
||||
function assertNamespace(namespace) {
|
||||
assertString("namespace", namespace);
|
||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function assertKey(key) {
|
||||
assertString("key", key);
|
||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeKvValue(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw new Error("value must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function deserializeKvValue(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ expires_at?: string | null }} row
|
||||
*/
|
||||
function isExpired(row) {
|
||||
if (!row.expires_at) return false;
|
||||
return Date.parse(row.expires_at) <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function kvGet(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const row = await db("script_state").where({ namespace, key }).first();
|
||||
if (!row) return null;
|
||||
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key }).del();
|
||||
return null;
|
||||
}
|
||||
|
||||
return deserializeKvValue(row.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
export async function kvSet(namespace, key, value, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const json = serializeKvValue(value);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
await db("script_state")
|
||||
.insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
})
|
||||
.onConflict(["namespace", "key"])
|
||||
.merge({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function kvDelete(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
||||
*/
|
||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx("script_state").where({ namespace, key }).first();
|
||||
|
||||
if (row && isExpired(row)) {
|
||||
await trx("script_state").where({ namespace, key }).del();
|
||||
}
|
||||
|
||||
const currentRow =
|
||||
row && !isExpired(row)
|
||||
? row
|
||||
: await trx("script_state").where({ namespace, key }).first();
|
||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
||||
|
||||
if (!valuesEqual(previous, expected)) {
|
||||
return { ok: false, previous };
|
||||
}
|
||||
|
||||
const json = serializeKvValue(next);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await trx("script_state").where({ namespace, key }).update({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
} else {
|
||||
await trx("script_state").insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, previous };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {{ limit?: number }} [opts]
|
||||
*/
|
||||
export async function kvList(namespace, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
const limit = Math.min(
|
||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
||||
MAX_LIST_LIMIT,
|
||||
);
|
||||
|
||||
const rows = await db("script_state")
|
||||
.where({ namespace })
|
||||
.orderBy("updated_at", "desc")
|
||||
.limit(limit);
|
||||
|
||||
const items = [];
|
||||
for (const row of rows) {
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key: row.key }).del();
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function escapeLike(value) {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
async function pruneExpiredKv() {
|
||||
await db("script_state")
|
||||
.whereNotNull("expires_at")
|
||||
.andWhere("expires_at", "<=", nowIso())
|
||||
.del();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* namespace?: string,
|
||||
* q?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* }} [opts]
|
||||
*/
|
||||
export async function kvQuery(opts = {}) {
|
||||
await pruneExpiredKv();
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
||||
|
||||
let q = db("script_state");
|
||||
if (opts.namespace) {
|
||||
assertNamespace(opts.namespace);
|
||||
q = q.where({ namespace: opts.namespace });
|
||||
}
|
||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
||||
const like = `%${escapeLike(opts.q)}%`;
|
||||
q = q.where(function likeSearch() {
|
||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
||||
"value LIKE ? ESCAPE '\\'",
|
||||
[like],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
const rows = await q
|
||||
.clone()
|
||||
.orderBy("updated_at", "desc")
|
||||
.orderBy("namespace", "asc")
|
||||
.orderBy("key", "asc")
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
namespace: row.namespace,
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function kvNamespaces() {
|
||||
await pruneExpiredKv();
|
||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
||||
return rows.map((row) => row.namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultNamespace
|
||||
*/
|
||||
export function createKvApi(defaultNamespace) {
|
||||
assertNamespace(defaultNamespace);
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
function resolveNamespace(opts = {}) {
|
||||
const namespace = opts.namespace ?? defaultNamespace;
|
||||
assertNamespace(namespace);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
get(key, opts) {
|
||||
return kvGet(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
set(key, value, opts) {
|
||||
const { namespace, expiresAt } = opts ?? {};
|
||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
delete(key, opts) {
|
||||
return kvDelete(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
compareAndSet(key, expected, next, opts) {
|
||||
const { expiresAt } = opts ?? {};
|
||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
||||
*/
|
||||
list(opts) {
|
||||
const { namespace, limit } = opts ?? {};
|
||||
return kvList(resolveNamespace(opts), { limit });
|
||||
},
|
||||
};
|
||||
}
|
||||
export * from "./src/stores/kv-store.js";
|
||||
|
||||
@@ -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("error");
|
||||
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(
|
||||
@@ -28,45 +31,11 @@ export async function up(knex) {
|
||||
await knex.schema.raw(
|
||||
"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(
|
||||
"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(
|
||||
"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
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("logs");
|
||||
await knex.schema.dropTableIfExists("step_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
|
||||
*/
|
||||
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.integer("system").notNullable().defaultTo(0);
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = await knex("http_pages").where({ name: "email-default" }).first();
|
||||
if (!existing) {
|
||||
await knex("http_pages").insert({
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "email-default",
|
||||
content: DEFAULT_EMAIL_TEMPLATE,
|
||||
mime: "html",
|
||||
status: 200,
|
||||
kind: "template",
|
||||
system: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
await knex("http_pages").insert({
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "email-default",
|
||||
content: DEFAULT_EMAIL_TEMPLATE,
|
||||
mime: "html",
|
||||
status: 200,
|
||||
kind: "template",
|
||||
system: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex("http_pages").where({ name: "email-default", system: 1 }).del();
|
||||
await knex.schema.alterTable("http_pages", (t) => {
|
||||
t.dropColumn("kind");
|
||||
t.dropColumn("system");
|
||||
});
|
||||
await knex.schema.dropTableIfExists("http_pages");
|
||||
}
|
||||
+2
-13
@@ -2,21 +2,11 @@
|
||||
* @param {import("knex").Knex} 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) => {
|
||||
t.text("id").primary();
|
||||
t.text("name").notNullable().unique();
|
||||
t.text("type").notNullable(); // bearer | basic | header
|
||||
t.text("config").notNullable(); // JSON
|
||||
t.text("type").notNullable();
|
||||
t.text("config").notNullable();
|
||||
t.integer("unauthorized_status").nullable();
|
||||
t.text("unauthorized_response").nullable();
|
||||
t.text("created_at").notNullable();
|
||||
@@ -29,5 +19,4 @@ export async function up(knex) {
|
||||
*/
|
||||
export async function down(knex) {
|
||||
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(
|
||||
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)",
|
||||
);
|
||||
|
||||
await knex.schema.createTable("workflow_trash", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("workflow_id").notNullable();
|
||||
t.text("owner").notNullable();
|
||||
t.text("file").notNullable();
|
||||
t.text("name");
|
||||
t.text("deleted_at").notNullable();
|
||||
t.text("trash_path").notNullable();
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
|
||||
);
|
||||
await knex.schema.raw(
|
||||
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("workflow_trash");
|
||||
await knex.schema.dropTableIfExists("workflow_revisions");
|
||||
}
|
||||
@@ -0,0 +1,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:worker": "node worker.js",
|
||||
"start:control": "node control.js",
|
||||
"start:web": "node web-server.js",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||
"test:workflow-history": "node test/workflow-history-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": "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": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -37,7 +43,8 @@
|
||||
"nodemailer": "^9.0.5",
|
||||
"pino": "^10.3.1",
|
||||
"pino-roll": "^4.0.0",
|
||||
"pm2": "^6.0.13",
|
||||
"pm2": "6.0.14",
|
||||
"rss-parser": "^3.13.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"webdav": "^5.10.0",
|
||||
"yaml": "^2.9.0"
|
||||
|
||||
+43
-12
@@ -1,18 +1,49 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "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 WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||
export const PLUGINS_DIR =
|
||||
process.env.JFLOW_PLUGINS_DIR ??
|
||||
path.resolve(SERVER_ROOT, "../../plugins");
|
||||
/** Example plugin sources shipped with the repo. */
|
||||
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/plugins",
|
||||
|
||||
/** Prefer `preferred` unless only `legacy` already has files. */
|
||||
function existingDir(preferred, legacy, probe) {
|
||||
const has = (dir) =>
|
||||
probe ? probe(dir) : fs.existsSync(dir);
|
||||
if (has(preferred) || !has(legacy)) return preferred;
|
||||
return legacy;
|
||||
}
|
||||
|
||||
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");
|
||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
||||
/** Live instance workflows (not shipped in git). Override for tests. */
|
||||
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,
|
||||
pluginDir,
|
||||
} from "./plugin-store.js";
|
||||
import { readManifestFile } from "./plugin-manifest.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) {
|
||||
});
|
||||
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
|
||||
* @returns {((id: string) => unknown) | null}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import path from "path";
|
||||
import pm2 from "pm2";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||
import { REPO_ROOT, SERVER_ROOT } from "./paths.js";
|
||||
|
||||
export const PM2_HTTP_NAME = "jflow-http";
|
||||
export const PM2_WORKER_NAME = "jflow-worker";
|
||||
@@ -164,13 +162,57 @@ export async function restartPm2Process(pmId) {
|
||||
return { name: proc.name, pmId: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* PM2 injects these into process.env of a managed app. Spreading them into
|
||||
* `pm2.start({ env })` overwrites `name` / `pm_exec_path` so God restarts
|
||||
* jflow-control instead of launching http/worker (EADDRINUSE :8600 loop).
|
||||
*/
|
||||
const PM2_META_KEYS = new Set([
|
||||
"name",
|
||||
"namespace",
|
||||
"exec_mode",
|
||||
"exec_interpreter",
|
||||
"instances",
|
||||
"instance_var",
|
||||
"node_app_instance",
|
||||
"unique_id",
|
||||
"status",
|
||||
"username",
|
||||
"windowsHide",
|
||||
"merge_logs",
|
||||
"vizion",
|
||||
"vizion_running",
|
||||
"autostart",
|
||||
"autorestart",
|
||||
"automation",
|
||||
"km_link",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env
|
||||
* @returns {NodeJS.ProcessEnv}
|
||||
*/
|
||||
export function withoutPm2Meta(env) {
|
||||
/** @type {NodeJS.ProcessEnv} */
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(env)) {
|
||||
if (val == null) continue;
|
||||
if (PM2_META_KEYS.has(key)) continue;
|
||||
if (key.startsWith("pm_") || key.startsWith("axm_") || key.startsWith("PM2_")) {
|
||||
continue;
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared env for child processes.
|
||||
* @param {{ generation: number }} opts
|
||||
*/
|
||||
export function childEnv(opts) {
|
||||
return {
|
||||
...process.env,
|
||||
...withoutPm2Meta(process.env),
|
||||
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
||||
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||
|
||||
@@ -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,
|
||||
} from "./step-result.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 { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||
import {
|
||||
createHttpTriggerHandler,
|
||||
ensureHttpWildcardRoute,
|
||||
rebuildHttpRoutes,
|
||||
} from "./workflow-http-routes.js";
|
||||
import { getProfilePlain } from "./profiles-store.js";
|
||||
import {
|
||||
buildFailureAlertData,
|
||||
resolveFailureTriggerConfig,
|
||||
@@ -46,17 +46,6 @@ import { publishReload } from "./control-bus.js";
|
||||
*/
|
||||
|
||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
function hasWorkflowTrigger(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return false;
|
||||
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
|
||||
.triggers;
|
||||
return (triggers ?? []).some((t) => t?.type === "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} server
|
||||
@@ -80,7 +69,14 @@ export function createRegistry(server, opts = {}) {
|
||||
let pruneTask = null;
|
||||
/** @type {Map<string, HttpRouteEntry>} */
|
||||
const httpRoutes = new Map();
|
||||
let httpDispatcherRegistered = false;
|
||||
const httpDispatcherState = { registered: false };
|
||||
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||
httpRoutes,
|
||||
workflows,
|
||||
namespacedPath,
|
||||
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||
@@ -186,118 +182,10 @@ export function createRegistry(server, opts = {}) {
|
||||
* Fastify route once so path/method changes apply on reregister without restart.
|
||||
*/
|
||||
function registerHttpTriggers() {
|
||||
httpRoutes.clear();
|
||||
|
||||
for (const [key, { owner, workflow }] of workflows) {
|
||||
if (workflow.enabled === false) {
|
||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const trigger of workflow.triggers ?? []) {
|
||||
if (trigger.type !== "HTTP") continue;
|
||||
|
||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
||||
const url = namespacedPath(owner, trigger.path);
|
||||
const routeKey = `${method} ${url}`;
|
||||
|
||||
if (httpRoutes.has(routeKey)) {
|
||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
||||
continue;
|
||||
}
|
||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!httpDispatcherRegistered) {
|
||||
httpDispatcherRegistered = true;
|
||||
server.route({
|
||||
method: HTTP_METHODS,
|
||||
url: "/u/*",
|
||||
handler: dispatchHttpTrigger,
|
||||
});
|
||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function dispatchHttpTrigger(req, reply) {
|
||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
||||
const method = String(req.method ?? "GET").toUpperCase();
|
||||
const routeKey = `${method} ${url}`;
|
||||
const mapped = httpRoutes.get(routeKey);
|
||||
|
||||
if (!mapped) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
|
||||
const entry = workflows.get(mapped.key);
|
||||
if (!entry || entry.workflow?.enabled === false) {
|
||||
return reply.code(404).send({ error: "workflow disabled" });
|
||||
}
|
||||
|
||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
||||
const liveTrigger =
|
||||
(entry.workflow.triggers ?? []).find((t) => {
|
||||
if (t?.type !== "HTTP") return false;
|
||||
const m = String(t.method ?? "POST").toUpperCase();
|
||||
const p = namespacedPath(entry.owner, t.path);
|
||||
return m === method && p === url;
|
||||
}) ?? mapped.trigger;
|
||||
|
||||
if (
|
||||
liveTrigger.auth != null &&
|
||||
liveTrigger.auth !== false &&
|
||||
!(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);
|
||||
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
function registerCronTriggers() {
|
||||
@@ -595,8 +483,21 @@ export function createRegistry(server, opts = {}) {
|
||||
owner,
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const unresolvedConfig = parsed.config;
|
||||
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
let unresolvedConfig = parsed.config;
|
||||
if (parsed.kind === "script" && parsed.profile) {
|
||||
const profile = await getProfilePlain(owner, parsed.profile);
|
||||
if (!profile) {
|
||||
throw new Error(`profile "${parsed.profile}" not found`);
|
||||
}
|
||||
if (parsed.script && parsed.script !== profile.script) {
|
||||
throw new Error(
|
||||
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
|
||||
);
|
||||
}
|
||||
script = profile.script;
|
||||
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
|
||||
}
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
@@ -610,6 +511,7 @@ export function createRegistry(server, opts = {}) {
|
||||
owner,
|
||||
workflowKey: key,
|
||||
context: incomingContext,
|
||||
data: ctx.data,
|
||||
});
|
||||
const stepCtx = {
|
||||
data: ctx.data,
|
||||
@@ -956,7 +858,10 @@ export function createRegistry(server, opts = {}) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
if (parsed.kind === "script") refs.add(parsed.script);
|
||||
if (parsed.kind === "script") {
|
||||
if (parsed.script) refs.add(parsed.script);
|
||||
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid steps
|
||||
}
|
||||
|
||||
@@ -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 { getSecretPlaintext } from "./secrets-store.js";
|
||||
import { getVariablePlain } from "./variables-store.js";
|
||||
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||
|
||||
const hostRequire = createRequire(import.meta.url);
|
||||
|
||||
@@ -443,7 +444,7 @@ function createScriptSandbox({
|
||||
log,
|
||||
script,
|
||||
workflowName,
|
||||
owner = "default",
|
||||
owner = DEFAULT_OWNER,
|
||||
$workflows = $workflowsStub,
|
||||
pluginDir = null,
|
||||
}) {
|
||||
@@ -486,7 +487,7 @@ const inspectLog = pino({ level: "silent" });
|
||||
* @param {unknown} fn
|
||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||
*/
|
||||
export function extractScriptMeta(fn) {
|
||||
function extractScriptMeta(fn) {
|
||||
if (typeof fn !== "function") {
|
||||
return { meta: null, metaError: "default export must be a function" };
|
||||
}
|
||||
@@ -563,7 +564,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
||||
log: opts.log ?? inspectLog,
|
||||
script,
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
owner: opts.owner ?? DEFAULT_OWNER,
|
||||
$workflows: opts.$workflows,
|
||||
pluginDir: opts.pluginDir ?? null,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const ALLOWED_METHODS = new Set([
|
||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eval context for fingerprint/transform JSONata (reads `data.*`).
|
||||
* @param {unknown} ctx
|
||||
* @param {Record<string, unknown>} data
|
||||
*/
|
||||
function evalCtx(ctx, data) {
|
||||
return {
|
||||
data,
|
||||
context: passContext(ctx),
|
||||
config: ctx?.config ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function detectUrlChanges(ctx) {
|
||||
const url = ctx.config?.url;
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
ensureDataObject(ctx);
|
||||
const data = mergeData(ctx.data);
|
||||
|
||||
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||
const response = await $axios.request({
|
||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
||||
"detect-url-changes: fetch complete",
|
||||
);
|
||||
|
||||
ctx.data.httpResponse = response.data;
|
||||
data.httpResponse = response.data;
|
||||
|
||||
let fingerprintSource = ctx.data.httpResponse;
|
||||
let fingerprintSource = data.httpResponse;
|
||||
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||
log.info(
|
||||
{ jsonata: fingerprintExpr },
|
||||
"detect-url-changes: evaluating fingerprint jsonata",
|
||||
);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
|
||||
}
|
||||
|
||||
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||
maxAge: ctx.config?.maxAge,
|
||||
});
|
||||
|
||||
ctx.data.hasChanges = result.changed;
|
||||
ctx.data.fingerprint = result.hash;
|
||||
ctx.data.fingerprintChanged = result.changed;
|
||||
ctx.data.fingerprintPrevious = result.previous;
|
||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
||||
ctx.data.fingerprintAge = result.ageMs;
|
||||
ctx.data.fingerprintExpired = result.expired;
|
||||
const extra = {
|
||||
hasChanges: result.changed,
|
||||
fingerprint: result.hash,
|
||||
fingerprintChanged: result.changed,
|
||||
fingerprintPrevious: result.previous,
|
||||
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||
fingerprintAge: result.ageMs,
|
||||
fingerprintExpired: result.expired,
|
||||
};
|
||||
Object.assign(data, extra);
|
||||
|
||||
log.info(
|
||||
{
|
||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
||||
{ outputVar, jsonata: transformExpr },
|
||||
"detect-url-changes: evaluating transform jsonata",
|
||||
);
|
||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
||||
ctx.data[outputVar] = transformed;
|
||||
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||
data[outputVar] = transformed;
|
||||
log.info(
|
||||
{ outputVar, value: previewValue(transformed) },
|
||||
"detect-url-changes: saved transform result",
|
||||
);
|
||||
} else {
|
||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
||||
data[outputVar] = data.httpResponse;
|
||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||
const envelope = {
|
||||
output: data,
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||
ctx.skipRemaining = true;
|
||||
envelope.skipRemaining = true;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
detectUrlChanges.meta = {
|
||||
description:
|
||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
||||
previewConfigKey: "url",
|
||||
tags: ["HTTP"],
|
||||
reads: "ctx",
|
||||
config: {
|
||||
url: { type: "string", required: true, description: "URL to fetch" },
|
||||
method: {
|
||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
context: {
|
||||
hasChanges: { type: "boolean" },
|
||||
fingerprint: { type: "string" },
|
||||
fingerprintChanged: { type: "boolean" },
|
||||
fingerprintPrevious: { type: "string", required: false },
|
||||
fingerprintAt: { type: "string", required: false },
|
||||
fingerprintAge: { type: "number", required: false },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -15,6 +15,10 @@ function ntfyHeaders(ctx) {
|
||||
headers.Title = ctx.data.title;
|
||||
}
|
||||
|
||||
if (ctx.config?.markdown === true) {
|
||||
headers.md = "true";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -149,6 +153,11 @@ ntfy.meta = {
|
||||
default: "https://ntfy.sh/jerapah-flow",
|
||||
description: "ntfy topic URL",
|
||||
},
|
||||
markdown: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Send as Markdown (ntfy md header)",
|
||||
},
|
||||
fingerprint: {
|
||||
type: "string",
|
||||
required: false,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,144 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
const err = new Error("value is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
export * from "./src/stores/secrets-store.js";
|
||||
|
||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
||||
/**
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function getMasterKey() {
|
||||
function getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
const raw = resolveSecretsKeyMaterial();
|
||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.delete("/kv", async (req, reply) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
try {
|
||||
const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? ""));
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "kv entry not found" });
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertProfileName,
|
||||
deleteProfile,
|
||||
getProfileById,
|
||||
getProfilePlain,
|
||||
listProfileUsages,
|
||||
listProfiles,
|
||||
upsertProfile,
|
||||
} from "../../profiles-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function profilesPlugin(fastify) {
|
||||
fastify.get("/profiles", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const profiles = await listProfiles({ owner });
|
||||
const withUsage = profiles.map((profile) => ({
|
||||
...profile,
|
||||
usageCount: listProfileUsages(profile.owner, profile.name).length,
|
||||
}));
|
||||
return { profiles: withUsage };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/profiles/:id/usage", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
return { usages: listProfileUsages(existing.owner, existing.name) };
|
||||
});
|
||||
|
||||
fastify.put("/profiles", async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
owner?: string,
|
||||
name?: string,
|
||||
script?: unknown,
|
||||
config?: unknown,
|
||||
description?: unknown,
|
||||
}} */ (req.body ?? {});
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertProfileName(String(body.name ?? ""));
|
||||
const profile = await upsertProfile({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
script: body.script,
|
||||
config: body.config,
|
||||
description: body.description,
|
||||
});
|
||||
return reply.send({ profile });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/profiles/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const q = /** @type {{ force?: string }} */ (req.query ?? {});
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
const usages = listProfileUsages(existing.owner, existing.name);
|
||||
const force = q.force === "1" || q.force === "true";
|
||||
if (usages.length > 0 && !force) {
|
||||
return reply.code(409).send({
|
||||
error: "profile is used by workflows",
|
||||
usages,
|
||||
});
|
||||
}
|
||||
await deleteProfile(id);
|
||||
return { ok: true, forced: force && usages.length > 0, usages };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as store from "../../store.js";
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} q
|
||||
*/
|
||||
export function parseRunQueryParams(q) {
|
||||
const limit = q.limit != null ? Number(q.limit) : undefined;
|
||||
const offset = q.offset != null ? Number(q.offset) : undefined;
|
||||
return {
|
||||
owner: q.owner || undefined,
|
||||
workflow: q.workflow || undefined,
|
||||
status: q.status || undefined,
|
||||
trigger_type: q.trigger || undefined,
|
||||
after: q.after || undefined,
|
||||
before: q.before || undefined,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
sort: q.sort || undefined,
|
||||
order: q.order || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} q
|
||||
*/
|
||||
export async function queryRunsFromRequest(q) {
|
||||
return store.queryRuns(parseRunQueryParams(q));
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import * as store from "../../store.js";
|
||||
import { queryRunsFromRequest } from "./run-query.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function runsPlugin(fastify) {
|
||||
fastify.get("/runs", async (req) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return { runs };
|
||||
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
|
||||
});
|
||||
|
||||
fastify.get("/consecutive-failures", async (req) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
getInstalledPlugin,
|
||||
duplicatePlugin,
|
||||
listCoreScriptNames,
|
||||
listInstalledPlugins,
|
||||
resolveScriptRef,
|
||||
@@ -22,11 +22,17 @@ import {
|
||||
installPluginFromZipBuffer,
|
||||
} from "../../plugin-install.js";
|
||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||
import {
|
||||
encodeBinaryForWire,
|
||||
reviveBinaryFromWire,
|
||||
} from "../../json-preview.js";
|
||||
import { normalizeStepResult } from "../../step-result.js";
|
||||
import { resolveConfigRefs } from "../../config-refs.js";
|
||||
import { getAppVersion } from "../../app-version.js";
|
||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
||||
import { 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
|
||||
@@ -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) => {
|
||||
const rawName = decodeURIComponent(
|
||||
/** @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 ?? {}
|
||||
);
|
||||
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 !== "") {
|
||||
try {
|
||||
owner = fsStore.assertOwner(String(body.owner));
|
||||
@@ -266,12 +303,93 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
}
|
||||
|
||||
const incomingContext =
|
||||
const incomingContext = reviveBinaryFromWire(
|
||||
body.context != null &&
|
||||
typeof body.context === "object" &&
|
||||
!Array.isArray(body.context)
|
||||
typeof body.context === "object" &&
|
||||
!Array.isArray(body.context)
|
||||
? body.context
|
||||
: {};
|
||||
: {},
|
||||
);
|
||||
const incomingData = reviveBinaryFromWire(body.data ?? null);
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
// Set steps are inline JSONata (no script file). Match registry runCompiledStep.
|
||||
if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) {
|
||||
const configObj =
|
||||
body.config != null &&
|
||||
typeof body.config === "object" &&
|
||||
!Array.isArray(body.config)
|
||||
? /** @type {Record<string, unknown>} */ (body.config)
|
||||
: null;
|
||||
const expression =
|
||||
typeof body.expression === "string"
|
||||
? body.expression
|
||||
: typeof configObj?.expression === "string"
|
||||
? configObj.expression
|
||||
: null;
|
||||
if (expression == null || !expression.trim()) {
|
||||
return reply.code(400).send({ error: "expression is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(
|
||||
{ ...(configObj ?? {}), expression },
|
||||
{
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
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 pluginDir =
|
||||
@@ -279,17 +397,15 @@ export default function scriptsPluginFactory(registry) {
|
||||
? resolved.pluginDir ?? null
|
||||
: null;
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(body.config ?? null, {
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
data: incomingData,
|
||||
});
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
data: incomingData,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
@@ -313,6 +429,8 @@ export default function scriptsPluginFactory(registry) {
|
||||
status: "success",
|
||||
output: safeSerialize(result.output),
|
||||
context: safeSerialize(result.context),
|
||||
wireOutput: encodeBinaryForWire(result.output),
|
||||
wireContext: encodeBinaryForWire(result.context),
|
||||
skipRemaining: result.skipRemaining,
|
||||
error: null,
|
||||
logs,
|
||||
@@ -475,7 +593,5 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void getInstalledPlugin;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
collectWorkflowWarnings,
|
||||
parseWorkflowDocument,
|
||||
} from "../../workflow-validate-warnings.js";
|
||||
import { getProfilePlain } from "../../profiles-store.js";
|
||||
import { resolveScriptRef } from "../../plugin-store.js";
|
||||
import {
|
||||
recordRevision,
|
||||
listRevisions,
|
||||
@@ -43,6 +45,11 @@ import {
|
||||
createWorkflowBackupBuffer,
|
||||
restoreWorkflowBackup,
|
||||
} from "../../workflow-backup.js";
|
||||
import {
|
||||
listExampleWorkflows,
|
||||
readExampleWorkflow,
|
||||
assertExampleWorkflowId,
|
||||
} from "../../workflow-examples.js";
|
||||
|
||||
/**
|
||||
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||
@@ -81,7 +88,9 @@ function scriptNames(workflow) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
||||
if (parsed.kind === "set") names.push("set");
|
||||
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
|
||||
else names.push(parsed.script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
@@ -89,6 +98,59 @@ function scriptNames(workflow) {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function collectProfileWarnings(parsed, owner) {
|
||||
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||
const warnings = [];
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
|
||||
return warnings;
|
||||
}
|
||||
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||
const profileName = raw.profile;
|
||||
if (typeof profileName !== "string" || !profileName) continue;
|
||||
const pathKey = `scripts[${i}]`;
|
||||
let profile;
|
||||
try {
|
||||
profile = await getProfilePlain(owner, profileName);
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" is not a valid name`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!profile) {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" not found`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
|
||||
warnings.push({
|
||||
code: "profile_script_mismatch",
|
||||
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
|
||||
path: pathKey,
|
||||
});
|
||||
}
|
||||
const resolved = resolveScriptRef(profile.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
code: "unknown_script",
|
||||
message: resolved.error,
|
||||
path: `${pathKey}.profile`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
@@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) {
|
||||
* }} opts
|
||||
*/
|
||||
async function saveWorkflowContent(opts) {
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
if (parsed) {
|
||||
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||
}
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
|
||||
if (!saveAnyway) {
|
||||
if (parseError) {
|
||||
@@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) {
|
||||
return { owners: fsStore.listOwners() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples", async () => {
|
||||
return { examples: listExampleWorkflows() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
if (!assertExampleWorkflowId(id)) {
|
||||
return reply.code(400).send({ error: "invalid example id" });
|
||||
}
|
||||
const example = readExampleWorkflow(id);
|
||||
if (!example) {
|
||||
return reply.code(404).send({ error: "example not found" });
|
||||
}
|
||||
return example;
|
||||
});
|
||||
|
||||
fastify.get("/workflows/trash", async () => {
|
||||
return { items: await listTrash() };
|
||||
});
|
||||
@@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
enabled: parsed ? parsed.enabled !== false : false,
|
||||
registered: registered.includes(file),
|
||||
loadError: loadError ?? (parsed ? null : "unreadable"),
|
||||
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
|
||||
lastInvokedAt: st.lastInvokedAt,
|
||||
lastStatus: st.lastStatus ?? null,
|
||||
invocationCount: st.invocationCount,
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -617,15 +706,14 @@ export default function workflowsPluginFactory(registry) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const raw = fsStore.readWorkflowYaml(owner, file);
|
||||
if (raw == null) {
|
||||
return reply.code(404).send({ error: "workflow not found" });
|
||||
}
|
||||
let name = null;
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
name = parsed?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
if (raw != null) {
|
||||
try {
|
||||
const parsed = yaml.parse(raw);
|
||||
name = parsed?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
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 workflowsPluginFactory from "./src/api/workflows.js";
|
||||
import runsPlugin from "./src/api/runs.js";
|
||||
import { queryRunsFromRequest } from "./src/api/run-query.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import kvPlugin from "./src/api/kv.js";
|
||||
import variablesPlugin from "./src/api/variables.js";
|
||||
import profilesPlugin from "./src/api/profiles.js";
|
||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
@@ -168,6 +170,7 @@ export async function startApp(opts = {}) {
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(variablesPlugin);
|
||||
await api.register(profilesPlugin);
|
||||
await api.register(kvPlugin);
|
||||
await api.register(httpPagesPlugin);
|
||||
await api.register(httpAuthsPlugin);
|
||||
@@ -198,16 +201,8 @@ export async function startApp(opts = {}) {
|
||||
"/admin/runs",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async (req, reply) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return reply.send({ runs });
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
return reply.send(await queryRunsFromRequest(q));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
export { isPlainObject };
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
|
||||
+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 {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* limit?: number,
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* }} [filters]
|
||||
* }} filters
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
||||
if (filters.owner) q = q.where("owner", filters.owner);
|
||||
function applyRunFilters(q, filters) {
|
||||
if (filters.owner) q.where("owner", filters.owner);
|
||||
if (filters.workflow) {
|
||||
const key = String(filters.workflow);
|
||||
if (key.includes("*")) {
|
||||
@@ -243,25 +253,102 @@ export async function listRuns(filters = {}) {
|
||||
.replaceAll("%", "\\%")
|
||||
.replaceAll("_", "\\_")
|
||||
.replaceAll("*", "%");
|
||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
} else {
|
||||
q = q.where("workflow", key);
|
||||
q.where("workflow", key);
|
||||
}
|
||||
}
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
q = q.whereIn("status", filters.status);
|
||||
q.whereIn("status", filters.status);
|
||||
} else {
|
||||
q = q.where("status", filters.status);
|
||||
q.where("status", filters.status);
|
||||
}
|
||||
}
|
||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map((row) => ({
|
||||
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||
if (filters.before) q.where("started_at", "<", filters.before);
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex.QueryBuilder} q
|
||||
* @param {string | undefined} sort
|
||||
* @param {string | undefined} order
|
||||
*/
|
||||
function applyRunSort(q, sort, order) {
|
||||
const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at";
|
||||
const direction = order === "asc" ? "asc" : "desc";
|
||||
q.orderBy(column, direction);
|
||||
if (column !== "started_at") q.orderBy("started_at", "desc");
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function mapRunRow(row) {
|
||||
return {
|
||||
...row,
|
||||
input: deserialize(row.input),
|
||||
output: deserialize(row.output),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* sort?: string,
|
||||
* order?: string,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function queryRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(Number(filters.offset) || 0, 0);
|
||||
|
||||
let q = db("workflow_runs");
|
||||
q = applyRunFilters(q, filters);
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
let rowsQ = q.clone().select("*");
|
||||
rowsQ = applyRunSort(rowsQ, filters.sort, filters.order);
|
||||
const rows = await rowsQ.limit(limit).offset(offset);
|
||||
|
||||
return {
|
||||
runs: rows.map(mapRunRow),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*");
|
||||
q = applyRunFilters(q, filters);
|
||||
q = applyRunSort(q, "started_at", "desc");
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map(mapRunRow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -495,12 +582,13 @@ export async function listUsers() {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ passwordHash?: string, role?: string }} patch
|
||||
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||
*/
|
||||
export async function updateUser(id, patch) {
|
||||
const update = { updated_at: nowIso() };
|
||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||
if (patch.role) update.role = patch.role;
|
||||
if (patch.username) update.username = patch.username;
|
||||
await db("users").where({ id }).update(update);
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { migrate, db } from "../db.js";
|
||||
import { upsertSecret, deleteSecret } from "../secrets-store.js";
|
||||
import { deleteVariable, upsertVariable } from "../variables-store.js";
|
||||
import { Secret } from "../secret-value.js";
|
||||
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js";
|
||||
import { resolveConfigRefs } from "../config-refs.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
@@ -23,126 +23,138 @@ async function assertRejects(fn, match) {
|
||||
throw new Error(`expected to reject (${match ?? "any error"})`);
|
||||
}
|
||||
|
||||
const owner = "default";
|
||||
const workflowKey = "default/config-refs-smoke.yaml";
|
||||
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 owner = "config_refs_smoke_owner";
|
||||
const ctx = { owner, workflowKey: `${owner}/config-refs-smoke.yaml`, context: {}, data: {} };
|
||||
|
||||
{
|
||||
const literal = await resolveConfigRefs("password123", ctx);
|
||||
assert(literal === "password123", "literal passthrough");
|
||||
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
|
||||
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);
|
||||
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
|
||||
const number = await resolveConfigRefs(42, ctx);
|
||||
assert(number === 42, "number passthrough");
|
||||
}
|
||||
|
||||
const secret = await upsertSecret({
|
||||
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,
|
||||
});
|
||||
|
||||
const created = [];
|
||||
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);
|
||||
assert(resolved === "s3cret-ok", "secret resolve");
|
||||
const resolved = await resolveConfigRefs("{{ secrets.config_refs_smoke_token }}", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret whole-value");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx);
|
||||
assert(resolved === "s3cret-ok", "secret resolve trimmed");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
|
||||
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_url }}", ctx);
|
||||
assert(resolved === "https://example.test", "var string");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx);
|
||||
assert(resolved === 3, "var number stays number");
|
||||
assert(typeof resolved === "number", "var number type");
|
||||
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_retry }}", ctx);
|
||||
assert(resolved === 3, "var number keeps type");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx);
|
||||
assert(resolved === false, "var boolean stays false");
|
||||
assert(typeof resolved === "boolean", "var boolean type");
|
||||
const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_debug }}", ctx);
|
||||
assert(resolved === false, "var boolean keeps type");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_token", {
|
||||
const resolved = await resolveConfigRefs("{{ context.token }}", {
|
||||
...ctx,
|
||||
context: { token: "ctx-token-ok" },
|
||||
});
|
||||
assert(resolved === "ctx-token-ok", "context string");
|
||||
}
|
||||
{
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_n", {
|
||||
const resolved = await resolveConfigRefs("{{ context.n }}", {
|
||||
...ctx,
|
||||
context: { n: 7 },
|
||||
});
|
||||
assert(resolved === "7", "context number stringify");
|
||||
assert(resolved === 7, "context number keeps type");
|
||||
}
|
||||
{
|
||||
const wrapped = new Secret("wrapped-secret-ok");
|
||||
const resolved = await resolveConfigRefs("$CONTEXT_tok", {
|
||||
const resolved = await resolveConfigRefs("{{ context.tok }}", {
|
||||
...ctx,
|
||||
context: { tok: wrapped },
|
||||
});
|
||||
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(
|
||||
{
|
||||
url: "$VAR_config_refs_smoke_url",
|
||||
retry: "$VAR_config_refs_smoke_retry",
|
||||
debug: "$VAR_config_refs_smoke_debug",
|
||||
password: "$SECRET_config_refs_smoke_token",
|
||||
url: "{{ vars.config_refs_smoke_url }}",
|
||||
retry: "{{ vars.config_refs_smoke_retry }}",
|
||||
debug: "{{ vars.config_refs_smoke_debug }}",
|
||||
password: "{{ secrets.config_refs_smoke_token }}",
|
||||
headers: { Authorization: "$KV_modem_password" },
|
||||
extra: ["$CONTEXT_token", "plain"],
|
||||
extra: ["{{ context.token }}", "plain"],
|
||||
},
|
||||
{ ...ctx, context: { token: "ctx-token-ok" } },
|
||||
);
|
||||
@@ -155,59 +167,47 @@ try {
|
||||
assert(nested.extra[1] === "plain", "nested array literal");
|
||||
}
|
||||
|
||||
const data = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const config = { password: "$SECRET_config_refs_smoke_token" };
|
||||
const data = { password: "{{ secrets.config_refs_smoke_token }}" };
|
||||
const config = { password: "{{ secrets.config_refs_smoke_token }}" };
|
||||
const resolvedConfig = await resolveConfigRefs(config, ctx);
|
||||
assert(resolvedConfig.password === "s3cret-ok", "config resolved");
|
||||
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked");
|
||||
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated");
|
||||
assert(data.password === "{{ secrets.config_refs_smoke_token }}", "data not walked");
|
||||
assert(config.password === "{{ secrets.config_refs_smoke_token }}", "input config not mutated");
|
||||
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx),
|
||||
() => resolveConfigRefs("{{ secrets.does_not_exist_xyz }}", ctx),
|
||||
'secret "does_not_exist_xyz" not found',
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_not valid", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$SECRET_", ctx),
|
||||
"invalid secret name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_missing", ctx),
|
||||
'context "missing" not found',
|
||||
() => resolveConfigRefs("{{ context.missing }}", ctx),
|
||||
"path not found",
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
resolveConfigRefs("$CONTEXT_obj", {
|
||||
resolveConfigRefs("Bearer {{ context.obj }}", {
|
||||
...ctx,
|
||||
context: { obj: { a: 1 } },
|
||||
}),
|
||||
'context "obj" is not a scalar',
|
||||
"not a scalar",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$CONTEXT_", ctx),
|
||||
"empty context key",
|
||||
() => resolveConfigRefs("{{ vars }}", ctx),
|
||||
"empty var name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx),
|
||||
'variable "does_not_exist_xyz" not found',
|
||||
() => resolveConfigRefs("{{ title }}", ctx),
|
||||
"unknown root",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_not valid", ctx),
|
||||
"invalid variable name",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolveConfigRefs("$VAR_", ctx),
|
||||
"empty variable name",
|
||||
() => resolveConfigRefs("{{ context.__proto__.x }}", ctx),
|
||||
"forbidden path segment",
|
||||
);
|
||||
} finally {
|
||||
await deleteSecret(secret.id);
|
||||
await deleteVariable(varUrl.id);
|
||||
await deleteVariable(varRetry.id);
|
||||
await deleteVariable(varDebug.id);
|
||||
for (const [kind, id] of created.reverse()) {
|
||||
if (kind === "secret") await deleteSecret(id);
|
||||
else await deleteVariable(id);
|
||||
}
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
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>";
|
||||
|
||||
const first = await run({ url });
|
||||
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(
|
||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
||||
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||
"httpResponse should hold the raw body",
|
||||
);
|
||||
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
|
||||
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
|
||||
|
||||
const second = await run({ url });
|
||||
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
|
||||
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||
const third = await run({ url });
|
||||
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(
|
||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
||||
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||
"fingerprintPrevious should equal the prior hash",
|
||||
);
|
||||
}
|
||||
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
|
||||
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||
|
||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(first.data.hasChanges === true, "json first run should report a change");
|
||||
assert(first.output.hasChanges === true, "json first run should report a change");
|
||||
|
||||
// Change only an unwatched field -> no change.
|
||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(
|
||||
second.data.hasChanges === false,
|
||||
second.output.hasChanges === false,
|
||||
"changing an unwatched field should not report a change",
|
||||
);
|
||||
|
||||
// Change the watched field -> change.
|
||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(third.data.hasChanges === true, "changing the watched field should report a change");
|
||||
assert(third.output.hasChanges === true, "changing the watched field should report a change");
|
||||
|
||||
state.contentType = "text/html; charset=utf-8";
|
||||
}
|
||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
||||
transform: '"changed=" & $string(data.hasChanges)',
|
||||
});
|
||||
assert(
|
||||
withTransform.data.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
||||
withTransform.output.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||
);
|
||||
|
||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||
assert(
|
||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
||||
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||
"outputVar without transform should store the raw response",
|
||||
);
|
||||
|
||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
||||
state.body = "<html><body>stable</body></html>";
|
||||
|
||||
const first = await run({ url, skipRemaining: true });
|
||||
assert(first.data.hasChanges === true, "skip test first run should change");
|
||||
assert(first.output.hasChanges === true, "skip test first run should change");
|
||||
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||
|
||||
const second = await run({ url, skipRemaining: true });
|
||||
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||
|
||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
|
||||
import {
|
||||
encodeBinaryForWire,
|
||||
jsonPreviewReplacer,
|
||||
reviveBinaryFromWire,
|
||||
summarizeBinary,
|
||||
} from "../json-preview.js";
|
||||
import { serialize, toDisplayValue } from "../store.js";
|
||||
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||
|
||||
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
|
||||
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
|
||||
}
|
||||
|
||||
const wired = encodeBinaryForWire({ file: png, n: 1n });
|
||||
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
|
||||
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
|
||||
}
|
||||
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
|
||||
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
|
||||
throw new Error("reviveBinaryFromWire failed to restore bytes");
|
||||
}
|
||||
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
|
||||
const left = reviveBinaryFromWire(previewOnly);
|
||||
if (Buffer.isBuffer(left)) {
|
||||
throw new Error("preview-only summary should not revive");
|
||||
}
|
||||
|
||||
console.log("json-preview-smoke: ok");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
|
||||
*
|
||||
* Run:
|
||||
* JFLOW_DATA_DIR=packages/server/data \
|
||||
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
* node packages/server/test/plugins-smoke.js
|
||||
@@ -12,6 +13,7 @@ import { migrate, db } from "../db.js";
|
||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
duplicatePlugin,
|
||||
resolveScriptRef,
|
||||
uninstallPlugin,
|
||||
listInstalledPlugins,
|
||||
@@ -82,6 +84,26 @@ async function main() {
|
||||
);
|
||||
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;
|
||||
try {
|
||||
forkCoreScript("ntfy.js", "ntfy");
|
||||
@@ -101,6 +123,7 @@ async function main() {
|
||||
|
||||
uninstallPlugin("jsonata-smoke-fork");
|
||||
uninstallPlugin("blank-smoke");
|
||||
uninstallPlugin("blank-smoke-copy");
|
||||
uninstallPlugin("get-current-time");
|
||||
|
||||
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"));
|
||||
|
||||
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({
|
||||
workflowId,
|
||||
owner,
|
||||
|
||||
@@ -1,190 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_STRING_BYTES = 64 * 1024;
|
||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertVariableName(name) {
|
||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid variable name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"string" | "number" | "boolean"}
|
||||
*/
|
||||
export function assertVariableType(type) {
|
||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||
throw httpError("type must be string, number, or boolean");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeVariableValue(type, value) {
|
||||
if (type === "string") {
|
||||
if (typeof value !== "string") {
|
||||
throw httpError("value must be a string");
|
||||
}
|
||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw httpError("value must be a finite number");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw httpError("value must be a boolean");
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {string} stored
|
||||
* @returns {string | number | boolean}
|
||||
*/
|
||||
export function decodeVariableValue(type, stored) {
|
||||
if (type === "string") return stored;
|
||||
if (type === "number") {
|
||||
const n = Number(stored);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicVariable(row) {
|
||||
const type = assertVariableType(row.type);
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type,
|
||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listVariables(filters = {}) {
|
||||
let q = db("variables")
|
||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicVariable(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getVariableById(id) {
|
||||
const row = await db("variables").where({ id }).first();
|
||||
return row ? publicVariable(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||
*/
|
||||
export async function upsertVariable({ owner, name, type, value }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const variableType = assertVariableType(type);
|
||||
const encoded = encodeVariableValue(variableType, value);
|
||||
const now = nowIso();
|
||||
const existing = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("variables")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("variables").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: variableName,
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteVariable(id) {
|
||||
const n = await db("variables").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed primitive for an owner/name. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | number | boolean | null>}
|
||||
*/
|
||||
export async function getVariablePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const row = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||
}
|
||||
export * from "./src/stores/variables-store.js";
|
||||
|
||||
@@ -0,0 +1,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 {
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
} from "@jerapah-flow/shared";
|
||||
import {
|
||||
newWorkflowFilename,
|
||||
workflowFileStem,
|
||||
} from "./workflow-normalize.js";
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
export { ensureWorkflowFilename, suggestCopyFilename };
|
||||
|
||||
/**
|
||||
* 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";
|
||||
export { namespacedPath } from "@jerapah-flow/shared";
|
||||
|
||||
export const SET_STEP_SCRIPT = "set";
|
||||
|
||||
@@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||
* @typedef {{
|
||||
* kind: "script",
|
||||
* script: string,
|
||||
* script: string,
|
||||
* profile: string | null,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* @typedef {{
|
||||
* kind: "set",
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* config: { expression: string },
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* profile: null,
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
@@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) {
|
||||
return await result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
|
||||
* @param {string} owner
|
||||
* @param {string} triggerPath
|
||||
*/
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath).replace(/^\/+/, "");
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} step
|
||||
* @returns {ParsedStep}
|
||||
@@ -85,7 +80,9 @@ export function parseScriptStep(step) {
|
||||
return {
|
||||
kind: "script",
|
||||
script: step,
|
||||
profile: null,
|
||||
config: null,
|
||||
name: null,
|
||||
id: null,
|
||||
needsKind: "none",
|
||||
needs: [],
|
||||
@@ -97,25 +94,35 @@ export function parseScriptStep(step) {
|
||||
}
|
||||
|
||||
const hasScript = step.script != null && step.script !== "";
|
||||
const hasProfile = step.profile != null && step.profile !== "";
|
||||
const hasSet = step.set != null;
|
||||
|
||||
if (hasScript && hasSet) {
|
||||
throw new Error("Step cannot have both script and set");
|
||||
}
|
||||
if (hasProfile && hasSet) {
|
||||
throw new Error("Step cannot have both profile and set");
|
||||
}
|
||||
|
||||
if (hasSet) {
|
||||
return parseSetStep(step);
|
||||
}
|
||||
|
||||
if (hasScript) {
|
||||
if (typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
if (hasProfile && typeof step.profile !== "string") {
|
||||
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
|
||||
}
|
||||
if (hasScript && typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
|
||||
if (hasScript || hasProfile) {
|
||||
const { needsKind, needs } = parseNeeds(step.needs);
|
||||
return {
|
||||
kind: "script",
|
||||
script: step.script,
|
||||
script: hasScript ? step.script : "",
|
||||
profile: hasProfile ? step.profile : null,
|
||||
config: step.config ?? null,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -265,8 +272,10 @@ function parseSetStep(step) {
|
||||
return {
|
||||
kind: "set",
|
||||
script: SET_STEP_SCRIPT,
|
||||
profile: null,
|
||||
config: { expression },
|
||||
expression,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -287,6 +296,19 @@ function parseWhen(when) {
|
||||
return when;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function parseOptionalName(name) {
|
||||
if (name == null || name === "") return null;
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string | null}
|
||||
|
||||
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
export function trashAgeMs(deletedAtIso) {
|
||||
function trashAgeMs(deletedAtIso) {
|
||||
return Date.now() - Date.parse(deletedAtIso);
|
||||
}
|
||||
|
||||
@@ -62,29 +62,49 @@ export async function isInTrash(owner, file) {
|
||||
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 {{
|
||||
* workflowId: string,
|
||||
* owner: string,
|
||||
* file: string,
|
||||
* name?: string | null,
|
||||
* }} opts
|
||||
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
|
||||
*/
|
||||
export async function moveWorkflowToTrash(opts) {
|
||||
unregisterWorkflow(opts.owner, opts.file);
|
||||
|
||||
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const err = new Error("workflow not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
const existing = await db("workflow_trash")
|
||||
.where({ owner: opts.owner, file: opts.file })
|
||||
.first();
|
||||
return existing ? rowToItem(existing) : null;
|
||||
}
|
||||
|
||||
const trashPath = trashFilePath(opts.owner, opts.file);
|
||||
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
||||
fs.renameSync(sourcePath, trashPath);
|
||||
|
||||
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
|
||||
fsStore.writeRegisters(opts.owner, registered);
|
||||
const existing = await db("workflow_trash")
|
||||
.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 deleted_at = nowIso();
|
||||
|
||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
||||
try {
|
||||
const step = parseScriptStep(raw);
|
||||
if (step.kind === "set") continue;
|
||||
if (step.profile && !step.script) continue;
|
||||
const resolved = resolveScriptRef(step.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
|
||||
@@ -18,7 +18,7 @@ scripts:
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: HTTP
|
||||
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:
|
||||
- time-to-ntfy-example.yaml
|
||||
- test.yaml
|
||||
- cron-example.yaml
|
||||
- fetch-devto.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- time-and-comic-to-ntfy.yaml
|
||||
- rss-selfhst-to-ntfy.yaml
|
||||
- send-gmail.yaml
|
||||
- test-send-gmail.yaml
|
||||
- track.yaml
|
||||
- rss-devto-to-ntfy.yaml
|
||||
- test-minio.yaml
|
||||
- dev-joplin-sync.yaml
|
||||
- dev-joplin-daily.yaml
|
||||
- dev-joplin-get-note.yaml
|
||||
- web-dave.yaml
|
||||
- jadwal-sholat-jakart.yaml
|
||||
- detect-example-changes.yaml
|
||||
- test-sftp.yaml
|
||||
- time-to-ntfy-example.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy (copy)
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss-rss-devto-to-ntfy
|
||||
enabled: false
|
||||
@@ -1,26 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss
|
||||
@@ -1,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
|
||||
config:
|
||||
key: ""
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
- profile: ntfy_default
|
||||
triggers:
|
||||
- type: HTTP
|
||||
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