refactor: split oversized modules and start server src/stores layout
Consolidate format helpers under lib/format, split React Query hooks by domain, extract AuthPicker/ConfigRefHint and HTTP trigger routing, demote file-private exports, and move KV/secrets/variables/profiles/http-auths stores under src/stores with root re-exports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** @returns {ControlState} */
|
/** @returns {ControlState} */
|
||||||
export function defaultControlState() {
|
function defaultControlState() {
|
||||||
return {
|
return {
|
||||||
http: "running",
|
http: "running",
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -1,390 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/http-auths-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertHttpStatus } from "./http-pages-store.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
const UUID_RE =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} id
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertAuthId(id) {
|
|
||||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
|
||||||
const err = new Error("invalid auth id");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return id.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertAuthName(name) {
|
|
||||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
||||||
const err = new Error("invalid auth name");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} type
|
|
||||||
* @returns {"bearer" | "basic" | "header"}
|
|
||||||
*/
|
|
||||||
export function assertAuthType(type) {
|
|
||||||
const t = String(type ?? "");
|
|
||||||
if (!ALLOWED_TYPES.has(t)) {
|
|
||||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect value source without exposing literal values.
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
|
||||||
*/
|
|
||||||
export function valueSourceKind(value) {
|
|
||||||
if (value == null) return "missing";
|
|
||||||
if (typeof value === "string") return "literal";
|
|
||||||
if (typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
if ("secret" in value) return "secret";
|
|
||||||
if ("kv" in value) return "kv";
|
|
||||||
}
|
|
||||||
return "literal";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redact config for API responses: replace literal strings with source markers.
|
|
||||||
* @param {Record<string, unknown>} config
|
|
||||||
* @param {string} type
|
|
||||||
*/
|
|
||||||
export function publicConfig(config, type) {
|
|
||||||
/** @type {Record<string, unknown>} */
|
|
||||||
const out = {};
|
|
||||||
if (type === "bearer") {
|
|
||||||
out.token = redactField(config.token);
|
|
||||||
} else if (type === "basic") {
|
|
||||||
out.user = redactField(config.user);
|
|
||||||
out.password = redactField(config.password);
|
|
||||||
} else if (type === "header") {
|
|
||||||
out.header = typeof config.header === "string" ? config.header : null;
|
|
||||||
out.value = redactField(config.value);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function redactField(value) {
|
|
||||||
const kind = valueSourceKind(value);
|
|
||||||
if (kind === "missing") return { source: "missing" };
|
|
||||||
if (kind === "kv") {
|
|
||||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
|
||||||
return {
|
|
||||||
source: "kv",
|
|
||||||
kv: v.kv,
|
|
||||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (kind === "secret") {
|
|
||||||
const v = /** @type {{ secret: string }} */ (value);
|
|
||||||
return { source: "secret", secret: v.secret };
|
|
||||||
}
|
|
||||||
return { source: "literal", set: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and normalize auth config for storage.
|
|
||||||
* @param {string} type
|
|
||||||
* @param {unknown} config
|
|
||||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
|
||||||
*/
|
|
||||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
|
||||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
|
||||||
? /** @type {Record<string, unknown>} */ (config)
|
|
||||||
: {};
|
|
||||||
const keep = opts.keepLiteralsFrom ?? {};
|
|
||||||
|
|
||||||
if (type === "bearer") {
|
|
||||||
return {
|
|
||||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (type === "basic") {
|
|
||||||
return {
|
|
||||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
|
||||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
|
||||||
allowEmpty: true,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// header
|
|
||||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
|
||||||
const err = new Error("header name must be a non-empty string");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
header: raw.header,
|
|
||||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {unknown} previous
|
|
||||||
* @param {string} label
|
|
||||||
* @param {{ allowEmpty?: boolean }} [opts]
|
|
||||||
*/
|
|
||||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
|
||||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
|
||||||
if (
|
|
||||||
value &&
|
|
||||||
typeof value === "object" &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
|
||||||
) {
|
|
||||||
if (typeof previous === "string") return previous;
|
|
||||||
if (previous && typeof previous === "object") return previous;
|
|
||||||
const err = new Error(`${label} was not previously set`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value == null || value === "") {
|
|
||||||
if (opts.allowEmpty && value === "") return "";
|
|
||||||
// Allow empty password for basic
|
|
||||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
|
||||||
if (typeof previous === "string") return previous;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const err = new Error(`${label} is required`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
|
|
||||||
if (typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
const v = /** @type {Record<string, unknown>} */ (value);
|
|
||||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
|
||||||
return { secret: v.secret };
|
|
||||||
}
|
|
||||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
|
||||||
/** @type {{ kv: string, namespace?: string }} */
|
|
||||||
const out = { kv: v.kv };
|
|
||||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
|
||||||
out.namespace = v.namespace;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = new Error(
|
|
||||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
|
||||||
);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfig(raw) {
|
|
||||||
if (typeof raw !== "string") return raw ?? {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicAuth(row, { includeConfig = true } = {}) {
|
|
||||||
const type = row.type;
|
|
||||||
const config = parseConfig(row.config);
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
type,
|
|
||||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
|
||||||
unauthorized_status: row.unauthorized_status ?? null,
|
|
||||||
unauthorized_response: row.unauthorized_response ?? null,
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal: full config including literals (for runtime auth checks).
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getHttpAuthInternal(id) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
const row = await db("http_auths").where({ id: authId }).first();
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
type: row.type,
|
|
||||||
config: parseConfig(row.config),
|
|
||||||
unauthorized_status: row.unauthorized_status ?? null,
|
|
||||||
unauthorized_response: row.unauthorized_response ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
|
||||||
*/
|
|
||||||
export async function revealHttpAuthLiterals(id) {
|
|
||||||
const internal = await getHttpAuthInternal(id);
|
|
||||||
if (!internal) return null;
|
|
||||||
/** @type {Record<string, string>} */
|
|
||||||
const literals = {};
|
|
||||||
const cfg = internal.config ?? {};
|
|
||||||
for (const key of ["token", "user", "password", "value"]) {
|
|
||||||
const v = cfg[key];
|
|
||||||
if (typeof v === "string") literals[key] = v;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: internal.id,
|
|
||||||
name: internal.name,
|
|
||||||
type: internal.type,
|
|
||||||
literals,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listHttpAuths() {
|
|
||||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
|
||||||
return rows.map((r) => publicAuth(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getHttpAuthById(id) {
|
|
||||||
let authId;
|
|
||||||
try {
|
|
||||||
authId = assertAuthId(id);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const row = await db("http_auths").where({ id: authId }).first();
|
|
||||||
return row ? publicAuth(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{
|
|
||||||
* id?: string | null,
|
|
||||||
* name: string,
|
|
||||||
* type: string,
|
|
||||||
* config?: unknown,
|
|
||||||
* unauthorized_status?: number | null,
|
|
||||||
* unauthorized_response?: string | null,
|
|
||||||
* }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertHttpAuth({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
config,
|
|
||||||
unauthorized_status,
|
|
||||||
unauthorized_response,
|
|
||||||
}) {
|
|
||||||
const authName = assertAuthName(name);
|
|
||||||
const authType = assertAuthType(type);
|
|
||||||
|
|
||||||
/** @type {Record<string, unknown> | null} */
|
|
||||||
let existing = null;
|
|
||||||
if (id != null && String(id).length > 0) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
existing = await db("http_auths").where({ id: authId }).first();
|
|
||||||
if (!existing) {
|
|
||||||
const err = new Error("auth not found");
|
|
||||||
err.statusCode = 404;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
|
||||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
|
||||||
const err = new Error(`auth name "${authName}" already exists`);
|
|
||||||
err.statusCode = 409;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
|
||||||
const normalized = normalizeAuthConfig(authType, config, {
|
|
||||||
keepLiteralsFrom: prevConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
let unauthStatus = null;
|
|
||||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
|
||||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
|
||||||
}
|
|
||||||
let unauthResponse = null;
|
|
||||||
if (
|
|
||||||
unauthorized_response != null &&
|
|
||||||
String(unauthorized_response).length > 0
|
|
||||||
) {
|
|
||||||
unauthResponse = String(unauthorized_response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso();
|
|
||||||
const configJson = JSON.stringify(normalized);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("http_auths")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
name: authName,
|
|
||||||
type: authType,
|
|
||||||
config: configJson,
|
|
||||||
unauthorized_status: unauthStatus,
|
|
||||||
unauthorized_response: unauthResponse,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = randomUUID();
|
|
||||||
await db("http_auths").insert({
|
|
||||||
id: newId,
|
|
||||||
name: authName,
|
|
||||||
type: authType,
|
|
||||||
config: configJson,
|
|
||||||
unauthorized_status: unauthStatus,
|
|
||||||
unauthorized_response: unauthResponse,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getHttpAuthById(newId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteHttpAuth(id) {
|
|
||||||
const authId = assertAuthId(id);
|
|
||||||
const n = await db("http_auths").where({ id: authId }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-399
@@ -1,399 +1 @@
|
|||||||
import { db } from "./db.js";
|
export * from "./src/stores/kv-store.js";
|
||||||
|
|
||||||
const MAX_KEY_LENGTH = 512;
|
|
||||||
const MAX_NAMESPACE_LENGTH = 512;
|
|
||||||
const MAX_VALUE_BYTES = 256 * 1024;
|
|
||||||
const DEFAULT_LIST_LIMIT = 100;
|
|
||||||
const MAX_LIST_LIMIT = 500;
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function valuesEqual(a, b) {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a == null || b == null) return a === b;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {unknown} value
|
|
||||||
*/
|
|
||||||
function assertString(label, value) {
|
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
|
||||||
throw new Error(`${label} must be a non-empty string`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} label
|
|
||||||
* @param {string} value
|
|
||||||
* @param {number} max
|
|
||||||
*/
|
|
||||||
function assertMaxLength(label, value, max) {
|
|
||||||
if (value.length > max) {
|
|
||||||
throw new Error(`${label} must be at most ${max} characters`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
*/
|
|
||||||
function assertNamespace(namespace) {
|
|
||||||
assertString("namespace", namespace);
|
|
||||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
*/
|
|
||||||
function assertKey(key) {
|
|
||||||
assertString("key", key);
|
|
||||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function serializeKvValue(value) {
|
|
||||||
let json;
|
|
||||||
try {
|
|
||||||
json = JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
throw new Error("value must be JSON-serializable");
|
|
||||||
}
|
|
||||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
|
||||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null | undefined} value
|
|
||||||
* @returns {unknown}
|
|
||||||
*/
|
|
||||||
function deserializeKvValue(value) {
|
|
||||||
if (value == null) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ expires_at?: string | null }} row
|
|
||||||
*/
|
|
||||||
function isExpired(row) {
|
|
||||||
if (!row.expires_at) return false;
|
|
||||||
return Date.parse(row.expires_at) <= Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
export async function kvGet(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const row = await db("script_state").where({ namespace, key }).first();
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key }).del();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return deserializeKvValue(row.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvSet(namespace, key, value, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
const json = serializeKvValue(value);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db("script_state")
|
|
||||||
.insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
})
|
|
||||||
.onConflict(["namespace", "key"])
|
|
||||||
.merge({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function kvDelete(namespace, key) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
|
||||||
return deleted > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
|
||||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
|
||||||
*/
|
|
||||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
assertKey(key);
|
|
||||||
|
|
||||||
return db.transaction(async (trx) => {
|
|
||||||
const row = await trx("script_state").where({ namespace, key }).first();
|
|
||||||
|
|
||||||
if (row && isExpired(row)) {
|
|
||||||
await trx("script_state").where({ namespace, key }).del();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRow =
|
|
||||||
row && !isExpired(row)
|
|
||||||
? row
|
|
||||||
: await trx("script_state").where({ namespace, key }).first();
|
|
||||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
|
||||||
|
|
||||||
if (!valuesEqual(previous, expected)) {
|
|
||||||
return { ok: false, previous };
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = serializeKvValue(next);
|
|
||||||
const updated_at = nowIso();
|
|
||||||
let expires_at = null;
|
|
||||||
if (opts.expiresAt != null) {
|
|
||||||
expires_at =
|
|
||||||
opts.expiresAt instanceof Date
|
|
||||||
? opts.expiresAt.toISOString()
|
|
||||||
: String(opts.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRow) {
|
|
||||||
await trx("script_state").where({ namespace, key }).update({
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await trx("script_state").insert({
|
|
||||||
namespace,
|
|
||||||
key,
|
|
||||||
value: json,
|
|
||||||
updated_at,
|
|
||||||
expires_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, previous };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} namespace
|
|
||||||
* @param {{ limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvList(namespace, opts = {}) {
|
|
||||||
assertNamespace(namespace);
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
|
||||||
MAX_LIST_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = await db("script_state")
|
|
||||||
.where({ namespace })
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (isExpired(row)) {
|
|
||||||
await db("script_state").where({ namespace, key: row.key }).del();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items.push({
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeLike(value) {
|
|
||||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneExpiredKv() {
|
|
||||||
await db("script_state")
|
|
||||||
.whereNotNull("expires_at")
|
|
||||||
.andWhere("expires_at", "<=", nowIso())
|
|
||||||
.del();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{
|
|
||||||
* namespace?: string,
|
|
||||||
* q?: string,
|
|
||||||
* limit?: number,
|
|
||||||
* offset?: number,
|
|
||||||
* }} [opts]
|
|
||||||
*/
|
|
||||||
export async function kvQuery(opts = {}) {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
|
|
||||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
|
||||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
|
||||||
|
|
||||||
let q = db("script_state");
|
|
||||||
if (opts.namespace) {
|
|
||||||
assertNamespace(opts.namespace);
|
|
||||||
q = q.where({ namespace: opts.namespace });
|
|
||||||
}
|
|
||||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
|
||||||
const like = `%${escapeLike(opts.q)}%`;
|
|
||||||
q = q.where(function likeSearch() {
|
|
||||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
|
||||||
"value LIKE ? ESCAPE '\\'",
|
|
||||||
[like],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const countRow = await q.clone().count({ count: "*" }).first();
|
|
||||||
const total = Number(countRow?.count ?? 0);
|
|
||||||
|
|
||||||
const rows = await q
|
|
||||||
.clone()
|
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.orderBy("namespace", "asc")
|
|
||||||
.orderBy("key", "asc")
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: rows.map((row) => ({
|
|
||||||
namespace: row.namespace,
|
|
||||||
key: row.key,
|
|
||||||
value: deserializeKvValue(row.value),
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
expiresAt: row.expires_at ?? null,
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Promise<string[]>}
|
|
||||||
*/
|
|
||||||
export async function kvNamespaces() {
|
|
||||||
await pruneExpiredKv();
|
|
||||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
|
||||||
return rows.map((row) => row.namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} defaultNamespace
|
|
||||||
*/
|
|
||||||
export function createKvApi(defaultNamespace) {
|
|
||||||
assertNamespace(defaultNamespace);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
function resolveNamespace(opts = {}) {
|
|
||||||
const namespace = opts.namespace ?? defaultNamespace;
|
|
||||||
assertNamespace(namespace);
|
|
||||||
return namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
namespace: defaultNamespace,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
get(key, opts) {
|
|
||||||
return kvGet(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} value
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
set(key, value, opts) {
|
|
||||||
const { namespace, expiresAt } = opts ?? {};
|
|
||||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {{ namespace?: string }} [opts]
|
|
||||||
*/
|
|
||||||
delete(key, opts) {
|
|
||||||
return kvDelete(resolveNamespace(opts), key);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {unknown} expected
|
|
||||||
* @param {unknown} next
|
|
||||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
|
||||||
*/
|
|
||||||
compareAndSet(key, expected, next, opts) {
|
|
||||||
const { expiresAt } = opts ?? {};
|
|
||||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
|
||||||
*/
|
|
||||||
list(opts) {
|
|
||||||
const { namespace, limit } = opts ?? {};
|
|
||||||
return kvList(resolveNamespace(opts), { limit });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,244 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/profiles-store.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-120
@@ -23,15 +23,13 @@ import {
|
|||||||
storedEnvelope,
|
storedEnvelope,
|
||||||
} from "./step-result.js";
|
} from "./step-result.js";
|
||||||
import * as fsStore from "./fs-store.js";
|
import * as fsStore from "./fs-store.js";
|
||||||
import {
|
|
||||||
checkAnyHttpAuth,
|
|
||||||
resolveAuthMechanisms,
|
|
||||||
resolveUnauthorizedSpec,
|
|
||||||
sendHttpPageOrJson,
|
|
||||||
sendSuccessPage,
|
|
||||||
} from "./http-trigger-auth.js";
|
|
||||||
import { resolveConfigRefs } from "./config-refs.js";
|
import { resolveConfigRefs } from "./config-refs.js";
|
||||||
import { HTTP_METHODS, hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
import { hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||||
|
import {
|
||||||
|
createHttpTriggerHandler,
|
||||||
|
ensureHttpWildcardRoute,
|
||||||
|
rebuildHttpRoutes,
|
||||||
|
} from "./workflow-http-routes.js";
|
||||||
import { getProfilePlain } from "./profiles-store.js";
|
import { getProfilePlain } from "./profiles-store.js";
|
||||||
import {
|
import {
|
||||||
buildFailureAlertData,
|
buildFailureAlertData,
|
||||||
@@ -69,7 +67,14 @@ export function createRegistry(server, opts = {}) {
|
|||||||
let pruneTask = null;
|
let pruneTask = null;
|
||||||
/** @type {Map<string, HttpRouteEntry>} */
|
/** @type {Map<string, HttpRouteEntry>} */
|
||||||
const httpRoutes = new Map();
|
const httpRoutes = new Map();
|
||||||
let httpDispatcherRegistered = false;
|
const httpDispatcherState = { registered: false };
|
||||||
|
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||||
|
httpRoutes,
|
||||||
|
workflows,
|
||||||
|
namespacedPath,
|
||||||
|
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||||
@@ -175,118 +180,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
* Fastify route once so path/method changes apply on reregister without restart.
|
* Fastify route once so path/method changes apply on reregister without restart.
|
||||||
*/
|
*/
|
||||||
function registerHttpTriggers() {
|
function registerHttpTriggers() {
|
||||||
httpRoutes.clear();
|
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||||
|
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||||
for (const [key, { owner, workflow }] of workflows) {
|
log,
|
||||||
if (workflow.enabled === false) {
|
|
||||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const trigger of workflow.triggers ?? []) {
|
|
||||||
if (trigger.type !== "HTTP") continue;
|
|
||||||
|
|
||||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
|
||||||
const url = namespacedPath(owner, trigger.path);
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
|
|
||||||
if (httpRoutes.has(routeKey)) {
|
|
||||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
|
||||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpDispatcherRegistered) {
|
|
||||||
httpDispatcherRegistered = true;
|
|
||||||
server.route({
|
|
||||||
method: HTTP_METHODS,
|
|
||||||
url: "/u/*",
|
|
||||||
handler: dispatchHttpTrigger,
|
|
||||||
});
|
});
|
||||||
log.debug("Registered HTTP trigger wildcard dispatcher /u/*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("fastify").FastifyRequest} req
|
|
||||||
* @param {import("fastify").FastifyReply} reply
|
|
||||||
*/
|
|
||||||
async function dispatchHttpTrigger(req, reply) {
|
|
||||||
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
|
|
||||||
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
|
|
||||||
const method = String(req.method ?? "GET").toUpperCase();
|
|
||||||
const routeKey = `${method} ${url}`;
|
|
||||||
const mapped = httpRoutes.get(routeKey);
|
|
||||||
|
|
||||||
if (!mapped) {
|
|
||||||
return reply.code(404).send({ error: "not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = workflows.get(mapped.key);
|
|
||||||
if (!entry || entry.workflow?.enabled === false) {
|
|
||||||
return reply.code(404).send({ error: "workflow disabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer live trigger from current workflow YAML (auth/response edits)
|
|
||||||
const liveTrigger =
|
|
||||||
(entry.workflow.triggers ?? []).find((t) => {
|
|
||||||
if (t?.type !== "HTTP") return false;
|
|
||||||
const m = String(t.method ?? "POST").toUpperCase();
|
|
||||||
const p = namespacedPath(entry.owner, t.path);
|
|
||||||
return m === method && p === url;
|
|
||||||
}) ?? mapped.trigger;
|
|
||||||
|
|
||||||
if (
|
|
||||||
liveTrigger.auth != null &&
|
|
||||||
liveTrigger.auth !== false &&
|
|
||||||
!(Array.isArray(liveTrigger.auth) && liveTrigger.auth.length === 0)
|
|
||||||
) {
|
|
||||||
const mechanisms = await resolveAuthMechanisms(liveTrigger.auth);
|
|
||||||
if (mechanisms.length === 0) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(liveTrigger, null);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ok = await checkAnyHttpAuth(req, mechanisms, {
|
|
||||||
owner: entry.owner,
|
|
||||||
workflowKey: mapped.key,
|
|
||||||
});
|
|
||||||
if (!ok) {
|
|
||||||
const { status, pageName } = resolveUnauthorizedSpec(
|
|
||||||
liveTrigger,
|
|
||||||
mechanisms[0],
|
|
||||||
);
|
|
||||||
return sendHttpPageOrJson(reply, status, pageName, {
|
|
||||||
error: "unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await enqueueWorkflow(
|
|
||||||
mapped.key,
|
|
||||||
{ data: req.body },
|
|
||||||
{ type: "http", detail: `${method} ${url}` },
|
|
||||||
);
|
|
||||||
if (result.status === "failed") {
|
|
||||||
return reply.code(result.runId ? 500 : 404).send({
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
error: result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultBody = {
|
|
||||||
runId: result.runId,
|
|
||||||
status: result.status,
|
|
||||||
};
|
|
||||||
if (typeof liveTrigger.response === "string" && liveTrigger.response) {
|
|
||||||
return sendSuccessPage(reply, liveTrigger.response, defaultBody);
|
|
||||||
}
|
|
||||||
return reply.code(202).send(defaultBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCronTriggers() {
|
function registerCronTriggers() {
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
|
|||||||
* @param {unknown} fn
|
* @param {unknown} fn
|
||||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||||
*/
|
*/
|
||||||
export function extractScriptMeta(fn) {
|
function extractScriptMeta(fn) {
|
||||||
if (typeof fn !== "function") {
|
if (typeof fn !== "function") {
|
||||||
return { meta: null, metaError: "default export must be a function" };
|
return { meta: null, metaError: "default export must be a function" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/secrets-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertOwner } from "./fs-store.js";
|
|
||||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
|
||||||
import { registerPlaintext } from "./secret-value.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertSecretName(name) {
|
|
||||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
|
||||||
const err = new Error("invalid secret name");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicSecret(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
owner: row.owner,
|
|
||||||
name: row.name,
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner?: string }} [filters]
|
|
||||||
*/
|
|
||||||
export async function listSecrets(filters = {}) {
|
|
||||||
let q = db("secrets")
|
|
||||||
.select("id", "owner", "name", "created_at", "updated_at")
|
|
||||||
.orderBy("owner", "asc")
|
|
||||||
.orderBy("name", "asc");
|
|
||||||
if (filters.owner) {
|
|
||||||
q = q.where("owner", assertOwner(filters.owner));
|
|
||||||
}
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getSecretById(id) {
|
|
||||||
const row = await db("secrets")
|
|
||||||
.select("id", "owner", "name", "created_at", "updated_at")
|
|
||||||
.where({ id })
|
|
||||||
.first();
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner: string, name: string, value: string }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertSecret({ owner, name, value }) {
|
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
|
||||||
const err = new Error("value is required");
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const secretName = assertSecretName(name);
|
|
||||||
registerPlaintext(value);
|
|
||||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
|
||||||
const now = nowIso();
|
|
||||||
const existing = await db("secrets")
|
|
||||||
.where({ owner: ownerName, name: secretName })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("secrets")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
ciphertext,
|
|
||||||
iv,
|
|
||||||
auth_tag: authTag,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getSecretById(existing.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID();
|
|
||||||
await db("secrets").insert({
|
|
||||||
id,
|
|
||||||
owner: ownerName,
|
|
||||||
name: secretName,
|
|
||||||
ciphertext,
|
|
||||||
iv,
|
|
||||||
auth_tag: authTag,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getSecretById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteSecret(id) {
|
|
||||||
const n = await db("secrets").where({ id }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a named secret for an owner. Returns null if missing.
|
|
||||||
* @param {string} owner
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {Promise<string | null>}
|
|
||||||
*/
|
|
||||||
export async function getSecretPlaintext(owner, name) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const secretName = assertSecretName(name);
|
|
||||||
const row = await db("secrets")
|
|
||||||
.where({ owner: ownerName, name: secretName })
|
|
||||||
.first();
|
|
||||||
if (!row) return null;
|
|
||||||
try {
|
|
||||||
const plaintext = decryptSecret({
|
|
||||||
ciphertext: row.ciphertext,
|
|
||||||
iv: row.iv,
|
|
||||||
authTag: row.auth_tag,
|
|
||||||
});
|
|
||||||
registerPlaintext(plaintext);
|
|
||||||
return plaintext;
|
|
||||||
} catch {
|
|
||||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
|||||||
/**
|
/**
|
||||||
* @returns {Buffer}
|
* @returns {Buffer}
|
||||||
*/
|
*/
|
||||||
export function getMasterKey() {
|
function getMasterKey() {
|
||||||
if (cachedKey) return cachedKey;
|
if (cachedKey) return cachedKey;
|
||||||
const raw = resolveSecretsKeyMaterial();
|
const raw = resolveSecretsKeyMaterial();
|
||||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||||
|
|||||||
@@ -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 ?? ""));
|
||||||
|
}
|
||||||
@@ -1,190 +1 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export * from "./src/stores/variables-store.js";
|
||||||
import { db } from "./db.js";
|
|
||||||
import { assertOwner } from "./fs-store.js";
|
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 128;
|
|
||||||
const MAX_STRING_BYTES = 64 * 1024;
|
|
||||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpError(message, statusCode = 400) {
|
|
||||||
const err = new Error(message);
|
|
||||||
err.statusCode = statusCode;
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function assertVariableName(name) {
|
|
||||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
|
||||||
throw httpError("invalid variable name");
|
|
||||||
}
|
|
||||||
if (name.length > MAX_NAME_LENGTH) {
|
|
||||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} type
|
|
||||||
* @returns {"string" | "number" | "boolean"}
|
|
||||||
*/
|
|
||||||
export function assertVariableType(type) {
|
|
||||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
|
||||||
throw httpError("type must be string, number, or boolean");
|
|
||||||
}
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {"string" | "number" | "boolean"} type
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function encodeVariableValue(type, value) {
|
|
||||||
if (type === "string") {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw httpError("value must be a string");
|
|
||||||
}
|
|
||||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
|
||||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (type === "number") {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
||||||
throw httpError("value must be a finite number");
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
if (typeof value !== "boolean") {
|
|
||||||
throw httpError("value must be a boolean");
|
|
||||||
}
|
|
||||||
return value ? "true" : "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {"string" | "number" | "boolean"} type
|
|
||||||
* @param {string} stored
|
|
||||||
* @returns {string | number | boolean}
|
|
||||||
*/
|
|
||||||
export function decodeVariableValue(type, stored) {
|
|
||||||
if (type === "string") return stored;
|
|
||||||
if (type === "number") {
|
|
||||||
const n = Number(stored);
|
|
||||||
if (!Number.isFinite(n)) {
|
|
||||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
if (stored === "true") return true;
|
|
||||||
if (stored === "false") return false;
|
|
||||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Record<string, unknown>} row
|
|
||||||
*/
|
|
||||||
function publicVariable(row) {
|
|
||||||
const type = assertVariableType(row.type);
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
owner: row.owner,
|
|
||||||
name: row.name,
|
|
||||||
type,
|
|
||||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner?: string }} [filters]
|
|
||||||
*/
|
|
||||||
export async function listVariables(filters = {}) {
|
|
||||||
let q = db("variables")
|
|
||||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
|
||||||
.orderBy("owner", "asc")
|
|
||||||
.orderBy("name", "asc");
|
|
||||||
if (filters.owner) {
|
|
||||||
q = q.where("owner", assertOwner(filters.owner));
|
|
||||||
}
|
|
||||||
const rows = await q;
|
|
||||||
return rows.map((row) => publicVariable(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
export async function getVariableById(id) {
|
|
||||||
const row = await db("variables").where({ id }).first();
|
|
||||||
return row ? publicVariable(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
|
||||||
*/
|
|
||||||
export async function upsertVariable({ owner, name, type, value }) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const variableName = assertVariableName(name);
|
|
||||||
const variableType = assertVariableType(type);
|
|
||||||
const encoded = encodeVariableValue(variableType, value);
|
|
||||||
const now = nowIso();
|
|
||||||
const existing = await db("variables")
|
|
||||||
.where({ owner: ownerName, name: variableName })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db("variables")
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
type: variableType,
|
|
||||||
value: encoded,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getVariableById(existing.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID();
|
|
||||||
await db("variables").insert({
|
|
||||||
id,
|
|
||||||
owner: ownerName,
|
|
||||||
name: variableName,
|
|
||||||
type: variableType,
|
|
||||||
value: encoded,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
return getVariableById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function deleteVariable(id) {
|
|
||||||
const n = await db("variables").where({ id }).del();
|
|
||||||
return n > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed primitive for an owner/name. Returns null if missing.
|
|
||||||
* @param {string} owner
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {Promise<string | number | boolean | null>}
|
|
||||||
*/
|
|
||||||
export async function getVariablePlain(owner, name) {
|
|
||||||
const ownerName = assertOwner(owner);
|
|
||||||
const variableName = assertVariableName(name);
|
|
||||||
const row = await db("variables")
|
|
||||||
.where({ owner: ownerName, name: variableName })
|
|
||||||
.first();
|
|
||||||
if (!row) return null;
|
|
||||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ function trashFilePath(owner, file) {
|
|||||||
/**
|
/**
|
||||||
* @param {string} deletedAtIso
|
* @param {string} deletedAtIso
|
||||||
*/
|
*/
|
||||||
export function trashAgeMs(deletedAtIso) {
|
function trashAgeMs(deletedAtIso) {
|
||||||
return Date.now() - Date.parse(deletedAtIso);
|
return Date.now() - Date.parse(deletedAtIso);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,761 +1 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
export * from "./hooks/index.js";
|
||||||
import { api, opsApi } from "./client.js";
|
|
||||||
|
|
||||||
export function useBootstrap() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["bootstrap"],
|
|
||||||
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMe() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["me"],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return (await api.get("/auth/me")).data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response?.status === 401) return { user: null };
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.setQueryData(["me"], data);
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRegister() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.setQueryData(["me"], data);
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogout() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await api.post("/auth/logout")).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.setQueryData(["me"], null);
|
|
||||||
qc.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDashboard() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["dashboard"],
|
|
||||||
queryFn: async () => (await api.get("/dashboard")).data,
|
|
||||||
refetchInterval: (query) =>
|
|
||||||
query.state.data?.running?.length ? 4000 : 15000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScripts() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["scripts"],
|
|
||||||
queryFn: async () => (await api.get("/scripts")).data.scripts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScript(name, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["scripts", name],
|
|
||||||
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
|
|
||||||
enabled: Boolean(name) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, content }) =>
|
|
||||||
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePlugin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ id, content, description }) =>
|
|
||||||
(await api.post("/plugins/create", { id, content, description })).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useForkScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, id, description }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
|
|
||||||
id,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstallPlugin() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteScript() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (name) =>
|
|
||||||
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDryRunScript() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ name, content, data, context, config, owner }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
|
||||||
content,
|
|
||||||
data,
|
|
||||||
context,
|
|
||||||
config,
|
|
||||||
owner,
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflows(owner) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner ?? "all"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = owner ? { owner } : {};
|
|
||||||
return (await api.get("/workflows", { params })).data.workflows;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflow(owner, file, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file],
|
|
||||||
queryFn: async () =>
|
|
||||||
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
|
|
||||||
.data,
|
|
||||||
enabled: Boolean(owner && file) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOwners() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["owners"],
|
|
||||||
queryFn: async () => (await api.get("/owners")).data.owners,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, content, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.put(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetWorkflowEnabled() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, enabled }) =>
|
|
||||||
(
|
|
||||||
await api.patch(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
{ enabled },
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file }) =>
|
|
||||||
(
|
|
||||||
await api.delete(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDuplicateWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
|
||||||
{
|
|
||||||
...(destOwner ? { owner: destOwner } : {}),
|
|
||||||
...(destFile ? { file: destFile } : {}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRunWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, data }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
|
|
||||||
data !== undefined ? { data } : {},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["runs"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReregisterWorkflows() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => (await api.post("/workflows/reregister")).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRuns(filters = {}) {
|
|
||||||
const { owner, workflow, status, 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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 useWorkflowTrash() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", "trash"],
|
|
||||||
queryFn: async () => (await api.get("/workflows/trash")).data.items,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestoreWorkflowTrash() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePurgeWorkflowTrash() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (id) =>
|
|
||||||
(await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowRevisions(owner, file, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file, "revisions"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await api.get(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
enabled: Boolean(owner && file) && enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowRevision(owner, file, revision) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["workflows", owner, file, "revisions", revision],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await api.get(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`,
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
enabled: Boolean(owner && file && revision != null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRevertWorkflowRevision() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, file, revision, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.post(
|
|
||||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`,
|
|
||||||
saveAnyway ? { saveAnyway: true } : {},
|
|
||||||
)
|
|
||||||
).data,
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkflow() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ owner, content, file, saveAnyway }) =>
|
|
||||||
(
|
|
||||||
await api.post(`/workflows/${encodeURIComponent(owner)}`, {
|
|
||||||
content,
|
|
||||||
...(file ? { file } : {}),
|
|
||||||
...(saveAnyway ? { saveAnyway: true } : {}),
|
|
||||||
})
|
|
||||||
).data,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDownloadWorkflowBackup() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const res = await api.get("/workflows/backup", { responseType: "blob" });
|
|
||||||
const disposition = res.headers["content-disposition"] ?? "";
|
|
||||||
const match = disposition.match(/filename="([^"]+)"/);
|
|
||||||
const filename = match?.[1] ?? "jerapah-flow-backup.zip";
|
|
||||||
const url = URL.createObjectURL(res.data);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRestoreWorkflowBackup() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ file, mode }) => {
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
|
||||||
return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useBootstrap() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bootstrap"],
|
||||||
|
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return (await api.get("/auth/me")).data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 401) return { user: null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["me"], data);
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["me"], data);
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await api.post("/auth/logout")).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.setQueryData(["me"], null);
|
||||||
|
qc.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: async () => (await api.get("/users")).data.users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.post("/users", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }) =>
|
||||||
|
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../client.js";
|
||||||
|
|
||||||
|
export function useHttpPages() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["http-pages"],
|
||||||
|
queryFn: async () => (await api.get("/http-pages")).data.pages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertHttpPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteHttpPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHttpAuths() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["http-auths"],
|
||||||
|
queryFn: async () => (await api.get("/http-auths")).data.auths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertHttpAuth() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteHttpAuth() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id) =>
|
||||||
|
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||||
|
|
||||||
|
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||||
|
export async function fetchHttpAuthLiterals(id) {
|
||||||
|
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from "./auth.js";
|
||||||
|
export * from "./scripts.js";
|
||||||
|
export * from "./workflows.js";
|
||||||
|
export * from "./runs.js";
|
||||||
|
export * from "./secrets.js";
|
||||||
|
export * from "./variables.js";
|
||||||
|
export * from "./profiles.js";
|
||||||
|
export * from "./kv.js";
|
||||||
|
export * from "./http.js";
|
||||||
|
export * from "./ops.js";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,99 @@
|
|||||||
|
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, data, context, config, owner }) =>
|
||||||
|
(
|
||||||
|
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
||||||
|
content,
|
||||||
|
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,273 @@
|
|||||||
|
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 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx";
|
import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx";
|
||||||
import { useNotifications } from "../notifications.jsx";
|
import { useNotifications } from "../notifications.jsx";
|
||||||
import { formatBytes, formatCpu, formatDuration } from "../lib/format.js";
|
import { formatBytes, formatCpu, formatDuration } from "../lib/format";
|
||||||
|
|
||||||
const MAX_WORKERS = 32;
|
const MAX_WORKERS = 32;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuX } from "react-icons/lu";
|
import { LuX } from "react-icons/lu";
|
||||||
import { levelName } from "../lib/format.jsx";
|
import { levelName } from "../lib/format";
|
||||||
|
|
||||||
export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) {
|
export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) {
|
||||||
const [wordWrap, setWordWrap] = useState(true);
|
const [wordWrap, setWordWrap] = useState(true);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatBytes, formatCpu } from "../lib/format.js";
|
import { formatBytes, formatCpu } from "../lib/format";
|
||||||
|
|
||||||
export function ProcessResourcesCard({ children }) {
|
export function ProcessResourcesCard({ children }) {
|
||||||
const totals = children?.totals ?? { memory: 0, cpu: 0 };
|
const totals = children?.totals ?? { memory: 0, cpu: 0 };
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { LuChevronDown, LuX } from "react-icons/lu";
|
||||||
|
import { FormInput } from "../FormControls.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable dropdown: pick an auth to add; chips with X to remove.
|
||||||
|
* YAML stores profile UUIDs; UI shows names.
|
||||||
|
*/
|
||||||
|
export function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
|
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
|
||||||
|
|
||||||
|
const available = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return auths
|
||||||
|
.filter((a) => !selectedSet.has(a.id))
|
||||||
|
.filter((a) => {
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
a.name.toLowerCase().includes(q) ||
|
||||||
|
String(a.type).toLowerCase().includes(q) ||
|
||||||
|
a.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [auths, selectedSet, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDoc(e) {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function addAuth(id) {
|
||||||
|
if (selectedSet.has(id)) return;
|
||||||
|
onChange([...selectedIds, id]);
|
||||||
|
setQuery("");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAuth(id) {
|
||||||
|
onChange(selectedIds.filter((x) => x !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1" ref={rootRef}>
|
||||||
|
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
|
||||||
|
|
||||||
|
{selectedIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedIds.map((id) => {
|
||||||
|
const auth = byId.get(id);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
className="badge badge-outline gap-1 h-7 px-2 font-normal"
|
||||||
|
title={id}
|
||||||
|
>
|
||||||
|
<span className="max-w-[10rem] truncate">
|
||||||
|
{auth ? (
|
||||||
|
<>
|
||||||
|
{auth.name}
|
||||||
|
<span className="opacity-60"> · {auth.type}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-60">missing</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square -mr-1"
|
||||||
|
title="Remove"
|
||||||
|
aria-label={`Remove ${auth?.name ?? id}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeAuth(id)}
|
||||||
|
>
|
||||||
|
<LuX className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="join w-full">
|
||||||
|
<FormInput
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
className="join-item w-full min-w-0"
|
||||||
|
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
|
||||||
|
value={query}
|
||||||
|
disabled={disabled || auths.length === 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (available[0]) addAuth(available[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm join-item btn-square"
|
||||||
|
disabled={disabled || auths.length === 0}
|
||||||
|
aria-label="Open auth list"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen((v) => !v);
|
||||||
|
if (!open) inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && !disabled && auths.length > 0 ? (
|
||||||
|
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
|
||||||
|
{available.length === 0 ? (
|
||||||
|
<li className="disabled">
|
||||||
|
<span className="opacity-60">
|
||||||
|
{query.trim() ? "No matches" : "All profiles selected"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
available.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<button type="button" onClick={() => addAuth(a.id)}>
|
||||||
|
<span className="font-mono text-sm">{a.name}</span>
|
||||||
|
<span className="opacity-60 text-xs">{a.type}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inlineCount > 0 ? (
|
||||||
|
<span className="text-xs opacity-60">
|
||||||
|
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,153 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu";
|
|
||||||
import { useVariables } from "../../api/hooks.js";
|
|
||||||
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
||||||
import { prettyJson } from "../../lib/script.js";
|
import { prettyJson } from "../../lib/script.js";
|
||||||
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
||||||
import { FieldLabel } from "./FieldHelp.jsx";
|
import { FieldLabel } from "./FieldHelp.jsx";
|
||||||
|
import { ConfigRefHint } from "./ConfigRefHint.jsx";
|
||||||
|
|
||||||
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||||
const VAR_PEEK_MAX = 48;
|
|
||||||
|
|
||||||
const CONFIG_REF_PREFIXES = [
|
|
||||||
{ prefix: "$SECRET_", label: "secret" },
|
|
||||||
{ prefix: "$CONTEXT_", label: "context" },
|
|
||||||
{ prefix: "$VAR_", label: "variable" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function describeConfigRef(value) {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
|
||||||
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
|
||||||
return { label, name: trimmed.slice(prefix.length), kind: prefix };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVarDisplay(value) {
|
|
||||||
if (typeof value === "string") return value === "" ? '""' : value;
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
|
|
||||||
if (!text) return "";
|
|
||||||
if (text.length <= maxLen) return text;
|
|
||||||
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
|
|
||||||
function lookupVariable(variables, owner, name) {
|
|
||||||
const list = Array.isArray(variables) ? variables : [];
|
|
||||||
const match = list.find((v) => v.owner === owner && v.name === name);
|
|
||||||
if (match) {
|
|
||||||
return { status: "found", value: match.value, type: match.type };
|
|
||||||
}
|
|
||||||
const otherOwners = [
|
|
||||||
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
|
|
||||||
];
|
|
||||||
if (otherOwners.length > 0) {
|
|
||||||
return { status: "other_owner", otherOwners };
|
|
||||||
}
|
|
||||||
return { status: "missing" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function variablesDeepLink({ owner, name, missing }) {
|
|
||||||
if (missing) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (owner) params.set("owner", owner);
|
|
||||||
if (name) params.set("name", name);
|
|
||||||
const q = params.toString();
|
|
||||||
return q ? `/variables/new?${q}` : "/variables/new";
|
|
||||||
}
|
|
||||||
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigRefHint({ value, owner }) {
|
|
||||||
const ref = describeConfigRef(value);
|
|
||||||
const isVar = ref?.kind === "$VAR_";
|
|
||||||
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRevealed(false);
|
|
||||||
}, [value, owner]);
|
|
||||||
|
|
||||||
if (!ref) return null;
|
|
||||||
|
|
||||||
if (!isVar) {
|
|
||||||
return (
|
|
||||||
<p className="text-xs opacity-60">
|
|
||||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = !owner
|
|
||||||
? { status: "missing" }
|
|
||||||
: isPending
|
|
||||||
? { status: "loading" }
|
|
||||||
: lookupVariable(variables, owner, ref.name);
|
|
||||||
const linkTo = variablesDeepLink({
|
|
||||||
owner: owner || "",
|
|
||||||
name: ref.name,
|
|
||||||
missing: lookup.status !== "found",
|
|
||||||
});
|
|
||||||
|
|
||||||
let statusNote = null;
|
|
||||||
if (lookup.status === "missing") {
|
|
||||||
statusNote = <span className="text-warning">(missing)</span>;
|
|
||||||
} else if (lookup.status === "other_owner") {
|
|
||||||
statusNote = (
|
|
||||||
<span className="text-warning">
|
|
||||||
(missing · other owner: {lookup.otherOwners.join(", ")})
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
|
|
||||||
const peek = truncatePeek(fullText);
|
|
||||||
const masked = lookup.status === "found" ? "******" : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
|
|
||||||
<span>
|
|
||||||
from variable <span className="font-mono">{ref.name}</span>
|
|
||||||
{lookup.status === "found" ? ":" : null}
|
|
||||||
</span>
|
|
||||||
{lookup.status === "found" ? (
|
|
||||||
<>
|
|
||||||
<span className="font-mono" title={revealed ? fullText : undefined}>
|
|
||||||
{revealed ? peek : masked}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
|
|
||||||
aria-label={revealed ? "Hide value" : "Show value"}
|
|
||||||
title={revealed ? "Hide value" : "Show value"}
|
|
||||||
onClick={() => setRevealed((v) => !v)}
|
|
||||||
>
|
|
||||||
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{statusNote}
|
|
||||||
{lookup.status === "loading" ? null : (
|
|
||||||
<Link
|
|
||||||
to={linkTo}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link link-hover inline-flex items-center gap-0.5"
|
|
||||||
>
|
|
||||||
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
|
|
||||||
<LuExternalLink className="size-3" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldSpec(meta, key) {
|
function fieldSpec(meta, key) {
|
||||||
const spec = meta?.config?.[key];
|
const spec = meta?.config?.[key];
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
|
||||||
|
import { useVariables } from "../../api/hooks.js";
|
||||||
|
|
||||||
|
const VAR_PEEK_MAX = 48;
|
||||||
|
|
||||||
|
const CONFIG_REF_PREFIXES = [
|
||||||
|
{ prefix: "$SECRET_", label: "secret" },
|
||||||
|
{ prefix: "$CONTEXT_", label: "context" },
|
||||||
|
{ prefix: "$VAR_", label: "variable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function describeConfigRef(value) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
||||||
|
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
||||||
|
return { label, name: trimmed.slice(prefix.length), kind: prefix };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVarDisplay(value) {
|
||||||
|
if (typeof value === "string") return value === "" ? '""' : value;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
|
||||||
|
function lookupVariable(variables, owner, name) {
|
||||||
|
const list = Array.isArray(variables) ? variables : [];
|
||||||
|
const match = list.find((v) => v.owner === owner && v.name === name);
|
||||||
|
if (match) {
|
||||||
|
return { status: "found", value: match.value, type: match.type };
|
||||||
|
}
|
||||||
|
const otherOwners = [
|
||||||
|
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
|
||||||
|
];
|
||||||
|
if (otherOwners.length > 0) {
|
||||||
|
return { status: "other_owner", otherOwners };
|
||||||
|
}
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function variablesDeepLink({ owner, name, missing }) {
|
||||||
|
if (missing) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (owner) params.set("owner", owner);
|
||||||
|
if (name) params.set("name", name);
|
||||||
|
const q = params.toString();
|
||||||
|
return q ? `/variables/new?${q}` : "/variables/new";
|
||||||
|
}
|
||||||
|
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigRefHint({ value, owner }) {
|
||||||
|
const ref = describeConfigRef(value);
|
||||||
|
const isVar = ref?.kind === "$VAR_";
|
||||||
|
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRevealed(false);
|
||||||
|
}, [value, owner]);
|
||||||
|
|
||||||
|
if (!ref) return null;
|
||||||
|
|
||||||
|
if (!isVar) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs opacity-60">
|
||||||
|
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = !owner
|
||||||
|
? { status: "missing" }
|
||||||
|
: isPending
|
||||||
|
? { status: "loading" }
|
||||||
|
: lookupVariable(variables, owner, ref.name);
|
||||||
|
const linkTo = variablesDeepLink({
|
||||||
|
owner: owner || "",
|
||||||
|
name: ref.name,
|
||||||
|
missing: lookup.status !== "found",
|
||||||
|
});
|
||||||
|
|
||||||
|
let statusNote = null;
|
||||||
|
if (lookup.status === "missing") {
|
||||||
|
statusNote = <span className="text-warning">(missing)</span>;
|
||||||
|
} else if (lookup.status === "other_owner") {
|
||||||
|
statusNote = (
|
||||||
|
<span className="text-warning">
|
||||||
|
(missing · other owner: {lookup.otherOwners.join(", ")})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
|
||||||
|
const peek = truncatePeek(fullText);
|
||||||
|
const masked = lookup.status === "found" ? "******" : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
|
||||||
|
<span>
|
||||||
|
from variable <span className="font-mono">{ref.name}</span>
|
||||||
|
{lookup.status === "found" ? ":" : null}
|
||||||
|
</span>
|
||||||
|
{lookup.status === "found" ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono" title={revealed ? fullText : undefined}>
|
||||||
|
{revealed ? peek : masked}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
|
||||||
|
aria-label={revealed ? "Hide value" : "Show value"}
|
||||||
|
title={revealed ? "Hide value" : "Show value"}
|
||||||
|
onClick={() => setRevealed((v) => !v)}
|
||||||
|
>
|
||||||
|
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{statusNote}
|
||||||
|
{lookup.status === "loading" ? null : (
|
||||||
|
<Link
|
||||||
|
to={linkTo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link link-hover inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
|
||||||
|
<LuExternalLink className="size-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { errorMessage } from "../../api/client.js";
|
|||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
||||||
import { LogViewer } from "../LogViewer.jsx";
|
import { LogViewer } from "../LogViewer.jsx";
|
||||||
import { StatusBadge } from "../../lib/format.jsx";
|
import { StatusBadge } from "../../lib/format";
|
||||||
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||||
import { ConfigFields } from "./ConfigFields.jsx";
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||||
@@ -390,7 +390,7 @@ function needsMode(needs) {
|
|||||||
return "map";
|
return "map";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||||
const existing = useScript(script);
|
const existing = useScript(script);
|
||||||
const dryRun = useDryRunScript();
|
const dryRun = useDryRunScript();
|
||||||
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
|
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu";
|
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import cronstrue from "cronstrue";
|
import cronstrue from "cronstrue";
|
||||||
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
|
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
|
||||||
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
||||||
import { FormInput, FormSelect } from "../FormControls.jsx";
|
import { FormInput, FormSelect } from "../FormControls.jsx";
|
||||||
|
import { AuthPicker } from "./AuthPicker.jsx";
|
||||||
|
|
||||||
export function TriggerCard({
|
export function TriggerCard({
|
||||||
trigger,
|
trigger,
|
||||||
@@ -253,164 +254,6 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Searchable dropdown: pick an auth to add; chips with X to remove.
|
|
||||||
* YAML stores profile UUIDs; UI shows names.
|
|
||||||
*/
|
|
||||||
function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const rootRef = useRef(null);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
||||||
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
|
|
||||||
|
|
||||||
const available = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return auths
|
|
||||||
.filter((a) => !selectedSet.has(a.id))
|
|
||||||
.filter((a) => {
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
a.name.toLowerCase().includes(q) ||
|
|
||||||
String(a.type).toLowerCase().includes(q) ||
|
|
||||||
a.id.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [auths, selectedSet, query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onDoc(e) {
|
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target)) {
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onDoc);
|
|
||||||
return () => document.removeEventListener("mousedown", onDoc);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function addAuth(id) {
|
|
||||||
if (selectedSet.has(id)) return;
|
|
||||||
onChange([...selectedIds, id]);
|
|
||||||
setQuery("");
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAuth(id) {
|
|
||||||
onChange(selectedIds.filter((x) => x !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-1" ref={rootRef}>
|
|
||||||
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
|
|
||||||
|
|
||||||
{selectedIds.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{selectedIds.map((id) => {
|
|
||||||
const auth = byId.get(id);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={id}
|
|
||||||
className="badge badge-outline gap-1 h-7 px-2 font-normal"
|
|
||||||
title={id}
|
|
||||||
>
|
|
||||||
<span className="max-w-[10rem] truncate">
|
|
||||||
{auth ? (
|
|
||||||
<>
|
|
||||||
{auth.name}
|
|
||||||
<span className="opacity-60"> · {auth.type}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="opacity-60">missing</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-xs btn-square -mr-1"
|
|
||||||
title="Remove"
|
|
||||||
aria-label={`Remove ${auth?.name ?? id}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => removeAuth(id)}
|
|
||||||
>
|
|
||||||
<LuX className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="relative w-full">
|
|
||||||
<div className="join w-full">
|
|
||||||
<FormInput
|
|
||||||
ref={inputRef}
|
|
||||||
type="search"
|
|
||||||
className="join-item w-full min-w-0"
|
|
||||||
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
|
|
||||||
value={query}
|
|
||||||
disabled={disabled || auths.length === 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
inputRef.current?.blur();
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (available[0]) addAuth(available[0].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm join-item btn-square"
|
|
||||||
disabled={disabled || auths.length === 0}
|
|
||||||
aria-label="Open auth list"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen((v) => !v);
|
|
||||||
if (!open) inputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{open && !disabled && auths.length > 0 ? (
|
|
||||||
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
|
|
||||||
{available.length === 0 ? (
|
|
||||||
<li className="disabled">
|
|
||||||
<span className="opacity-60">
|
|
||||||
{query.trim() ? "No matches" : "All profiles selected"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
available.map((a) => (
|
|
||||||
<li key={a.id}>
|
|
||||||
<button type="button" onClick={() => addAuth(a.id)}>
|
|
||||||
<span className="font-mono text-sm">{a.name}</span>
|
|
||||||
<span className="opacity-60 text-xs">{a.type}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inlineCount > 0 ? (
|
|
||||||
<span className="text-xs opacity-60">
|
|
||||||
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CronFields({ trigger, disabled, onChange, alertDestinations }) {
|
function CronFields({ trigger, disabled, onChange, alertDestinations }) {
|
||||||
const preset = matchCronPreset(trigger.schedule);
|
const preset = matchCronPreset(trigger.schedule);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useRevertWorkflowRevision,
|
useRevertWorkflowRevision,
|
||||||
useWorkflowRevisions,
|
useWorkflowRevisions,
|
||||||
} from "../../api/hooks.js";
|
} from "../../api/hooks.js";
|
||||||
import { formatTime } from "../../lib/format.jsx";
|
import { formatTime } from "../../lib/format";
|
||||||
import { useNotifications } from "../../notifications.jsx";
|
import { useNotifications } from "../../notifications.jsx";
|
||||||
import {
|
import {
|
||||||
SaveWorkflowWarningsDialog,
|
SaveWorkflowWarningsDialog,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { LuHistory } from "react-icons/lu";
|
import { LuHistory } from "react-icons/lu";
|
||||||
import { errorMessage } from "../../api/client.js";
|
import { errorMessage } from "../../api/client.js";
|
||||||
import { useRevertWorkflowRevision } from "../../api/hooks.js";
|
import { useRevertWorkflowRevision } from "../../api/hooks.js";
|
||||||
import { formatTime } from "../../lib/format.jsx";
|
import { formatTime } from "../../lib/format";
|
||||||
import { useNotifications } from "../../notifications.jsx";
|
import { useNotifications } from "../../notifications.jsx";
|
||||||
import { ConfirmDialog } from "../ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../ConfirmDialog.jsx";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
|||||||
import { api, errorMessage } from "../../api/client.js";
|
import { api, errorMessage } from "../../api/client.js";
|
||||||
import { useRunWorkflow } from "../../api/hooks.js";
|
import { useRunWorkflow } from "../../api/hooks.js";
|
||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
import { StatusBadge } from "../../lib/format.jsx";
|
import { StatusBadge } from "../../lib/format";
|
||||||
import { prettyJson } from "../../lib/script.js";
|
import { prettyJson } from "../../lib/script.js";
|
||||||
import {
|
import {
|
||||||
overlayWorkflowTestData,
|
overlayWorkflowTestData,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { TriggersTab } from "./TriggersTab.jsx";
|
|||||||
import { YamlTab } from "./YamlTab.jsx";
|
import { YamlTab } from "./YamlTab.jsx";
|
||||||
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
|
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
|
||||||
|
|
||||||
export function useYamlPreview(content) {
|
function useYamlPreview(content) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
try {
|
try {
|
||||||
const parsedYaml = parseYaml(content);
|
const parsedYaml = parseYaml(content);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { formatBytes, formatCpu, formatDuration } from "./numbers.js";
|
||||||
|
export { formatTime, StatusBadge, WorkflowStatusBadge, levelName } from "./badges.jsx";
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { AuthEditorModal } from "../components/AuthEditorModal.jsx";
|
import { AuthEditorModal } from "../components/AuthEditorModal.jsx";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) {
|
function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
|||||||
import { useRun } from "../api/hooks.js";
|
import { useRun } from "../api/hooks.js";
|
||||||
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
|
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
|
||||||
import { LogViewer } from "../components/LogViewer.jsx";
|
import { LogViewer } from "../components/LogViewer.jsx";
|
||||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
import { formatTime, StatusBadge } from "../lib/format";
|
||||||
|
|
||||||
function stepLabel(s) {
|
function stepLabel(s) {
|
||||||
if (s.script === "set") return "set";
|
if (s.script === "set") return "set";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useOwners, useRuns } from "../api/hooks.js";
|
import { useOwners, useRuns } from "../api/hooks.js";
|
||||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
import { formatTime, StatusBadge } from "../lib/format";
|
||||||
|
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
const DEFAULT_LIMIT = 50;
|
const DEFAULT_LIMIT = 50;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useConsecutiveFailures } from "../api/hooks.js";
|
import { useConsecutiveFailures } from "../api/hooks.js";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
export function FailuresPage() {
|
export function FailuresPage() {
|
||||||
const { data, isLoading, error } = useConsecutiveFailures();
|
const { data, isLoading, error } = useConsecutiveFailures();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
LuTriangleAlert,
|
LuTriangleAlert,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { useDashboard } from "../api/hooks.js";
|
import { useDashboard } from "../api/hooks.js";
|
||||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
import { formatTime, StatusBadge } from "../lib/format";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { data, isLoading, error } = useDashboard();
|
const { data, isLoading, error } = useDashboard();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useKv, useKvNamespaces } from "../api/hooks.js";
|
import { useKv, useKvNamespaces } from "../api/hooks.js";
|
||||||
import { FormSelect } from "../components/FormControls.jsx";
|
import { FormSelect } from "../components/FormControls.jsx";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Modal } from "../components/Modal.jsx";
|
|||||||
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
|
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
|
||||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
function previewConfig(config) {
|
function previewConfig(config) {
|
||||||
if (!config || typeof config !== "object") return "";
|
if (!config || typeof config !== "object") return "";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
useUpsertHttpPage,
|
useUpsertHttpPage,
|
||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
name: "",
|
name: "",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx";
|
|||||||
import { LogViewer } from "../components/LogViewer.jsx";
|
import { LogViewer } from "../components/LogViewer.jsx";
|
||||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||||
import { StatusBadge } from "../lib/format.jsx";
|
import { StatusBadge } from "../lib/format";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INPUT_CONTEXT,
|
DEFAULT_INPUT_CONTEXT,
|
||||||
NEW_SCRIPT_TEMPLATE,
|
NEW_SCRIPT_TEMPLATE,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
|||||||
import { FormSelect } from "../components/FormControls.jsx";
|
import { FormSelect } from "../components/FormControls.jsx";
|
||||||
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
|
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
|
||||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
export function SecretsPage() {
|
export function SecretsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
|||||||
import { FormSelect } from "../components/FormControls.jsx";
|
import { FormSelect } from "../components/FormControls.jsx";
|
||||||
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
|
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
|
||||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
function displayValue(value) {
|
function displayValue(value) {
|
||||||
if (typeof value === "string") return value === "" ? '""' : value;
|
if (typeof value === "string") return value === "" ? '""' : value;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useWorkflowTrash,
|
useWorkflowTrash,
|
||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||||
import { formatTime } from "../lib/format.jsx";
|
import { formatTime } from "../lib/format";
|
||||||
import { useNotifications } from "../notifications.jsx";
|
import { useNotifications } from "../notifications.jsx";
|
||||||
|
|
||||||
function formatAge(ms) {
|
function formatAge(ms) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||||
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
|
import { formatTime, WorkflowStatusBadge } from "../lib/format";
|
||||||
|
|
||||||
export function WorkflowsPage() {
|
export function WorkflowsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
Reference in New Issue
Block a user