This commit is contained in:
+5
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
data/
|
||||
logs/
|
||||
*.db
|
||||
@@ -9,8 +10,11 @@ packages/web/dist
|
||||
# Personal/local scripts and workflows (not for the repo)
|
||||
debug-*.js
|
||||
|
||||
# Legacy live workflow tree (migrated to packages/server/data/workflows/)
|
||||
packages/server/workflows/
|
||||
|
||||
# Plugin install staging and per-plugin deps
|
||||
plugins/.staging-*
|
||||
plugins/*/node_modules/
|
||||
|
||||
.gitea/workflows/
|
||||
.gitea/workflows/
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
|
||||
|
||||
## Workflows (instance data)
|
||||
|
||||
Live workflows are **not** product source. They live under `packages/server/data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
|
||||
|
||||
| Kind | In git? | Path |
|
||||
|---|---|---|
|
||||
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` |
|
||||
| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) |
|
||||
|
||||
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
|
||||
|
||||
## Where things live
|
||||
|
||||
| Kind | YAML `script` | Editable | Path |
|
||||
@@ -127,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
- script: plugin/my-plugin
|
||||
- name: Notify to channel
|
||||
script: plugin/my-plugin
|
||||
config:
|
||||
url: http://10.8.0.6:3030/notes
|
||||
token: $SECRET_joplin_api_token
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ pnpm dev
|
||||
- UI (dev): http://localhost:8500
|
||||
- API: http://localhost:8700
|
||||
|
||||
The first account created becomes **admin**. Later accounts are created from Users.
|
||||
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
|
||||
|
||||
Reset or create the admin login from the host:
|
||||
|
||||
```bash
|
||||
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||
```
|
||||
|
||||
### Process modes
|
||||
|
||||
@@ -36,13 +42,26 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
| Kind | Name in YAML | Editable | Location |
|
||||
|---|---|---|---|
|
||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||
| **Plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||
| **User plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||
| **Example source** | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
|
||||
|
||||
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
||||
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
||||
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
||||
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
||||
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
|
||||
- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`.
|
||||
|
||||
## Workflows (instance data vs examples)
|
||||
|
||||
| Kind | Loaded by runner? | Location |
|
||||
|---|---|---|
|
||||
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
|
||||
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
|
||||
|
||||
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
|
||||
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
|
||||
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
|
||||
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
|
||||
|
||||
```bash
|
||||
# Smoke
|
||||
@@ -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_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
||||
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
|
||||
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). |
|
||||
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
|
||||
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
||||
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||
|
||||
@@ -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 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:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||
"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",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"rss-parser": "^3.13.0"
|
||||
}
|
||||
"packageManager": "pnpm@11.22.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
||||
@@ -75,5 +74,3 @@ export function satisfiesRange(version, range) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void fileURLToPath;
|
||||
|
||||
@@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
|
||||
*/
|
||||
|
||||
/** @returns {ControlState} */
|
||||
export function defaultControlState() {
|
||||
function defaultControlState() {
|
||||
return {
|
||||
http: "running",
|
||||
workers: 1,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Dev ecosystem for `pnpm dev:pm2`.
|
||||
* Local multi-process Ops UI ecosystem (`pnpm dev:pm2`).
|
||||
* Prefer starting children via control.js (desired state) rather than this file.
|
||||
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
|
||||
* For production monolith, use root ecosystem.config.cjs instead.
|
||||
*/
|
||||
const path = require("path");
|
||||
|
||||
|
||||
+27
-32
@@ -49,14 +49,8 @@ export function readScript(name) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
export function writeScript(name, content) {
|
||||
assertScriptName(name);
|
||||
fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
|
||||
* @returns {{ filePath: string, contentType: string } | null}
|
||||
*/
|
||||
export function resolveScriptIcon(name) {
|
||||
@@ -65,6 +59,7 @@ export function resolveScriptIcon(name) {
|
||||
for (const { ext, contentType } of [
|
||||
{ ext: "png", contentType: "image/png" },
|
||||
{ ext: "jpg", contentType: "image/jpeg" },
|
||||
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||
]) {
|
||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
@@ -78,22 +73,6 @@ export function scriptHasIcon(name) {
|
||||
return resolveScriptIcon(name) != null;
|
||||
}
|
||||
|
||||
export function deleteScript(name) {
|
||||
assertScriptName(name);
|
||||
const filePath = path.join(SCRIPTS_DIR, name);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const icon = resolveScriptIcon(name);
|
||||
fs.unlinkSync(filePath);
|
||||
if (icon) {
|
||||
try {
|
||||
fs.unlinkSync(icon.filePath);
|
||||
} catch {
|
||||
// ignore missing icon
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listOwners() {
|
||||
if (!fs.existsSync(WORKFLOWS_DIR)) return [];
|
||||
return fs
|
||||
@@ -132,6 +111,31 @@ export function readWorkflowYaml(owner, file) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Last content change time for a workflow YAML file.
|
||||
* Uses birthtime (creation) when the file has not been modified since it was created.
|
||||
* @returns {string | null} ISO timestamp
|
||||
*/
|
||||
export function workflowLastModifiedAt(owner, file) {
|
||||
try {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
||||
try {
|
||||
const st = fs.statSync(filePath);
|
||||
const birthMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
|
||||
const mtimeMs = Number.isFinite(st.mtimeMs) && st.mtimeMs > 0 ? st.mtimeMs : null;
|
||||
const unmodified = birthMs != null && (mtimeMs == null || mtimeMs <= birthMs + 1000);
|
||||
const ms = unmodified ? birthMs : (mtimeMs ?? birthMs);
|
||||
return ms != null ? new Date(ms).toISOString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowYaml(owner, file, content) {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
@@ -140,15 +144,6 @@ export function writeWorkflowYaml(owner, file, content) {
|
||||
fs.writeFileSync(path.join(ownerDir, file), content, "utf8");
|
||||
}
|
||||
|
||||
export function deleteWorkflowYaml(owner, file) {
|
||||
assertOwner(owner);
|
||||
assertWorkflowFile(file);
|
||||
const filePath = path.join(WORKFLOWS_DIR, owner, file);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listOwnerYamlFiles(owner) {
|
||||
const ownerDir = path.join(WORKFLOWS_DIR, owner);
|
||||
if (!fs.existsSync(ownerDir)) return [];
|
||||
|
||||
@@ -1,390 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertHttpStatus } from "./http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthId(id) {
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||
const err = new Error("invalid auth id");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthName(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
const err = new Error("invalid auth name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"bearer" | "basic" | "header"}
|
||||
*/
|
||||
export function assertAuthType(type) {
|
||||
const t = String(type ?? "");
|
||||
if (!ALLOWED_TYPES.has(t)) {
|
||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect value source without exposing literal values.
|
||||
* @param {unknown} value
|
||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
||||
*/
|
||||
export function valueSourceKind(value) {
|
||||
if (value == null) return "missing";
|
||||
if (typeof value === "string") return "literal";
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if ("secret" in value) return "secret";
|
||||
if ("kv" in value) return "kv";
|
||||
}
|
||||
return "literal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact config for API responses: replace literal strings with source markers.
|
||||
* @param {Record<string, unknown>} config
|
||||
* @param {string} type
|
||||
*/
|
||||
export function publicConfig(config, type) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (type === "bearer") {
|
||||
out.token = redactField(config.token);
|
||||
} else if (type === "basic") {
|
||||
out.user = redactField(config.user);
|
||||
out.password = redactField(config.password);
|
||||
} else if (type === "header") {
|
||||
out.header = typeof config.header === "string" ? config.header : null;
|
||||
out.value = redactField(config.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function redactField(value) {
|
||||
const kind = valueSourceKind(value);
|
||||
if (kind === "missing") return { source: "missing" };
|
||||
if (kind === "kv") {
|
||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
||||
return {
|
||||
source: "kv",
|
||||
kv: v.kv,
|
||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === "secret") {
|
||||
const v = /** @type {{ secret: string }} */ (value);
|
||||
return { source: "secret", secret: v.secret };
|
||||
}
|
||||
return { source: "literal", set: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize auth config for storage.
|
||||
* @param {string} type
|
||||
* @param {unknown} config
|
||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
||||
*/
|
||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
||||
? /** @type {Record<string, unknown>} */ (config)
|
||||
: {};
|
||||
const keep = opts.keepLiteralsFrom ?? {};
|
||||
|
||||
if (type === "bearer") {
|
||||
return {
|
||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
||||
};
|
||||
}
|
||||
if (type === "basic") {
|
||||
return {
|
||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// header
|
||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
||||
const err = new Error("header name must be a non-empty string");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
header: raw.header,
|
||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {unknown} previous
|
||||
* @param {string} label
|
||||
* @param {{ allowEmpty?: boolean }} [opts]
|
||||
*/
|
||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
||||
) {
|
||||
if (typeof previous === "string") return previous;
|
||||
if (previous && typeof previous === "object") return previous;
|
||||
const err = new Error(`${label} was not previously set`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (value == null || value === "") {
|
||||
if (opts.allowEmpty && value === "") return "";
|
||||
// Allow empty password for basic
|
||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
||||
if (typeof previous === "string") return previous;
|
||||
return "";
|
||||
}
|
||||
const err = new Error(`${label} is required`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const v = /** @type {Record<string, unknown>} */ (value);
|
||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
||||
return { secret: v.secret };
|
||||
}
|
||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
||||
/** @type {{ kv: string, namespace?: string }} */
|
||||
const out = { kv: v.kv };
|
||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
||||
out.namespace = v.namespace;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function parseConfig(raw) {
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function publicAuth(row, { includeConfig = true } = {}) {
|
||||
const type = row.type;
|
||||
const config = parseConfig(row.config);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type,
|
||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: full config including literals (for runtime auth checks).
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthInternal(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: parseConfig(row.config),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||
* @param {string} id
|
||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function revealHttpAuthLiterals(id) {
|
||||
const internal = await getHttpAuthInternal(id);
|
||||
if (!internal) return null;
|
||||
/** @type {Record<string, string>} */
|
||||
const literals = {};
|
||||
const cfg = internal.config ?? {};
|
||||
for (const key of ["token", "user", "password", "value"]) {
|
||||
const v = cfg[key];
|
||||
if (typeof v === "string") literals[key] = v;
|
||||
}
|
||||
return {
|
||||
id: internal.id,
|
||||
name: internal.name,
|
||||
type: internal.type,
|
||||
literals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHttpAuths() {
|
||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
||||
return rows.map((r) => publicAuth(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthById(id) {
|
||||
let authId;
|
||||
try {
|
||||
authId = assertAuthId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id?: string | null,
|
||||
* name: string,
|
||||
* type: string,
|
||||
* config?: unknown,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertHttpAuth({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
unauthorized_status,
|
||||
unauthorized_response,
|
||||
}) {
|
||||
const authName = assertAuthName(name);
|
||||
const authType = assertAuthType(type);
|
||||
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
let existing = null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
const authId = assertAuthId(id);
|
||||
existing = await db("http_auths").where({ id: authId }).first();
|
||||
if (!existing) {
|
||||
const err = new Error("auth not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||
const err = new Error(`auth name "${authName}" already exists`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||
const normalized = normalizeAuthConfig(authType, config, {
|
||||
keepLiteralsFrom: prevConfig,
|
||||
});
|
||||
|
||||
let unauthStatus = null;
|
||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
||||
}
|
||||
let unauthResponse = null;
|
||||
if (
|
||||
unauthorized_response != null &&
|
||||
String(unauthorized_response).length > 0
|
||||
) {
|
||||
unauthResponse = String(unauthorized_response);
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const configJson = JSON.stringify(normalized);
|
||||
|
||||
if (existing) {
|
||||
await db("http_auths")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||
}
|
||||
|
||||
const newId = randomUUID();
|
||||
await db("http_auths").insert({
|
||||
id: newId,
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(newId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteHttpAuth(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const n = await db("http_auths").where({ id: authId }).del();
|
||||
return n > 0;
|
||||
}
|
||||
export * from "./src/stores/http-auths-store.js";
|
||||
|
||||
@@ -11,16 +11,21 @@ export function isBinary(value) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function toBuffer(value) {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function summarizeBinary(value) {
|
||||
const buf = Buffer.isBuffer(value)
|
||||
? value
|
||||
: value instanceof ArrayBuffer
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
const buf = toBuffer(value);
|
||||
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
|
||||
return {
|
||||
type: "Buffer",
|
||||
@@ -30,6 +35,84 @@ export function summarizeBinary(value) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-safe Buffer that can be revived (dry-run / Try chaining).
|
||||
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
|
||||
*/
|
||||
export function encodeBinary(value) {
|
||||
const buf = toBuffer(value);
|
||||
return {
|
||||
type: "Buffer",
|
||||
encoding: "base64",
|
||||
data: buf.toString("base64"),
|
||||
length: buf.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isWireBuffer(value) {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const obj = /** @type {{ type?: unknown, encoding?: unknown, data?: unknown }} */ (value);
|
||||
if (obj.type !== "Buffer") return false;
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") return true;
|
||||
return Array.isArray(obj.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace live Buffers with reconstructable JSON (for dry-run responses).
|
||||
* Display still uses summarizeBinary / safeSerialize.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function encodeBinaryForWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (isBinary(v)) return encodeBinary(v);
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (seen.has(v)) return "[Circular]";
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
|
||||
return out;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revive `{ type: "Buffer", encoding: "base64", data }` or Node `{ type, data: number[] }`.
|
||||
* Preview-only summaries (`preview` / `truncated`, no payload) are left as-is.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function reviveBinaryFromWire(value) {
|
||||
const seen = new WeakSet();
|
||||
/** @param {unknown} v */
|
||||
function walk(v) {
|
||||
if (v == null || typeof v !== "object") return v;
|
||||
if (isWireBuffer(v)) {
|
||||
const obj = /** @type {{ encoding?: unknown, data: string | number[] }} */ (v);
|
||||
if (obj.encoding === "base64" && typeof obj.data === "string") {
|
||||
return Buffer.from(obj.data, "base64");
|
||||
}
|
||||
return Buffer.from(/** @type {number[]} */ (obj.data));
|
||||
}
|
||||
if (seen.has(v)) return v;
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
for (let i = 0; i < v.length; i++) v[i] = walk(v[i]);
|
||||
return v;
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (v);
|
||||
for (const k of Object.keys(obj)) obj[k] = walk(obj[k]);
|
||||
return obj;
|
||||
}
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify replacer. Must be a real function so `this` is the holder:
|
||||
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
|
||||
|
||||
+1
-399
@@ -1,399 +1 @@
|
||||
import { db } from "./db.js";
|
||||
|
||||
const MAX_KEY_LENGTH = 512;
|
||||
const MAX_NAMESPACE_LENGTH = 512;
|
||||
const MAX_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
const MAX_LIST_LIMIT = 500;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function valuesEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function assertString(label, value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {number} max
|
||||
*/
|
||||
function assertMaxLength(label, value, max) {
|
||||
if (value.length > max) {
|
||||
throw new Error(`${label} must be at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
*/
|
||||
function assertNamespace(namespace) {
|
||||
assertString("namespace", namespace);
|
||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function assertKey(key) {
|
||||
assertString("key", key);
|
||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeKvValue(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw new Error("value must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function deserializeKvValue(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ expires_at?: string | null }} row
|
||||
*/
|
||||
function isExpired(row) {
|
||||
if (!row.expires_at) return false;
|
||||
return Date.parse(row.expires_at) <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function kvGet(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const row = await db("script_state").where({ namespace, key }).first();
|
||||
if (!row) return null;
|
||||
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key }).del();
|
||||
return null;
|
||||
}
|
||||
|
||||
return deserializeKvValue(row.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
export async function kvSet(namespace, key, value, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const json = serializeKvValue(value);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
await db("script_state")
|
||||
.insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
})
|
||||
.onConflict(["namespace", "key"])
|
||||
.merge({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function kvDelete(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
||||
*/
|
||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx("script_state").where({ namespace, key }).first();
|
||||
|
||||
if (row && isExpired(row)) {
|
||||
await trx("script_state").where({ namespace, key }).del();
|
||||
}
|
||||
|
||||
const currentRow =
|
||||
row && !isExpired(row)
|
||||
? row
|
||||
: await trx("script_state").where({ namespace, key }).first();
|
||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
||||
|
||||
if (!valuesEqual(previous, expected)) {
|
||||
return { ok: false, previous };
|
||||
}
|
||||
|
||||
const json = serializeKvValue(next);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await trx("script_state").where({ namespace, key }).update({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
} else {
|
||||
await trx("script_state").insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, previous };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {{ limit?: number }} [opts]
|
||||
*/
|
||||
export async function kvList(namespace, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
const limit = Math.min(
|
||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
||||
MAX_LIST_LIMIT,
|
||||
);
|
||||
|
||||
const rows = await db("script_state")
|
||||
.where({ namespace })
|
||||
.orderBy("updated_at", "desc")
|
||||
.limit(limit);
|
||||
|
||||
const items = [];
|
||||
for (const row of rows) {
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key: row.key }).del();
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function escapeLike(value) {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
async function pruneExpiredKv() {
|
||||
await db("script_state")
|
||||
.whereNotNull("expires_at")
|
||||
.andWhere("expires_at", "<=", nowIso())
|
||||
.del();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* namespace?: string,
|
||||
* q?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* }} [opts]
|
||||
*/
|
||||
export async function kvQuery(opts = {}) {
|
||||
await pruneExpiredKv();
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
||||
|
||||
let q = db("script_state");
|
||||
if (opts.namespace) {
|
||||
assertNamespace(opts.namespace);
|
||||
q = q.where({ namespace: opts.namespace });
|
||||
}
|
||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
||||
const like = `%${escapeLike(opts.q)}%`;
|
||||
q = q.where(function likeSearch() {
|
||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
||||
"value LIKE ? ESCAPE '\\'",
|
||||
[like],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
const rows = await q
|
||||
.clone()
|
||||
.orderBy("updated_at", "desc")
|
||||
.orderBy("namespace", "asc")
|
||||
.orderBy("key", "asc")
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
namespace: row.namespace,
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function kvNamespaces() {
|
||||
await pruneExpiredKv();
|
||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
||||
return rows.map((row) => row.namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultNamespace
|
||||
*/
|
||||
export function createKvApi(defaultNamespace) {
|
||||
assertNamespace(defaultNamespace);
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
function resolveNamespace(opts = {}) {
|
||||
const namespace = opts.namespace ?? defaultNamespace;
|
||||
assertNamespace(namespace);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
get(key, opts) {
|
||||
return kvGet(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
set(key, value, opts) {
|
||||
const { namespace, expiresAt } = opts ?? {};
|
||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
delete(key, opts) {
|
||||
return kvDelete(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
compareAndSet(key, expected, next, opts) {
|
||||
const { expiresAt } = opts ?? {};
|
||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
||||
*/
|
||||
list(opts) {
|
||||
const { namespace, limit } = opts ?? {};
|
||||
return kvList(resolveNamespace(opts), { limit });
|
||||
},
|
||||
};
|
||||
}
|
||||
export * from "./src/stores/kv-store.js";
|
||||
|
||||
@@ -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",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||
"test:workflow-history": "node test/workflow-history-smoke.js"
|
||||
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
|
||||
"reset-admin": "node reset-admin.js",
|
||||
"test:profiles": "node test/profiles-smoke.js",
|
||||
"test:set-dry-run": "node test/set-dry-run-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -38,6 +42,7 @@
|
||||
"pino": "^10.3.1",
|
||||
"pino-roll": "^4.0.0",
|
||||
"pm2": "^6.0.13",
|
||||
"rss-parser": "^3.13.0",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"webdav": "^5.10.0",
|
||||
"yaml": "^2.9.0"
|
||||
|
||||
@@ -3,8 +3,12 @@ import { fileURLToPath } from "url";
|
||||
|
||||
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||
/** Live instance workflows (not shipped in git). Override for tests. */
|
||||
export const WORKFLOWS_DIR =
|
||||
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows");
|
||||
/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */
|
||||
export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||
export const PLUGINS_DIR =
|
||||
process.env.JFLOW_PLUGINS_DIR ??
|
||||
@@ -14,5 +18,10 @@ export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/plugins",
|
||||
);
|
||||
/** Example workflow YAML presets (not loaded by the runner). */
|
||||
export const EXAMPLE_WORKFLOWS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/workflows",
|
||||
);
|
||||
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
installPluginFromDirectory,
|
||||
pluginDir,
|
||||
} from "./plugin-store.js";
|
||||
import { readManifestFile } from "./plugin-manifest.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) {
|
||||
});
|
||||
return { ...installed, already: false };
|
||||
}
|
||||
|
||||
void readManifestFile;
|
||||
|
||||
@@ -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,
|
||||
} from "./step-result.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
import {
|
||||
checkAnyHttpAuth,
|
||||
resolveAuthMechanisms,
|
||||
resolveUnauthorizedSpec,
|
||||
sendHttpPageOrJson,
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||
import {
|
||||
createHttpTriggerHandler,
|
||||
ensureHttpWildcardRoute,
|
||||
rebuildHttpRoutes,
|
||||
} from "./workflow-http-routes.js";
|
||||
import { getProfilePlain } from "./profiles-store.js";
|
||||
import {
|
||||
buildFailureAlertData,
|
||||
resolveFailureTriggerConfig,
|
||||
@@ -46,17 +46,6 @@ import { publishReload } from "./control-bus.js";
|
||||
*/
|
||||
|
||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
function hasWorkflowTrigger(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return false;
|
||||
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
|
||||
.triggers;
|
||||
return (triggers ?? []).some((t) => t?.type === "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} server
|
||||
@@ -80,7 +69,14 @@ export function createRegistry(server, opts = {}) {
|
||||
let pruneTask = null;
|
||||
/** @type {Map<string, HttpRouteEntry>} */
|
||||
const httpRoutes = new Map();
|
||||
let httpDispatcherRegistered = false;
|
||||
const httpDispatcherState = { registered: false };
|
||||
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||
httpRoutes,
|
||||
workflows,
|
||||
namespacedPath,
|
||||
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||
@@ -186,118 +182,10 @@ export function createRegistry(server, opts = {}) {
|
||||
* Fastify route once so path/method changes apply on reregister without restart.
|
||||
*/
|
||||
function registerHttpTriggers() {
|
||||
httpRoutes.clear();
|
||||
|
||||
for (const [key, { owner, workflow }] of workflows) {
|
||||
if (workflow.enabled === false) {
|
||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const trigger of workflow.triggers ?? []) {
|
||||
if (trigger.type !== "HTTP") continue;
|
||||
|
||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
||||
const url = namespacedPath(owner, trigger.path);
|
||||
const routeKey = `${method} ${url}`;
|
||||
|
||||
if (httpRoutes.has(routeKey)) {
|
||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
||||
continue;
|
||||
}
|
||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!httpDispatcherRegistered) {
|
||||
httpDispatcherRegistered = true;
|
||||
server.route({
|
||||
method: HTTP_METHODS,
|
||||
url: "/u/*",
|
||||
handler: dispatchHttpTrigger,
|
||||
});
|
||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
*/
|
||||
async function dispatchHttpTrigger(req, reply) {
|
||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
||||
const method = String(req.method ?? "GET").toUpperCase();
|
||||
const routeKey = `${method} ${url}`;
|
||||
const mapped = httpRoutes.get(routeKey);
|
||||
|
||||
if (!mapped) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
|
||||
const entry = workflows.get(mapped.key);
|
||||
if (!entry || entry.workflow?.enabled === false) {
|
||||
return reply.code(404).send({ error: "workflow disabled" });
|
||||
}
|
||||
|
||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
||||
const liveTrigger =
|
||||
(entry.workflow.triggers ?? []).find((t) => {
|
||||
if (t?.type !== "HTTP") return false;
|
||||
const m = String(t.method ?? "POST").toUpperCase();
|
||||
const p = namespacedPath(entry.owner, t.path);
|
||||
return m === method && p === url;
|
||||
}) ?? mapped.trigger;
|
||||
|
||||
if (
|
||||
liveTrigger.auth != null &&
|
||||
liveTrigger.auth !== false &&
|
||||
!(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0)
|
||||
) {
|
||||
const mechanisms = await resolveAuthMechanisms(liveTrigger.auth);
|
||||
if (mechanisms.length === 0) {
|
||||
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
||||
return sendHttpPageOrJson(reply, status, pageName, {
|
||||
error: "unauthorized",
|
||||
});
|
||||
}
|
||||
const ok = await checkAnyHttpAuth(req, mechanisms, {
|
||||
owner: entry.owner,
|
||||
workflowKey: mapped.key,
|
||||
});
|
||||
if (!ok) {
|
||||
const { status, pageName } = resolveUnauthorizedSpec(
|
||||
liveTrigger,
|
||||
mechanisms[0],
|
||||
);
|
||||
return sendHttpPageOrJson(reply, status, pageName, {
|
||||
error: "unauthorized",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await enqueueWorkflow(
|
||||
mapped.key,
|
||||
{ data: req.body },
|
||||
{ type: "http", detail: `${method} ${url}` },
|
||||
);
|
||||
if (result.status === "failed") {
|
||||
return reply.code(result.runId ? 500 : 404).send({
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultBody = {
|
||||
runId: result.runId,
|
||||
status: result.status,
|
||||
};
|
||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
||||
}
|
||||
return reply.code(202).send(defaultBody);
|
||||
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
function registerCronTriggers() {
|
||||
@@ -595,8 +483,21 @@ export function createRegistry(server, opts = {}) {
|
||||
owner,
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const unresolvedConfig = parsed.config;
|
||||
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
let unresolvedConfig = parsed.config;
|
||||
if (parsed.kind === "script" && parsed.profile) {
|
||||
const profile = await getProfilePlain(owner, parsed.profile);
|
||||
if (!profile) {
|
||||
throw new Error(`profile "${parsed.profile}" not found`);
|
||||
}
|
||||
if (parsed.script && parsed.script !== profile.script) {
|
||||
throw new Error(
|
||||
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
|
||||
);
|
||||
}
|
||||
script = profile.script;
|
||||
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
|
||||
}
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
@@ -956,7 +857,10 @@ export function createRegistry(server, opts = {}) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
if (parsed.kind === "script") refs.add(parsed.script);
|
||||
if (parsed.kind === "script") {
|
||||
if (parsed.script) refs.add(parsed.script);
|
||||
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid steps
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Reset (or create) the admin username and password.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
|
||||
*
|
||||
* Uses JFLOW_DB_PATH like the app. Never prints the password.
|
||||
*/
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, migrate } from "./db.js";
|
||||
import * as store from "./store.js";
|
||||
import { validateCredentials } from "./src/api/auth.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ username?: string, password?: string }} */
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--username" || arg === "-u") {
|
||||
out.username = argv[++i];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--password" || arg === "-p") {
|
||||
out.password = argv[++i];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
out.help = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
pnpm --dir packages/server reset-admin -- --username <name> --password <secret>
|
||||
|
||||
Creates an admin if none exist; otherwise updates the oldest admin's
|
||||
username and password. Credentials must match login rules
|
||||
(username 3-32 [A-Za-z0-9_], password at least 8 characters).`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const username = typeof args.username === "string" ? args.username.trim() : "";
|
||||
const password = typeof args.password === "string" ? args.password : "";
|
||||
if (!username || !password) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const credErr = validateCredentials(username, password);
|
||||
if (credErr) {
|
||||
console.error(credErr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate();
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const admins = await db("users")
|
||||
.where({ role: "admin" })
|
||||
.orderBy("created_at", "asc")
|
||||
.select("id", "username");
|
||||
|
||||
if (admins.length === 0) {
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
console.log(`Created admin user "${user.username}" (${user.id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = admins[0];
|
||||
const taken = await store.getUserAuthByUsername(username);
|
||||
if (taken && taken.id !== admin.id) {
|
||||
console.error(`username "${username}" is already taken by another user`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = await store.updateUser(admin.id, {
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
console.log(
|
||||
`Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
@@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
|
||||
* @param {unknown} fn
|
||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||
*/
|
||||
export function extractScriptMeta(fn) {
|
||||
function extractScriptMeta(fn) {
|
||||
if (typeof fn !== "function") {
|
||||
return { meta: null, metaError: "default export must be a function" };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import jsonata from "jsonata";
|
||||
|
||||
function ensureDataObject(ctx) {
|
||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
||||
ctx.data = {};
|
||||
function passContext(ctx) {
|
||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||
return { ...ctx.context };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeData(data) {
|
||||
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return { ...data };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const ALLOWED_METHODS = new Set([
|
||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eval context for fingerprint/transform JSONata (reads `data.*`).
|
||||
* @param {unknown} ctx
|
||||
* @param {Record<string, unknown>} data
|
||||
*/
|
||||
function evalCtx(ctx, data) {
|
||||
return {
|
||||
data,
|
||||
context: passContext(ctx),
|
||||
config: ctx?.config ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function detectUrlChanges(ctx) {
|
||||
const url = ctx.config?.url;
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
ensureDataObject(ctx);
|
||||
const data = mergeData(ctx.data);
|
||||
|
||||
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||
const response = await $axios.request({
|
||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
||||
"detect-url-changes: fetch complete",
|
||||
);
|
||||
|
||||
ctx.data.httpResponse = response.data;
|
||||
data.httpResponse = response.data;
|
||||
|
||||
let fingerprintSource = ctx.data.httpResponse;
|
||||
let fingerprintSource = data.httpResponse;
|
||||
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||
log.info(
|
||||
{ jsonata: fingerprintExpr },
|
||||
"detect-url-changes: evaluating fingerprint jsonata",
|
||||
);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
|
||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
|
||||
}
|
||||
|
||||
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||
maxAge: ctx.config?.maxAge,
|
||||
});
|
||||
|
||||
ctx.data.hasChanges = result.changed;
|
||||
ctx.data.fingerprint = result.hash;
|
||||
ctx.data.fingerprintChanged = result.changed;
|
||||
ctx.data.fingerprintPrevious = result.previous;
|
||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
||||
ctx.data.fingerprintAge = result.ageMs;
|
||||
ctx.data.fingerprintExpired = result.expired;
|
||||
const extra = {
|
||||
hasChanges: result.changed,
|
||||
fingerprint: result.hash,
|
||||
fingerprintChanged: result.changed,
|
||||
fingerprintPrevious: result.previous,
|
||||
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||
fingerprintAge: result.ageMs,
|
||||
fingerprintExpired: result.expired,
|
||||
};
|
||||
Object.assign(data, extra);
|
||||
|
||||
log.info(
|
||||
{
|
||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
||||
{ outputVar, jsonata: transformExpr },
|
||||
"detect-url-changes: evaluating transform jsonata",
|
||||
);
|
||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
||||
ctx.data[outputVar] = transformed;
|
||||
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||
data[outputVar] = transformed;
|
||||
log.info(
|
||||
{ outputVar, value: previewValue(transformed) },
|
||||
"detect-url-changes: saved transform result",
|
||||
);
|
||||
} else {
|
||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
||||
data[outputVar] = data.httpResponse;
|
||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||
const envelope = {
|
||||
output: data,
|
||||
context: { ...passContext(ctx), ...extra },
|
||||
};
|
||||
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||
ctx.skipRemaining = true;
|
||||
envelope.skipRemaining = true;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
detectUrlChanges.meta = {
|
||||
description:
|
||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
||||
previewConfigKey: "url",
|
||||
tags: ["HTTP"],
|
||||
reads: "ctx",
|
||||
config: {
|
||||
url: { type: "string", required: true, description: "URL to fetch" },
|
||||
method: {
|
||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
context: {
|
||||
hasChanges: { type: "boolean" },
|
||||
fingerprint: { type: "string" },
|
||||
fingerprintChanged: { type: "boolean" },
|
||||
fingerprintPrevious: { type: "string", required: false },
|
||||
fingerprintAt: { type: "string", required: false },
|
||||
fingerprintAge: { type: "number", required: false },
|
||||
fingerprintExpired: { type: "boolean" },
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,144 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
const err = new Error("value is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
export * from "./src/stores/secrets-store.js";
|
||||
|
||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
||||
/**
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function getMasterKey() {
|
||||
function getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
const raw = resolveSecretsKeyMaterial();
|
||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.delete("/kv", async (req, reply) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
try {
|
||||
const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? ""));
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "kv entry not found" });
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertProfileName,
|
||||
deleteProfile,
|
||||
getProfileById,
|
||||
getProfilePlain,
|
||||
listProfileUsages,
|
||||
listProfiles,
|
||||
upsertProfile,
|
||||
} from "../../profiles-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function profilesPlugin(fastify) {
|
||||
fastify.get("/profiles", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const profiles = await listProfiles({ owner });
|
||||
const withUsage = profiles.map((profile) => ({
|
||||
...profile,
|
||||
usageCount: listProfileUsages(profile.owner, profile.name).length,
|
||||
}));
|
||||
return { profiles: withUsage };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/profiles/:id/usage", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
return { usages: listProfileUsages(existing.owner, existing.name) };
|
||||
});
|
||||
|
||||
fastify.put("/profiles", async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
owner?: string,
|
||||
name?: string,
|
||||
script?: unknown,
|
||||
config?: unknown,
|
||||
description?: unknown,
|
||||
}} */ (req.body ?? {});
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertProfileName(String(body.name ?? ""));
|
||||
const profile = await upsertProfile({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
script: body.script,
|
||||
config: body.config,
|
||||
description: body.description,
|
||||
});
|
||||
return reply.send({ profile });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/profiles/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const q = /** @type {{ force?: string }} */ (req.query ?? {});
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
const usages = listProfileUsages(existing.owner, existing.name);
|
||||
const force = q.force === "1" || q.force === "true";
|
||||
if (usages.length > 0 && !force) {
|
||||
return reply.code(409).send({
|
||||
error: "profile is used by workflows",
|
||||
usages,
|
||||
});
|
||||
}
|
||||
await deleteProfile(id);
|
||||
return { ok: true, forced: force && usages.length > 0, usages };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as store from "../../store.js";
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} q
|
||||
*/
|
||||
export function parseRunQueryParams(q) {
|
||||
const limit = q.limit != null ? Number(q.limit) : undefined;
|
||||
const offset = q.offset != null ? Number(q.offset) : undefined;
|
||||
return {
|
||||
owner: q.owner || undefined,
|
||||
workflow: q.workflow || undefined,
|
||||
status: q.status || undefined,
|
||||
trigger_type: q.trigger || undefined,
|
||||
after: q.after || undefined,
|
||||
before: q.before || undefined,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
sort: q.sort || undefined,
|
||||
order: q.order || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} q
|
||||
*/
|
||||
export async function queryRunsFromRequest(q) {
|
||||
return store.queryRuns(parseRunQueryParams(q));
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import * as store from "../../store.js";
|
||||
import { queryRunsFromRequest } from "./run-query.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function runsPlugin(fastify) {
|
||||
fastify.get("/runs", async (req) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return { runs };
|
||||
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
|
||||
});
|
||||
|
||||
fastify.get("/consecutive-failures", async (req) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
getInstalledPlugin,
|
||||
listCoreScriptNames,
|
||||
listInstalledPlugins,
|
||||
resolveScriptRef,
|
||||
@@ -22,11 +21,16 @@ import {
|
||||
installPluginFromZipBuffer,
|
||||
} from "../../plugin-install.js";
|
||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||
import {
|
||||
encodeBinaryForWire,
|
||||
reviveBinaryFromWire,
|
||||
} from "../../json-preview.js";
|
||||
import { normalizeStepResult } from "../../step-result.js";
|
||||
import { resolveConfigRefs } from "../../config-refs.js";
|
||||
import { getAppVersion } from "../../app-version.js";
|
||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
||||
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
@@ -250,14 +254,11 @@ export default function scriptsPluginFactory(registry) {
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
|
||||
let owner = "default";
|
||||
let owner = "local";
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
try {
|
||||
owner = fsStore.assertOwner(String(body.owner));
|
||||
@@ -266,12 +267,92 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
}
|
||||
|
||||
const incomingContext =
|
||||
const incomingContext = reviveBinaryFromWire(
|
||||
body.context != null &&
|
||||
typeof body.context === "object" &&
|
||||
!Array.isArray(body.context)
|
||||
typeof body.context === "object" &&
|
||||
!Array.isArray(body.context)
|
||||
? body.context
|
||||
: {};
|
||||
: {},
|
||||
);
|
||||
const incomingData = reviveBinaryFromWire(body.data ?? null);
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
// Set steps are inline JSONata (no script file). Match registry runCompiledStep.
|
||||
if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) {
|
||||
const configObj =
|
||||
body.config != null &&
|
||||
typeof body.config === "object" &&
|
||||
!Array.isArray(body.config)
|
||||
? /** @type {Record<string, unknown>} */ (body.config)
|
||||
: null;
|
||||
const expression =
|
||||
typeof body.expression === "string"
|
||||
? body.expression
|
||||
: typeof configObj?.expression === "string"
|
||||
? configObj.expression
|
||||
: null;
|
||||
if (expression == null || !expression.trim()) {
|
||||
return reply.code(400).send({ error: "expression is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(
|
||||
{ ...(configObj ?? {}), expression },
|
||||
{
|
||||
owner,
|
||||
workflowKey: "dry-run",
|
||||
context: incomingContext,
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
data: incomingData,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
const value = await evaluateJsonata(expression, ctx);
|
||||
const result = normalizeStepResult(
|
||||
{
|
||||
output: value,
|
||||
context: incomingContext,
|
||||
skipRemaining: false,
|
||||
},
|
||||
incomingContext,
|
||||
SET_STEP_SCRIPT,
|
||||
);
|
||||
log.info({ expression }, "set: dry-run evaluated");
|
||||
return {
|
||||
status: "success",
|
||||
output: safeSerialize(result.output),
|
||||
context: safeSerialize(result.context),
|
||||
wireOutput: encodeBinaryForWire(result.output),
|
||||
wireContext: encodeBinaryForWire(result.context),
|
||||
skipRemaining: result.skipRemaining,
|
||||
error: null,
|
||||
logs,
|
||||
durationMs: Date.now() - started,
|
||||
meta: null,
|
||||
metaError: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
context: null,
|
||||
skipRemaining: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
logs,
|
||||
durationMs: Date.now() - started,
|
||||
meta: null,
|
||||
metaError: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
|
||||
const resolved = resolveScriptRef(rawName);
|
||||
const pluginDir =
|
||||
@@ -279,9 +360,6 @@ export default function scriptsPluginFactory(registry) {
|
||||
? resolved.pluginDir ?? null
|
||||
: null;
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const config = await resolveConfigRefs(body.config ?? null, {
|
||||
owner,
|
||||
@@ -289,7 +367,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
context: incomingContext,
|
||||
});
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
data: incomingData,
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
@@ -313,6 +391,8 @@ export default function scriptsPluginFactory(registry) {
|
||||
status: "success",
|
||||
output: safeSerialize(result.output),
|
||||
context: safeSerialize(result.context),
|
||||
wireOutput: encodeBinaryForWire(result.output),
|
||||
wireContext: encodeBinaryForWire(result.context),
|
||||
skipRemaining: result.skipRemaining,
|
||||
error: null,
|
||||
logs,
|
||||
@@ -475,7 +555,5 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void getInstalledPlugin;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
collectWorkflowWarnings,
|
||||
parseWorkflowDocument,
|
||||
} from "../../workflow-validate-warnings.js";
|
||||
import { getProfilePlain } from "../../profiles-store.js";
|
||||
import { resolveScriptRef } from "../../plugin-store.js";
|
||||
import {
|
||||
recordRevision,
|
||||
listRevisions,
|
||||
@@ -43,6 +45,11 @@ import {
|
||||
createWorkflowBackupBuffer,
|
||||
restoreWorkflowBackup,
|
||||
} from "../../workflow-backup.js";
|
||||
import {
|
||||
listExampleWorkflows,
|
||||
readExampleWorkflow,
|
||||
assertExampleWorkflowId,
|
||||
} from "../../workflow-examples.js";
|
||||
|
||||
/**
|
||||
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||
@@ -81,7 +88,9 @@ function scriptNames(workflow) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
||||
if (parsed.kind === "set") names.push("set");
|
||||
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
|
||||
else names.push(parsed.script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
@@ -89,6 +98,59 @@ function scriptNames(workflow) {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function collectProfileWarnings(parsed, owner) {
|
||||
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||
const warnings = [];
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
|
||||
return warnings;
|
||||
}
|
||||
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||
const profileName = raw.profile;
|
||||
if (typeof profileName !== "string" || !profileName) continue;
|
||||
const pathKey = `scripts[${i}]`;
|
||||
let profile;
|
||||
try {
|
||||
profile = await getProfilePlain(owner, profileName);
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" is not a valid name`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!profile) {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" not found`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
|
||||
warnings.push({
|
||||
code: "profile_script_mismatch",
|
||||
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
|
||||
path: pathKey,
|
||||
});
|
||||
}
|
||||
const resolved = resolveScriptRef(profile.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
code: "unknown_script",
|
||||
message: resolved.error,
|
||||
path: `${pathKey}.profile`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
@@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) {
|
||||
* }} opts
|
||||
*/
|
||||
async function saveWorkflowContent(opts) {
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
if (parsed) {
|
||||
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||
}
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
|
||||
if (!saveAnyway) {
|
||||
if (parseError) {
|
||||
@@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) {
|
||||
return { owners: fsStore.listOwners() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples", async () => {
|
||||
return { examples: listExampleWorkflows() };
|
||||
});
|
||||
|
||||
fastify.get("/workflow-examples/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
if (!assertExampleWorkflowId(id)) {
|
||||
return reply.code(400).send({ error: "invalid example id" });
|
||||
}
|
||||
const example = readExampleWorkflow(id);
|
||||
if (!example) {
|
||||
return reply.code(404).send({ error: "example not found" });
|
||||
}
|
||||
return example;
|
||||
});
|
||||
|
||||
fastify.get("/workflows/trash", async () => {
|
||||
return { items: await listTrash() };
|
||||
});
|
||||
@@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) {
|
||||
enabled: parsed ? parsed.enabled !== false : false,
|
||||
registered: registered.includes(file),
|
||||
loadError: loadError ?? (parsed ? null : "unreadable"),
|
||||
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
|
||||
lastInvokedAt: st.lastInvokedAt,
|
||||
lastStatus: st.lastStatus ?? null,
|
||||
invocationCount: st.invocationCount,
|
||||
@@ -315,6 +397,13 @@ export default function workflowsPluginFactory(registry) {
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (byName !== 0) return byName;
|
||||
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
|
||||
});
|
||||
return { workflows: items };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "../../db.js";
|
||||
import { assertHttpStatus } from "../../http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthId(id) {
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||
const err = new Error("invalid auth id");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthName(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
const err = new Error("invalid auth name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"bearer" | "basic" | "header"}
|
||||
*/
|
||||
export function assertAuthType(type) {
|
||||
const t = String(type ?? "");
|
||||
if (!ALLOWED_TYPES.has(t)) {
|
||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect value source without exposing literal values.
|
||||
* @param {unknown} value
|
||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
||||
*/
|
||||
export function valueSourceKind(value) {
|
||||
if (value == null) return "missing";
|
||||
if (typeof value === "string") return "literal";
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if ("secret" in value) return "secret";
|
||||
if ("kv" in value) return "kv";
|
||||
}
|
||||
return "literal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact config for API responses: replace literal strings with source markers.
|
||||
* @param {Record<string, unknown>} config
|
||||
* @param {string} type
|
||||
*/
|
||||
export function publicConfig(config, type) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (type === "bearer") {
|
||||
out.token = redactField(config.token);
|
||||
} else if (type === "basic") {
|
||||
out.user = redactField(config.user);
|
||||
out.password = redactField(config.password);
|
||||
} else if (type === "header") {
|
||||
out.header = typeof config.header === "string" ? config.header : null;
|
||||
out.value = redactField(config.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function redactField(value) {
|
||||
const kind = valueSourceKind(value);
|
||||
if (kind === "missing") return { source: "missing" };
|
||||
if (kind === "kv") {
|
||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
||||
return {
|
||||
source: "kv",
|
||||
kv: v.kv,
|
||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === "secret") {
|
||||
const v = /** @type {{ secret: string }} */ (value);
|
||||
return { source: "secret", secret: v.secret };
|
||||
}
|
||||
return { source: "literal", set: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize auth config for storage.
|
||||
* @param {string} type
|
||||
* @param {unknown} config
|
||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
||||
*/
|
||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
||||
? /** @type {Record<string, unknown>} */ (config)
|
||||
: {};
|
||||
const keep = opts.keepLiteralsFrom ?? {};
|
||||
|
||||
if (type === "bearer") {
|
||||
return {
|
||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
||||
};
|
||||
}
|
||||
if (type === "basic") {
|
||||
return {
|
||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// header
|
||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
||||
const err = new Error("header name must be a non-empty string");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
header: raw.header,
|
||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {unknown} previous
|
||||
* @param {string} label
|
||||
* @param {{ allowEmpty?: boolean }} [opts]
|
||||
*/
|
||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
||||
) {
|
||||
if (typeof previous === "string") return previous;
|
||||
if (previous && typeof previous === "object") return previous;
|
||||
const err = new Error(`${label} was not previously set`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (value == null || value === "") {
|
||||
if (opts.allowEmpty && value === "") return "";
|
||||
// Allow empty password for basic
|
||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
||||
if (typeof previous === "string") return previous;
|
||||
return "";
|
||||
}
|
||||
const err = new Error(`${label} is required`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const v = /** @type {Record<string, unknown>} */ (value);
|
||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
||||
return { secret: v.secret };
|
||||
}
|
||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
||||
/** @type {{ kv: string, namespace?: string }} */
|
||||
const out = { kv: v.kv };
|
||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
||||
out.namespace = v.namespace;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function parseConfig(raw) {
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function publicAuth(row, { includeConfig = true } = {}) {
|
||||
const type = row.type;
|
||||
const config = parseConfig(row.config);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type,
|
||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: full config including literals (for runtime auth checks).
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthInternal(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: parseConfig(row.config),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||
* @param {string} id
|
||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function revealHttpAuthLiterals(id) {
|
||||
const internal = await getHttpAuthInternal(id);
|
||||
if (!internal) return null;
|
||||
/** @type {Record<string, string>} */
|
||||
const literals = {};
|
||||
const cfg = internal.config ?? {};
|
||||
for (const key of ["token", "user", "password", "value"]) {
|
||||
const v = cfg[key];
|
||||
if (typeof v === "string") literals[key] = v;
|
||||
}
|
||||
return {
|
||||
id: internal.id,
|
||||
name: internal.name,
|
||||
type: internal.type,
|
||||
literals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHttpAuths() {
|
||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
||||
return rows.map((r) => publicAuth(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthById(id) {
|
||||
let authId;
|
||||
try {
|
||||
authId = assertAuthId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id?: string | null,
|
||||
* name: string,
|
||||
* type: string,
|
||||
* config?: unknown,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertHttpAuth({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
unauthorized_status,
|
||||
unauthorized_response,
|
||||
}) {
|
||||
const authName = assertAuthName(name);
|
||||
const authType = assertAuthType(type);
|
||||
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
let existing = null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
const authId = assertAuthId(id);
|
||||
existing = await db("http_auths").where({ id: authId }).first();
|
||||
if (!existing) {
|
||||
const err = new Error("auth not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||
const err = new Error(`auth name "${authName}" already exists`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||
const normalized = normalizeAuthConfig(authType, config, {
|
||||
keepLiteralsFrom: prevConfig,
|
||||
});
|
||||
|
||||
let unauthStatus = null;
|
||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
||||
}
|
||||
let unauthResponse = null;
|
||||
if (
|
||||
unauthorized_response != null &&
|
||||
String(unauthorized_response).length > 0
|
||||
) {
|
||||
unauthResponse = String(unauthorized_response);
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const configJson = JSON.stringify(normalized);
|
||||
|
||||
if (existing) {
|
||||
await db("http_auths")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||
}
|
||||
|
||||
const newId = randomUUID();
|
||||
await db("http_auths").insert({
|
||||
id: newId,
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(newId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteHttpAuth(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const n = await db("http_auths").where({ id: authId }).del();
|
||||
return n > 0;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { db } from "../../db.js";
|
||||
|
||||
const MAX_KEY_LENGTH = 512;
|
||||
const MAX_NAMESPACE_LENGTH = 512;
|
||||
const MAX_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
const MAX_LIST_LIMIT = 500;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function valuesEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function assertString(label, value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {number} max
|
||||
*/
|
||||
function assertMaxLength(label, value, max) {
|
||||
if (value.length > max) {
|
||||
throw new Error(`${label} must be at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
*/
|
||||
function assertNamespace(namespace) {
|
||||
assertString("namespace", namespace);
|
||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function assertKey(key) {
|
||||
assertString("key", key);
|
||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function serializeKvValue(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw new Error("value must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function deserializeKvValue(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ expires_at?: string | null }} row
|
||||
*/
|
||||
function isExpired(row) {
|
||||
if (!row.expires_at) return false;
|
||||
return Date.parse(row.expires_at) <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function kvGet(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const row = await db("script_state").where({ namespace, key }).first();
|
||||
if (!row) return null;
|
||||
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key }).del();
|
||||
return null;
|
||||
}
|
||||
|
||||
return deserializeKvValue(row.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
export async function kvSet(namespace, key, value, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const json = serializeKvValue(value);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
await db("script_state")
|
||||
.insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
})
|
||||
.onConflict(["namespace", "key"])
|
||||
.merge({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function kvDelete(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
||||
*/
|
||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx("script_state").where({ namespace, key }).first();
|
||||
|
||||
if (row && isExpired(row)) {
|
||||
await trx("script_state").where({ namespace, key }).del();
|
||||
}
|
||||
|
||||
const currentRow =
|
||||
row && !isExpired(row)
|
||||
? row
|
||||
: await trx("script_state").where({ namespace, key }).first();
|
||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
||||
|
||||
if (!valuesEqual(previous, expected)) {
|
||||
return { ok: false, previous };
|
||||
}
|
||||
|
||||
const json = serializeKvValue(next);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await trx("script_state").where({ namespace, key }).update({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
} else {
|
||||
await trx("script_state").insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, previous };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {{ limit?: number }} [opts]
|
||||
*/
|
||||
export async function kvList(namespace, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
const limit = Math.min(
|
||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
||||
MAX_LIST_LIMIT,
|
||||
);
|
||||
|
||||
const rows = await db("script_state")
|
||||
.where({ namespace })
|
||||
.orderBy("updated_at", "desc")
|
||||
.limit(limit);
|
||||
|
||||
const items = [];
|
||||
for (const row of rows) {
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key: row.key }).del();
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function escapeLike(value) {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
async function pruneExpiredKv() {
|
||||
await db("script_state")
|
||||
.whereNotNull("expires_at")
|
||||
.andWhere("expires_at", "<=", nowIso())
|
||||
.del();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* namespace?: string,
|
||||
* q?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* }} [opts]
|
||||
*/
|
||||
export async function kvQuery(opts = {}) {
|
||||
await pruneExpiredKv();
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
||||
|
||||
let q = db("script_state");
|
||||
if (opts.namespace) {
|
||||
assertNamespace(opts.namespace);
|
||||
q = q.where({ namespace: opts.namespace });
|
||||
}
|
||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
||||
const like = `%${escapeLike(opts.q)}%`;
|
||||
q = q.where(function likeSearch() {
|
||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
||||
"value LIKE ? ESCAPE '\\'",
|
||||
[like],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
const rows = await q
|
||||
.clone()
|
||||
.orderBy("updated_at", "desc")
|
||||
.orderBy("namespace", "asc")
|
||||
.orderBy("key", "asc")
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
namespace: row.namespace,
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function kvNamespaces() {
|
||||
await pruneExpiredKv();
|
||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
||||
return rows.map((row) => row.namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultNamespace
|
||||
*/
|
||||
export function createKvApi(defaultNamespace) {
|
||||
assertNamespace(defaultNamespace);
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
function resolveNamespace(opts = {}) {
|
||||
const namespace = opts.namespace ?? defaultNamespace;
|
||||
assertNamespace(namespace);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
get(key, opts) {
|
||||
return kvGet(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
set(key, value, opts) {
|
||||
const { namespace, expiresAt } = opts ?? {};
|
||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
delete(key, opts) {
|
||||
return kvDelete(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
compareAndSet(key, expected, next, opts) {
|
||||
const { expiresAt } = opts ?? {};
|
||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
||||
*/
|
||||
list(opts) {
|
||||
const { namespace, limit } = opts ?? {};
|
||||
return kvList(resolveNamespace(opts), { limit });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import yaml from "yaml";
|
||||
import { db } from "../../db.js";
|
||||
import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "../../fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileName(name) {
|
||||
if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid profile name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} script
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileScript(script) {
|
||||
if (typeof script !== "string" || script.trim().length === 0) {
|
||||
throw httpError("script is required");
|
||||
}
|
||||
const trimmed = script.trim();
|
||||
if (trimmed.length > 256) {
|
||||
throw httpError("script name is too long");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} description
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileDescription(description) {
|
||||
if (description == null) return "";
|
||||
if (typeof description !== "string") {
|
||||
throw httpError("description must be a string");
|
||||
}
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} config
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeProfileConfig(config) {
|
||||
if (config == null) return "{}";
|
||||
if (typeof config !== "object" || Array.isArray(config)) {
|
||||
throw httpError("config must be an object");
|
||||
}
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(config);
|
||||
} catch {
|
||||
throw httpError("config must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) {
|
||||
throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stored
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function decodeProfileConfig(stored) {
|
||||
if (stored == null || stored === "") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`);
|
||||
}
|
||||
throw new Error("corrupt profile config: not an object");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicProfile(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
script: row.script,
|
||||
config: decodeProfileConfig(String(row.config ?? "{}")),
|
||||
description: row.description == null ? "" : String(row.description),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listProfiles(filters = {}) {
|
||||
let q = db("profiles")
|
||||
.select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicProfile(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getProfileById(id) {
|
||||
const row = await db("profiles").where({ id }).first();
|
||||
return row ? publicProfile(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function getProfilePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
const row = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||
return row ? publicProfile(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner: string,
|
||||
* name: string,
|
||||
* script: unknown,
|
||||
* config?: unknown,
|
||||
* description?: unknown,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertProfile({ owner, name, script, config, description }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
const scriptName = assertProfileScript(script);
|
||||
const encoded = encodeProfileConfig(config ?? {});
|
||||
const desc = assertProfileDescription(description);
|
||||
const now = nowIso();
|
||||
const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||
|
||||
if (existing) {
|
||||
await db("profiles")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
script: scriptName,
|
||||
config: encoded,
|
||||
description: desc,
|
||||
updated_at: now,
|
||||
});
|
||||
return getProfileById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("profiles").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: profileName,
|
||||
script: scriptName,
|
||||
config: encoded,
|
||||
description: desc,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getProfileById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteProfile(id) {
|
||||
const n = await db("profiles").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows (same owner) whose YAML steps reference this profile name.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {{ file: string, name: string, steps: number }[]}
|
||||
*/
|
||||
export function listProfileUsages(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
/** @type {{ file: string, name: string, steps: number }[]} */
|
||||
const usages = [];
|
||||
for (const file of listOwnerYamlFiles(ownerName)) {
|
||||
const content = readWorkflowYaml(ownerName, file);
|
||||
if (content == null) continue;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
||||
const scripts = parsed.scripts;
|
||||
if (!Array.isArray(scripts)) continue;
|
||||
let steps = 0;
|
||||
for (const step of scripts) {
|
||||
if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) {
|
||||
steps += 1;
|
||||
}
|
||||
}
|
||||
if (steps > 0) {
|
||||
usages.push({
|
||||
file,
|
||||
name: typeof parsed.name === "string" && parsed.name ? parsed.name : file,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "../../db.js";
|
||||
import { assertOwner } from "../../fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "../../secrets.js";
|
||||
import { registerPlaintext } from "../../secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
const err = new Error("value is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "../../db.js";
|
||||
import { assertOwner } from "../../fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_STRING_BYTES = 64 * 1024;
|
||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertVariableName(name) {
|
||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid variable name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"string" | "number" | "boolean"}
|
||||
*/
|
||||
export function assertVariableType(type) {
|
||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||
throw httpError("type must be string, number, or boolean");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeVariableValue(type, value) {
|
||||
if (type === "string") {
|
||||
if (typeof value !== "string") {
|
||||
throw httpError("value must be a string");
|
||||
}
|
||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw httpError("value must be a finite number");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw httpError("value must be a boolean");
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {string} stored
|
||||
* @returns {string | number | boolean}
|
||||
*/
|
||||
export function decodeVariableValue(type, stored) {
|
||||
if (type === "string") return stored;
|
||||
if (type === "number") {
|
||||
const n = Number(stored);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicVariable(row) {
|
||||
const type = assertVariableType(row.type);
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type,
|
||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listVariables(filters = {}) {
|
||||
let q = db("variables")
|
||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicVariable(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getVariableById(id) {
|
||||
const row = await db("variables").where({ id }).first();
|
||||
return row ? publicVariable(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||
*/
|
||||
export async function upsertVariable({ owner, name, type, value }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const variableType = assertVariableType(type);
|
||||
const encoded = encodeVariableValue(variableType, value);
|
||||
const now = nowIso();
|
||||
const existing = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("variables")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("variables").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: variableName,
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteVariable(id) {
|
||||
const n = await db("variables").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed primitive for an owner/name. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | number | boolean | null>}
|
||||
*/
|
||||
export async function getVariablePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const row = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import usersPlugin from "./src/api/users.js";
|
||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||
import runsPlugin from "./src/api/runs.js";
|
||||
import { queryRunsFromRequest } from "./src/api/run-query.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import kvPlugin from "./src/api/kv.js";
|
||||
import variablesPlugin from "./src/api/variables.js";
|
||||
import profilesPlugin from "./src/api/profiles.js";
|
||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
getRedisUrlForLog,
|
||||
} from "./workflow-queue.js";
|
||||
import { purgeExpiredTrash } from "./workflow-trash.js";
|
||||
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
|
||||
import {
|
||||
getConfigGeneration,
|
||||
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, {
|
||||
queue: workflowQueue,
|
||||
// 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(secretsPlugin);
|
||||
await api.register(variablesPlugin);
|
||||
await api.register(profilesPlugin);
|
||||
await api.register(kvPlugin);
|
||||
await api.register(httpPagesPlugin);
|
||||
await api.register(httpAuthsPlugin);
|
||||
@@ -198,16 +208,8 @@ export async function startApp(opts = {}) {
|
||||
"/admin/runs",
|
||||
{ onRequest: [server.authenticate] },
|
||||
async (req, reply) => {
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
||||
const limit = q.limit ? Number(q.limit) : undefined;
|
||||
const runs = await store.listRuns({
|
||||
owner: q.owner,
|
||||
workflow: q.workflow,
|
||||
status: q.status,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
before: q.before,
|
||||
});
|
||||
return reply.send({ runs });
|
||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||
return reply.send(await queryRunsFromRequest(q));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
export { isPlainObject };
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
|
||||
+103
-15
@@ -222,19 +222,29 @@ export async function insertLogs(rows) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const RUN_SORT_COLUMNS = {
|
||||
status: "status",
|
||||
workflow: "workflow_name",
|
||||
revision: "workflow_revision",
|
||||
trigger: "trigger_type",
|
||||
started_at: "started_at",
|
||||
duration: "duration_ms",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex.QueryBuilder} q
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* limit?: number,
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* }} [filters]
|
||||
* }} filters
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
||||
if (filters.owner) q = q.where("owner", filters.owner);
|
||||
function applyRunFilters(q, filters) {
|
||||
if (filters.owner) q.where("owner", filters.owner);
|
||||
if (filters.workflow) {
|
||||
const key = String(filters.workflow);
|
||||
if (key.includes("*")) {
|
||||
@@ -243,25 +253,102 @@ export async function listRuns(filters = {}) {
|
||||
.replaceAll("%", "\\%")
|
||||
.replaceAll("_", "\\_")
|
||||
.replaceAll("*", "%");
|
||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||
} else {
|
||||
q = q.where("workflow", key);
|
||||
q.where("workflow", key);
|
||||
}
|
||||
}
|
||||
if (filters.status) {
|
||||
if (Array.isArray(filters.status)) {
|
||||
q = q.whereIn("status", filters.status);
|
||||
q.whereIn("status", filters.status);
|
||||
} else {
|
||||
q = q.where("status", filters.status);
|
||||
q.where("status", filters.status);
|
||||
}
|
||||
}
|
||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map((row) => ({
|
||||
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||
if (filters.before) q.where("started_at", "<", filters.before);
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex.QueryBuilder} q
|
||||
* @param {string | undefined} sort
|
||||
* @param {string | undefined} order
|
||||
*/
|
||||
function applyRunSort(q, sort, order) {
|
||||
const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at";
|
||||
const direction = order === "asc" ? "asc" : "desc";
|
||||
q.orderBy(column, direction);
|
||||
if (column !== "started_at") q.orderBy("started_at", "desc");
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function mapRunRow(row) {
|
||||
return {
|
||||
...row,
|
||||
input: deserialize(row.input),
|
||||
output: deserialize(row.output),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* sort?: string,
|
||||
* order?: string,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function queryRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(Number(filters.offset) || 0, 0);
|
||||
|
||||
let q = db("workflow_runs");
|
||||
q = applyRunFilters(q, filters);
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
let rowsQ = q.clone().select("*");
|
||||
rowsQ = applyRunSort(rowsQ, filters.sort, filters.order);
|
||||
const rows = await rowsQ.limit(limit).offset(offset);
|
||||
|
||||
return {
|
||||
runs: rows.map(mapRunRow),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner?: string,
|
||||
* workflow?: string,
|
||||
* status?: string | string[],
|
||||
* trigger_type?: string,
|
||||
* after?: string,
|
||||
* before?: string,
|
||||
* limit?: number,
|
||||
* }} [filters]
|
||||
*/
|
||||
export async function listRuns(filters = {}) {
|
||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
||||
let q = db("workflow_runs").select("*");
|
||||
q = applyRunFilters(q, filters);
|
||||
q = applyRunSort(q, "started_at", "desc");
|
||||
const rows = await q.limit(limit);
|
||||
return rows.map(mapRunRow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -495,12 +582,13 @@ export async function listUsers() {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ passwordHash?: string, role?: string }} patch
|
||||
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
|
||||
*/
|
||||
export async function updateUser(id, patch) {
|
||||
const update = { updated_at: nowIso() };
|
||||
if (patch.passwordHash) update.password_hash = patch.passwordHash;
|
||||
if (patch.role) update.role = patch.role;
|
||||
if (patch.username) update.username = patch.username;
|
||||
await db("users").where({ id }).update(update);
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
|
||||
state.body = "<html><body>v1</body></html>";
|
||||
|
||||
const first = await run({ url });
|
||||
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
|
||||
assert(
|
||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
||||
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||
"httpResponse should hold the raw body",
|
||||
);
|
||||
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
|
||||
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
|
||||
|
||||
const second = await run({ url });
|
||||
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||
|
||||
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||
const third = await run({ url });
|
||||
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
|
||||
assert(
|
||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
||||
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||
"fingerprintPrevious should equal the prior hash",
|
||||
);
|
||||
}
|
||||
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
|
||||
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||
|
||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(first.data.hasChanges === true, "json first run should report a change");
|
||||
assert(first.output.hasChanges === true, "json first run should report a change");
|
||||
|
||||
// Change only an unwatched field -> no change.
|
||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(
|
||||
second.data.hasChanges === false,
|
||||
second.output.hasChanges === false,
|
||||
"changing an unwatched field should not report a change",
|
||||
);
|
||||
|
||||
// Change the watched field -> change.
|
||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||
assert(third.data.hasChanges === true, "changing the watched field should report a change");
|
||||
assert(third.output.hasChanges === true, "changing the watched field should report a change");
|
||||
|
||||
state.contentType = "text/html; charset=utf-8";
|
||||
}
|
||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
||||
transform: '"changed=" & $string(data.hasChanges)',
|
||||
});
|
||||
assert(
|
||||
withTransform.data.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
||||
withTransform.output.message === "changed=true",
|
||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||
);
|
||||
|
||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||
assert(
|
||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
||||
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||
"outputVar without transform should store the raw response",
|
||||
);
|
||||
|
||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
||||
state.body = "<html><body>stable</body></html>";
|
||||
|
||||
const first = await run({ url, skipRemaining: true });
|
||||
assert(first.data.hasChanges === true, "skip test first run should change");
|
||||
assert(first.output.hasChanges === true, "skip test first run should change");
|
||||
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||
|
||||
const second = await run({ url, skipRemaining: true });
|
||||
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
|
||||
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||
|
||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
|
||||
import {
|
||||
encodeBinaryForWire,
|
||||
jsonPreviewReplacer,
|
||||
reviveBinaryFromWire,
|
||||
summarizeBinary,
|
||||
} from "../json-preview.js";
|
||||
import { serialize, toDisplayValue } from "../store.js";
|
||||
import { safeSerialize } from "../src/api/dry-run-logger.js";
|
||||
|
||||
@@ -53,4 +58,18 @@ if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
|
||||
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
|
||||
}
|
||||
|
||||
const wired = encodeBinaryForWire({ file: png, n: 1n });
|
||||
if (wired.n !== "1" || wired.file.encoding !== "base64" || typeof wired.file.data !== "string") {
|
||||
throw new Error(`encodeBinaryForWire: ${JSON.stringify(wired)}`);
|
||||
}
|
||||
const revived = reviveBinaryFromWire(JSON.parse(JSON.stringify(wired)));
|
||||
if (!Buffer.isBuffer(revived.file) || !revived.file.equals(png)) {
|
||||
throw new Error("reviveBinaryFromWire failed to restore bytes");
|
||||
}
|
||||
const previewOnly = { type: "Buffer", length: png.length, preview: "89", truncated: true };
|
||||
const left = reviveBinaryFromWire(previewOnly);
|
||||
if (Buffer.isBuffer(left)) {
|
||||
throw new Error("preview-only summary should not revive");
|
||||
}
|
||||
|
||||
console.log("json-preview-smoke: ok");
|
||||
|
||||
@@ -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";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_STRING_BYTES = 64 * 1024;
|
||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertVariableName(name) {
|
||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid variable name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"string" | "number" | "boolean"}
|
||||
*/
|
||||
export function assertVariableType(type) {
|
||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||
throw httpError("type must be string, number, or boolean");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeVariableValue(type, value) {
|
||||
if (type === "string") {
|
||||
if (typeof value !== "string") {
|
||||
throw httpError("value must be a string");
|
||||
}
|
||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw httpError("value must be a finite number");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw httpError("value must be a boolean");
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {string} stored
|
||||
* @returns {string | number | boolean}
|
||||
*/
|
||||
export function decodeVariableValue(type, stored) {
|
||||
if (type === "string") return stored;
|
||||
if (type === "number") {
|
||||
const n = Number(stored);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicVariable(row) {
|
||||
const type = assertVariableType(row.type);
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type,
|
||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listVariables(filters = {}) {
|
||||
let q = db("variables")
|
||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicVariable(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getVariableById(id) {
|
||||
const row = await db("variables").where({ id }).first();
|
||||
return row ? publicVariable(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||
*/
|
||||
export async function upsertVariable({ owner, name, type, value }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const variableType = assertVariableType(type);
|
||||
const encoded = encodeVariableValue(variableType, value);
|
||||
const now = nowIso();
|
||||
const existing = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("variables")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("variables").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: variableName,
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteVariable(id) {
|
||||
const n = await db("variables").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed primitive for an owner/name. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | number | boolean | null>}
|
||||
*/
|
||||
export async function getVariablePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const row = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||
}
|
||||
export * from "./src/stores/variables-store.js";
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import yaml from "yaml";
|
||||
import {
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
} from "@jerapah-flow/shared";
|
||||
import {
|
||||
newWorkflowFilename,
|
||||
workflowFileStem,
|
||||
} from "./workflow-normalize.js";
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy human-readable copy name (kept for UI hints).
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
export { ensureWorkflowFilename, suggestCopyFilename };
|
||||
|
||||
/**
|
||||
* UUID-based duplicate filename (default for new duplicates).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import yaml from "yaml";
|
||||
import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js";
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string | null} safe basename without extension, or null if invalid
|
||||
*/
|
||||
export function assertExampleWorkflowId(id) {
|
||||
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) {
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to an example YAML, or null if missing/unsafe.
|
||||
* @param {string} id
|
||||
*/
|
||||
export function exampleWorkflowPath(id) {
|
||||
const safe = assertExampleWorkflowId(id);
|
||||
if (!safe) return null;
|
||||
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`);
|
||||
const resolved = path.resolve(filePath);
|
||||
if (
|
||||
resolved !== EXAMPLE_WORKFLOWS_DIR &&
|
||||
!resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ id: string, name: string, description: string }[]}
|
||||
*/
|
||||
export function listExampleWorkflows() {
|
||||
if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return [];
|
||||
return fs
|
||||
.readdirSync(EXAMPLE_WORKFLOWS_DIR)
|
||||
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
||||
.sort()
|
||||
.map((f) => {
|
||||
const id = f.replace(/\.ya?ml$/i, "");
|
||||
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f);
|
||||
let name = id;
|
||||
let description = "";
|
||||
try {
|
||||
const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {};
|
||||
if (typeof parsed.name === "string" && parsed.name.trim()) {
|
||||
name = parsed.name.trim();
|
||||
}
|
||||
if (parsed.description != null) {
|
||||
description = String(parsed.description).trim();
|
||||
}
|
||||
} catch {
|
||||
// keep id as name
|
||||
}
|
||||
return { id, name, description };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {{ id: string, content: string } | null}
|
||||
*/
|
||||
export function readExampleWorkflow(id) {
|
||||
const filePath = exampleWorkflowPath(id);
|
||||
if (!filePath) return null;
|
||||
const safe = assertExampleWorkflowId(id);
|
||||
return {
|
||||
id: /** @type {string} */ (safe),
|
||||
content: fs.readFileSync(filePath, "utf8"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
export { namespacedPath } from "@jerapah-flow/shared";
|
||||
|
||||
export const SET_STEP_SCRIPT = "set";
|
||||
|
||||
@@ -6,19 +7,23 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||
* @typedef {{
|
||||
* kind: "script",
|
||||
* script: string,
|
||||
* script: string,
|
||||
* profile: string | null,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* @typedef {{
|
||||
* kind: "set",
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* config: { expression: string },
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* profile: null,
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* name: string | null,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
@@ -66,16 +71,6 @@ export async function evaluateJsonata(source, ctx) {
|
||||
return await result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
|
||||
* @param {string} owner
|
||||
* @param {string} triggerPath
|
||||
*/
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath).replace(/^\/+/, "");
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} step
|
||||
* @returns {ParsedStep}
|
||||
@@ -85,7 +80,9 @@ export function parseScriptStep(step) {
|
||||
return {
|
||||
kind: "script",
|
||||
script: step,
|
||||
profile: null,
|
||||
config: null,
|
||||
name: null,
|
||||
id: null,
|
||||
needsKind: "none",
|
||||
needs: [],
|
||||
@@ -97,25 +94,35 @@ export function parseScriptStep(step) {
|
||||
}
|
||||
|
||||
const hasScript = step.script != null && step.script !== "";
|
||||
const hasProfile = step.profile != null && step.profile !== "";
|
||||
const hasSet = step.set != null;
|
||||
|
||||
if (hasScript && hasSet) {
|
||||
throw new Error("Step cannot have both script and set");
|
||||
}
|
||||
if (hasProfile && hasSet) {
|
||||
throw new Error("Step cannot have both profile and set");
|
||||
}
|
||||
|
||||
if (hasSet) {
|
||||
return parseSetStep(step);
|
||||
}
|
||||
|
||||
if (hasScript) {
|
||||
if (typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
if (hasProfile && typeof step.profile !== "string") {
|
||||
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
|
||||
}
|
||||
if (hasScript && typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
|
||||
if (hasScript || hasProfile) {
|
||||
const { needsKind, needs } = parseNeeds(step.needs);
|
||||
return {
|
||||
kind: "script",
|
||||
script: step.script,
|
||||
script: hasScript ? step.script : "",
|
||||
profile: hasProfile ? step.profile : null,
|
||||
config: step.config ?? null,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -265,8 +272,10 @@ function parseSetStep(step) {
|
||||
return {
|
||||
kind: "set",
|
||||
script: SET_STEP_SCRIPT,
|
||||
profile: null,
|
||||
config: { expression },
|
||||
expression,
|
||||
name: parseOptionalName(step.name),
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
needs,
|
||||
@@ -287,6 +296,19 @@ function parseWhen(when) {
|
||||
return when;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function parseOptionalName(name) {
|
||||
if (name == null || name === "") return null;
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string | null}
|
||||
|
||||
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
export function trashAgeMs(deletedAtIso) {
|
||||
function trashAgeMs(deletedAtIso) {
|
||||
return Date.now() - Date.parse(deletedAtIso);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
||||
try {
|
||||
const step = parseScriptStep(raw);
|
||||
if (step.kind === "set") continue;
|
||||
if (step.profile && !step.script) continue;
|
||||
const resolved = resolveScriptRef(step.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
|
||||
@@ -18,7 +18,7 @@ scripts:
|
||||
- script: fetch-binary.js
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Joplin nightly daily log
|
||||
description: |
|
||||
POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy.
|
||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||
scripts:
|
||||
- script: plugin/joplin-api
|
||||
config:
|
||||
url: http://10.8.0.6:3040/api/logs/run
|
||||
method: POST
|
||||
token: $SECRET_joplin_setup_token
|
||||
timeoutMs: 600000
|
||||
- set:
|
||||
expression: |
|
||||
{
|
||||
"title": data.ok ? "Bullet journal" : "Bullet journal failed",
|
||||
"message": data.ok
|
||||
? "The bullet journal for " & data.httpResponse.date & " has been created."
|
||||
: data.message
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: "0 4 * * *"
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /dev-joplin-daily
|
||||
enabled: false
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Joplin get note
|
||||
description: |
|
||||
GET a Joplin note by id from joplin-api. Input (data): id (32-char hex).
|
||||
Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note.
|
||||
scripts:
|
||||
- script: plugin/joplin-api
|
||||
config:
|
||||
url: http://10.8.0.6:3030/notes
|
||||
method: GET
|
||||
token: $SECRET_joplin_api_token
|
||||
timeoutMs: 60000
|
||||
triggers:
|
||||
- type: workflow
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /dev-joplin-get-note
|
||||
enabled: false
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Joplin nightly sync
|
||||
description: |
|
||||
POST joplin-auto /api/sync at 03:00, then ntfy success or failure.
|
||||
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||
scripts:
|
||||
- script: plugin/joplin-api
|
||||
config:
|
||||
url: http://10.8.0.6:3040/api/sync
|
||||
method: POST
|
||||
token: $SECRET_joplin_setup_token
|
||||
timeoutMs: 600000
|
||||
- set:
|
||||
expression: |
|
||||
{
|
||||
"title": data.ok ? "Joplin sync ok" : "Joplin sync failed",
|
||||
"message": data.message
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: "0 3 * * *"
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /dev-joplin-sync
|
||||
enabled: false
|
||||
@@ -1,17 +0,0 @@
|
||||
name: dev-zte-sms
|
||||
description: |
|
||||
Send an SMS via a ZTE modem web UI.
|
||||
Input (data): to, message
|
||||
Password is the named secret zte_modem_password ($SECRET_).
|
||||
scripts:
|
||||
- script: plugin/send-sms
|
||||
config:
|
||||
url: http://192.168.5.1/reqproc/proc_post
|
||||
password: $SECRET_sms_secret
|
||||
triggers:
|
||||
- type: workflow
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /dev-zte-sms
|
||||
auth:
|
||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
||||
@@ -1,12 +0,0 @@
|
||||
name: fetch-devto
|
||||
scripts:
|
||||
- script: fetch-html.js
|
||||
config:
|
||||
url: https://dev.to/t/productivity/top/week
|
||||
selector: "#substories h2"
|
||||
outputVar: titles
|
||||
jsonata: "$[].text"
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /fetch-dev-to
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Jadwal Solat Jakarta ntfy
|
||||
scripts:
|
||||
- id: fetch
|
||||
script: fetch-http.js
|
||||
config:
|
||||
url: https://kemenag.go.id/api/prayer-times/1301
|
||||
method: GET
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
- id: transform
|
||||
script: jsonata.js
|
||||
config:
|
||||
expression: |-
|
||||
{
|
||||
"title": data.httpResponse.data.date,
|
||||
"message": "Imsak:" & data.httpResponse.data.imsak & "\n" &
|
||||
"Subuh:" & data.httpResponse.data.subuh & "\n" &
|
||||
"Dzuhur:" & data.httpResponse.data.dzuhur & "\n" &
|
||||
"Ashar:" & data.httpResponse.data.ashar & "\n" &
|
||||
"Maghrib:" & data.httpResponse.data.maghrib & "\n" &
|
||||
"Isya:" & data.httpResponse.data.isya
|
||||
}
|
||||
needs:
|
||||
- fetch
|
||||
- id: ntfy
|
||||
script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
fingerprint: true
|
||||
needs:
|
||||
- transform
|
||||
- id: slack
|
||||
script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel2
|
||||
fingerprint: fingerprint:ntfy2
|
||||
needs:
|
||||
- transform
|
||||
- script: slack-webhook.js
|
||||
config:
|
||||
webhookUrlSecret: slack_deploy_webhook
|
||||
fingerprint: true
|
||||
fingerprintMaxAge: 1h
|
||||
text: $INPUT_message
|
||||
needs:
|
||||
- transform
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: 0 5 * * *
|
||||
@@ -1,20 +1,6 @@
|
||||
scripts:
|
||||
- time-to-ntfy-example.yaml
|
||||
- test.yaml
|
||||
- cron-example.yaml
|
||||
- fetch-devto.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- time-and-comic-to-ntfy.yaml
|
||||
- rss-selfhst-to-ntfy.yaml
|
||||
- send-gmail.yaml
|
||||
- test-send-gmail.yaml
|
||||
- track.yaml
|
||||
- rss-devto-to-ntfy.yaml
|
||||
- test-minio.yaml
|
||||
- dev-joplin-sync.yaml
|
||||
- dev-joplin-daily.yaml
|
||||
- dev-joplin-get-note.yaml
|
||||
- web-dave.yaml
|
||||
- jadwal-sholat-jakart.yaml
|
||||
- detect-example-changes.yaml
|
||||
- test-sftp.yaml
|
||||
- time-to-ntfy-example.yaml
|
||||
- comic-monkeyuser-to-ntfy.yaml
|
||||
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy (copy)
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss-rss-devto-to-ntfy
|
||||
enabled: false
|
||||
@@ -1,26 +0,0 @@
|
||||
name: RSS - selfh.st first item to ntfy
|
||||
scripts:
|
||||
- script: fetch-rss-feed.js
|
||||
config:
|
||||
url: https://selfh.st/rss/
|
||||
outputVar: item
|
||||
jsonata: items[0]
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: selfhst-latest
|
||||
jsonata: "data.item.guid ? data.item.guid : data.item.link"
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.item.title,
|
||||
"message": data.item.contentSnippet & "\n" & data.item.link,
|
||||
"attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined)
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /selfhst-rss
|
||||
@@ -1,40 +0,0 @@
|
||||
name: send-gmail
|
||||
description: |
|
||||
Send email via Gmail SMTP. Configure user/from and the named secret,
|
||||
then call from other workflows with trigger-workflow.js (name: send-gmail).
|
||||
|
||||
Input (data):
|
||||
to optional recipient(s); defaults to your Gmail below
|
||||
subject required
|
||||
text plain body (aliases: body, message)
|
||||
html optional HTML body
|
||||
from, cc, bcc, replyTo, priority, headers optional
|
||||
scripts:
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"to": $exists(data.to) ? data.to : "nasyarobby@gmail.com",
|
||||
"from": data.from,
|
||||
"subject": data.subject,
|
||||
"text": $exists(data.text) ? data.text : ($exists(data.body) ? data.body : data.message),
|
||||
"html": data.html,
|
||||
"cc": data.cc,
|
||||
"bcc": data.bcc,
|
||||
"replyTo": data.replyTo,
|
||||
"priority": data.priority,
|
||||
"headers": data.headers
|
||||
}
|
||||
- script: send-email.js
|
||||
config:
|
||||
service: Gmail
|
||||
user: elevent16th@gmail.com
|
||||
from: elevent16th@gmail.com
|
||||
passwordSecret: gmail_app_password
|
||||
triggers:
|
||||
- type: workflow
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /send-gmail
|
||||
auth:
|
||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Test MinIO
|
||||
scripts:
|
||||
- script: fetch-binary.js
|
||||
config:
|
||||
outputVar: file
|
||||
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
|
||||
filename: test.png
|
||||
- script: s3.js
|
||||
config:
|
||||
action: write
|
||||
endpoint: http://localhost:9000
|
||||
bucket: default
|
||||
forcePathStyle: true
|
||||
accessKeyIdSecret: minio_user
|
||||
secretAccessKeySecret: minio_pass
|
||||
key: file.png
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
@@ -1,18 +0,0 @@
|
||||
name: test-send-gmail
|
||||
description: |
|
||||
Kick send-gmail with sample data. Edit the payload below, then Run
|
||||
(or POST /u/default/test-send-gmail).
|
||||
scripts:
|
||||
- script: trigger-workflow.js
|
||||
config:
|
||||
name: send-gmail
|
||||
expression: |
|
||||
{
|
||||
"subject": "JerapahFlow test",
|
||||
"text": "Hello from test-send-gmail",
|
||||
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
|
||||
}
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /test-send-gmail
|
||||
@@ -1,22 +0,0 @@
|
||||
name: SFTP Test
|
||||
scripts:
|
||||
- script: remote-fs.js
|
||||
config:
|
||||
protocol: sftp
|
||||
action: list
|
||||
host: localhost
|
||||
path: /Users/nsrb/
|
||||
username: nsrb
|
||||
port: 22
|
||||
password: JKLjkl
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"message": $join(data.entries.name, "\n")}'
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: $VAR_ntfy_channel
|
||||
fingerprint: "false"
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
@@ -1,12 +0,0 @@
|
||||
name: jsonata
|
||||
scripts:
|
||||
- plugin/get-current-time
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"message": data.datetime & " " & data.processId}'
|
||||
- ntfy.js
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /mt
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: time and comic to ntfy
|
||||
description: >
|
||||
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
||||
scripts:
|
||||
- id: time
|
||||
script: plugin/get-current-time
|
||||
|
||||
- id: comic
|
||||
script: fetch-html.js
|
||||
config:
|
||||
url: "https://www.monkeyuser.com/"
|
||||
outputVar: "httpResponse"
|
||||
selector: ".comic img"
|
||||
jsonata: |
|
||||
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
|
||||
|
||||
- id: compose
|
||||
script: jsonata.js
|
||||
needs: [time, comic]
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.comic.httpResponse.title,
|
||||
"message": data.time.datetime & " " & data.comic.httpResponse.title,
|
||||
"attach": data.comic.httpResponse.url
|
||||
}
|
||||
|
||||
- id: notify
|
||||
script: ntfy.js
|
||||
needs: [compose]
|
||||
config:
|
||||
url: https://ntfy.sh/jerapah-flow
|
||||
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /time-and-comic
|
||||
@@ -6,9 +6,7 @@ scripts:
|
||||
- script: plugin/get-current-time
|
||||
config:
|
||||
key: ""
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
- profile: ntfy_default
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Track Gojek Trip
|
||||
scripts:
|
||||
- script: fetch-http.js
|
||||
config:
|
||||
url: https://api.gojekapi.com/live/track/491c3132c2cc7043
|
||||
method: GET
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
Origin: https://track.gojek.com
|
||||
- script: fingerprint.js
|
||||
config:
|
||||
key: gojek-track-status
|
||||
jsonata: data.httpResponse.status
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: |
|
||||
{
|
||||
"title": data.httpResponse.customer_name,
|
||||
"message": data.httpResponse.customer_name & " : " & data.httpResponse.status
|
||||
}
|
||||
- script: ntfy.js
|
||||
config:
|
||||
url: https://n.0dev.web.id/system
|
||||
triggers:
|
||||
- type: cron
|
||||
schedule: "* * * * *"
|
||||
- type: HTTP
|
||||
method: GET
|
||||
path: /new
|
||||
enabled: false
|
||||
@@ -1,14 +0,0 @@
|
||||
name: Dab 0dev
|
||||
scripts:
|
||||
- script: list-webdav.js
|
||||
config:
|
||||
url: https://dav.0dev.web.id/books
|
||||
path: /
|
||||
includeDirectories: true
|
||||
recursive: false
|
||||
username: nsrb
|
||||
passwordSecret: dav_0dev_password
|
||||
triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /new
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@jerapah-flow/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
"dev": "vite",
|
||||
"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": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.0",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"axios": "^1.11.0",
|
||||
"cronstrue": "^3.24.0",
|
||||
"mermaid": "^11.9.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -25,9 +28,14 @@
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"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",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||
import { ProfilesPage } from "./pages/ProfilesPage.jsx";
|
||||
import { OpsPage } from "./pages/OpsPage.jsx";
|
||||
import { BackupPage } from "./pages/BackupPage.jsx";
|
||||
|
||||
@@ -71,6 +72,9 @@ export function App() {
|
||||
<Route path="/variables" element={<VariablesPage />} />
|
||||
<Route path="/variables/new" 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/new" element={<AuthProfilesPage />} />
|
||||
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
|
||||
|
||||
@@ -1,723 +1 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from "./hooks/index.js";
|
||||
|
||||
@@ -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