This commit is contained in:
+5
-1
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
*.db
|
*.db
|
||||||
@@ -9,8 +10,11 @@ packages/web/dist
|
|||||||
# Personal/local scripts and workflows (not for the repo)
|
# Personal/local scripts and workflows (not for the repo)
|
||||||
debug-*.js
|
debug-*.js
|
||||||
|
|
||||||
|
# Legacy live workflow tree (migrated to packages/server/data/workflows/)
|
||||||
|
packages/server/workflows/
|
||||||
|
|
||||||
# Plugin install staging and per-plugin deps
|
# Plugin install staging and per-plugin deps
|
||||||
plugins/.staging-*
|
plugins/.staging-*
|
||||||
plugins/*/node_modules/
|
plugins/*/node_modules/
|
||||||
|
|
||||||
.gitea/workflows/
|
.gitea/workflows/
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
|
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
|
||||||
|
|
||||||
|
## Workflows (instance data)
|
||||||
|
|
||||||
|
Live workflows are **not** product source. They live under `packages/server/data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
|
||||||
|
|
||||||
|
| Kind | In git? | Path |
|
||||||
|
|---|---|---|
|
||||||
|
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` |
|
||||||
|
| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) |
|
||||||
|
|
||||||
|
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
| Kind | YAML `script` | Editable | Path |
|
| Kind | YAML `script` | Editable | Path |
|
||||||
@@ -127,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scripts:
|
scripts:
|
||||||
- script: plugin/my-plugin
|
- name: Notify to channel
|
||||||
|
script: plugin/my-plugin
|
||||||
config:
|
config:
|
||||||
url: http://10.8.0.6:3030/notes
|
url: http://10.8.0.6:3030/notes
|
||||||
token: $SECRET_joplin_api_token
|
token: $SECRET_joplin_api_token
|
||||||
```
|
```
|
||||||
|
|
||||||
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ pnpm dev
|
|||||||
- UI (dev): http://localhost:8500
|
- UI (dev): http://localhost:8500
|
||||||
- API: http://localhost:8700
|
- API: http://localhost:8700
|
||||||
|
|
||||||
The first account created becomes **admin**. Later accounts are created from Users.
|
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
|
||||||
|
|
||||||
|
Reset or create the admin login from the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||||
|
```
|
||||||
|
|
||||||
### Process modes
|
### Process modes
|
||||||
|
|
||||||
@@ -36,13 +42,26 @@ The first account created becomes **admin**. Later accounts are created from Use
|
|||||||
| Kind | Name in YAML | Editable | Location |
|
| Kind | Name in YAML | Editable | Location |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||||
| **Plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
| **User plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||||
|
| **Example source** | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
|
||||||
|
|
||||||
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
||||||
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
||||||
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
|
||||||
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`.
|
||||||
|
|
||||||
|
## Workflows (instance data vs examples)
|
||||||
|
|
||||||
|
| Kind | Loaded by runner? | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
|
||||||
|
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
|
||||||
|
|
||||||
|
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
|
||||||
|
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
|
||||||
|
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
|
||||||
|
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke
|
# Smoke
|
||||||
@@ -113,6 +132,7 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
|||||||
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
|
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
|
||||||
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
||||||
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
|
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
|
||||||
|
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). |
|
||||||
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
||||||
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
||||||
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Production PM2 ecosystem (monolith runner).
|
||||||
|
* Use for deployed/single-process starts. For local multi-process Ops UI, use
|
||||||
|
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead.
|
||||||
|
*/
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Comic - monkeyuser to ntfy
|
||||||
|
description: |
|
||||||
|
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel).
|
||||||
|
scripts:
|
||||||
|
- script: fetch-html.js
|
||||||
|
config:
|
||||||
|
url: https://www.monkeyuser.com/
|
||||||
|
outputVar: comic
|
||||||
|
selector: .comic img
|
||||||
|
jsonata: |
|
||||||
|
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||||
|
- script: jsonata.js
|
||||||
|
config:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"title": data.comic.title,
|
||||||
|
"message": data.comic.title,
|
||||||
|
"attach": data.comic.url
|
||||||
|
}
|
||||||
|
- script: fetch-binary.js
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: $VAR_ntfy_channel
|
||||||
|
triggers:
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /comic-monkeyuser
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: detect example.com changes
|
||||||
|
description: |
|
||||||
|
Fetch example.com, fingerprint the response, and report whether it changed
|
||||||
|
since the previous run. Uses detect-url-changes with a transform that builds
|
||||||
|
a human-readable message into ctx.data.message.
|
||||||
|
scripts:
|
||||||
|
- script: detect-url-changes.js
|
||||||
|
config:
|
||||||
|
url: https://example.com/
|
||||||
|
outputVar: message
|
||||||
|
transform: |
|
||||||
|
data.hasChanges
|
||||||
|
? "example.com changed (fingerprint " & data.fingerprint & ")"
|
||||||
|
: "example.com unchanged since " & data.fingerprintAt
|
||||||
|
triggers:
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /detect-example
|
||||||
|
- type: cron
|
||||||
|
schedule: "* * * * *"
|
||||||
|
onConsecutiveFailures: 3
|
||||||
|
enabled: false
|
||||||
+4
-11
@@ -14,16 +14,9 @@
|
|||||||
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||||
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
||||||
|
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
||||||
|
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.25.0",
|
"packageManager": "pnpm@11.22.0"
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"better-sqlite3",
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"rss-parser": "^3.13.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { SERVER_ROOT } from "./paths.js";
|
import { SERVER_ROOT } from "./paths.js";
|
||||||
|
|
||||||
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
||||||
@@ -75,5 +74,3 @@ export function satisfiesRange(version, range) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fileURLToPath;
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** @returns {ControlState} */
|
/** @returns {ControlState} */
|
||||||
export function defaultControlState() {
|
function defaultControlState() {
|
||||||
return {
|
return {
|
||||||
http: "running",
|
http: "running",
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Dev ecosystem for `pnpm dev:pm2`.
|
* Local multi-process Ops UI ecosystem (`pnpm dev:pm2`).
|
||||||
* Prefer starting children via control.js (desired state) rather than this file.
|
* Prefer starting children via control.js (desired state) rather than this file.
|
||||||
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
|
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
|
||||||
|
* For production monolith, use root ecosystem.config.cjs instead.
|
||||||
*/
|
*/
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
|
|||||||
+27
-32
@@ -49,14 +49,8 @@ export function readScript(name) {
|
|||||||
return fs.readFileSync(filePath, "utf8");
|
return fs.readFileSync(filePath, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeScript(name, content) {
|
|
||||||
assertScriptName(name);
|
|
||||||
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
|
||||||
* @returns {{ filePath: string, contentType: string } | null}
|
* @returns {{ filePath: string, contentType: string } | null}
|
||||||
*/
|
*/
|
||||||
export function resolveScriptIcon(name) {
|
export function resolveScriptIcon(name) {
|
||||||
@@ -65,6 +59,7 @@ export function resolveScriptIcon(name) {
|
|||||||
for (const { ext, contentType } of [
|
for (const { ext, contentType } of [
|
||||||
{ ext: "png", contentType: "image/png" },
|
{ ext: "png", contentType: "image/png" },
|
||||||
{ ext: "jpg", contentType: "image/jpeg" },
|
{ ext: "jpg", contentType: "image/jpeg" },
|
||||||
|
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||||
]) {
|
]) {
|
||||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
@@ -78,22 +73,6 @@ export function scriptHasIcon(name) {
|
|||||||
return resolveScriptIcon(name) != null;
|
return resolveScriptIcon(name) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteScript(name) {
|
|
||||||
assertScriptName(name);
|
|
||||||
const filePath = path.join(SCRIPTS_DIR, name);
|
|
||||||
if (!fs.existsSync(filePath)) return false;
|
|
||||||
const icon = resolveScriptIcon(name);
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
if (icon) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(icon.filePath);
|
|
||||||
} catch {
|
|
||||||
// ignore missing icon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listOwners() {
|
export function listOwners() {
|
||||||
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
||||||
return fs
|
return fs
|
||||||
@@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) {
|
|||||||
return fs.readFileSync(filePath, "utf8");
|
return fs.readFileSync(filePath, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last content change time for a workflow YAML file.
|
||||||
|
* Uses birthtime (creation) when the file has not been modified since it was created.
|
||||||
|
* @returns {string | null} ISO timestamp
|
||||||
|
*/
|
||||||
|
export function workflowLastModifiedAt(owner, file) {
|
||||||
|
try {
|
||||||
|
assertOwner(owner);
|
||||||
|
assertWorkflowFile(file);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(filePath);
|
||||||
|
const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
|
||||||
|
const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null;
|
||||||
|
const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000);
|
||||||
|
const ms = unmodified ? birthMs : (mtimeMs ?? birthMs);
|
||||||
|
return ms != null ? new Date(ms).toISOString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function writeWorkflowYaml(owner, file, content) {
|
export function writeWorkflowYaml(owner, file, content) {
|
||||||
assertOwner(owner);
|
assertOwner(owner);
|
||||||
assertWorkflowFile(file);
|
assertWorkflowFile(file);
|
||||||
@@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) {
|
|||||||
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
|
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteWorkflowYaml(owner, file) {
|
|
||||||
assertOwner(owner);
|
|
||||||
assertWorkflowFile(file);
|
|
||||||
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
|
||||||
if (!fs.existsSync(filePath)) return false;
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listOwnerYamlFiles(owner) {
|
export function listOwnerYamlFiles(owner) {
|
||||||
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
||||||
if (!fs.existsSync(ownerDir)) return [];
|
if (!fs.existsSync(ownerDir)) return [];
|
||||||
|
|||||||
@@ -1,390 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/http-auths-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertHttpStatus } from "./http-pages-store.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
const UUID_RE =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} id
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertAuthId(id) {
|
|
||||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
|
||||||
const err = new Error("invalid auth id");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return id.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertAuthName(name) {
|
|
||||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
||||||
const err = new Error("invalid auth name");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} type
|
|
||||||
* @returns {"bearer" | "basic" | "header"}
|
|
||||||
*/
|
|
||||||
export function assertAuthType(type) {
|
|
||||||
const t = String(type ?? "");
|
|
||||||
if (!ALLOWED_TYPES.has(t)) {
|
|
||||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect value source without exposing literal values.
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
|
||||||
*/
|
|
||||||
export function valueSourceKind(value) {
|
|
||||||
if (value == null) return "missing";
|
|
||||||
if (typeof value === "string") return "literal";
|
|
||||||
if (typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
if ("secret" in value) return "secret";
|
|
||||||
if ("kv" in value) return "kv";
|
|
||||||
}
|
|
||||||
return "literal";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redact config for API responses: replace literal strings with source markers.
|
|
||||||
* @param {Record<string, unknown>} config
|
|
||||||
* @param {string} type
|
|
||||||
*/
|
|
||||||
export function publicConfig(config, type) {
|
|
||||||
/** @type {Record<string, unknown>} */
|
|
||||||
const out = {};
|
|
||||||
if (type === "bearer") {
|
|
||||||
out.token = redactField(config.token);
|
|
||||||
} else if (type === "basic") {
|
|
||||||
out.user = redactField(config.user);
|
|
||||||
out.password = redactField(config.password);
|
|
||||||
} else if (type === "header") {
|
|
||||||
out.header = typeof config.header === "string" ? config.header : null;
|
|
||||||
out.value = redactField(config.value);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function redactField(value) {
|
|
||||||
const kind = valueSourceKind(value);
|
|
||||||
if (kind === "missing") return { source: "missing" };
|
|
||||||
if (kind === "kv") {
|
|
||||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
|
||||||
return {
|
|
||||||
source: "kv",
|
|
||||||
kv: v.kv,
|
|
||||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (kind === "secret") {
|
|
||||||
const v = /** @type {{ secret: string }} */ (value);
|
|
||||||
return { source: "secret", secret: v.secret };
|
|
||||||
}
|
|
||||||
return { source: "literal", set: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and normalize auth config for storage.
|
|
||||||
* @param {string} type
|
|
||||||
* @param {unknown} config
|
|
||||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
|
||||||
*/
|
|
||||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
|
||||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
|
||||||
? /** @type {Record<string, unknown>} */ (config)
|
|
||||||
: {};
|
|
||||||
const keep = opts.keepLiteralsFrom ?? {};
|
|
||||||
|
|
||||||
if (type === "bearer") {
|
|
||||||
return {
|
|
||||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (type === "basic") {
|
|
||||||
return {
|
|
||||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
|
||||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
|
||||||
allowEmpty: true,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// header
|
|
||||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
|
||||||
const err = new Error("header name must be a non-empty string");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
header: raw.header,
|
|
||||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {unknown} previous
|
|
||||||
* @param {string} label
|
|
||||||
* @param {{ allowEmpty?: boolean }} [opts]
|
|
||||||
*/
|
|
||||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
|
||||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
|
||||||
if (
|
|
||||||
value &&
|
|
||||||
typeof value === "object" &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
|
||||||
) {
|
|
||||||
if (typeof previous === "string") return previous;
|
|
||||||
if (previous && typeof previous === "object") return previous;
|
|
||||||
const err = new Error(`${label} was not previously set`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value == null || value === "") {
|
|
||||||
if (opts.allowEmpty && value === "") return "";
|
|
||||||
// Allow empty password for basic
|
|
||||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
|
||||||
if (typeof previous === "string") return previous;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const err = new Error(`${label} is required`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
|
|
||||||
if (typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
const v = /** @type {Record<string, unknown>} */ (value);
|
|
||||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
|
||||||
return { secret: v.secret };
|
|
||||||
}
|
|
||||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
|
||||||
/** @type {{ kv: string, namespace?: string }} */
|
|
||||||
const out = { kv: v.kv };
|
|
||||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
|
||||||
out.namespace = v.namespace;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = new Error(
|
|
||||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
|
||||||
);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfig(raw) {
|
|
||||||
if (typeof raw !== "string") return raw ?? {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicAuth(row, { includeConfig = true } = {}) {
|
|
||||||
const type = row.type;
|
|
||||||
const config = parseConfig(row.config);
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
type,
|
|
||||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
|
||||||
unauthorized_status: row.unauthorized_status ?? null,
|
|
||||||
unauthorized_response: row.unauthorized_response ?? null,
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal: full config including literals (for runtime auth checks).
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getHttpAuthInternal(id) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
const row = await db("http_auths").where({ id: authId }).first();
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
type: row.type,
|
|
||||||
config: parseConfig(row.config),
|
|
||||||
unauthorized_status: row.unauthorized_status ?? null,
|
|
||||||
unauthorized_response: row.unauthorized_response ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
|
||||||
*/
|
|
||||||
export async function revealHttpAuthLiterals(id) {
|
|
||||||
const internal = await getHttpAuthInternal(id);
|
|
||||||
if (!internal) return null;
|
|
||||||
/** @type {Record<string, string>} */
|
|
||||||
const literals = {};
|
|
||||||
const cfg = internal.config ?? {};
|
|
||||||
for (const key of ["token", "user", "password", "value"]) {
|
|
||||||
const v = cfg[key];
|
|
||||||
if (typeof v === "string") literals[key] = v;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: internal.id,
|
|
||||||
name: internal.name,
|
|
||||||
type: internal.type,
|
|
||||||
literals,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listHttpAuths() {
|
|
||||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
|
||||||
return rows.map((r) => publicAuth(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getHttpAuthById(id) {
|
|
||||||
let authId;
|
|
||||||
try {
|
|
||||||
authId = assertAuthId(id);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const row = await db("http_auths").where({ id: authId }).first();
|
|
||||||
return row ? publicAuth(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{
|
|
||||||
* id?: string | null,
|
|
||||||
* name: string,
|
|
||||||
* type: string,
|
|
||||||
* config?: unknown,
|
|
||||||
* unauthorized_status?: number | null,
|
|
||||||
* unauthorized_response?: string | null,
|
|
||||||
* }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertHttpAuth({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
config,
|
|
||||||
unauthorized_status,
|
|
||||||
unauthorized_response,
|
|
||||||
}) {
|
|
||||||
const authName = assertAuthName(name);
|
|
||||||
const authType = assertAuthType(type);
|
|
||||||
|
|
||||||
/** @type {Record<string, unknown> | null} */
|
|
||||||
let existing = null;
|
|
||||||
if (id != null && String(id).length > 0) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
existing = await db("http_auths").where({ id: authId }).first();
|
|
||||||
if (!existing) {
|
|
||||||
const err = new Error("auth not found");
|
|
||||||
err.statusCode = 404;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
|
||||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
|
||||||
const err = new Error(`auth name "${authName}" already exists`);
|
|
||||||
err.statusCode = 409;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
|
||||||
const normalized = normalizeAuthConfig(authType, config, {
|
|
||||||
keepLiteralsFrom: prevConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
let unauthStatus = null;
|
|
||||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
|
||||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
|
||||||
}
|
|
||||||
let unauthResponse = null;
|
|
||||||
if (
|
|
||||||
unauthorized_response != null &&
|
|
||||||
String(unauthorized_response).length > 0
|
|
||||||
) {
|
|
||||||
unauthResponse = String(unauthorized_response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso();
|
|
||||||
const configJson = JSON.stringify(normalized);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("http_auths")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
name: authName,
|
|
||||||
type: authType,
|
|
||||||
config: configJson,
|
|
||||||
unauthorized_status: unauthStatus,
|
|
||||||
unauthorized_response: unauthResponse,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = randomUUID();
|
|
||||||
await db("http_auths").insert({
|
|
||||||
id: newId,
|
|
||||||
name: authName,
|
|
||||||
type: authType,
|
|
||||||
config: configJson,
|
|
||||||
unauthorized_status: unauthStatus,
|
|
||||||
unauthorized_response: unauthResponse,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getHttpAuthById(newId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteHttpAuth(id) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
const n = await db("http_auths").where({ id: authId }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,16 +11,21 @@ export function isBinary(value) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||||
|
*/
|
||||||
|
export function toBuffer(value) {
|
||||||
|
if (Buffer.isBuffer(value)) return value;
|
||||||
|
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
||||||
|
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
||||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||||
*/
|
*/
|
||||||
export function summarizeBinary(value) {
|
export function summarizeBinary(value) {
|
||||||
const buf = Buffer.isBuffer(value)
|
const buf = toBuffer(value);
|
||||||
? value
|
|
||||||
: value instanceof ArrayBuffer
|
|
||||||
? Buffer.from(value)
|
|
||||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
||||||
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
||||||
return {
|
return {
|
||||||
type: "Buffer",
|
type: "Buffer",
|
||||||
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
|
||||||
|
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||||
|
*/
|
||||||
|
export function encodeBinary(value) {
|
||||||
|
const buf = toBuffer(value);
|
||||||
|
return {
|
||||||
|
type: "Buffer",
|
||||||
|
encoding: "base64",
|
||||||
|
data: buf.toString("base64"),
|
||||||
|
length: buf.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
export function isWireBuffer(value) {
|
||||||
|
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
|
||||||
|
if (obj.type !== "Buffer") return false;
|
||||||
|
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
|
||||||
|
return Array.isArray(obj.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace live Buffers with reconstructable JSON (for dry-run responses).
|
||||||
|
* Display still uses summarizeBinary / safeSerialize.
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
export function encodeBinaryForWire(value) {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
/** @param {unknown} v */
|
||||||
|
function walk(v) {
|
||||||
|
if (isBinary(v)) return encodeBinary(v);
|
||||||
|
if (typeof v === "bigint") return v.toString();
|
||||||
|
if (v == null || typeof v !== "object") return v;
|
||||||
|
if (seen.has(v)) return "[Circular]";
|
||||||
|
seen.add(v);
|
||||||
|
if (Array.isArray(v)) return v.map(walk);
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {};
|
||||||
|
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return walk(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`.
|
||||||
|
* Preview-only summaries (`preview` / `truncated`, no payload) are left as-is.
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
export function reviveBinaryFromWire(value) {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
/** @param {unknown} v */
|
||||||
|
function walk(v) {
|
||||||
|
if (v == null || typeof v !== "object") return v;
|
||||||
|
if (isWireBuffer(v)) {
|
||||||
|
const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v);
|
||||||
|
if (obj.encoding === "base64" && typeof obj.data === "string") {
|
||||||
|
return Buffer.from(obj.data, "base64");
|
||||||
|
}
|
||||||
|
return Buffer.from(/** @type {number[]} */ (obj.data));
|
||||||
|
}
|
||||||
|
if (seen.has(v)) return v;
|
||||||
|
seen.add(v);
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (let i = 0; i < v.length; i++) v[i] = walk(v[i]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
const obj = /** @type {Record<string, unknown>} */ (v);
|
||||||
|
for (const k of Object.keys(obj)) obj[k] = walk(obj[k]);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
return walk(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON.stringify replacer. Must be a real function so `this` is the holder:
|
* JSON.stringify replacer. Must be a real function so `this` is the holder:
|
||||||
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
||||||
|
|||||||
+1
-399
@@ -1,399 +1 @@
|
|||||||
import { db } from "./db.js";
|
export * from "./src/stores/kv-store.js";
|
||||||
|
|
||||||
const MAX_KEY_LENGTH = 512;
|
|
||||||
const MAX_NAMESPACE_LENGTH = 512;
|
|
||||||
const MAX_VALUE_BYTES = 256 * 1024;
|
|
||||||
const DEFAULT_LIST_LIMIT = 100;
|
|
||||||
const MAX_LIST_LIMIT = 500;
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function valuesEqual(a, b) {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a == null || b == null) return a === b;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function assertString(label, value) {
|
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
|
||||||
throw new Error(`${label} must be a non-empty string`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {string} value
|
|
||||||
* @param {number} max
|
|
||||||
*/
|
|
||||||
function assertMaxLength(label, value, max) {
|
|
||||||
if (value.length > max) {
|
|
||||||
throw new Error(`${label} must be at most ${max} characters`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
*/
|
|
||||||
function assertNamespace(namespace) {
|
|
||||||
assertString("namespace", namespace);
|
|
||||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
*/
|
|
||||||
function assertKey(key) {
|
|
||||||
assertString("key", key);
|
|
||||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function serializeKvValue(value) {
|
|
||||||
let json;
|
|
||||||
try {
|
|
||||||
json = JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
throw new Error("value must be JSON-serializable");
|
|
||||||
}
|
|
||||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
|
||||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null | undefined} value
|
|
||||||
* @returns {unknown}
|
|
||||||
*/
|
|
||||||
function deserializeKvValue(value) {
|
|
||||||
if (value == null) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ expires_at?: string | null }} row
|
|
||||||
*/
|
|
||||||
function isExpired(row) {
|
|
||||||
if (!row.expires_at) return false;
|
|
||||||
return Date.parse(row.expires_at) <= Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
export async function kvGet(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const row = await db("script_state").where({ namespace, key }).first();
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key }).del();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return deserializeKvValue(row.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvSet(namespace, key, value, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const json = serializeKvValue(value);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db("script_state")
|
|
||||||
.insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
})
|
|
||||||
.onConflict(["namespace", "key"])
|
|
||||||
.merge({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function kvDelete(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
|
||||||
return deleted > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
|
||||||
*/
|
|
||||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
return db.transaction(async (trx) => {
|
|
||||||
const row = await trx("script_state").where({ namespace, key }).first();
|
|
||||||
|
|
||||||
if (row && isExpired(row)) {
|
|
||||||
await trx("script_state").where({ namespace, key }).del();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRow =
|
|
||||||
row && !isExpired(row)
|
|
||||||
? row
|
|
||||||
: await trx("script_state").where({ namespace, key }).first();
|
|
||||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
|
||||||
|
|
||||||
if (!valuesEqual(previous, expected)) {
|
|
||||||
return { ok: false, previous };
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = serializeKvValue(next);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRow) {
|
|
||||||
await trx("script_state").where({ namespace, key }).update({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await trx("script_state").insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, previous };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {{ limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvList(namespace, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
|
||||||
MAX_LIST_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = await db("script_state")
|
|
||||||
.where({ namespace })
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key: row.key }).del();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items.push({
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeLike(value) {
|
|
||||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneExpiredKv() {
|
|
||||||
await db("script_state")
|
|
||||||
.whereNotNull("expires_at")
|
|
||||||
.andWhere("expires_at", "<=", nowIso())
|
|
||||||
.del();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{
|
|
||||||
* namespace?: string,
|
|
||||||
* q?: string,
|
|
||||||
* limit?: number,
|
|
||||||
* offset?: number,
|
|
||||||
* }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvQuery(opts = {}) {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
|
|
||||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
|
||||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
|
||||||
|
|
||||||
let q = db("script_state");
|
|
||||||
if (opts.namespace) {
|
|
||||||
assertNamespace(opts.namespace);
|
|
||||||
q = q.where({ namespace: opts.namespace });
|
|
||||||
}
|
|
||||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
|
||||||
const like = `%${escapeLike(opts.q)}%`;
|
|
||||||
q = q.where(function likeSearch() {
|
|
||||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
|
||||||
"value LIKE ? ESCAPE '\\'",
|
|
||||||
[like],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const countRow = await q.clone().count({ count: "*" }).first();
|
|
||||||
const total = Number(countRow?.count ?? 0);
|
|
||||||
|
|
||||||
const rows = await q
|
|
||||||
.clone()
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.orderBy("namespace", "asc")
|
|
||||||
.orderBy("key", "asc")
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: rows.map((row) => ({
|
|
||||||
namespace: row.namespace,
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<string[]>}
|
|
||||||
*/
|
|
||||||
export async function kvNamespaces() {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
|
||||||
return rows.map((row) => row.namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} defaultNamespace
|
|
||||||
*/
|
|
||||||
export function createKvApi(defaultNamespace) {
|
|
||||||
assertNamespace(defaultNamespace);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
function resolveNamespace(opts = {}) {
|
|
||||||
const namespace = opts.namespace ?? defaultNamespace;
|
|
||||||
assertNamespace(namespace);
|
|
||||||
return namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
namespace: defaultNamespace,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
get(key, opts) {
|
|
||||||
return kvGet(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
set(key, value, opts) {
|
|
||||||
const { namespace, expiresAt } = opts ?? {};
|
|
||||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
delete(key, opts) {
|
|
||||||
return kvDelete(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
compareAndSet(key, expected, next, opts) {
|
|
||||||
const { expiresAt } = opts ?? {};
|
|
||||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
list(opts) {
|
|
||||||
const { namespace, limit } = opts ?? {};
|
|
||||||
return kvList(resolveNamespace(opts), { limit });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function up(knex) {
|
||||||
|
await knex.schema.createTable("profiles", (t) => {
|
||||||
|
t.text("id").primary();
|
||||||
|
t.text("owner").notNullable();
|
||||||
|
t.text("name").notNullable();
|
||||||
|
t.text("script").notNullable();
|
||||||
|
t.text("config").notNullable();
|
||||||
|
t.text("description").notNullable().defaultTo("");
|
||||||
|
t.text("created_at").notNullable();
|
||||||
|
t.text("updated_at").notNullable();
|
||||||
|
t.unique(["owner", "name"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("knex").Knex} knex
|
||||||
|
*/
|
||||||
|
export async function down(knex) {
|
||||||
|
await knex.schema.dropTableIfExists("profiles");
|
||||||
|
}
|
||||||
@@ -13,9 +13,13 @@
|
|||||||
"start:control": "node control.js",
|
"start:control": "node control.js",
|
||||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||||
"test:workflow-history": "node test/workflow-history-smoke.js"
|
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||||
|
"reset-admin": "node reset-admin.js",
|
||||||
|
"test:profiles": "node test/profiles-smoke.js",
|
||||||
|
"test:set-dry-run": "node test/set-dry-run-smoke.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jerapah-flow/shared": "workspace:*",
|
||||||
"@aws-sdk/client-s3": "^3.1111.0",
|
"@aws-sdk/client-s3": "^3.1111.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -38,6 +42,7 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-roll": "^4.0.0",
|
"pino-roll": "^4.0.0",
|
||||||
"pm2": "^6.0.13",
|
"pm2": "^6.0.13",
|
||||||
|
"rss-parser": "^3.13.0",
|
||||||
"ssh2-sftp-client": "^12.1.1",
|
"ssh2-sftp-client": "^12.1.1",
|
||||||
"webdav": "^5.10.0",
|
"webdav": "^5.10.0",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { fileURLToPath } from "url";
|
|||||||
|
|
||||||
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||||
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
|
||||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||||
|
/** Live instance workflows (not shipped in git). Override for tests. */
|
||||||
|
export const WORKFLOWS_DIR =
|
||||||
|
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows");
|
||||||
|
/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */
|
||||||
|
export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||||
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||||
export const PLUGINS_DIR =
|
export const PLUGINS_DIR =
|
||||||
process.env.JFLOW_PLUGINS_DIR ??
|
process.env.JFLOW_PLUGINS_DIR ??
|
||||||
@@ -14,5 +18,10 @@ export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
|||||||
SERVER_ROOT,
|
SERVER_ROOT,
|
||||||
"../../examples/plugins",
|
"../../examples/plugins",
|
||||||
);
|
);
|
||||||
|
/** Example workflow YAML presets (not loaded by the runner). */
|
||||||
|
export const EXAMPLE_WORKFLOWS_DIR = path.resolve(
|
||||||
|
SERVER_ROOT,
|
||||||
|
"../../examples/workflows",
|
||||||
|
);
|
||||||
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
||||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
installPluginFromDirectory,
|
installPluginFromDirectory,
|
||||||
pluginDir,
|
pluginDir,
|
||||||
} from "./plugin-store.js";
|
} from "./plugin-store.js";
|
||||||
import { readManifestFile } from "./plugin-manifest.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) {
|
|||||||
});
|
});
|
||||||
return { ...installed, already: false };
|
return { ...installed, already: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
void readManifestFile;
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "@jerapah-flow/shared";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src/stores/profiles-store.js";
|
||||||
+38
-134
@@ -23,14 +23,14 @@ import {
|
|||||||
storedEnvelope,
|
storedEnvelope,
|
||||||
} from "./step-result.js";
|
} from "./step-result.js";
|
||||||
import * as fsStore from "./fs-store.js";
|
import * as fsStore from "./fs-store.js";
|
||||||
import {
|
|
||||||
checkAnyHttpAuth,
|
|
||||||
resolveAuthMechanisms,
|
|
||||||
resolveUnauthorizedSpec,
|
|
||||||
sendHttpPageOrJson,
|
|
||||||
sendSuccessPage,
|
|
||||||
} from "./http-trigger-auth.js";
|
|
||||||
import { resolveConfigRefs } from "./config-refs.js";
|
import { resolveConfigRefs } from "./config-refs.js";
|
||||||
|
import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||||
|
import {
|
||||||
|
createHttpTriggerHandler,
|
||||||
|
ensureHttpWildcardRoute,
|
||||||
|
rebuildHttpRoutes,
|
||||||
|
} from "./workflow-http-routes.js";
|
||||||
|
import { getProfilePlain } from "./profiles-store.js";
|
||||||
import {
|
import {
|
||||||
buildFailureAlertData,
|
buildFailureAlertData,
|
||||||
resolveFailureTriggerConfig,
|
resolveFailureTriggerConfig,
|
||||||
@@ -46,17 +46,6 @@ import { publishReload } from "./control-bus.js";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
||||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} workflow
|
|
||||||
*/
|
|
||||||
function hasWorkflowTrigger(workflow) {
|
|
||||||
if (!workflow || typeof workflow !== "object") return false;
|
|
||||||
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
|
|
||||||
.triggers;
|
|
||||||
return (triggers ?? []).some((t) => t?.type === "workflow");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} server
|
* @param {import("fastify").FastifyInstance} server
|
||||||
@@ -80,7 +69,14 @@ export function createRegistry(server, opts = {}) {
|
|||||||
let pruneTask = null;
|
let pruneTask = null;
|
||||||
/** @type {Map<string, HttpRouteEntry>} */
|
/** @type {Map<string, HttpRouteEntry>} */
|
||||||
const httpRoutes = new Map();
|
const httpRoutes = new Map();
|
||||||
let httpDispatcherRegistered = false;
|
const httpDispatcherState = { registered: false };
|
||||||
|
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||||
|
httpRoutes,
|
||||||
|
workflows,
|
||||||
|
namespacedPath,
|
||||||
|
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||||
@@ -186,118 +182,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
* Fastify route once so path/method changes apply on reregister without restart.
|
* Fastify route once so path/method changes apply on reregister without restart.
|
||||||
*/
|
*/
|
||||||
function registerHttpTriggers() {
|
function registerHttpTriggers() {
|
||||||
httpRoutes.clear();
|
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||||
|
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||||
for (const [key, { owner, workflow }] of workflows) {
|
log,
|
||||||
if (workflow.enabled === false) {
|
});
|
||||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const trigger of workflow.triggers ?? []) {
|
|
||||||
if (trigger.type !== "HTTP") continue;
|
|
||||||
|
|
||||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
|
||||||
const url = namespacedPath(owner, trigger.path);
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
|
|
||||||
if (httpRoutes.has(routeKey)) {
|
|
||||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
|
||||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpDispatcherRegistered) {
|
|
||||||
httpDispatcherRegistered = true;
|
|
||||||
server.route({
|
|
||||||
method: HTTP_METHODS,
|
|
||||||
url: "/u/*",
|
|
||||||
handler: dispatchHttpTrigger,
|
|
||||||
});
|
|
||||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("fastify").FastifyRequest} req
|
|
||||||
* @param {import("fastify").FastifyReply} reply
|
|
||||||
*/
|
|
||||||
async function dispatchHttpTrigger(req, reply) {
|
|
||||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
|
||||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
|
||||||
const method = String(req.method ?? "GET").toUpperCase();
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
const mapped = httpRoutes.get(routeKey);
|
|
||||||
|
|
||||||
if (!mapped) {
|
|
||||||
return reply.code(404).send({ error: "not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = workflows.get(mapped.key);
|
|
||||||
if (!entry || entry.workflow?.enabled === false) {
|
|
||||||
return reply.code(404).send({ error: "workflow disabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
|
||||||
const liveTrigger =
|
|
||||||
(entry.workflow.triggers ?? []).find((t) => {
|
|
||||||
if (t?.type !== "HTTP") return false;
|
|
||||||
const m = String(t.method ?? "POST").toUpperCase();
|
|
||||||
const p = namespacedPath(entry.owner, t.path);
|
|
||||||
return m === method && p === url;
|
|
||||||
}) ?? mapped.trigger;
|
|
||||||
|
|
||||||
if (
|
|
||||||
liveTrigger.auth != null &&
|
|
||||||
liveTrigger.auth !== false &&
|
|
||||||
!(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0)
|
|
||||||
) {
|
|
||||||
const mechanisms = await resolveAuthMechanisms(liveTrigger.auth);
|
|
||||||
if (mechanisms.length === 0) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ok = await checkAnyHttpAuth(req, mechanisms, {
|
|
||||||
owner: entry.owner,
|
|
||||||
workflowKey: mapped.key,
|
|
||||||
});
|
|
||||||
if (!ok) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(
|
|
||||||
liveTrigger,
|
|
||||||
mechanisms[0],
|
|
||||||
);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await enqueueWorkflow(
|
|
||||||
mapped.key,
|
|
||||||
{ data: req.body },
|
|
||||||
{ type: "http", detail: `${method} ${url}` },
|
|
||||||
);
|
|
||||||
if (result.status === "failed") {
|
|
||||||
return reply.code(result.runId ? 500 : 404).send({
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
error: result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultBody = {
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
};
|
|
||||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
|
||||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
|
||||||
}
|
|
||||||
return reply.code(202).send(defaultBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCronTriggers() {
|
function registerCronTriggers() {
|
||||||
@@ -595,8 +483,21 @@ export function createRegistry(server, opts = {}) {
|
|||||||
owner,
|
owner,
|
||||||
depth,
|
depth,
|
||||||
) {
|
) {
|
||||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||||
const unresolvedConfig = parsed.config;
|
let unresolvedConfig = parsed.config;
|
||||||
|
if (parsed.kind === "script" && parsed.profile) {
|
||||||
|
const profile = await getProfilePlain(owner, parsed.profile);
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`profile "${parsed.profile}" not found`);
|
||||||
|
}
|
||||||
|
if (parsed.script && parsed.script !== profile.script) {
|
||||||
|
throw new Error(
|
||||||
|
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
script = profile.script;
|
||||||
|
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
|
||||||
|
}
|
||||||
const incomingContext = normalizeContext(ctx.context);
|
const incomingContext = normalizeContext(ctx.context);
|
||||||
const step = await store.startStep({
|
const step = await store.startStep({
|
||||||
runId,
|
runId,
|
||||||
@@ -956,7 +857,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
const parsed = parseScriptStep(raw);
|
||||||
if (parsed.kind === "script") refs.add(parsed.script);
|
if (parsed.kind === "script") {
|
||||||
|
if (parsed.script) refs.add(parsed.script);
|
||||||
|
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// skip invalid steps
|
// skip invalid steps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Reset (or create) the admin username and password.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||||
|
*
|
||||||
|
* Uses JFLOW_DB_PATH like the app. Never prints the password.
|
||||||
|
*/
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { db, migrate } from "./db.js";
|
||||||
|
import * as store from "./store.js";
|
||||||
|
import { validateCredentials } from "./src/api/auth.js";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
/** @type {{ username?: string, password?: string }} */
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--username" || arg === "-u") {
|
||||||
|
out.username = argv[++i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--password" || arg === "-p") {
|
||||||
|
out.password = argv[++i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
out.help = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Usage:
|
||||||
|
pnpm --dir packages/server reset-admin -- --username <name> --password <secret>
|
||||||
|
|
||||||
|
Creates an admin if none exist; otherwise updates the oldest admin's
|
||||||
|
username and password. Credentials must match login rules
|
||||||
|
(username 3-32 [A-Za-z0-9_], password at least 8 characters).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = typeof args.username === "string" ? args.username.trim() : "";
|
||||||
|
const password = typeof args.password === "string" ? args.password : "";
|
||||||
|
if (!username || !password) {
|
||||||
|
usage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credErr = validateCredentials(username, password);
|
||||||
|
if (credErr) {
|
||||||
|
console.error(credErr);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const admins = await db("users")
|
||||||
|
.where({ role: "admin" })
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.select("id", "username");
|
||||||
|
|
||||||
|
if (admins.length === 0) {
|
||||||
|
const user = await store.createUser({
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
role: "admin",
|
||||||
|
});
|
||||||
|
console.log(`Created admin user "${user.username}" (${user.id})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = admins[0];
|
||||||
|
const taken = await store.getUserAuthByUsername(username);
|
||||||
|
if (taken && taken.id !== admin.id) {
|
||||||
|
console.error(`username "${username}" is already taken by another user`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await store.updateUser(admin.id, {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
role: "admin",
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
@@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
|
|||||||
* @param {unknown} fn
|
* @param {unknown} fn
|
||||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||||
*/
|
*/
|
||||||
export function extractScriptMeta(fn) {
|
function extractScriptMeta(fn) {
|
||||||
if (typeof fn !== "function") {
|
if (typeof fn !== "function") {
|
||||||
return { meta: null, metaError: "default export must be a function" };
|
return { meta: null, metaError: "default export must be a function" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
function ensureDataObject(ctx) {
|
function passContext(ctx) {
|
||||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
ctx.data = {};
|
return { ...ctx.context };
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeData(data) {
|
||||||
|
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
return { ...data };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_METHODS = new Set([
|
const ALLOWED_METHODS = new Set([
|
||||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
|||||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eval context for fingerprint/transform JSONata (reads `data.*`).
|
||||||
|
* @param {unknown} ctx
|
||||||
|
* @param {Record<string, unknown>} data
|
||||||
|
*/
|
||||||
|
function evalCtx(ctx, data) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
context: passContext(ctx),
|
||||||
|
config: ctx?.config ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function detectUrlChanges(ctx) {
|
async function detectUrlChanges(ctx) {
|
||||||
const url = ctx.config?.url;
|
const url = ctx.config?.url;
|
||||||
if (typeof url !== "string" || url.length === 0) {
|
if (typeof url !== "string" || url.length === 0) {
|
||||||
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDataObject(ctx);
|
const data = mergeData(ctx.data);
|
||||||
|
|
||||||
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||||
const response = await $axios.request({
|
const response = await $axios.request({
|
||||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
|||||||
"detect-url-changes: fetch complete",
|
"detect-url-changes: fetch complete",
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.data.httpResponse = response.data;
|
data.httpResponse = response.data;
|
||||||
|
|
||||||
let fingerprintSource = ctx.data.httpResponse;
|
let fingerprintSource = data.httpResponse;
|
||||||
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||||
log.info(
|
log.info(
|
||||||
{ jsonata: fingerprintExpr },
|
{ jsonata: fingerprintExpr },
|
||||||
"detect-url-changes: evaluating fingerprint jsonata",
|
"detect-url-changes: evaluating fingerprint jsonata",
|
||||||
);
|
);
|
||||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
|
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await $fingerprint.claim(key, fingerprintSource, {
|
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||||
maxAge: ctx.config?.maxAge,
|
maxAge: ctx.config?.maxAge,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.hasChanges = result.changed;
|
const extra = {
|
||||||
ctx.data.fingerprint = result.hash;
|
hasChanges: result.changed,
|
||||||
ctx.data.fingerprintChanged = result.changed;
|
fingerprint: result.hash,
|
||||||
ctx.data.fingerprintPrevious = result.previous;
|
fingerprintChanged: result.changed,
|
||||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
fingerprintPrevious: result.previous,
|
||||||
ctx.data.fingerprintAge = result.ageMs;
|
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||||
ctx.data.fingerprintExpired = result.expired;
|
fingerprintAge: result.ageMs,
|
||||||
|
fingerprintExpired: result.expired,
|
||||||
|
};
|
||||||
|
Object.assign(data, extra);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
|||||||
{ outputVar, jsonata: transformExpr },
|
{ outputVar, jsonata: transformExpr },
|
||||||
"detect-url-changes: evaluating transform jsonata",
|
"detect-url-changes: evaluating transform jsonata",
|
||||||
);
|
);
|
||||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||||
ctx.data[outputVar] = transformed;
|
data[outputVar] = transformed;
|
||||||
log.info(
|
log.info(
|
||||||
{ outputVar, value: previewValue(transformed) },
|
{ outputVar, value: previewValue(transformed) },
|
||||||
"detect-url-changes: saved transform result",
|
"detect-url-changes: saved transform result",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
data[outputVar] = data.httpResponse;
|
||||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||||
|
const envelope = {
|
||||||
|
output: data,
|
||||||
|
context: { ...passContext(ctx), ...extra },
|
||||||
|
};
|
||||||
if (skipRemainingWhenUnchanged && !result.changed) {
|
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||||
ctx.skipRemaining = true;
|
envelope.skipRemaining = true;
|
||||||
}
|
}
|
||||||
|
return envelope;
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
detectUrlChanges.meta = {
|
detectUrlChanges.meta = {
|
||||||
description:
|
description:
|
||||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP"],
|
||||||
|
reads: "ctx",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "URL to fetch" },
|
url: { type: "string", required: true, description: "URL to fetch" },
|
||||||
method: {
|
method: {
|
||||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
|||||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||||
fingerprintExpired: { type: "boolean" },
|
fingerprintExpired: { type: "boolean" },
|
||||||
},
|
},
|
||||||
|
context: {
|
||||||
|
hasChanges: { type: "boolean" },
|
||||||
|
fingerprint: { type: "string" },
|
||||||
|
fingerprintChanged: { type: "boolean" },
|
||||||
|
fingerprintPrevious: { type: "string", required: false },
|
||||||
|
fingerprintAt: { type: "string", required: false },
|
||||||
|
fingerprintAge: { type: "number", required: false },
|
||||||
|
fingerprintExpired: { type: "boolean" },
|
||||||
|
},
|
||||||
example: {
|
example: {
|
||||||
data: {},
|
data: {},
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,144 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/secrets-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertOwner } from "./fs-store.js";
|
|
||||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
|
||||||
import { registerPlaintext } from "./secret-value.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertSecretName(name) {
|
|
||||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
|
||||||
const err = new Error("invalid secret name");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicSecret(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
owner: row.owner,
|
|
||||||
name: row.name,
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner?: string }} [filters]
|
|
||||||
*/
|
|
||||||
export async function listSecrets(filters = {}) {
|
|
||||||
let q = db("secrets")
|
|
||||||
.select("id", "owner", "name", "created_at", "updated_at")
|
|
||||||
.orderBy("owner", "asc")
|
|
||||||
.orderBy("name", "asc");
|
|
||||||
if (filters.owner) {
|
|
||||||
q = q.where("owner", assertOwner(filters.owner));
|
|
||||||
}
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getSecretById(id) {
|
|
||||||
const row = await db("secrets")
|
|
||||||
.select("id", "owner", "name", "created_at", "updated_at")
|
|
||||||
.where({ id })
|
|
||||||
.first();
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner: string, name: string, value: string }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertSecret({ owner, name, value }) {
|
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
|
||||||
const err = new Error("value is required");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const secretName = assertSecretName(name);
|
|
||||||
registerPlaintext(value);
|
|
||||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
|
||||||
const now = nowIso();
|
|
||||||
const existing = await db("secrets")
|
|
||||||
.where({ owner: ownerName, name: secretName })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("secrets")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
ciphertext,
|
|
||||||
iv,
|
|
||||||
auth_tag: authTag,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getSecretById(existing.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID();
|
|
||||||
await db("secrets").insert({
|
|
||||||
id,
|
|
||||||
owner: ownerName,
|
|
||||||
name: secretName,
|
|
||||||
ciphertext,
|
|
||||||
iv,
|
|
||||||
auth_tag: authTag,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getSecretById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteSecret(id) {
|
|
||||||
const n = await db("secrets").where({ id }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a named secret for an owner. Returns null if missing.
|
|
||||||
* @param {string} owner
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {Promise<string | null>}
|
|
||||||
*/
|
|
||||||
export async function getSecretPlaintext(owner, name) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const secretName = assertSecretName(name);
|
|
||||||
const row = await db("secrets")
|
|
||||||
.where({ owner: ownerName, name: secretName })
|
|
||||||
.first();
|
|
||||||
if (!row) return null;
|
|
||||||
try {
|
|
||||||
const plaintext = decryptSecret({
|
|
||||||
ciphertext: row.ciphertext,
|
|
||||||
iv: row.iv,
|
|
||||||
authTag: row.auth_tag,
|
|
||||||
});
|
|
||||||
registerPlaintext(plaintext);
|
|
||||||
return plaintext;
|
|
||||||
} catch {
|
|
||||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
|||||||
/**
|
/**
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
*/
|
*/
|
||||||
export function getMasterKey() {
|
function getMasterKey() {
|
||||||
if (cachedKey) return cachedKey;
|
if (cachedKey) return cachedKey;
|
||||||
const raw = resolveSecretsKeyMaterial();
|
const raw = resolveSecretsKeyMaterial();
|
||||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { kvNamespaces, kvQuery } from "../../kv-store.js";
|
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
|
|||||||
offset: Number.isFinite(offset) ? offset : undefined,
|
offset: Number.isFinite(offset) ? offset : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.delete("/kv", async (req, reply) => {
|
||||||
|
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||||
|
try {
|
||||||
|
const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? ""));
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "kv entry not found" });
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as fsStore from "../../fs-store.js";
|
||||||
|
import {
|
||||||
|
assertProfileName,
|
||||||
|
deleteProfile,
|
||||||
|
getProfileById,
|
||||||
|
getProfilePlain,
|
||||||
|
listProfileUsages,
|
||||||
|
listProfiles,
|
||||||
|
upsertProfile,
|
||||||
|
} from "../../profiles-store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function profilesPlugin(fastify) {
|
||||||
|
fastify.get("/profiles", async (req, reply) => {
|
||||||
|
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||||
|
try {
|
||||||
|
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||||
|
const profiles = await listProfiles({ owner });
|
||||||
|
const withUsage = profiles.map((profile) => ({
|
||||||
|
...profile,
|
||||||
|
usageCount: listProfileUsages(profile.owner, profile.name).length,
|
||||||
|
}));
|
||||||
|
return { profiles: withUsage };
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/profiles/:id/usage", async (req, reply) => {
|
||||||
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
|
const existing = await getProfileById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ error: "profile not found" });
|
||||||
|
}
|
||||||
|
return { usages: listProfileUsages(existing.owner, existing.name) };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.put("/profiles", async (req, reply) => {
|
||||||
|
const body = /** @type {{
|
||||||
|
owner?: string,
|
||||||
|
name?: string,
|
||||||
|
script?: unknown,
|
||||||
|
config?: unknown,
|
||||||
|
description?: unknown,
|
||||||
|
}} */ (req.body ?? {});
|
||||||
|
try {
|
||||||
|
fsStore.assertOwner(String(body.owner ?? ""));
|
||||||
|
assertProfileName(String(body.name ?? ""));
|
||||||
|
const profile = await upsertProfile({
|
||||||
|
owner: String(body.owner),
|
||||||
|
name: String(body.name),
|
||||||
|
script: body.script,
|
||||||
|
config: body.config,
|
||||||
|
description: body.description,
|
||||||
|
});
|
||||||
|
return reply.send({ profile });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete("/profiles/:id", async (req, reply) => {
|
||||||
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
|
const q = /** @type {{ force?: string }} */ (req.query ?? {});
|
||||||
|
const existing = await getProfileById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ error: "profile not found" });
|
||||||
|
}
|
||||||
|
const usages = listProfileUsages(existing.owner, existing.name);
|
||||||
|
const force = q.force === "1" || q.force === "true";
|
||||||
|
if (usages.length > 0 && !force) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "profile is used by workflows",
|
||||||
|
usages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deleteProfile(id);
|
||||||
|
return { ok: true, forced: force && usages.length > 0, usages };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as store from "../../store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | undefined>} q
|
||||||
|
*/
|
||||||
|
export function parseRunQueryParams(q) {
|
||||||
|
const limit = q.limit != null ? Number(q.limit) : undefined;
|
||||||
|
const offset = q.offset != null ? Number(q.offset) : undefined;
|
||||||
|
return {
|
||||||
|
owner: q.owner || undefined,
|
||||||
|
workflow: q.workflow || undefined,
|
||||||
|
status: q.status || undefined,
|
||||||
|
trigger_type: q.trigger || undefined,
|
||||||
|
after: q.after || undefined,
|
||||||
|
before: q.before || undefined,
|
||||||
|
limit: Number.isFinite(limit) ? limit : undefined,
|
||||||
|
offset: Number.isFinite(offset) ? offset : undefined,
|
||||||
|
sort: q.sort || undefined,
|
||||||
|
order: q.order || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | undefined>} q
|
||||||
|
*/
|
||||||
|
export async function queryRunsFromRequest(q) {
|
||||||
|
return store.queryRuns(parseRunQueryParams(q));
|
||||||
|
}
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
import * as store from "../../store.js";
|
import * as store from "../../store.js";
|
||||||
|
import { queryRunsFromRequest } from "./run-query.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
export default async function runsPlugin(fastify) {
|
export default async function runsPlugin(fastify) {
|
||||||
fastify.get("/runs", async (req) => {
|
fastify.get("/runs", async (req) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
|
||||||
const runs = await store.listRuns({
|
|
||||||
owner: q.owner,
|
|
||||||
workflow: q.workflow,
|
|
||||||
status: q.status,
|
|
||||||
limit: Number.isFinite(limit) ? limit : undefined,
|
|
||||||
before: q.before,
|
|
||||||
});
|
|
||||||
return { runs };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/consecutive-failures", async (req) => {
|
fastify.get("/consecutive-failures", async (req) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import * as fsStore from "../../fs-store.js";
|
import * as fsStore from "../../fs-store.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
getInstalledPlugin,
|
|
||||||
listCoreScriptNames,
|
listCoreScriptNames,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
@@ -22,11 +21,16 @@ import {
|
|||||||
installPluginFromZipBuffer,
|
installPluginFromZipBuffer,
|
||||||
} from "../../plugin-install.js";
|
} from "../../plugin-install.js";
|
||||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||||
|
import {
|
||||||
|
encodeBinaryForWire,
|
||||||
|
reviveBinaryFromWire,
|
||||||
|
} from "../../json-preview.js";
|
||||||
import { normalizeStepResult } from "../../step-result.js";
|
import { normalizeStepResult } from "../../step-result.js";
|
||||||
import { resolveConfigRefs } from "../../config-refs.js";
|
import { resolveConfigRefs } from "../../config-refs.js";
|
||||||
import { getAppVersion } from "../../app-version.js";
|
import { getAppVersion } from "../../app-version.js";
|
||||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
import { pluginScriptRef } from "../../plugin-manifest.js";
|
||||||
|
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||||
@@ -250,14 +254,11 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
const rawName = decodeURIComponent(
|
const rawName = decodeURIComponent(
|
||||||
/** @type {{ name: string }} */ (req.params).name,
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
);
|
);
|
||||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||||
req.body ?? {}
|
req.body ?? {}
|
||||||
);
|
);
|
||||||
if (typeof body.content !== "string") {
|
|
||||||
return reply.code(400).send({ error: "content is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner = "default";
|
let owner = "local";
|
||||||
if (body.owner != null && body.owner !== "") {
|
if (body.owner != null && body.owner !== "") {
|
||||||
try {
|
try {
|
||||||
owner = fsStore.assertOwner(String(body.owner));
|
owner = fsStore.assertOwner(String(body.owner));
|
||||||
@@ -266,12 +267,92 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingContext =
|
const incomingContext = reviveBinaryFromWire(
|
||||||
body.context != null &&
|
body.context != null &&
|
||||||
typeof body.context === "object" &&
|
typeof body.context === "object" &&
|
||||||
!Array.isArray(body.context)
|
!Array.isArray(body.context)
|
||||||
? body.context
|
? body.context
|
||||||
: {};
|
: {},
|
||||||
|
);
|
||||||
|
const incomingData = reviveBinaryFromWire(body.data ?? null);
|
||||||
|
|
||||||
|
const { log, logs } = createDryRunLogger();
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
// Set steps are inline JSONata (no script file). Match registry runCompiledStep.
|
||||||
|
if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) {
|
||||||
|
const configObj =
|
||||||
|
body.config != null &&
|
||||||
|
typeof body.config === "object" &&
|
||||||
|
!Array.isArray(body.config)
|
||||||
|
? /** @type {Record<string, unknown>} */ (body.config)
|
||||||
|
: null;
|
||||||
|
const expression =
|
||||||
|
typeof body.expression === "string"
|
||||||
|
? body.expression
|
||||||
|
: typeof configObj?.expression === "string"
|
||||||
|
? configObj.expression
|
||||||
|
: null;
|
||||||
|
if (expression == null || !expression.trim()) {
|
||||||
|
return reply.code(400).send({ error: "expression is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await resolveConfigRefs(
|
||||||
|
{ ...(configObj ?? {}), expression },
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
workflowKey: "dry-run",
|
||||||
|
context: incomingContext,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ctx = {
|
||||||
|
data: incomingData,
|
||||||
|
context: incomingContext,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
const value = await evaluateJsonata(expression, ctx);
|
||||||
|
const result = normalizeStepResult(
|
||||||
|
{
|
||||||
|
output: value,
|
||||||
|
context: incomingContext,
|
||||||
|
skipRemaining: false,
|
||||||
|
},
|
||||||
|
incomingContext,
|
||||||
|
SET_STEP_SCRIPT,
|
||||||
|
);
|
||||||
|
log.info({ expression }, "set: dry-run evaluated");
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
output: safeSerialize(result.output),
|
||||||
|
context: safeSerialize(result.context),
|
||||||
|
wireOutput: encodeBinaryForWire(result.output),
|
||||||
|
wireContext: encodeBinaryForWire(result.context),
|
||||||
|
skipRemaining: result.skipRemaining,
|
||||||
|
error: null,
|
||||||
|
logs,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
meta: null,
|
||||||
|
metaError: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
output: null,
|
||||||
|
context: null,
|
||||||
|
skipRemaining: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
logs,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
meta: null,
|
||||||
|
metaError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.content !== "string") {
|
||||||
|
return reply.code(400).send({ error: "content is required" });
|
||||||
|
}
|
||||||
|
|
||||||
const resolved = resolveScriptRef(rawName);
|
const resolved = resolveScriptRef(rawName);
|
||||||
const pluginDir =
|
const pluginDir =
|
||||||
@@ -279,9 +360,6 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
? resolved.pluginDir ?? null
|
? resolved.pluginDir ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { log, logs } = createDryRunLogger();
|
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await resolveConfigRefs(body.config ?? null, {
|
const config = await resolveConfigRefs(body.config ?? null, {
|
||||||
owner,
|
owner,
|
||||||
@@ -289,7 +367,7 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
});
|
});
|
||||||
const ctx = {
|
const ctx = {
|
||||||
data: body.data ?? null,
|
data: incomingData,
|
||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
@@ -313,6 +391,8 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
status: "success",
|
status: "success",
|
||||||
output: safeSerialize(result.output),
|
output: safeSerialize(result.output),
|
||||||
context: safeSerialize(result.context),
|
context: safeSerialize(result.context),
|
||||||
|
wireOutput: encodeBinaryForWire(result.output),
|
||||||
|
wireContext: encodeBinaryForWire(result.context),
|
||||||
skipRemaining: result.skipRemaining,
|
skipRemaining: result.skipRemaining,
|
||||||
error: null,
|
error: null,
|
||||||
logs,
|
logs,
|
||||||
@@ -475,7 +555,5 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
void getInstalledPlugin;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
collectWorkflowWarnings,
|
collectWorkflowWarnings,
|
||||||
parseWorkflowDocument,
|
parseWorkflowDocument,
|
||||||
} from "../../workflow-validate-warnings.js";
|
} from "../../workflow-validate-warnings.js";
|
||||||
|
import { getProfilePlain } from "../../profiles-store.js";
|
||||||
|
import { resolveScriptRef } from "../../plugin-store.js";
|
||||||
import {
|
import {
|
||||||
recordRevision,
|
recordRevision,
|
||||||
listRevisions,
|
listRevisions,
|
||||||
@@ -43,6 +45,11 @@ import {
|
|||||||
createWorkflowBackupBuffer,
|
createWorkflowBackupBuffer,
|
||||||
restoreWorkflowBackup,
|
restoreWorkflowBackup,
|
||||||
} from "../../workflow-backup.js";
|
} from "../../workflow-backup.js";
|
||||||
|
import {
|
||||||
|
listExampleWorkflows,
|
||||||
|
readExampleWorkflow,
|
||||||
|
assertExampleWorkflowId,
|
||||||
|
} from "../../workflow-examples.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload this process and notify other HTTP/worker processes via Redis.
|
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||||
@@ -81,7 +88,9 @@ function scriptNames(workflow) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
const parsed = parseScriptStep(raw);
|
||||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
if (parsed.kind === "set") names.push("set");
|
||||||
|
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
|
||||||
|
else names.push(parsed.script);
|
||||||
} catch {
|
} catch {
|
||||||
names.push(null);
|
names.push(null);
|
||||||
}
|
}
|
||||||
@@ -89,6 +98,59 @@ function scriptNames(workflow) {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} parsed
|
||||||
|
* @param {string} owner
|
||||||
|
*/
|
||||||
|
async function collectProfileWarnings(parsed, owner) {
|
||||||
|
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||||
|
const warnings = [];
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||||
|
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||||
|
const profileName = raw.profile;
|
||||||
|
if (typeof profileName !== "string" || !profileName) continue;
|
||||||
|
const pathKey = `scripts[${i}]`;
|
||||||
|
let profile;
|
||||||
|
try {
|
||||||
|
profile = await getProfilePlain(owner, profileName);
|
||||||
|
} catch {
|
||||||
|
warnings.push({
|
||||||
|
code: "unknown_profile",
|
||||||
|
message: `Profile "${profileName}" is not a valid name`,
|
||||||
|
path: pathKey,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!profile) {
|
||||||
|
warnings.push({
|
||||||
|
code: "unknown_profile",
|
||||||
|
message: `Profile "${profileName}" not found`,
|
||||||
|
path: pathKey,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
|
||||||
|
warnings.push({
|
||||||
|
code: "profile_script_mismatch",
|
||||||
|
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
|
||||||
|
path: pathKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const resolved = resolveScriptRef(profile.script);
|
||||||
|
if (resolved.error) {
|
||||||
|
warnings.push({
|
||||||
|
code: "unknown_script",
|
||||||
|
message: resolved.error,
|
||||||
|
path: `${pathKey}.profile`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} parsed
|
* @param {unknown} parsed
|
||||||
*/
|
*/
|
||||||
@@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) {
|
|||||||
* }} opts
|
* }} opts
|
||||||
*/
|
*/
|
||||||
async function saveWorkflowContent(opts) {
|
async function saveWorkflowContent(opts) {
|
||||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||||
const saveAnyway = Boolean(opts.saveAnyway);
|
if (parsed) {
|
||||||
|
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||||
|
}
|
||||||
|
const saveAnyway = Boolean(opts.saveAnyway);
|
||||||
|
|
||||||
if (!saveAnyway) {
|
if (!saveAnyway) {
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
@@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return { owners: fsStore.listOwners() };
|
return { owners: fsStore.listOwners() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/workflow-examples", async () => {
|
||||||
|
return { examples: listExampleWorkflows() };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/workflow-examples/:id", async (req, reply) => {
|
||||||
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
|
if (!assertExampleWorkflowId(id)) {
|
||||||
|
return reply.code(400).send({ error: "invalid example id" });
|
||||||
|
}
|
||||||
|
const example = readExampleWorkflow(id);
|
||||||
|
if (!example) {
|
||||||
|
return reply.code(404).send({ error: "example not found" });
|
||||||
|
}
|
||||||
|
return example;
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/workflows/trash", async () => {
|
fastify.get("/workflows/trash", async () => {
|
||||||
return { items: await listTrash() };
|
return { items: await listTrash() };
|
||||||
});
|
});
|
||||||
@@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
enabled: parsed ? parsed.enabled !== false : false,
|
enabled: parsed ? parsed.enabled !== false : false,
|
||||||
registered: registered.includes(file),
|
registered: registered.includes(file),
|
||||||
loadError: loadError ?? (parsed ? null : "unreadable"),
|
loadError: loadError ?? (parsed ? null : "unreadable"),
|
||||||
|
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
|
||||||
lastInvokedAt: st.lastInvokedAt,
|
lastInvokedAt: st.lastInvokedAt,
|
||||||
lastStatus: st.lastStatus ?? null,
|
lastStatus: st.lastStatus ?? null,
|
||||||
invocationCount: st.invocationCount,
|
invocationCount: st.invocationCount,
|
||||||
@@ -315,6 +397,13 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
|
||||||
|
});
|
||||||
return { workflows: items };
|
return { workflows: items };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../../db.js";
|
||||||
|
import { assertHttpStatus } from "../../http-pages-store.js";
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 128;
|
||||||
|
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
const UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} id
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertAuthId(id) {
|
||||||
|
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||||
|
const err = new Error("invalid auth id");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return id.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertAuthName(name) {
|
||||||
|
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||||
|
const err = new Error("invalid auth name");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} type
|
||||||
|
* @returns {"bearer" | "basic" | "header"}
|
||||||
|
*/
|
||||||
|
export function assertAuthType(type) {
|
||||||
|
const t = String(type ?? "");
|
||||||
|
if (!ALLOWED_TYPES.has(t)) {
|
||||||
|
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect value source without exposing literal values.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {"literal" | "kv" | "secret" | "missing"}
|
||||||
|
*/
|
||||||
|
export function valueSourceKind(value) {
|
||||||
|
if (value == null) return "missing";
|
||||||
|
if (typeof value === "string") return "literal";
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
if ("secret" in value) return "secret";
|
||||||
|
if ("kv" in value) return "kv";
|
||||||
|
}
|
||||||
|
return "literal";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact config for API responses: replace literal strings with source markers.
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
* @param {string} type
|
||||||
|
*/
|
||||||
|
export function publicConfig(config, type) {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {};
|
||||||
|
if (type === "bearer") {
|
||||||
|
out.token = redactField(config.token);
|
||||||
|
} else if (type === "basic") {
|
||||||
|
out.user = redactField(config.user);
|
||||||
|
out.password = redactField(config.password);
|
||||||
|
} else if (type === "header") {
|
||||||
|
out.header = typeof config.header === "string" ? config.header : null;
|
||||||
|
out.value = redactField(config.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
function redactField(value) {
|
||||||
|
const kind = valueSourceKind(value);
|
||||||
|
if (kind === "missing") return { source: "missing" };
|
||||||
|
if (kind === "kv") {
|
||||||
|
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
||||||
|
return {
|
||||||
|
source: "kv",
|
||||||
|
kv: v.kv,
|
||||||
|
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === "secret") {
|
||||||
|
const v = /** @type {{ secret: string }} */ (value);
|
||||||
|
return { source: "secret", secret: v.secret };
|
||||||
|
}
|
||||||
|
return { source: "literal", set: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize auth config for storage.
|
||||||
|
* @param {string} type
|
||||||
|
* @param {unknown} config
|
||||||
|
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
||||||
|
*/
|
||||||
|
export function normalizeAuthConfig(type, config, opts = {}) {
|
||||||
|
const raw = config && typeof config === "object" && !Array.isArray(config)
|
||||||
|
? /** @type {Record<string, unknown>} */ (config)
|
||||||
|
: {};
|
||||||
|
const keep = opts.keepLiteralsFrom ?? {};
|
||||||
|
|
||||||
|
if (type === "bearer") {
|
||||||
|
return {
|
||||||
|
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "basic") {
|
||||||
|
return {
|
||||||
|
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
||||||
|
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
||||||
|
allowEmpty: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// header
|
||||||
|
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
||||||
|
const err = new Error("header name must be a non-empty string");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
header: raw.header,
|
||||||
|
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {unknown} previous
|
||||||
|
* @param {string} label
|
||||||
|
* @param {{ allowEmpty?: boolean }} [opts]
|
||||||
|
*/
|
||||||
|
function normalizeCredentialField(value, previous, label, opts = {}) {
|
||||||
|
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
/** @type {{ keep?: boolean }} */ (value).keep === true
|
||||||
|
) {
|
||||||
|
if (typeof previous === "string") return previous;
|
||||||
|
if (previous && typeof previous === "object") return previous;
|
||||||
|
const err = new Error(`${label} was not previously set`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null || value === "") {
|
||||||
|
if (opts.allowEmpty && value === "") return "";
|
||||||
|
// Allow empty password for basic
|
||||||
|
if (opts.allowEmpty && (value === "" || value == null)) {
|
||||||
|
if (typeof previous === "string") return previous;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const err = new Error(`${label} is required`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const v = /** @type {Record<string, unknown>} */ (value);
|
||||||
|
if (typeof v.secret === "string" && v.secret.length > 0) {
|
||||||
|
return { secret: v.secret };
|
||||||
|
}
|
||||||
|
if (typeof v.kv === "string" && v.kv.length > 0) {
|
||||||
|
/** @type {{ kv: string, namespace?: string }} */
|
||||||
|
const out = { kv: v.kv };
|
||||||
|
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
||||||
|
out.namespace = v.namespace;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error(
|
||||||
|
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
||||||
|
);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(raw) {
|
||||||
|
if (typeof raw !== "string") return raw ?? {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicAuth(row, { includeConfig = true } = {}) {
|
||||||
|
const type = row.type;
|
||||||
|
const config = parseConfig(row.config);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type,
|
||||||
|
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
||||||
|
unauthorized_status: row.unauthorized_status ?? null,
|
||||||
|
unauthorized_response: row.unauthorized_response ?? null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: full config including literals (for runtime auth checks).
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function getHttpAuthInternal(id) {
|
||||||
|
const authId = assertAuthId(id);
|
||||||
|
const row = await db("http_auths").where({ id: authId }).first();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
config: parseConfig(row.config),
|
||||||
|
unauthorized_status: row.unauthorized_status ?? null,
|
||||||
|
unauthorized_response: row.unauthorized_response ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||||
|
*/
|
||||||
|
export async function revealHttpAuthLiterals(id) {
|
||||||
|
const internal = await getHttpAuthInternal(id);
|
||||||
|
if (!internal) return null;
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const literals = {};
|
||||||
|
const cfg = internal.config ?? {};
|
||||||
|
for (const key of ["token", "user", "password", "value"]) {
|
||||||
|
const v = cfg[key];
|
||||||
|
if (typeof v === "string") literals[key] = v;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: internal.id,
|
||||||
|
name: internal.name,
|
||||||
|
type: internal.type,
|
||||||
|
literals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHttpAuths() {
|
||||||
|
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
||||||
|
return rows.map((r) => publicAuth(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function getHttpAuthById(id) {
|
||||||
|
let authId;
|
||||||
|
try {
|
||||||
|
authId = assertAuthId(id);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const row = await db("http_auths").where({ id: authId }).first();
|
||||||
|
return row ? publicAuth(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* id?: string | null,
|
||||||
|
* name: string,
|
||||||
|
* type: string,
|
||||||
|
* config?: unknown,
|
||||||
|
* unauthorized_status?: number | null,
|
||||||
|
* unauthorized_response?: string | null,
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export async function upsertHttpAuth({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
config,
|
||||||
|
unauthorized_status,
|
||||||
|
unauthorized_response,
|
||||||
|
}) {
|
||||||
|
const authName = assertAuthName(name);
|
||||||
|
const authType = assertAuthType(type);
|
||||||
|
|
||||||
|
/** @type {Record<string, unknown> | null} */
|
||||||
|
let existing = null;
|
||||||
|
if (id != null && String(id).length > 0) {
|
||||||
|
const authId = assertAuthId(id);
|
||||||
|
existing = await db("http_auths").where({ id: authId }).first();
|
||||||
|
if (!existing) {
|
||||||
|
const err = new Error("auth not found");
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||||
|
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||||
|
const err = new Error(`auth name "${authName}" already exists`);
|
||||||
|
err.statusCode = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||||
|
const normalized = normalizeAuthConfig(authType, config, {
|
||||||
|
keepLiteralsFrom: prevConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
let unauthStatus = null;
|
||||||
|
if (unauthorized_status != null && unauthorized_status !== "") {
|
||||||
|
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
||||||
|
}
|
||||||
|
let unauthResponse = null;
|
||||||
|
if (
|
||||||
|
unauthorized_response != null &&
|
||||||
|
String(unauthorized_response).length > 0
|
||||||
|
) {
|
||||||
|
unauthResponse = String(unauthorized_response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = nowIso();
|
||||||
|
const configJson = JSON.stringify(normalized);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db("http_auths")
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
name: authName,
|
||||||
|
type: authType,
|
||||||
|
config: configJson,
|
||||||
|
unauthorized_status: unauthStatus,
|
||||||
|
unauthorized_response: unauthResponse,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = randomUUID();
|
||||||
|
await db("http_auths").insert({
|
||||||
|
id: newId,
|
||||||
|
name: authName,
|
||||||
|
type: authType,
|
||||||
|
config: configJson,
|
||||||
|
unauthorized_status: unauthStatus,
|
||||||
|
unauthorized_response: unauthResponse,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getHttpAuthById(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function deleteHttpAuth(id) {
|
||||||
|
const authId = assertAuthId(id);
|
||||||
|
const n = await db("http_auths").where({ id: authId }).del();
|
||||||
|
return n > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { db } from "../../db.js";
|
||||||
|
|
||||||
|
const MAX_KEY_LENGTH = 512;
|
||||||
|
const MAX_NAMESPACE_LENGTH = 512;
|
||||||
|
const MAX_VALUE_BYTES = 256 * 1024;
|
||||||
|
const DEFAULT_LIST_LIMIT = 100;
|
||||||
|
const MAX_LIST_LIMIT = 500;
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
function valuesEqual(a, b) {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a == null || b == null) return a === b;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} label
|
||||||
|
* @param {unknown} value
|
||||||
|
*/
|
||||||
|
function assertString(label, value) {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`${label} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} label
|
||||||
|
* @param {string} value
|
||||||
|
* @param {number} max
|
||||||
|
*/
|
||||||
|
function assertMaxLength(label, value, max) {
|
||||||
|
if (value.length > max) {
|
||||||
|
throw new Error(`${label} must be at most ${max} characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
*/
|
||||||
|
function assertNamespace(namespace) {
|
||||||
|
assertString("namespace", namespace);
|
||||||
|
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
function assertKey(key) {
|
||||||
|
assertString("key", key);
|
||||||
|
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function serializeKvValue(value) {
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error("value must be JSON-serializable");
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
||||||
|
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} value
|
||||||
|
* @returns {unknown}
|
||||||
|
*/
|
||||||
|
function deserializeKvValue(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ expires_at?: string | null }} row
|
||||||
|
*/
|
||||||
|
function isExpired(row) {
|
||||||
|
if (!row.expires_at) return false;
|
||||||
|
return Date.parse(row.expires_at) <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<unknown>}
|
||||||
|
*/
|
||||||
|
export async function kvGet(namespace, key) {
|
||||||
|
assertNamespace(namespace);
|
||||||
|
assertKey(key);
|
||||||
|
|
||||||
|
const row = await db("script_state").where({ namespace, key }).first();
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
if (isExpired(row)) {
|
||||||
|
await db("script_state").where({ namespace, key }).del();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserializeKvValue(row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {string} key
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||||
|
*/
|
||||||
|
export async function kvSet(namespace, key, value, opts = {}) {
|
||||||
|
assertNamespace(namespace);
|
||||||
|
assertKey(key);
|
||||||
|
|
||||||
|
const json = serializeKvValue(value);
|
||||||
|
const updated_at = nowIso();
|
||||||
|
let expires_at = null;
|
||||||
|
if (opts.expiresAt != null) {
|
||||||
|
expires_at =
|
||||||
|
opts.expiresAt instanceof Date
|
||||||
|
? opts.expiresAt.toISOString()
|
||||||
|
: String(opts.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db("script_state")
|
||||||
|
.insert({
|
||||||
|
namespace,
|
||||||
|
key,
|
||||||
|
value: json,
|
||||||
|
updated_at,
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
.onConflict(["namespace", "key"])
|
||||||
|
.merge({
|
||||||
|
value: json,
|
||||||
|
updated_at,
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function kvDelete(namespace, key) {
|
||||||
|
assertNamespace(namespace);
|
||||||
|
assertKey(key);
|
||||||
|
const deleted = await db("script_state").where({ namespace, key }).del();
|
||||||
|
return deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {string} key
|
||||||
|
* @param {unknown} expected
|
||||||
|
* @param {unknown} next
|
||||||
|
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||||
|
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
||||||
|
*/
|
||||||
|
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
||||||
|
assertNamespace(namespace);
|
||||||
|
assertKey(key);
|
||||||
|
|
||||||
|
return db.transaction(async (trx) => {
|
||||||
|
const row = await trx("script_state").where({ namespace, key }).first();
|
||||||
|
|
||||||
|
if (row && isExpired(row)) {
|
||||||
|
await trx("script_state").where({ namespace, key }).del();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRow =
|
||||||
|
row && !isExpired(row)
|
||||||
|
? row
|
||||||
|
: await trx("script_state").where({ namespace, key }).first();
|
||||||
|
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
||||||
|
|
||||||
|
if (!valuesEqual(previous, expected)) {
|
||||||
|
return { ok: false, previous };
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = serializeKvValue(next);
|
||||||
|
const updated_at = nowIso();
|
||||||
|
let expires_at = null;
|
||||||
|
if (opts.expiresAt != null) {
|
||||||
|
expires_at =
|
||||||
|
opts.expiresAt instanceof Date
|
||||||
|
? opts.expiresAt.toISOString()
|
||||||
|
: String(opts.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow) {
|
||||||
|
await trx("script_state").where({ namespace, key }).update({
|
||||||
|
value: json,
|
||||||
|
updated_at,
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await trx("script_state").insert({
|
||||||
|
namespace,
|
||||||
|
key,
|
||||||
|
value: json,
|
||||||
|
updated_at,
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, previous };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {{ limit?: number }} [opts]
|
||||||
|
*/
|
||||||
|
export async function kvList(namespace, opts = {}) {
|
||||||
|
assertNamespace(namespace);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
||||||
|
MAX_LIST_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db("script_state")
|
||||||
|
.where({ namespace })
|
||||||
|
.orderBy("updated_at", "desc")
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (isExpired(row)) {
|
||||||
|
await db("script_state").where({ namespace, key: row.key }).del();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
key: row.key,
|
||||||
|
value: deserializeKvValue(row.value),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
expiresAt: row.expires_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLike(value) {
|
||||||
|
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneExpiredKv() {
|
||||||
|
await db("script_state")
|
||||||
|
.whereNotNull("expires_at")
|
||||||
|
.andWhere("expires_at", "<=", nowIso())
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* namespace?: string,
|
||||||
|
* q?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* offset?: number,
|
||||||
|
* }} [opts]
|
||||||
|
*/
|
||||||
|
export async function kvQuery(opts = {}) {
|
||||||
|
await pruneExpiredKv();
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||||
|
const offset = Math.max(Number(opts.offset) || 0, 0);
|
||||||
|
|
||||||
|
let q = db("script_state");
|
||||||
|
if (opts.namespace) {
|
||||||
|
assertNamespace(opts.namespace);
|
||||||
|
q = q.where({ namespace: opts.namespace });
|
||||||
|
}
|
||||||
|
if (typeof opts.q === "string" && opts.q.length > 0) {
|
||||||
|
const like = `%${escapeLike(opts.q)}%`;
|
||||||
|
q = q.where(function likeSearch() {
|
||||||
|
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
||||||
|
"value LIKE ? ESCAPE '\\'",
|
||||||
|
[like],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const countRow = await q.clone().count({ count: "*" }).first();
|
||||||
|
const total = Number(countRow?.count ?? 0);
|
||||||
|
|
||||||
|
const rows = await q
|
||||||
|
.clone()
|
||||||
|
.orderBy("updated_at", "desc")
|
||||||
|
.orderBy("namespace", "asc")
|
||||||
|
.orderBy("key", "asc")
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
namespace: row.namespace,
|
||||||
|
key: row.key,
|
||||||
|
value: deserializeKvValue(row.value),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
expiresAt: row.expires_at ?? null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function kvNamespaces() {
|
||||||
|
await pruneExpiredKv();
|
||||||
|
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
||||||
|
return rows.map((row) => row.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} defaultNamespace
|
||||||
|
*/
|
||||||
|
export function createKvApi(defaultNamespace) {
|
||||||
|
assertNamespace(defaultNamespace);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ namespace?: string }} [opts]
|
||||||
|
*/
|
||||||
|
function resolveNamespace(opts = {}) {
|
||||||
|
const namespace = opts.namespace ?? defaultNamespace;
|
||||||
|
assertNamespace(namespace);
|
||||||
|
return namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
namespace: defaultNamespace,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {{ namespace?: string }} [opts]
|
||||||
|
*/
|
||||||
|
get(key, opts) {
|
||||||
|
return kvGet(resolveNamespace(opts), key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {unknown} value
|
||||||
|
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||||
|
*/
|
||||||
|
set(key, value, opts) {
|
||||||
|
const { namespace, expiresAt } = opts ?? {};
|
||||||
|
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {{ namespace?: string }} [opts]
|
||||||
|
*/
|
||||||
|
delete(key, opts) {
|
||||||
|
return kvDelete(resolveNamespace(opts), key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {unknown} expected
|
||||||
|
* @param {unknown} next
|
||||||
|
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||||
|
*/
|
||||||
|
compareAndSet(key, expected, next, opts) {
|
||||||
|
const { expiresAt } = opts ?? {};
|
||||||
|
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ namespace?: string, limit?: number }} [opts]
|
||||||
|
*/
|
||||||
|
list(opts) {
|
||||||
|
const { namespace, limit } = opts ?? {};
|
||||||
|
return kvList(resolveNamespace(opts), { limit });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import yaml from "yaml";
|
||||||
|
import { db } from "../../db.js";
|
||||||
|
import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "../../fs-store.js";
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 128;
|
||||||
|
const MAX_DESCRIPTION_LENGTH = 500;
|
||||||
|
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||||
|
const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(message, statusCode = 400) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.statusCode = statusCode;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertProfileName(name) {
|
||||||
|
if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) {
|
||||||
|
throw httpError("invalid profile name");
|
||||||
|
}
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} script
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertProfileScript(script) {
|
||||||
|
if (typeof script !== "string" || script.trim().length === 0) {
|
||||||
|
throw httpError("script is required");
|
||||||
|
}
|
||||||
|
const trimmed = script.trim();
|
||||||
|
if (trimmed.length > 256) {
|
||||||
|
throw httpError("script name is too long");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} description
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertProfileDescription(description) {
|
||||||
|
if (description == null) return "";
|
||||||
|
if (typeof description !== "string") {
|
||||||
|
throw httpError("description must be a string");
|
||||||
|
}
|
||||||
|
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||||
|
throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} config
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function encodeProfileConfig(config) {
|
||||||
|
if (config == null) return "{}";
|
||||||
|
if (typeof config !== "object" || Array.isArray(config)) {
|
||||||
|
throw httpError("config must be an object");
|
||||||
|
}
|
||||||
|
let encoded;
|
||||||
|
try {
|
||||||
|
encoded = JSON.stringify(config);
|
||||||
|
} catch {
|
||||||
|
throw httpError("config must be JSON-serializable");
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) {
|
||||||
|
throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`);
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} stored
|
||||||
|
* @returns {Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
export function decodeProfileConfig(stored) {
|
||||||
|
if (stored == null || stored === "") return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
throw new Error("corrupt profile config: not an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} row
|
||||||
|
*/
|
||||||
|
function publicProfile(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
owner: row.owner,
|
||||||
|
name: row.name,
|
||||||
|
script: row.script,
|
||||||
|
config: decodeProfileConfig(String(row.config ?? "{}")),
|
||||||
|
description: row.description == null ? "" : String(row.description),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ owner?: string }} [filters]
|
||||||
|
*/
|
||||||
|
export async function listProfiles(filters = {}) {
|
||||||
|
let q = db("profiles")
|
||||||
|
.select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at")
|
||||||
|
.orderBy("owner", "asc")
|
||||||
|
.orderBy("name", "asc");
|
||||||
|
if (filters.owner) {
|
||||||
|
q = q.where("owner", assertOwner(filters.owner));
|
||||||
|
}
|
||||||
|
const rows = await q;
|
||||||
|
return rows.map((row) => publicProfile(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function getProfileById(id) {
|
||||||
|
const row = await db("profiles").where({ id }).first();
|
||||||
|
return row ? publicProfile(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} owner
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
export async function getProfilePlain(owner, name) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const profileName = assertProfileName(name);
|
||||||
|
const row = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||||
|
return row ? publicProfile(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* owner: string,
|
||||||
|
* name: string,
|
||||||
|
* script: unknown,
|
||||||
|
* config?: unknown,
|
||||||
|
* description?: unknown,
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export async function upsertProfile({ owner, name, script, config, description }) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const profileName = assertProfileName(name);
|
||||||
|
const scriptName = assertProfileScript(script);
|
||||||
|
const encoded = encodeProfileConfig(config ?? {});
|
||||||
|
const desc = assertProfileDescription(description);
|
||||||
|
const now = nowIso();
|
||||||
|
const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db("profiles")
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
script: scriptName,
|
||||||
|
config: encoded,
|
||||||
|
description: desc,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getProfileById(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
await db("profiles").insert({
|
||||||
|
id,
|
||||||
|
owner: ownerName,
|
||||||
|
name: profileName,
|
||||||
|
script: scriptName,
|
||||||
|
config: encoded,
|
||||||
|
description: desc,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getProfileById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function deleteProfile(id) {
|
||||||
|
const n = await db("profiles").where({ id }).del();
|
||||||
|
return n > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflows (same owner) whose YAML steps reference this profile name.
|
||||||
|
* @param {string} owner
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {{ file: string, name: string, steps: number }[]}
|
||||||
|
*/
|
||||||
|
export function listProfileUsages(owner, name) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const profileName = assertProfileName(name);
|
||||||
|
/** @type {{ file: string, name: string, steps: number }[]} */
|
||||||
|
const usages = [];
|
||||||
|
for (const file of listOwnerYamlFiles(ownerName)) {
|
||||||
|
const content = readWorkflowYaml(ownerName, file);
|
||||||
|
if (content == null) continue;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = yaml.parse(content);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
||||||
|
const scripts = parsed.scripts;
|
||||||
|
if (!Array.isArray(scripts)) continue;
|
||||||
|
let steps = 0;
|
||||||
|
for (const step of scripts) {
|
||||||
|
if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) {
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (steps > 0) {
|
||||||
|
usages.push({
|
||||||
|
file,
|
||||||
|
name: typeof parsed.name === "string" && parsed.name ? parsed.name : file,
|
||||||
|
steps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../../db.js";
|
||||||
|
import { assertOwner } from "../../fs-store.js";
|
||||||
|
import { decryptSecret, encryptSecret } from "../../secrets.js";
|
||||||
|
import { registerPlaintext } from "../../secret-value.js";
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 128;
|
||||||
|
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertSecretName(name) {
|
||||||
|
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||||
|
const err = new Error("invalid secret name");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicSecret(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
owner: row.owner,
|
||||||
|
name: row.name,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ owner?: string }} [filters]
|
||||||
|
*/
|
||||||
|
export async function listSecrets(filters = {}) {
|
||||||
|
let q = db("secrets")
|
||||||
|
.select("id", "owner", "name", "created_at", "updated_at")
|
||||||
|
.orderBy("owner", "asc")
|
||||||
|
.orderBy("name", "asc");
|
||||||
|
if (filters.owner) {
|
||||||
|
q = q.where("owner", assertOwner(filters.owner));
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function getSecretById(id) {
|
||||||
|
const row = await db("secrets")
|
||||||
|
.select("id", "owner", "name", "created_at", "updated_at")
|
||||||
|
.where({ id })
|
||||||
|
.first();
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ owner: string, name: string, value: string }} opts
|
||||||
|
*/
|
||||||
|
export async function upsertSecret({ owner, name, value }) {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
const err = new Error("value is required");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const secretName = assertSecretName(name);
|
||||||
|
registerPlaintext(value);
|
||||||
|
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||||
|
const now = nowIso();
|
||||||
|
const existing = await db("secrets")
|
||||||
|
.where({ owner: ownerName, name: secretName })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db("secrets")
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
auth_tag: authTag,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getSecretById(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
await db("secrets").insert({
|
||||||
|
id,
|
||||||
|
owner: ownerName,
|
||||||
|
name: secretName,
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
auth_tag: authTag,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getSecretById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function deleteSecret(id) {
|
||||||
|
const n = await db("secrets").where({ id }).del();
|
||||||
|
return n > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a named secret for an owner. Returns null if missing.
|
||||||
|
* @param {string} owner
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
export async function getSecretPlaintext(owner, name) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const secretName = assertSecretName(name);
|
||||||
|
const row = await db("secrets")
|
||||||
|
.where({ owner: ownerName, name: secretName })
|
||||||
|
.first();
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
const plaintext = decryptSecret({
|
||||||
|
ciphertext: row.ciphertext,
|
||||||
|
iv: row.iv,
|
||||||
|
authTag: row.auth_tag,
|
||||||
|
});
|
||||||
|
registerPlaintext(plaintext);
|
||||||
|
return plaintext;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } from "../../db.js";
|
||||||
|
import { assertOwner } from "../../fs-store.js";
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 128;
|
||||||
|
const MAX_STRING_BYTES = 64 * 1024;
|
||||||
|
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(message, statusCode = 400) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.statusCode = statusCode;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assertVariableName(name) {
|
||||||
|
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||||
|
throw httpError("invalid variable name");
|
||||||
|
}
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} type
|
||||||
|
* @returns {"string" | "number" | "boolean"}
|
||||||
|
*/
|
||||||
|
export function assertVariableType(type) {
|
||||||
|
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||||
|
throw httpError("type must be string, number, or boolean");
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {"string" | "number" | "boolean"} type
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function encodeVariableValue(type, value) {
|
||||||
|
if (type === "string") {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw httpError("value must be a string");
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||||
|
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (type === "number") {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw httpError("value must be a finite number");
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw httpError("value must be a boolean");
|
||||||
|
}
|
||||||
|
return value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {"string" | "number" | "boolean"} type
|
||||||
|
* @param {string} stored
|
||||||
|
* @returns {string | number | boolean}
|
||||||
|
*/
|
||||||
|
export function decodeVariableValue(type, stored) {
|
||||||
|
if (type === "string") return stored;
|
||||||
|
if (type === "number") {
|
||||||
|
const n = Number(stored);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
if (stored === "true") return true;
|
||||||
|
if (stored === "false") return false;
|
||||||
|
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} row
|
||||||
|
*/
|
||||||
|
function publicVariable(row) {
|
||||||
|
const type = assertVariableType(row.type);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
owner: row.owner,
|
||||||
|
name: row.name,
|
||||||
|
type,
|
||||||
|
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ owner?: string }} [filters]
|
||||||
|
*/
|
||||||
|
export async function listVariables(filters = {}) {
|
||||||
|
let q = db("variables")
|
||||||
|
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||||
|
.orderBy("owner", "asc")
|
||||||
|
.orderBy("name", "asc");
|
||||||
|
if (filters.owner) {
|
||||||
|
q = q.where("owner", assertOwner(filters.owner));
|
||||||
|
}
|
||||||
|
const rows = await q;
|
||||||
|
return rows.map((row) => publicVariable(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function getVariableById(id) {
|
||||||
|
const row = await db("variables").where({ id }).first();
|
||||||
|
return row ? publicVariable(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||||
|
*/
|
||||||
|
export async function upsertVariable({ owner, name, type, value }) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const variableName = assertVariableName(name);
|
||||||
|
const variableType = assertVariableType(type);
|
||||||
|
const encoded = encodeVariableValue(variableType, value);
|
||||||
|
const now = nowIso();
|
||||||
|
const existing = await db("variables")
|
||||||
|
.where({ owner: ownerName, name: variableName })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db("variables")
|
||||||
|
.where({ id: existing.id })
|
||||||
|
.update({
|
||||||
|
type: variableType,
|
||||||
|
value: encoded,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getVariableById(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
await db("variables").insert({
|
||||||
|
id,
|
||||||
|
owner: ownerName,
|
||||||
|
name: variableName,
|
||||||
|
type: variableType,
|
||||||
|
value: encoded,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return getVariableById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function deleteVariable(id) {
|
||||||
|
const n = await db("variables").where({ id }).del();
|
||||||
|
return n > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed primitive for an owner/name. Returns null if missing.
|
||||||
|
* @param {string} owner
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<string | number | boolean | null>}
|
||||||
|
*/
|
||||||
|
export async function getVariablePlain(owner, name) {
|
||||||
|
const ownerName = assertOwner(owner);
|
||||||
|
const variableName = assertVariableName(name);
|
||||||
|
const row = await db("variables")
|
||||||
|
.where({ owner: ownerName, name: variableName })
|
||||||
|
.first();
|
||||||
|
if (!row) return null;
|
||||||
|
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ import usersPlugin from "./src/api/users.js";
|
|||||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||||
import runsPlugin from "./src/api/runs.js";
|
import runsPlugin from "./src/api/runs.js";
|
||||||
|
import { queryRunsFromRequest } from "./src/api/run-query.js";
|
||||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||||
import secretsPlugin from "./src/api/secrets.js";
|
import secretsPlugin from "./src/api/secrets.js";
|
||||||
import kvPlugin from "./src/api/kv.js";
|
import kvPlugin from "./src/api/kv.js";
|
||||||
import variablesPlugin from "./src/api/variables.js";
|
import variablesPlugin from "./src/api/variables.js";
|
||||||
|
import profilesPlugin from "./src/api/profiles.js";
|
||||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||||
import { WEB_DIST } from "./paths.js";
|
import { WEB_DIST } from "./paths.js";
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
getRedisUrlForLog,
|
getRedisUrlForLog,
|
||||||
} from "./workflow-queue.js";
|
} from "./workflow-queue.js";
|
||||||
import { purgeExpiredTrash } from "./workflow-trash.js";
|
import { purgeExpiredTrash } from "./workflow-trash.js";
|
||||||
|
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
|
||||||
import {
|
import {
|
||||||
getConfigGeneration,
|
getConfigGeneration,
|
||||||
startHeartbeatLoop,
|
startHeartbeatLoop,
|
||||||
@@ -126,6 +129,12 @@ export async function startApp(opts = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrateLegacyWorkflowsIfNeeded();
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err }, "legacy workflow migrate failed");
|
||||||
|
}
|
||||||
|
|
||||||
const registry = createRegistry(server, {
|
const registry = createRegistry(server, {
|
||||||
queue: workflowQueue,
|
queue: workflowQueue,
|
||||||
// Cron + HTTP triggers enqueue jobs; only the API process may own them.
|
// Cron + HTTP triggers enqueue jobs; only the API process may own them.
|
||||||
@@ -168,6 +177,7 @@ export async function startApp(opts = {}) {
|
|||||||
await api.register(usersPlugin);
|
await api.register(usersPlugin);
|
||||||
await api.register(secretsPlugin);
|
await api.register(secretsPlugin);
|
||||||
await api.register(variablesPlugin);
|
await api.register(variablesPlugin);
|
||||||
|
await api.register(profilesPlugin);
|
||||||
await api.register(kvPlugin);
|
await api.register(kvPlugin);
|
||||||
await api.register(httpPagesPlugin);
|
await api.register(httpPagesPlugin);
|
||||||
await api.register(httpAuthsPlugin);
|
await api.register(httpAuthsPlugin);
|
||||||
@@ -198,16 +208,8 @@ export async function startApp(opts = {}) {
|
|||||||
"/admin/runs",
|
"/admin/runs",
|
||||||
{ onRequest: [server.authenticate] },
|
{ onRequest: [server.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
return reply.send(await queryRunsFromRequest(q));
|
||||||
const runs = await store.listRuns({
|
|
||||||
owner: q.owner,
|
|
||||||
workflow: q.workflow,
|
|
||||||
status: q.status,
|
|
||||||
limit: Number.isFinite(limit) ? limit : undefined,
|
|
||||||
before: q.before,
|
|
||||||
});
|
|
||||||
return reply.send({ runs });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
import { isPlainObject } from "@jerapah-flow/shared";
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
export { isPlainObject };
|
||||||
export function isPlainObject(value) {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
|
|||||||
+103
-15
@@ -222,19 +222,29 @@ export async function insertLogs(rows) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const RUN_SORT_COLUMNS = {
|
||||||
|
status: "status",
|
||||||
|
workflow: "workflow_name",
|
||||||
|
revision: "workflow_revision",
|
||||||
|
trigger: "trigger_type",
|
||||||
|
started_at: "started_at",
|
||||||
|
duration: "duration_ms",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param {import("knex").Knex.QueryBuilder} q
|
||||||
* @param {{
|
* @param {{
|
||||||
* owner?: string,
|
* owner?: string,
|
||||||
* workflow?: string,
|
* workflow?: string,
|
||||||
* status?: string | string[],
|
* status?: string | string[],
|
||||||
* limit?: number,
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
* before?: string,
|
* before?: string,
|
||||||
* }} [filters]
|
* }} filters
|
||||||
*/
|
*/
|
||||||
export async function listRuns(filters = {}) {
|
function applyRunFilters(q, filters) {
|
||||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
if (filters.owner) q.where("owner", filters.owner);
|
||||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
|
||||||
if (filters.owner) q = q.where("owner", filters.owner);
|
|
||||||
if (filters.workflow) {
|
if (filters.workflow) {
|
||||||
const key = String(filters.workflow);
|
const key = String(filters.workflow);
|
||||||
if (key.includes("*")) {
|
if (key.includes("*")) {
|
||||||
@@ -243,25 +253,102 @@ export async function listRuns(filters = {}) {
|
|||||||
.replaceAll("%", "\\%")
|
.replaceAll("%", "\\%")
|
||||||
.replaceAll("_", "\\_")
|
.replaceAll("_", "\\_")
|
||||||
.replaceAll("*", "%");
|
.replaceAll("*", "%");
|
||||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("workflow", key);
|
q.where("workflow", key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
if (Array.isArray(filters.status)) {
|
if (Array.isArray(filters.status)) {
|
||||||
q = q.whereIn("status", filters.status);
|
q.whereIn("status", filters.status);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("status", filters.status);
|
q.where("status", filters.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||||
const rows = await q.limit(limit);
|
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||||
return rows.map((row) => ({
|
if (filters.before) q.where("started_at", "<", filters.before);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("knex").Knex.QueryBuilder} q
|
||||||
|
* @param {string | undefined} sort
|
||||||
|
* @param {string | undefined} order
|
||||||
|
*/
|
||||||
|
function applyRunSort(q, sort, order) {
|
||||||
|
const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at";
|
||||||
|
const direction = order === "asc" ? "asc" : "desc";
|
||||||
|
q.orderBy(column, direction);
|
||||||
|
if (column !== "started_at") q.orderBy("started_at", "desc");
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} row
|
||||||
|
*/
|
||||||
|
function mapRunRow(row) {
|
||||||
|
return {
|
||||||
...row,
|
...row,
|
||||||
input: deserialize(row.input),
|
input: deserialize(row.input),
|
||||||
output: deserialize(row.output),
|
output: deserialize(row.output),
|
||||||
}));
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* owner?: string,
|
||||||
|
* workflow?: string,
|
||||||
|
* status?: string | string[],
|
||||||
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
|
* before?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* offset?: number,
|
||||||
|
* sort?: string,
|
||||||
|
* order?: string,
|
||||||
|
* }} [filters]
|
||||||
|
*/
|
||||||
|
export async function queryRuns(filters = {}) {
|
||||||
|
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||||
|
const offset = Math.max(Number(filters.offset) || 0, 0);
|
||||||
|
|
||||||
|
let q = db("workflow_runs");
|
||||||
|
q = applyRunFilters(q, filters);
|
||||||
|
|
||||||
|
const countRow = await q.clone().count({ count: "*" }).first();
|
||||||
|
const total = Number(countRow?.count ?? 0);
|
||||||
|
|
||||||
|
let rowsQ = q.clone().select("*");
|
||||||
|
rowsQ = applyRunSort(rowsQ, filters.sort, filters.order);
|
||||||
|
const rows = await rowsQ.limit(limit).offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs: rows.map(mapRunRow),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* owner?: string,
|
||||||
|
* workflow?: string,
|
||||||
|
* status?: string | string[],
|
||||||
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
|
* before?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* }} [filters]
|
||||||
|
*/
|
||||||
|
export async function listRuns(filters = {}) {
|
||||||
|
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||||
|
let q = db("workflow_runs").select("*");
|
||||||
|
q = applyRunFilters(q, filters);
|
||||||
|
q = applyRunSort(q, "started_at", "desc");
|
||||||
|
const rows = await q.limit(limit);
|
||||||
|
return rows.map(mapRunRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -495,12 +582,13 @@ export async function listUsers() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {{ passwordHash?: string, role?: string }} patch
|
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||||
*/
|
*/
|
||||||
export async function updateUser(id, patch) {
|
export async function updateUser(id, patch) {
|
||||||
const update = { updated_at: nowIso() };
|
const update = { updated_at: nowIso() };
|
||||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||||
if (patch.role) update.role = patch.role;
|
if (patch.role) update.role = patch.role;
|
||||||
|
if (patch.username) update.username = patch.username;
|
||||||
await db("users").where({ id }).update(update);
|
await db("users").where({ id }).update(update);
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>v1</body></html>";
|
state.body = "<html><body>v1</body></html>";
|
||||||
|
|
||||||
const first = await run({ url });
|
const first = await run({ url });
|
||||||
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
|
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
|
||||||
assert(
|
assert(
|
||||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||||
"httpResponse should hold the raw body",
|
"httpResponse should hold the raw body",
|
||||||
);
|
);
|
||||||
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
|
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
|
||||||
|
|
||||||
const second = await run({ url });
|
const second = await run({ url });
|
||||||
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
|
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||||
|
|
||||||
state.body = "<html><body>v2 CHANGED</body></html>";
|
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||||
const third = await run({ url });
|
const third = await run({ url });
|
||||||
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
|
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
|
||||||
assert(
|
assert(
|
||||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||||
"fingerprintPrevious should equal the prior hash",
|
"fingerprintPrevious should equal the prior hash",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
|
|||||||
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||||
|
|
||||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(first.data.hasChanges === true, "json first run should report a change");
|
assert(first.output.hasChanges === true, "json first run should report a change");
|
||||||
|
|
||||||
// Change only an unwatched field -> no change.
|
// Change only an unwatched field -> no change.
|
||||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(
|
assert(
|
||||||
second.data.hasChanges === false,
|
second.output.hasChanges === false,
|
||||||
"changing an unwatched field should not report a change",
|
"changing an unwatched field should not report a change",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change the watched field -> change.
|
// Change the watched field -> change.
|
||||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(third.data.hasChanges === true, "changing the watched field should report a change");
|
assert(third.output.hasChanges === true, "changing the watched field should report a change");
|
||||||
|
|
||||||
state.contentType = "text/html; charset=utf-8";
|
state.contentType = "text/html; charset=utf-8";
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
|||||||
transform: '"changed=" & $string(data.hasChanges)',
|
transform: '"changed=" & $string(data.hasChanges)',
|
||||||
});
|
});
|
||||||
assert(
|
assert(
|
||||||
withTransform.data.message === "changed=true",
|
withTransform.output.message === "changed=true",
|
||||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||||
assert(
|
assert(
|
||||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||||
"outputVar without transform should store the raw response",
|
"outputVar without transform should store the raw response",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>stable</body></html>";
|
state.body = "<html><body>stable</body></html>";
|
||||||
|
|
||||||
const first = await run({ url, skipRemaining: true });
|
const first = await run({ url, skipRemaining: true });
|
||||||
assert(first.data.hasChanges === true, "skip test first run should change");
|
assert(first.output.hasChanges === true, "skip test first run should change");
|
||||||
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||||
|
|
||||||
const second = await run({ url, skipRemaining: true });
|
const second = await run({ url, skipRemaining: true });
|
||||||
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
|
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
|
||||||
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||||
|
|
||||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
|
import {
|
||||||
|
encodeBinaryForWire,
|
||||||
|
jsonPreviewReplacer,
|
||||||
|
reviveBinaryFromWire,
|
||||||
|
summarizeBinary,
|
||||||
|
} from "../json-preview.js";
|
||||||
import { serialize, toDisplayValue } from "../store.js";
|
import { serialize, toDisplayValue } from "../store.js";
|
||||||
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||||
|
|
||||||
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
|
|||||||
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
|
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wired = encodeBinaryForWire({ file: png, n: 1n });
|
||||||
|
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
|
||||||
|
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
|
||||||
|
}
|
||||||
|
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
|
||||||
|
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
|
||||||
|
throw new Error("reviveBinaryFromWire failed to restore bytes");
|
||||||
|
}
|
||||||
|
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
|
||||||
|
const left = reviveBinaryFromWire(previewOnly);
|
||||||
|
if (Buffer.isBuffer(left)) {
|
||||||
|
throw new Error("preview-only summary should not revive");
|
||||||
|
}
|
||||||
|
|
||||||
console.log("json-preview-smoke: ok");
|
console.log("json-preview-smoke: ok");
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { migrate, db } from "../db.js";
|
||||||
|
import {
|
||||||
|
assertProfileName,
|
||||||
|
deleteProfile,
|
||||||
|
encodeProfileConfig,
|
||||||
|
getProfilePlain,
|
||||||
|
listProfileUsages,
|
||||||
|
upsertProfile,
|
||||||
|
} from "../profiles-store.js";
|
||||||
|
import { mergeProfileConfig } from "../profile-config.js";
|
||||||
|
import { parseScriptStep } from "../workflow-parse.js";
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertThrows(fn, match) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (match && !message.includes(match)) {
|
||||||
|
throw new Error(`threw "${message}", expected to include "${match}"`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`expected to throw (${match ?? "any error"})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeProfileConfig(
|
||||||
|
{ url: "https://n.example/ops", fingerprint: true },
|
||||||
|
{ fingerprint: "comic-rss" },
|
||||||
|
);
|
||||||
|
assert(merged.url === "https://n.example/ops", "profile url kept");
|
||||||
|
assert(merged.fingerprint === "comic-rss", "overlay wins");
|
||||||
|
|
||||||
|
const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {});
|
||||||
|
assert(emptyOverlay.url === "https://n.example/ops", "empty overlay");
|
||||||
|
|
||||||
|
const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" });
|
||||||
|
assert(emptyWins.url === "", "empty string overlay wins");
|
||||||
|
|
||||||
|
const profileOnly = parseScriptStep({
|
||||||
|
profile: "ops-ntfy",
|
||||||
|
config: { fingerprint: "x" },
|
||||||
|
});
|
||||||
|
assert(profileOnly.kind === "script", "profile step kind");
|
||||||
|
assert(profileOnly.script === "", "script supplied by profile at runtime");
|
||||||
|
assert(profileOnly.profile === "ops-ntfy", "profile name");
|
||||||
|
|
||||||
|
const both = parseScriptStep({
|
||||||
|
script: "ntfy.js",
|
||||||
|
profile: "ops-ntfy",
|
||||||
|
});
|
||||||
|
assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile");
|
||||||
|
|
||||||
|
await assertThrows(
|
||||||
|
() => parseScriptStep({ profile: "ops", set: { expression: "1" } }),
|
||||||
|
"profile and set",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name");
|
||||||
|
await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name");
|
||||||
|
await assertThrows(() => encodeProfileConfig([]), "config must be an object");
|
||||||
|
|
||||||
|
const owner = "default";
|
||||||
|
const name = `profiles_smoke_${Date.now()}`;
|
||||||
|
const created = await upsertProfile({
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
script: "ntfy.js",
|
||||||
|
config: { url: "$VAR_ntfy_channel" },
|
||||||
|
description: "smoke",
|
||||||
|
});
|
||||||
|
assert(created.name === name, "created");
|
||||||
|
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip");
|
||||||
|
assert(created.script === "ntfy.js", "script locked on profile");
|
||||||
|
|
||||||
|
const fetched = await getProfilePlain(owner, name);
|
||||||
|
assert(fetched?.id === created.id, "get by owner/name");
|
||||||
|
|
||||||
|
const updated = await upsertProfile({
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
script: "send-email.js",
|
||||||
|
config: { service: "Gmail" },
|
||||||
|
description: "now mail",
|
||||||
|
});
|
||||||
|
assert(updated.id === created.id, "upsert same row");
|
||||||
|
assert(updated.script === "send-email.js", "script may change");
|
||||||
|
|
||||||
|
const usages = listProfileUsages(owner, name);
|
||||||
|
assert(Array.isArray(usages) && usages.length === 0, "unused profile");
|
||||||
|
|
||||||
|
await deleteProfile(created.id);
|
||||||
|
assert((await getProfilePlain(owner, name)) == null, "deleted");
|
||||||
|
|
||||||
|
await db.destroy();
|
||||||
|
console.log("profiles-smoke: ok");
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Smoke: set dry-run path (evaluateJsonata + envelope), mirrors
|
||||||
|
* POST /scripts/set/dry-run in src/api/scripts.js.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { evaluateJsonata, SET_STEP_SCRIPT } from "../workflow-parse.js";
|
||||||
|
import { normalizeStepResult } from "../step-result.js";
|
||||||
|
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||||
|
|
||||||
|
assert.equal(SET_STEP_SCRIPT, "set");
|
||||||
|
|
||||||
|
async function dryRunSet({ expression, data, context = {} }) {
|
||||||
|
if (typeof expression !== "string" || !expression.trim()) {
|
||||||
|
throw new Error("expression is required");
|
||||||
|
}
|
||||||
|
const incomingContext =
|
||||||
|
context != null && typeof context === "object" && !Array.isArray(context)
|
||||||
|
? context
|
||||||
|
: {};
|
||||||
|
const config = { expression };
|
||||||
|
const ctx = { data: data ?? null, context: incomingContext, config };
|
||||||
|
const value = await evaluateJsonata(expression, ctx);
|
||||||
|
const result = normalizeStepResult(
|
||||||
|
{ output: value, context: incomingContext, skipRemaining: false },
|
||||||
|
incomingContext,
|
||||||
|
SET_STEP_SCRIPT,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
output: safeSerialize(result.output),
|
||||||
|
context: safeSerialize(result.context),
|
||||||
|
skipRemaining: result.skipRemaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await dryRunSet({
|
||||||
|
expression: '{"title": data.title, "ok": true}',
|
||||||
|
data: { title: "Hello" },
|
||||||
|
context: { runId: "dry" },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, "success");
|
||||||
|
assert.deepEqual(res.output, { title: "Hello", ok: true });
|
||||||
|
assert.deepEqual(res.context, { runId: "dry" });
|
||||||
|
assert.equal(res.skipRemaining, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await dryRunSet({
|
||||||
|
expression: "data.count + 1",
|
||||||
|
data: { count: 41 },
|
||||||
|
context: { token: "abc" },
|
||||||
|
});
|
||||||
|
assert.equal(res.output, 42);
|
||||||
|
// Sets never mutate context
|
||||||
|
assert.deepEqual(res.context, { token: "abc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let hit = false;
|
||||||
|
try {
|
||||||
|
await dryRunSet({ expression: " ", data: {} });
|
||||||
|
} catch (err) {
|
||||||
|
hit = true;
|
||||||
|
assert.match(String(err.message), /expression is required/);
|
||||||
|
}
|
||||||
|
assert.equal(hit, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let hit = false;
|
||||||
|
try {
|
||||||
|
await dryRunSet({ expression: "data.{" , data: {} });
|
||||||
|
} catch (err) {
|
||||||
|
hit = true;
|
||||||
|
assert.ok(err instanceof Error);
|
||||||
|
}
|
||||||
|
assert.equal(hit, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("set-dry-run-smoke: ok");
|
||||||
@@ -1,190 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/variables-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertOwner } from "./fs-store.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const MAX_STRING_BYTES = 64 * 1024;
|
|
||||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpError(message, statusCode = 400) {
|
|
||||||
const err = new Error(message);
|
|
||||||
err.statusCode = statusCode;
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertVariableName(name) {
|
|
||||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
|
||||||
throw httpError("invalid variable name");
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} type
|
|
||||||
* @returns {"string" | "number" | "boolean"}
|
|
||||||
*/
|
|
||||||
export function assertVariableType(type) {
|
|
||||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
|
||||||
throw httpError("type must be string, number, or boolean");
|
|
||||||
}
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {"string" | "number" | "boolean"} type
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function encodeVariableValue(type, value) {
|
|
||||||
if (type === "string") {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw httpError("value must be a string");
|
|
||||||
}
|
|
||||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
|
||||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (type === "number") {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
||||||
throw httpError("value must be a finite number");
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
if (typeof value !== "boolean") {
|
|
||||||
throw httpError("value must be a boolean");
|
|
||||||
}
|
|
||||||
return value ? "true" : "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {"string" | "number" | "boolean"} type
|
|
||||||
* @param {string} stored
|
|
||||||
* @returns {string | number | boolean}
|
|
||||||
*/
|
|
||||||
export function decodeVariableValue(type, stored) {
|
|
||||||
if (type === "string") return stored;
|
|
||||||
if (type === "number") {
|
|
||||||
const n = Number(stored);
|
|
||||||
if (!Number.isFinite(n)) {
|
|
||||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
if (stored === "true") return true;
|
|
||||||
if (stored === "false") return false;
|
|
||||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Record<string, unknown>} row
|
|
||||||
*/
|
|
||||||
function publicVariable(row) {
|
|
||||||
const type = assertVariableType(row.type);
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
owner: row.owner,
|
|
||||||
name: row.name,
|
|
||||||
type,
|
|
||||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner?: string }} [filters]
|
|
||||||
*/
|
|
||||||
export async function listVariables(filters = {}) {
|
|
||||||
let q = db("variables")
|
|
||||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
|
||||||
.orderBy("owner", "asc")
|
|
||||||
.orderBy("name", "asc");
|
|
||||||
if (filters.owner) {
|
|
||||||
q = q.where("owner", assertOwner(filters.owner));
|
|
||||||
}
|
|
||||||
const rows = await q;
|
|
||||||
return rows.map((row) => publicVariable(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getVariableById(id) {
|
|
||||||
const row = await db("variables").where({ id }).first();
|
|
||||||
return row ? publicVariable(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertVariable({ owner, name, type, value }) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const variableName = assertVariableName(name);
|
|
||||||
const variableType = assertVariableType(type);
|
|
||||||
const encoded = encodeVariableValue(variableType, value);
|
|
||||||
const now = nowIso();
|
|
||||||
const existing = await db("variables")
|
|
||||||
.where({ owner: ownerName, name: variableName })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("variables")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
type: variableType,
|
|
||||||
value: encoded,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getVariableById(existing.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID();
|
|
||||||
await db("variables").insert({
|
|
||||||
id,
|
|
||||||
owner: ownerName,
|
|
||||||
name: variableName,
|
|
||||||
type: variableType,
|
|
||||||
value: encoded,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getVariableById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteVariable(id) {
|
|
||||||
const n = await db("variables").where({ id }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed primitive for an owner/name. Returns null if missing.
|
|
||||||
* @param {string} owner
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {Promise<string | number | boolean | null>}
|
|
||||||
*/
|
|
||||||
export async function getVariablePlain(owner, name) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const variableName = assertVariableName(name);
|
|
||||||
const row = await db("variables")
|
|
||||||
.where({ owner: ownerName, name: variableName })
|
|
||||||
.first();
|
|
||||||
if (!row) return null;
|
|
||||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +1,14 @@
|
|||||||
import yaml from "yaml";
|
import yaml from "yaml";
|
||||||
|
import {
|
||||||
|
ensureWorkflowFilename,
|
||||||
|
suggestCopyFilename,
|
||||||
|
} from "@jerapah-flow/shared";
|
||||||
import {
|
import {
|
||||||
newWorkflowFilename,
|
newWorkflowFilename,
|
||||||
workflowFileStem,
|
workflowFileStem,
|
||||||
} from "./workflow-normalize.js";
|
} from "./workflow-normalize.js";
|
||||||
|
|
||||||
export function ensureWorkflowFilename(file) {
|
export { ensureWorkflowFilename, suggestCopyFilename };
|
||||||
const trimmed = String(file ?? "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy human-readable copy name (kept for UI hints).
|
|
||||||
* @param {string} file
|
|
||||||
* @param {string[]} existingFiles
|
|
||||||
*/
|
|
||||||
export function suggestCopyFilename(file, existingFiles = []) {
|
|
||||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
|
||||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
|
||||||
const base = match ? match[1] : name;
|
|
||||||
const ext = match ? match[2] : ".yaml";
|
|
||||||
const existing = new Set(existingFiles);
|
|
||||||
|
|
||||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
|
||||||
const root = copyMatch ? copyMatch[1] : base;
|
|
||||||
const candidate = (i) =>
|
|
||||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
|
||||||
|
|
||||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
|
||||||
while (existing.has(candidate(n))) n += 1;
|
|
||||||
return candidate(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UUID-based duplicate filename (default for new duplicates).
|
* UUID-based duplicate filename (default for new duplicates).
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import yaml from "yaml";
|
||||||
|
import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {string | null} safe basename without extension, or null if invalid
|
||||||
|
*/
|
||||||
|
export function assertExampleWorkflowId(id) {
|
||||||
|
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to an example YAML, or null if missing/unsafe.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export function exampleWorkflowPath(id) {
|
||||||
|
const safe = assertExampleWorkflowId(id);
|
||||||
|
if (!safe) return null;
|
||||||
|
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`);
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (
|
||||||
|
resolved !== EXAMPLE_WORKFLOWS_DIR &&
|
||||||
|
!resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ id: string, name: string, description: string }[]}
|
||||||
|
*/
|
||||||
|
export function listExampleWorkflows() {
|
||||||
|
if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(EXAMPLE_WORKFLOWS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
||||||
|
.sort()
|
||||||
|
.map((f) => {
|
||||||
|
const id = f.replace(/\.ya?ml$/i, "");
|
||||||
|
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f);
|
||||||
|
let name = id;
|
||||||
|
let description = "";
|
||||||
|
try {
|
||||||
|
const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {};
|
||||||
|
if (typeof parsed.name === "string" && parsed.name.trim()) {
|
||||||
|
name = parsed.name.trim();
|
||||||
|
}
|
||||||
|
if (parsed.description != null) {
|
||||||
|
description = String(parsed.description).trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep id as name
|
||||||
|
}
|
||||||
|
return { id, name, description };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {{ id: string, content: string } | null}
|
||||||
|
*/
|
||||||
|
export function readExampleWorkflow(id) {
|
||||||
|
const filePath = exampleWorkflowPath(id);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const safe = assertExampleWorkflowId(id);
|
||||||
|
return {
|
||||||
|
id: /** @type {string} */ (safe),
|
||||||
|
content: fs.readFileSync(filePath, "utf8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { HTTP_METHODS } from "@jerapah-flow/shared";
|
||||||
|
import {
|
||||||
|
checkAnyHttpAuth,
|
||||||
|
resolveAuthMechanisms,
|
||||||
|
resolveUnauthorizedSpec,
|
||||||
|
sendHttpPageOrJson,
|
||||||
|
sendSuccessPage,
|
||||||
|
} from "./http-trigger-auth.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild METHOD+path → workflow map from loaded workflows.
|
||||||
|
*
|
||||||
|
* @param {Map<string, { owner: string, workflow: any }>} workflows
|
||||||
|
* @param {Map<string, { key: string, owner: string, trigger: any }>} httpRoutes
|
||||||
|
* @param {{
|
||||||
|
* namespacedPath: (owner: string, path: unknown) => string,
|
||||||
|
* log: { debug: Function, warn: Function },
|
||||||
|
* }} deps
|
||||||
|
*/
|
||||||
|
export function rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log }) {
|
||||||
|
httpRoutes.clear();
|
||||||
|
|
||||||
|
for (const [key, { owner, workflow }] of workflows) {
|
||||||
|
if (workflow.enabled === false) {
|
||||||
|
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trigger of workflow.triggers ?? []) {
|
||||||
|
if (trigger.type !== "HTTP") continue;
|
||||||
|
|
||||||
|
const method = String(trigger.method ?? "POST").toUpperCase();
|
||||||
|
const url = namespacedPath(owner, trigger.path);
|
||||||
|
const routeKey = `${method} ${url}`;
|
||||||
|
|
||||||
|
if (httpRoutes.has(routeKey)) {
|
||||||
|
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
httpRoutes.set(routeKey, { key, owner, trigger });
|
||||||
|
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the /u/* Fastify wildcard once; subsequent rebuilds only refresh the map.
|
||||||
|
*
|
||||||
|
* @param {import("fastify").FastifyInstance} server
|
||||||
|
* @param {(req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => any} handler
|
||||||
|
* @param {{ registered: boolean }} state
|
||||||
|
* @param {{ log: { debug: Function } }} deps
|
||||||
|
*/
|
||||||
|
export function ensureHttpWildcardRoute(server, handler, state, { log }) {
|
||||||
|
if (state.registered) return;
|
||||||
|
state.registered = true;
|
||||||
|
server.route({
|
||||||
|
method: HTTP_METHODS,
|
||||||
|
url: "/u/*",
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the HTTP trigger request handler bound to registry state.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* httpRoutes: Map<string, { key: string, owner: string, trigger: any }>,
|
||||||
|
* workflows: Map<string, { owner: string, workflow: any }>,
|
||||||
|
* namespacedPath: (owner: string, path: unknown) => string,
|
||||||
|
* enqueueWorkflow: (key: string, ctx: any, trigger: any) => Promise<any>,
|
||||||
|
* }} deps
|
||||||
|
*/
|
||||||
|
export function createHttpTriggerHandler({
|
||||||
|
httpRoutes,
|
||||||
|
workflows,
|
||||||
|
namespacedPath,
|
||||||
|
enqueueWorkflow,
|
||||||
|
}) {
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
return async function dispatchHttpTrigger(req, reply) {
|
||||||
|
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
||||||
|
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
||||||
|
const method = String(req.method ?? "GET").toUpperCase();
|
||||||
|
const routeKey = `${method} ${url}`;
|
||||||
|
const mapped = httpRoutes.get(routeKey);
|
||||||
|
|
||||||
|
if (!mapped) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = workflows.get(mapped.key);
|
||||||
|
if (!entry || entry.workflow?.enabled === false) {
|
||||||
|
return reply.code(404).send({ error: "workflow disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer live trigger from current workflow YAML (auth/response edits)
|
||||||
|
const liveTrigger =
|
||||||
|
(entry.workflow.triggers ?? []).find((t) => {
|
||||||
|
if (t?.type !== "HTTP") return false;
|
||||||
|
const m = String(t.method ?? "POST").toUpperCase();
|
||||||
|
const p = namespacedPath(entry.owner, t.path);
|
||||||
|
return m === method && p === url;
|
||||||
|
}) ?? mapped.trigger;
|
||||||
|
|
||||||
|
if (
|
||||||
|
liveTrigger.auth != null &&
|
||||||
|
liveTrigger.auth !== false &&
|
||||||
|
!(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0)
|
||||||
|
) {
|
||||||
|
const mechanisms = await resolveAuthMechanisms(liveTrigger.auth);
|
||||||
|
if (mechanisms.length === 0) {
|
||||||
|
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
||||||
|
return sendHttpPageOrJson(reply, status, pageName, {
|
||||||
|
error: "unauthorized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ok = await checkAnyHttpAuth(req, mechanisms, {
|
||||||
|
owner: entry.owner,
|
||||||
|
workflowKey: mapped.key,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
const { status, pageName } = resolveUnauthorizedSpec(
|
||||||
|
liveTrigger,
|
||||||
|
mechanisms[0],
|
||||||
|
);
|
||||||
|
return sendHttpPageOrJson(reply, status, pageName, {
|
||||||
|
error: "unauthorized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await enqueueWorkflow(
|
||||||
|
mapped.key,
|
||||||
|
{ data: req.body },
|
||||||
|
{ type: "http", detail: `${method} ${url}` },
|
||||||
|
);
|
||||||
|
if (result.status === "failed") {
|
||||||
|
return reply.code(result.runId ? 500 : 404).send({
|
||||||
|
runId: result.runId,
|
||||||
|
status: result.status,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBody = {
|
||||||
|
runId: result.runId,
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
||||||
|
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
||||||
|
}
|
||||||
|
return reply.code(202).send(defaultBody);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
|
||||||
|
import { log } from "./logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy a directory.
|
||||||
|
* @param {string} src
|
||||||
|
* @param {string} dest
|
||||||
|
*/
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const from = path.join(src, entry.name);
|
||||||
|
const to = path.join(dest, entry.name);
|
||||||
|
if (entry.isDirectory()) copyDir(from, to);
|
||||||
|
else fs.copyFileSync(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when WORKFLOWS_DIR has no owner subdirectories.
|
||||||
|
* @param {string} dir
|
||||||
|
*/
|
||||||
|
function isEmptyWorkflowsDir(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return true;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return !entries.some((e) => e.isDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot: copy packages/server/workflows → data/workflows when the new
|
||||||
|
* store is empty and the legacy tree still exists.
|
||||||
|
* Does not copy from examples/workflows.
|
||||||
|
*/
|
||||||
|
export function migrateLegacyWorkflowsIfNeeded() {
|
||||||
|
if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false;
|
||||||
|
if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false;
|
||||||
|
const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
if (!legacyEntries.some((e) => e.isDirectory())) return false;
|
||||||
|
|
||||||
|
fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
|
||||||
|
copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR);
|
||||||
|
log.info(
|
||||||
|
{ from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR },
|
||||||
|
"migrated legacy workflows into instance store",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
export { namespacedPath } from "@jerapah-flow/shared";
|
||||||
|
|
||||||
export const SET_STEP_SCRIPT = "set";
|
export const SET_STEP_SCRIPT = "set";
|
||||||
|
|
||||||
@@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "script",
|
* kind: "script",
|
||||||
* script: string,
|
* script: string,
|
||||||
|
* profile: string | null,
|
||||||
* config: unknown | null,
|
* config: unknown | null,
|
||||||
* expression?: undefined,
|
* expression?: undefined,
|
||||||
|
* name: string | null,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
* when: string | null,
|
* when: string | null,
|
||||||
* }} ParsedScriptStep
|
* }} ParsedScriptStep
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "set",
|
* kind: "set",
|
||||||
* script: typeof SET_STEP_SCRIPT,
|
* script: typeof SET_STEP_SCRIPT,
|
||||||
* config: { expression: string },
|
* profile: null,
|
||||||
|
* config: { expression: string },
|
||||||
* expression: string,
|
* expression: string,
|
||||||
|
* name: string | null,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
@@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) {
|
|||||||
return await result;
|
return await result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
|
|
||||||
* @param {string} owner
|
|
||||||
* @param {string} triggerPath
|
|
||||||
*/
|
|
||||||
export function namespacedPath(owner, triggerPath) {
|
|
||||||
const cleaned = String(triggerPath).replace(/^\/+/, "");
|
|
||||||
return `/u/${owner}/${cleaned}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} step
|
* @param {unknown} step
|
||||||
* @returns {ParsedStep}
|
* @returns {ParsedStep}
|
||||||
@@ -85,7 +80,9 @@ export function parseScriptStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step,
|
script: step,
|
||||||
|
profile: null,
|
||||||
config: null,
|
config: null,
|
||||||
|
name: null,
|
||||||
id: null,
|
id: null,
|
||||||
needsKind: "none",
|
needsKind: "none",
|
||||||
needs: [],
|
needs: [],
|
||||||
@@ -97,25 +94,35 @@ export function parseScriptStep(step) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasScript = step.script != null && step.script !== "";
|
const hasScript = step.script != null && step.script !== "";
|
||||||
|
const hasProfile = step.profile != null && step.profile !== "";
|
||||||
const hasSet = step.set != null;
|
const hasSet = step.set != null;
|
||||||
|
|
||||||
if (hasScript && hasSet) {
|
if (hasScript && hasSet) {
|
||||||
throw new Error("Step cannot have both script and set");
|
throw new Error("Step cannot have both script and set");
|
||||||
}
|
}
|
||||||
|
if (hasProfile && hasSet) {
|
||||||
|
throw new Error("Step cannot have both profile and set");
|
||||||
|
}
|
||||||
|
|
||||||
if (hasSet) {
|
if (hasSet) {
|
||||||
return parseSetStep(step);
|
return parseSetStep(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScript) {
|
if (hasProfile && typeof step.profile !== "string") {
|
||||||
if (typeof step.script !== "string") {
|
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
|
||||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
}
|
||||||
}
|
if (hasScript && typeof step.script !== "string") {
|
||||||
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasScript || hasProfile) {
|
||||||
const { needsKind, needs } = parseNeeds(step.needs);
|
const { needsKind, needs } = parseNeeds(step.needs);
|
||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step.script,
|
script: hasScript ? step.script : "",
|
||||||
|
profile: hasProfile ? step.profile : null,
|
||||||
config: step.config ?? null,
|
config: step.config ?? null,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -265,8 +272,10 @@ function parseSetStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "set",
|
kind: "set",
|
||||||
script: SET_STEP_SCRIPT,
|
script: SET_STEP_SCRIPT,
|
||||||
|
profile: null,
|
||||||
config: { expression },
|
config: { expression },
|
||||||
expression,
|
expression,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -287,6 +296,19 @@ function parseWhen(when) {
|
|||||||
return when;
|
return when;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function parseOptionalName(name) {
|
||||||
|
if (name == null || name === "") return null;
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} id
|
* @param {unknown} id
|
||||||
* @returns {string | null}
|
* @returns {string | null}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
|
|||||||
/**
|
/**
|
||||||
* @param {string} deletedAtIso
|
* @param {string} deletedAtIso
|
||||||
*/
|
*/
|
||||||
export function trashAgeMs(deletedAtIso) {
|
function trashAgeMs(deletedAtIso) {
|
||||||
return Date.now() - Date.parse(deletedAtIso);
|
return Date.now() - Date.parse(deletedAtIso);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
|||||||
try {
|
try {
|
||||||
const step = parseScriptStep(raw);
|
const step = parseScriptStep(raw);
|
||||||
if (step.kind === "set") continue;
|
if (step.kind === "set") continue;
|
||||||
|
if (step.profile && !step.script) continue;
|
||||||
const resolved = resolveScriptRef(step.script);
|
const resolved = resolveScriptRef(step.script);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ scripts:
|
|||||||
- script: fetch-binary.js
|
- script: fetch-binary.js
|
||||||
- script: ntfy.js
|
- script: ntfy.js
|
||||||
config:
|
config:
|
||||||
url: https://ntfy.sh/jerapah-flow
|
url: $VAR_ntfy_channel
|
||||||
triggers:
|
triggers:
|
||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Joplin nightly daily log
|
|
||||||
description: |
|
|
||||||
POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy.
|
|
||||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3040/api/logs/run
|
|
||||||
method: POST
|
|
||||||
token: $SECRET_joplin_setup_token
|
|
||||||
timeoutMs: 600000
|
|
||||||
- set:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.ok ? "Bullet journal" : "Bullet journal failed",
|
|
||||||
"message": data.ok
|
|
||||||
? "The bullet journal for " & data.httpResponse.date & " has been created."
|
|
||||||
: data.message
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "0 4 * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-daily
|
|
||||||
enabled: false
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: Joplin get note
|
|
||||||
description: |
|
|
||||||
GET a Joplin note by id from joplin-api. Input (data): id (32-char hex).
|
|
||||||
Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3030/notes
|
|
||||||
method: GET
|
|
||||||
token: $SECRET_joplin_api_token
|
|
||||||
timeoutMs: 60000
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-get-note
|
|
||||||
enabled: false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: Joplin nightly sync
|
|
||||||
description: |
|
|
||||||
POST joplin-auto /api/sync at 03:00, then ntfy success or failure.
|
|
||||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
|
||||||
scripts:
|
|
||||||
- script: plugin/joplin-api
|
|
||||||
config:
|
|
||||||
url: http://10.8.0.6:3040/api/sync
|
|
||||||
method: POST
|
|
||||||
token: $SECRET_joplin_setup_token
|
|
||||||
timeoutMs: 600000
|
|
||||||
- set:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.ok ? "Joplin sync ok" : "Joplin sync failed",
|
|
||||||
"message": data.message
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "0 3 * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-joplin-sync
|
|
||||||
enabled: false
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: dev-zte-sms
|
|
||||||
description: |
|
|
||||||
Send an SMS via a ZTE modem web UI.
|
|
||||||
Input (data): to, message
|
|
||||||
Password is the named secret zte_modem_password ($SECRET_).
|
|
||||||
scripts:
|
|
||||||
- script: plugin/send-sms
|
|
||||||
config:
|
|
||||||
url: http://192.168.5.1/reqproc/proc_post
|
|
||||||
password: $SECRET_sms_secret
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /dev-zte-sms
|
|
||||||
auth:
|
|
||||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: fetch-devto
|
|
||||||
scripts:
|
|
||||||
- script: fetch-html.js
|
|
||||||
config:
|
|
||||||
url: https://dev.to/t/productivity/top/week
|
|
||||||
selector: "#substories h2"
|
|
||||||
outputVar: titles
|
|
||||||
jsonata: "$[].text"
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /fetch-dev-to
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Jadwal Solat Jakarta ntfy
|
|
||||||
scripts:
|
|
||||||
- id: fetch
|
|
||||||
script: fetch-http.js
|
|
||||||
config:
|
|
||||||
url: https://kemenag.go.id/api/prayer-times/1301
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept: application/json
|
|
||||||
- id: transform
|
|
||||||
script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |-
|
|
||||||
{
|
|
||||||
"title": data.httpResponse.data.date,
|
|
||||||
"message": "Imsak:" & data.httpResponse.data.imsak & "\n" &
|
|
||||||
"Subuh:" & data.httpResponse.data.subuh & "\n" &
|
|
||||||
"Dzuhur:" & data.httpResponse.data.dzuhur & "\n" &
|
|
||||||
"Ashar:" & data.httpResponse.data.ashar & "\n" &
|
|
||||||
"Maghrib:" & data.httpResponse.data.maghrib & "\n" &
|
|
||||||
"Isya:" & data.httpResponse.data.isya
|
|
||||||
}
|
|
||||||
needs:
|
|
||||||
- fetch
|
|
||||||
- id: ntfy
|
|
||||||
script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
fingerprint: true
|
|
||||||
needs:
|
|
||||||
- transform
|
|
||||||
- id: slack
|
|
||||||
script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel2
|
|
||||||
fingerprint: fingerprint:ntfy2
|
|
||||||
needs:
|
|
||||||
- transform
|
|
||||||
- script: slack-webhook.js
|
|
||||||
config:
|
|
||||||
webhookUrlSecret: slack_deploy_webhook
|
|
||||||
fingerprint: true
|
|
||||||
fingerprintMaxAge: 1h
|
|
||||||
text: $INPUT_message
|
|
||||||
needs:
|
|
||||||
- transform
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: 0 5 * * *
|
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
scripts:
|
scripts:
|
||||||
- time-to-ntfy-example.yaml
|
|
||||||
- test.yaml
|
|
||||||
- cron-example.yaml
|
- cron-example.yaml
|
||||||
- fetch-devto.yaml
|
|
||||||
- comic-monkeyuser-to-ntfy.yaml
|
|
||||||
- time-and-comic-to-ntfy.yaml
|
|
||||||
- rss-selfhst-to-ntfy.yaml
|
|
||||||
- send-gmail.yaml
|
|
||||||
- test-send-gmail.yaml
|
|
||||||
- track.yaml
|
|
||||||
- rss-devto-to-ntfy.yaml
|
|
||||||
- test-minio.yaml
|
|
||||||
- dev-joplin-sync.yaml
|
|
||||||
- dev-joplin-daily.yaml
|
|
||||||
- dev-joplin-get-note.yaml
|
|
||||||
- web-dave.yaml
|
|
||||||
- jadwal-sholat-jakart.yaml
|
|
||||||
- detect-example-changes.yaml
|
- detect-example-changes.yaml
|
||||||
- test-sftp.yaml
|
- time-to-ntfy-example.yaml
|
||||||
|
- comic-monkeyuser-to-ntfy.yaml
|
||||||
|
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
name: RSS - selfh.st first item to ntfy (copy)
|
|
||||||
scripts:
|
|
||||||
- script: fetch-rss-feed.js
|
|
||||||
config:
|
|
||||||
url: https://selfh.st/rss/
|
|
||||||
outputVar: item
|
|
||||||
jsonata: items[0]
|
|
||||||
- script: fingerprint.js
|
|
||||||
config:
|
|
||||||
key: selfhst-latest
|
|
||||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.item.title,
|
|
||||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
|
||||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /selfhst-rss-rss-devto-to-ntfy
|
|
||||||
enabled: false
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
name: RSS - selfh.st first item to ntfy
|
|
||||||
scripts:
|
|
||||||
- script: fetch-rss-feed.js
|
|
||||||
config:
|
|
||||||
url: https://selfh.st/rss/
|
|
||||||
outputVar: item
|
|
||||||
jsonata: items[0]
|
|
||||||
- script: fingerprint.js
|
|
||||||
config:
|
|
||||||
key: selfhst-latest
|
|
||||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.item.title,
|
|
||||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
|
||||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /selfhst-rss
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
name: send-gmail
|
|
||||||
description: |
|
|
||||||
Send email via Gmail SMTP. Configure user/from and the named secret,
|
|
||||||
then call from other workflows with trigger-workflow.js (name: send-gmail).
|
|
||||||
|
|
||||||
Input (data):
|
|
||||||
to optional recipient(s); defaults to your Gmail below
|
|
||||||
subject required
|
|
||||||
text plain body (aliases: body, message)
|
|
||||||
html optional HTML body
|
|
||||||
from, cc, bcc, replyTo, priority, headers optional
|
|
||||||
scripts:
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
|
|
||||||
"from": data.from,
|
|
||||||
"subject": data.subject,
|
|
||||||
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
|
|
||||||
"html": data.html,
|
|
||||||
"cc": data.cc,
|
|
||||||
"bcc": data.bcc,
|
|
||||||
"replyTo": data.replyTo,
|
|
||||||
"priority": data.priority,
|
|
||||||
"headers": data.headers
|
|
||||||
}
|
|
||||||
- script: send-email.js
|
|
||||||
config:
|
|
||||||
service: Gmail
|
|
||||||
user: elevent16th@gmail.com
|
|
||||||
from: elevent16th@gmail.com
|
|
||||||
passwordSecret: gmail_app_password
|
|
||||||
triggers:
|
|
||||||
- type: workflow
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /send-gmail
|
|
||||||
auth:
|
|
||||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Test MinIO
|
|
||||||
scripts:
|
|
||||||
- script: fetch-binary.js
|
|
||||||
config:
|
|
||||||
outputVar: file
|
|
||||||
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
|
|
||||||
filename: test.png
|
|
||||||
- script: s3.js
|
|
||||||
config:
|
|
||||||
action: write
|
|
||||||
endpoint: http://localhost:9000
|
|
||||||
bucket: default
|
|
||||||
forcePathStyle: true
|
|
||||||
accessKeyIdSecret: minio_user
|
|
||||||
secretAccessKeySecret: minio_pass
|
|
||||||
key: file.png
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /new
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
name: test-send-gmail
|
|
||||||
description: |
|
|
||||||
Kick send-gmail with sample data. Edit the payload below, then Run
|
|
||||||
(or POST /u/default/test-send-gmail).
|
|
||||||
scripts:
|
|
||||||
- script: trigger-workflow.js
|
|
||||||
config:
|
|
||||||
name: send-gmail
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"subject": "JerapahFlow test",
|
|
||||||
"text": "Hello from test-send-gmail",
|
|
||||||
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
|
|
||||||
}
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /test-send-gmail
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: SFTP Test
|
|
||||||
scripts:
|
|
||||||
- script: remote-fs.js
|
|
||||||
config:
|
|
||||||
protocol: sftp
|
|
||||||
action: list
|
|
||||||
host: localhost
|
|
||||||
path: /Users/nsrb/
|
|
||||||
username: nsrb
|
|
||||||
port: 22
|
|
||||||
password: JKLjkl
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: '{"message": $join(data.entries.name, "\n")}'
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: $VAR_ntfy_channel
|
|
||||||
fingerprint: "false"
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /new
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: jsonata
|
|
||||||
scripts:
|
|
||||||
- plugin/get-current-time
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: '{"message": data.datetime & " " & data.processId}'
|
|
||||||
- ntfy.js
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /mt
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: time and comic to ntfy
|
|
||||||
description: >
|
|
||||||
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
|
||||||
scripts:
|
|
||||||
- id: time
|
|
||||||
script: plugin/get-current-time
|
|
||||||
|
|
||||||
- id: comic
|
|
||||||
script: fetch-html.js
|
|
||||||
config:
|
|
||||||
url: "https://www.monkeyuser.com/"
|
|
||||||
outputVar: "httpResponse"
|
|
||||||
selector: ".comic img"
|
|
||||||
jsonata: |
|
|
||||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
|
||||||
|
|
||||||
- id: compose
|
|
||||||
script: jsonata.js
|
|
||||||
needs: [time, comic]
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.comic.httpResponse.title,
|
|
||||||
"message": data.time.datetime & " " & data.comic.httpResponse.title,
|
|
||||||
"attach": data.comic.httpResponse.url
|
|
||||||
}
|
|
||||||
|
|
||||||
- id: notify
|
|
||||||
script: ntfy.js
|
|
||||||
needs: [compose]
|
|
||||||
config:
|
|
||||||
url: https://ntfy.sh/jerapah-flow
|
|
||||||
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /time-and-comic
|
|
||||||
@@ -6,9 +6,7 @@ scripts:
|
|||||||
- script: plugin/get-current-time
|
- script: plugin/get-current-time
|
||||||
config:
|
config:
|
||||||
key: ""
|
key: ""
|
||||||
- script: ntfy.js
|
- profile: ntfy_default
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
triggers:
|
||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Track Gojek Trip
|
|
||||||
scripts:
|
|
||||||
- script: fetch-http.js
|
|
||||||
config:
|
|
||||||
url: https://api.gojekapi.com/live/track/491c3132c2cc7043
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept: application/json
|
|
||||||
Origin: https://track.gojek.com
|
|
||||||
- script: fingerprint.js
|
|
||||||
config:
|
|
||||||
key: gojek-track-status
|
|
||||||
jsonata: data.httpResponse.status
|
|
||||||
- script: jsonata.js
|
|
||||||
config:
|
|
||||||
expression: |
|
|
||||||
{
|
|
||||||
"title": data.httpResponse.customer_name,
|
|
||||||
"message": data.httpResponse.customer_name & " : " & data.httpResponse.status
|
|
||||||
}
|
|
||||||
- script: ntfy.js
|
|
||||||
config:
|
|
||||||
url: https://n.0dev.web.id/system
|
|
||||||
triggers:
|
|
||||||
- type: cron
|
|
||||||
schedule: "* * * * *"
|
|
||||||
- type: HTTP
|
|
||||||
method: GET
|
|
||||||
path: /new
|
|
||||||
enabled: false
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
name: Dab 0dev
|
|
||||||
scripts:
|
|
||||||
- script: list-webdav.js
|
|
||||||
config:
|
|
||||||
url: https://dav.0dev.web.id/books
|
|
||||||
path: /
|
|
||||||
includeDirectories: true
|
|
||||||
recursive: false
|
|
||||||
username: nsrb
|
|
||||||
passwordSecret: dav_0dev_password
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /new
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@jerapah-flow/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { isPlainObject } from "./is-plain-object.js";
|
||||||
|
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js";
|
||||||
|
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
|
||||||
|
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function isPlainObject(value) {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Shallow merge: step overlay keys replace profile defaults (including "").
|
||||||
|
* Nested objects/arrays are replaced, not deep-merged.
|
||||||
|
*
|
||||||
|
* @param {unknown} profileConfig
|
||||||
|
* @param {unknown} stepConfig
|
||||||
|
* @returns {Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
export function mergeProfileConfig(profileConfig, stepConfig) {
|
||||||
|
const base =
|
||||||
|
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
|
||||||
|
? { ...profileConfig }
|
||||||
|
: {};
|
||||||
|
if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return { ...base, ...stepConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of merge for Apply-to-card: keep only keys whose values differ from the profile.
|
||||||
|
* No profile → return the merged object as-is (full step config).
|
||||||
|
*
|
||||||
|
* @param {unknown} profileConfig
|
||||||
|
* @param {unknown} mergedConfig
|
||||||
|
* @returns {Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
export function overlayFromMerged(profileConfig, mergedConfig) {
|
||||||
|
const merged =
|
||||||
|
mergedConfig != null && typeof mergedConfig === "object" && !Array.isArray(mergedConfig)
|
||||||
|
? { ...mergedConfig }
|
||||||
|
: {};
|
||||||
|
const base =
|
||||||
|
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
|
||||||
|
? profileConfig
|
||||||
|
: null;
|
||||||
|
if (!base) return merged;
|
||||||
|
|
||||||
|
const overlay = {};
|
||||||
|
for (const [key, value] of Object.entries(merged)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(base, key) || !sameConfigValue(base[key], value)) {
|
||||||
|
overlay[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameConfigValue(a, b) {
|
||||||
|
if (Object.is(a, b)) return true;
|
||||||
|
if (a == null || b == null) return a === b;
|
||||||
|
if (typeof a !== "object" || typeof b !== "object") return false;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} config
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function configHasOverlay(config) {
|
||||||
|
return (
|
||||||
|
config != null &&
|
||||||
|
typeof config === "object" &&
|
||||||
|
!Array.isArray(config) &&
|
||||||
|
Object.keys(config).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
configHasOverlay,
|
||||||
|
ensureWorkflowFilename,
|
||||||
|
isPlainObject,
|
||||||
|
mergeProfileConfig,
|
||||||
|
namespacedPath,
|
||||||
|
overlayFromMerged,
|
||||||
|
} from "./index.js";
|
||||||
|
|
||||||
|
describe("isPlainObject", () => {
|
||||||
|
it("accepts plain objects and rejects arrays/null/primitives", () => {
|
||||||
|
expect(isPlainObject({})).toBe(true);
|
||||||
|
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||||
|
expect(isPlainObject([])).toBe(false);
|
||||||
|
expect(isPlainObject(null)).toBe(false);
|
||||||
|
expect(isPlainObject("x")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeProfileConfig", () => {
|
||||||
|
it("shallow-merges step overlay over profile defaults", () => {
|
||||||
|
expect(mergeProfileConfig({ a: 1, b: 2 }, { b: 3, c: 4 })).toEqual({
|
||||||
|
a: 1,
|
||||||
|
b: 3,
|
||||||
|
c: 4,
|
||||||
|
});
|
||||||
|
expect(mergeProfileConfig(null, { x: 1 })).toEqual({ x: 1 });
|
||||||
|
expect(mergeProfileConfig({ x: 1 }, null)).toEqual({ x: 1 });
|
||||||
|
expect(mergeProfileConfig({ nested: { a: 1 } }, { nested: { b: 2 } })).toEqual({
|
||||||
|
nested: { b: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overlayFromMerged", () => {
|
||||||
|
it("returns full merged when there is no profile", () => {
|
||||||
|
expect(overlayFromMerged(null, { a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
|
||||||
|
expect(overlayFromMerged(undefined, { x: 1 })).toEqual({ x: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only keys that differ from the profile", () => {
|
||||||
|
expect(overlayFromMerged({ a: 1, b: 2 }, { a: 1, b: 3, c: 4 })).toEqual({
|
||||||
|
b: 3,
|
||||||
|
c: 4,
|
||||||
|
});
|
||||||
|
expect(overlayFromMerged({ a: 1 }, { a: 1 })).toEqual({});
|
||||||
|
expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { a: 1 } })).toEqual({});
|
||||||
|
expect(overlayFromMerged({ nested: { a: 1 } }, { nested: { b: 2 } })).toEqual({
|
||||||
|
nested: { b: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty-string override as a real overlay key", () => {
|
||||||
|
expect(overlayFromMerged({ url: "http://a" }, { url: "" })).toEqual({ url: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configHasOverlay", () => {
|
||||||
|
it("is true only for non-empty plain objects", () => {
|
||||||
|
expect(configHasOverlay({ a: 1 })).toBe(true);
|
||||||
|
expect(configHasOverlay({})).toBe(false);
|
||||||
|
expect(configHasOverlay(null)).toBe(false);
|
||||||
|
expect(configHasOverlay([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureWorkflowFilename", () => {
|
||||||
|
it("adds .yaml when missing and preserves yaml/yml", () => {
|
||||||
|
expect(ensureWorkflowFilename("cron")).toBe("cron.yaml");
|
||||||
|
expect(ensureWorkflowFilename("cron.yaml")).toBe("cron.yaml");
|
||||||
|
expect(ensureWorkflowFilename("cron.YML")).toBe("cron.YML");
|
||||||
|
expect(ensureWorkflowFilename(" ")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("namespacedPath", () => {
|
||||||
|
it("builds /u/<owner>/<path> without leading slash duplication", () => {
|
||||||
|
expect(namespacedPath("default", "hooks/run")).toBe("/u/default/hooks/run");
|
||||||
|
expect(namespacedPath("local", "/hooks/run")).toBe("/u/local/hooks/run");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
|
||||||
|
export function namespacedPath(owner, triggerPath) {
|
||||||
|
const cleaned = String(triggerPath ?? "").replace(/^\/+/, "").trim();
|
||||||
|
return `/u/${owner}/${cleaned}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorkflowTrigger(workflow) {
|
||||||
|
if (!workflow || typeof workflow !== "object") return false;
|
||||||
|
const triggers = workflow.triggers;
|
||||||
|
if (!Array.isArray(triggers)) return false;
|
||||||
|
return triggers.some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Contributing (web UI)
|
||||||
|
|
||||||
|
Conventions for editors, dialogs, and routing in `packages/web`.
|
||||||
|
|
||||||
|
## Entity editing patterns
|
||||||
|
|
||||||
|
### Modal + route deep-link (simple entities)
|
||||||
|
|
||||||
|
Use a list page with a route-driven editor modal for:
|
||||||
|
|
||||||
|
- Secrets (`/secrets`, `/secrets/new`, `/secrets/:owner/:name/edit`)
|
||||||
|
- Variables
|
||||||
|
- Profiles
|
||||||
|
- Auth profiles (`/auth`, …)
|
||||||
|
- Users
|
||||||
|
|
||||||
|
Prefer [`useRouteDrivenModal`](src/hooks/useRouteDrivenModal.js) to open/close the modal from the URL, keep a single `openedRouteKey` guard, and navigate back to the list path on close.
|
||||||
|
|
||||||
|
Navigate to `/…/new` or `/…/:id/edit` from list actions; do not open the editor with local state alone when a deep-link route exists.
|
||||||
|
|
||||||
|
### Full-page editors
|
||||||
|
|
||||||
|
Scripts and workflows use dedicated full-page editors (not list+modal). Keep create/edit as their own routes and leave the list page for browsing only.
|
||||||
|
|
||||||
|
### Known exception: Responses
|
||||||
|
|
||||||
|
The Responses page may keep inline editing (no modal / no full-page editor). Treat that as intentional; do not refactor it to modal+route unless product requirements change.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Use | For |
|
||||||
|
|---|---|
|
||||||
|
| `ConfirmDialog` | Deletes and other yes/no confirmations |
|
||||||
|
| `FormInput` / `FormSelect` | Form fields in modals and pages |
|
||||||
|
| `Modal` | Custom dialog chrome when `ConfirmDialog` is not enough |
|
||||||
|
|
||||||
|
## Naming: `*Dialog` vs `*Modal`
|
||||||
|
|
||||||
|
- **`*Dialog`** — confirmations and pickers (e.g. `ConfirmDialog`, `AddTriggerDialog`, `AddScriptDialog`, `DuplicateWorkflowDialog`)
|
||||||
|
- **`*Modal`** — entity editors (e.g. `SecretEditorModal`, `ProfileEditorModal`, `AuthEditorModal`)
|
||||||
|
|
||||||
|
When adding a new overlay, pick the suffix from the table above. Do not rename existing `*EditorModal` components solely for consistency with older code.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ["dist/**"] },
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"jsx-a11y": jsxA11y,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"jsx-a11y/alt-text": "warn",
|
||||||
|
"jsx-a11y/anchor-has-content": "warn",
|
||||||
|
"jsx-a11y/aria-role": "warn",
|
||||||
|
"no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^([A-Z_]|Lu)",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -6,18 +6,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@jerapah-flow/shared": "workspace:*",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.84.0",
|
"@tanstack/react-query": "^5.84.0",
|
||||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||||
|
"@xyflow/react": "^12.11.3",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"cronstrue": "^3.24.0",
|
"cronstrue": "^3.24.0",
|
||||||
"mermaid": "^11.9.0",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
@@ -25,9 +28,14 @@
|
|||||||
"yaml": "^2.8.1"
|
"yaml": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"daisyui": "^5.0.50",
|
"daisyui": "^5.0.50",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"globals": "^16.3.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"vite": "^7.1.2"
|
"vite": "^7.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
|||||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||||
|
import { ProfilesPage } from "./pages/ProfilesPage.jsx";
|
||||||
import { OpsPage } from "./pages/OpsPage.jsx";
|
import { OpsPage } from "./pages/OpsPage.jsx";
|
||||||
import { BackupPage } from "./pages/BackupPage.jsx";
|
import { BackupPage } from "./pages/BackupPage.jsx";
|
||||||
|
|
||||||
@@ -71,6 +72,9 @@ export function App() {
|
|||||||
<Route path="/variables" element={<VariablesPage />} />
|
<Route path="/variables" element={<VariablesPage />} />
|
||||||
<Route path="/variables/new" element={<VariablesPage />} />
|
<Route path="/variables/new" element={<VariablesPage />} />
|
||||||
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
|
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
|
||||||
|
<Route path="/profiles" element={<ProfilesPage />} />
|
||||||
|
<Route path="/profiles/new" element={<ProfilesPage />} />
|
||||||
|
<Route path="/profiles/:owner/:name/edit" element={<ProfilesPage />} />
|
||||||
<Route path="/auth" element={<AuthProfilesPage />} />
|
<Route path="/auth" element={<AuthProfilesPage />} />
|
||||||
<Route path="/auth/new" element={<AuthProfilesPage />} />
|
<Route path="/auth/new" element={<AuthProfilesPage />} />
|
||||||
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
|
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
|
||||||
|
|||||||
@@ -1,723 +1 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
export * from "./hooks/index.js";
|
||||||
import { api, opsApi } from "./client.js";
|
|
||||||
|
|
||||||
export function useBootstrap() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["bootstrap"],
|
|
||||||
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMe() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["me"],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return (await api.get("/auth/me")).data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response?.status === 401) return { user: null };
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.setQueryData(["me"], data);
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRegister() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.setQueryData(["me"], data);
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogout() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await api.post("/auth/logout")).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.setQueryData(["me"], null);
|
|
||||||
qc.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDashboard() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["dashboard"],
|
|
||||||
queryFn: async () => (await api.get("/dashboard")).data,
|
|
||||||
refetchInterval: (query) =>
|
|
||||||
query.state.data?.running?.length ? 4000 : 15000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScripts() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["scripts"],
|
|
||||||
queryFn: async () => (await api.get("/scripts")).data.scripts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScript(name, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["scripts", name],
|
|
||||||
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
|
|
||||||
enabled: Boolean(name) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, content }) =>
|
|
||||||
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePlugin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ id, content, description }) =>
|
|
||||||
(await api.post("/plugins/create", { id, content, description })).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useForkScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, id, description }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
|
|
||||||
id,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstallPlugin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (name) =>
|
|
||||||
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDryRunScript() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, content, data, context, config, owner }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
|
||||||
content,
|
|
||||||
data,
|
|
||||||
context,
|
|
||||||
config,
|
|
||||||
owner,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflows(owner) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner ?? "all"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = owner ? { owner } : {};
|
|
||||||
return (await api.get("/workflows", { params })).data.workflows;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflow(owner, file, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file],
|
|
||||||
queryFn: async () =>
|
|
||||||
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
|
|
||||||
.data,
|
|
||||||
enabled: Boolean(owner && file) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOwners() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["owners"],
|
|
||||||
queryFn: async () => (await api.get("/owners")).data.owners,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, content, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.put(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetWorkflowEnabled() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, enabled }) =>
|
|
||||||
(
|
|
||||||
await api.patch(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
{ enabled },
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file }) =>
|
|
||||||
(
|
|
||||||
await api.delete(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDuplicateWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
|
||||||
{
|
|
||||||
...(destOwner ? { owner: destOwner } : {}),
|
|
||||||
...(destFile ? { file: destFile } : {}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRunWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, data }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
|
|
||||||
data !== undefined ? { data } : {},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["runs"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReregisterWorkflows() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await api.post("/workflows/reregister")).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRuns(filters = {}) {
|
|
||||||
const { owner, workflow, status, limit } = filters;
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["runs", { owner, workflow, status, limit }],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = {};
|
|
||||||
if (owner) params.owner = owner;
|
|
||||||
if (workflow) params.workflow = workflow;
|
|
||||||
if (status) params.status = status;
|
|
||||||
if (limit) params.limit = limit;
|
|
||||||
return (await api.get("/runs", { params })).data.runs;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConsecutiveFailures(limit) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["consecutive-failures", limit ?? "all"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = {};
|
|
||||||
if (limit) params.limit = limit;
|
|
||||||
return (await api.get("/consecutive-failures", { params })).data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRun(id) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["runs", id],
|
|
||||||
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
|
|
||||||
enabled: Boolean(id),
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.status;
|
|
||||||
return status === "running" || status === "queued" ? 1500 : false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUsers() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["users"],
|
|
||||||
queryFn: async () => (await api.get("/users")).data.users,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateUser() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/users", body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateUser() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ id, ...body }) =>
|
|
||||||
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteUser() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSecrets(owner) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["secrets", owner ?? "all"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = owner ? { owner } : {};
|
|
||||||
return (await api.get("/secrets", { params })).data.secrets;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertSecret() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.put("/secrets", body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteSecret() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVariables(owner, options = {}) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["variables", owner ?? "all"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = owner ? { owner } : {};
|
|
||||||
return (await api.get("/variables", { params })).data.variables;
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertVariable() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.put("/variables", body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteVariable() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKvNamespaces() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["kv", "namespaces"],
|
|
||||||
queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKv(filters = {}) {
|
|
||||||
const { namespace, q, limit, offset } = filters;
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["kv", { namespace, q, limit, offset }],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = {};
|
|
||||||
if (namespace) params.namespace = namespace;
|
|
||||||
if (q) params.q = q;
|
|
||||||
if (limit != null) params.limit = limit;
|
|
||||||
if (offset != null) params.offset = offset;
|
|
||||||
return (await api.get("/kv", { params })).data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHttpPages() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["http-pages"],
|
|
||||||
queryFn: async () => (await api.get("/http-pages")).data.pages,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertHttpPage() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteHttpPage() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHttpAuths() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["http-auths"],
|
|
||||||
queryFn: async () => (await api.get("/http-auths")).data.auths,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertHttpAuth() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteHttpAuth() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch plaintext literals only (not encrypted secrets). */
|
|
||||||
export async function fetchHttpAuthLiterals(id) {
|
|
||||||
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsStatus(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["ops-status"],
|
|
||||||
queryFn: async () => (await opsApi.get("/status")).data,
|
|
||||||
enabled,
|
|
||||||
retry: false,
|
|
||||||
refetchInterval: enabled ? 3000 : false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsPause() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await opsApi.post("/pause")).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsResume() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await opsApi.post("/resume")).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsReload() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await opsApi.post("/reload")).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsRestart() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ force = false } = {}) =>
|
|
||||||
(await opsApi.post("/restart", { force })).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsScale() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ workers, force = false }) =>
|
|
||||||
(await opsApi.post("/scale", { workers, force })).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsHttpStart() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await opsApi.post("/http/start")).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsHttpStop() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await opsApi.post("/http/stop")).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsProcessRestart() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ pmId }) =>
|
|
||||||
(await opsApi.post("/restart", { pmId: Number(pmId) })).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpsBumpGeneration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (reason) =>
|
|
||||||
(await opsApi.post("/generation/bump", { reason })).data,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowTrash() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", "trash"],
|
|
||||||
queryFn: async () => (await api.get("/workflows/trash")).data.items,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestoreWorkflowTrash() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePurgeWorkflowTrash() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowRevisions(owner, file, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file, "revisions"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await api.get(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
enabled: Boolean(owner && file) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowRevision(owner, file, revision) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file, "revisions", revision],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await api.get(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
enabled: Boolean(owner && file && revision != null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRevertWorkflowRevision() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, revision, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`,
|
|
||||||
saveAnyway ? { saveAnyway: true } : {},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, content, file, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/workflows/${encodeURIComponent(owner)}`, {
|
|
||||||
content,
|
|
||||||
...(file ? { file } : {}),
|
|
||||||
...(saveAnyway ? { saveAnyway: true } : {}),
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDownloadWorkflowBackup() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const res = await api.get("/workflows/backup", { responseType: "blob" });
|
|
||||||
const disposition = res.headers["content-disposition"] ?? "";
|
|
||||||
const match = disposition.match(/filename="([^"]+)"/);
|
|
||||||
const filename = match?.[1] ?? "jerapah-flow-backup.zip";
|
|
||||||
const url = URL.createObjectURL(res.data);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestoreWorkflowBackup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ file, mode }) => {
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
|
||||||
return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useBootstrap() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bootstrap"],
|
||||||
|
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return (await api.get("/auth/me")).data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 401) return { user: null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["me"], data);
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["me"], data);
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await api.post("/auth/logout")).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.setQueryData(["me"], null);
|
||||||
|
qc.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: async () => (await api.get("/users")).data.users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/users", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }) =>
|
||||||
|
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useHttpPages() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["http-pages"],
|
||||||
|
queryFn: async () => (await api.get("/http-pages")).data.pages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertHttpPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteHttpPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHttpAuths() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["http-auths"],
|
||||||
|
queryFn: async () => (await api.get("/http-auths")).data.auths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertHttpAuth() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteHttpAuth() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||||
|
|
||||||
|
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||||
|
export async function fetchHttpAuthLiterals(id) {
|
||||||
|
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from "./auth.js";
|
||||||
|
export * from "./scripts.js";
|
||||||
|
export * from "./workflows.js";
|
||||||
|
export * from "./runs.js";
|
||||||
|
export * from "./secrets.js";
|
||||||
|
export * from "./variables.js";
|
||||||
|
export * from "./profiles.js";
|
||||||
|
export * from "./kv.js";
|
||||||
|
export * from "./http.js";
|
||||||
|
export * from "./ops.js";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useKvNamespaces() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["kv", "namespaces"],
|
||||||
|
queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKv(filters = {}) {
|
||||||
|
const { namespace, q, limit, offset } = filters;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["kv", { namespace, q, limit, offset }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {};
|
||||||
|
if (namespace) params.namespace = namespace;
|
||||||
|
if (q) params.q = q;
|
||||||
|
if (limit != null) params.limit = limit;
|
||||||
|
if (offset != null) params.offset = offset;
|
||||||
|
return (await api.get("/kv", { params })).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteKv() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ namespace, key }) =>
|
||||||
|
(await api.delete("/kv", { params: { namespace, key } })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["kv"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { opsApi } from "../client.js";
|
||||||
|
|
||||||
|
export function useOpsStatus(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ops-status"],
|
||||||
|
queryFn: async () => (await opsApi.get("/status")).data,
|
||||||
|
enabled,
|
||||||
|
retry: false,
|
||||||
|
refetchInterval: enabled ? 3000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsPause() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/pause")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsResume() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/resume")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsReload() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/reload")).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsRestart() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ force = false } = {}) =>
|
||||||
|
(await opsApi.post("/restart", { force })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsScale() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ workers, force = false }) =>
|
||||||
|
(await opsApi.post("/scale", { workers, force })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsHttpStart() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/http/start")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsHttpStop() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/http/stop")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsProcessRestart() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ pmId }) =>
|
||||||
|
(await opsApi.post("/restart", { pmId: Number(pmId) })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useProfiles(owner, options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["profiles", owner ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = owner ? { owner } : {};
|
||||||
|
return (await api.get("/profiles", { params })).data.profiles;
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileUsage(id, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["profiles", "usage", id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages,
|
||||||
|
enabled: Boolean(id) && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertProfile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/profiles", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteProfile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, force }) =>
|
||||||
|
(
|
||||||
|
await api.delete(`/profiles/${encodeURIComponent(id)}`, {
|
||||||
|
params: force ? { force: "1" } : {},
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard"],
|
||||||
|
queryFn: async () => (await api.get("/dashboard")).data,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.running?.length ? 4000 : 15000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRuns(filters = {}) {
|
||||||
|
const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {};
|
||||||
|
if (owner) params.owner = owner;
|
||||||
|
if (workflow) params.workflow = workflow;
|
||||||
|
if (status) params.status = status;
|
||||||
|
if (trigger) params.trigger = trigger;
|
||||||
|
if (after) params.after = after;
|
||||||
|
if (before) params.before = before;
|
||||||
|
if (limit != null) params.limit = limit;
|
||||||
|
if (offset != null) params.offset = offset;
|
||||||
|
if (sort) params.sort = sort;
|
||||||
|
if (order) params.order = order;
|
||||||
|
return (await api.get("/runs", { params })).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConsecutiveFailures(limit) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["consecutive-failures", limit ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {};
|
||||||
|
if (limit) params.limit = limit;
|
||||||
|
return (await api.get("/consecutive-failures", { params })).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRun(id) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["runs", id],
|
||||||
|
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
|
||||||
|
enabled: Boolean(id),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.status;
|
||||||
|
return status === "running" || status === "queued" ? 1500 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useScripts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["scripts"],
|
||||||
|
queryFn: async () => (await api.get("/scripts")).data.scripts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScript(name, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["scripts", name],
|
||||||
|
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
|
||||||
|
enabled: Boolean(name) && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveScript() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ name, content }) =>
|
||||||
|
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePlugin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, content, description }) =>
|
||||||
|
(await api.post("/plugins/create", { id, content, description })).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForkScript() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ name, id, description }) =>
|
||||||
|
(
|
||||||
|
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstallPlugin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteScript() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (name) =>
|
||||||
|
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDryRunScript() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
expression,
|
||||||
|
data,
|
||||||
|
context,
|
||||||
|
config,
|
||||||
|
owner,
|
||||||
|
}) =>
|
||||||
|
(
|
||||||
|
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
||||||
|
...(content != null ? { content } : {}),
|
||||||
|
...(expression != null ? { expression } : {}),
|
||||||
|
data,
|
||||||
|
context,
|
||||||
|
config,
|
||||||
|
owner,
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useSecrets(owner) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["secrets", owner ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = owner ? { owner } : {};
|
||||||
|
return (await api.get("/secrets", { params })).data.secrets;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertSecret() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/secrets", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSecret() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useVariables(owner, options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["variables", owner ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = owner ? { owner } : {};
|
||||||
|
return (await api.get("/variables", { params })).data.variables;
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertVariable() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/variables", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteVariable() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useWorkflows(owner) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflows", owner ?? "all"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = owner ? { owner } : {};
|
||||||
|
return (await api.get("/workflows", { params })).data.workflows;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflow(owner, file, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflows", owner, file],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
|
||||||
|
.data,
|
||||||
|
enabled: Boolean(owner && file) && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOwners() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["owners"],
|
||||||
|
queryFn: async () => (await api.get("/owners")).data.owners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowExamples() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflow-examples"],
|
||||||
|
queryFn: async () => (await api.get("/workflow-examples")).data.examples,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveWorkflow() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, content, saveAnyway }) =>
|
||||||
|
(
|
||||||
|
await api.put(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||||
|
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetWorkflowEnabled() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, enabled }) =>
|
||||||
|
(
|
||||||
|
await api.patch(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||||
|
{ enabled },
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWorkflow() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file }) =>
|
||||||
|
(
|
||||||
|
await api.delete(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateWorkflow() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
||||||
|
(
|
||||||
|
await api.post(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
||||||
|
{
|
||||||
|
...(destOwner ? { owner: destOwner } : {}),
|
||||||
|
...(destFile ? { file: destFile } : {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunWorkflow() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, data }) =>
|
||||||
|
(
|
||||||
|
await api.post(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
|
||||||
|
data !== undefined ? { data } : {},
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["runs"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReregisterWorkflows() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await api.post("/workflows/reregister")).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowTrash() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflows", "trash"],
|
||||||
|
queryFn: async () => (await api.get("/workflows/trash")).data.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreWorkflowTrash() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePurgeWorkflowTrash() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowRevisions(owner, file, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflows", owner, file, "revisions"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await api.get(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`,
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
enabled: Boolean(owner && file) && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowRevision(owner, file, revision) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workflows", owner, file, "revisions", revision],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await api.get(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`,
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
enabled: Boolean(owner && file && revision != null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevertWorkflowRevision() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, file, revision, saveAnyway }) =>
|
||||||
|
(
|
||||||
|
await api.post(
|
||||||
|
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`,
|
||||||
|
saveAnyway ? { saveAnyway: true } : {},
|
||||||
|
)
|
||||||
|
).data,
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWorkflow() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ owner, content, file, saveAnyway }) =>
|
||||||
|
(
|
||||||
|
await api.post(`/workflows/${encodeURIComponent(owner)}`, {
|
||||||
|
content,
|
||||||
|
...(file ? { file } : {}),
|
||||||
|
...(saveAnyway ? { saveAnyway: true } : {}),
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadWorkflowBackup() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await api.get("/workflows/backup", { responseType: "blob" });
|
||||||
|
const disposition = res.headers["content-disposition"] ?? "";
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
|
const filename = match?.[1] ?? "jerapah-flow-backup.zip";
|
||||||
|
const url = URL.createObjectURL(res.data);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreWorkflowBackup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ file, mode }) => {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
||||||
|
return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user