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} */
|
||||
export function defaultControlState() {
|
||||
function defaultControlState() {
|
||||
return {
|
||||
http: "running",
|
||||
workers: 1,
|
||||
|
||||
@@ -1,390 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertHttpStatus } from "./http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthId(id) {
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||
const err = new Error("invalid auth id");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthName(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
const err = new Error("invalid auth name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"bearer" | "basic" | "header"}
|
||||
*/
|
||||
export function assertAuthType(type) {
|
||||
const t = String(type ?? "");
|
||||
if (!ALLOWED_TYPES.has(t)) {
|
||||
const err = new Error('auth type must be "bearer", "basic", or "header"');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return /** @type {"bearer" | "basic" | "header"} */ (t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect value source without exposing literal values.
|
||||
* @param {unknown} value
|
||||
* @returns {"literal" | "kv" | "secret" | "missing"}
|
||||
*/
|
||||
export function valueSourceKind(value) {
|
||||
if (value == null) return "missing";
|
||||
if (typeof value === "string") return "literal";
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if ("secret" in value) return "secret";
|
||||
if ("kv" in value) return "kv";
|
||||
}
|
||||
return "literal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact config for API responses: replace literal strings with source markers.
|
||||
* @param {Record<string, unknown>} config
|
||||
* @param {string} type
|
||||
*/
|
||||
export function publicConfig(config, type) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
if (type === "bearer") {
|
||||
out.token = redactField(config.token);
|
||||
} else if (type === "basic") {
|
||||
out.user = redactField(config.user);
|
||||
out.password = redactField(config.password);
|
||||
} else if (type === "header") {
|
||||
out.header = typeof config.header === "string" ? config.header : null;
|
||||
out.value = redactField(config.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function redactField(value) {
|
||||
const kind = valueSourceKind(value);
|
||||
if (kind === "missing") return { source: "missing" };
|
||||
if (kind === "kv") {
|
||||
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
|
||||
return {
|
||||
source: "kv",
|
||||
kv: v.kv,
|
||||
...(v.namespace != null ? { namespace: v.namespace } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === "secret") {
|
||||
const v = /** @type {{ secret: string }} */ (value);
|
||||
return { source: "secret", secret: v.secret };
|
||||
}
|
||||
return { source: "literal", set: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize auth config for storage.
|
||||
* @param {string} type
|
||||
* @param {unknown} config
|
||||
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
|
||||
*/
|
||||
export function normalizeAuthConfig(type, config, opts = {}) {
|
||||
const raw = config && typeof config === "object" && !Array.isArray(config)
|
||||
? /** @type {Record<string, unknown>} */ (config)
|
||||
: {};
|
||||
const keep = opts.keepLiteralsFrom ?? {};
|
||||
|
||||
if (type === "bearer") {
|
||||
return {
|
||||
token: normalizeCredentialField(raw.token, keep.token, "token"),
|
||||
};
|
||||
}
|
||||
if (type === "basic") {
|
||||
return {
|
||||
user: normalizeCredentialField(raw.user, keep.user, "user"),
|
||||
password: normalizeCredentialField(raw.password, keep.password, "password", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// header
|
||||
if (typeof raw.header !== "string" || raw.header.length === 0) {
|
||||
const err = new Error("header name must be a non-empty string");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
header: raw.header,
|
||||
value: normalizeCredentialField(raw.value, keep.value, "value"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {unknown} previous
|
||||
* @param {string} label
|
||||
* @param {{ allowEmpty?: boolean }} [opts]
|
||||
*/
|
||||
function normalizeCredentialField(value, previous, label, opts = {}) {
|
||||
// Explicit "keep previous literal" marker from UI when editing without re-entering
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
/** @type {{ keep?: boolean }} */ (value).keep === true
|
||||
) {
|
||||
if (typeof previous === "string") return previous;
|
||||
if (previous && typeof previous === "object") return previous;
|
||||
const err = new Error(`${label} was not previously set`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (value == null || value === "") {
|
||||
if (opts.allowEmpty && value === "") return "";
|
||||
// Allow empty password for basic
|
||||
if (opts.allowEmpty && (value === "" || value == null)) {
|
||||
if (typeof previous === "string") return previous;
|
||||
return "";
|
||||
}
|
||||
const err = new Error(`${label} is required`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const v = /** @type {Record<string, unknown>} */ (value);
|
||||
if (typeof v.secret === "string" && v.secret.length > 0) {
|
||||
return { secret: v.secret };
|
||||
}
|
||||
if (typeof v.kv === "string" && v.kv.length > 0) {
|
||||
/** @type {{ kv: string, namespace?: string }} */
|
||||
const out = { kv: v.kv };
|
||||
if (typeof v.namespace === "string" && v.namespace.length > 0) {
|
||||
out.namespace = v.namespace;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function parseConfig(raw) {
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function publicAuth(row, { includeConfig = true } = {}) {
|
||||
const type = row.type;
|
||||
const config = parseConfig(row.config);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type,
|
||||
...(includeConfig ? { config: publicConfig(config, type) } : {}),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: full config including literals (for runtime auth checks).
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthInternal(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: parseConfig(row.config),
|
||||
unauthorized_status: row.unauthorized_status ?? null,
|
||||
unauthorized_response: row.unauthorized_response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||
* @param {string} id
|
||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function revealHttpAuthLiterals(id) {
|
||||
const internal = await getHttpAuthInternal(id);
|
||||
if (!internal) return null;
|
||||
/** @type {Record<string, string>} */
|
||||
const literals = {};
|
||||
const cfg = internal.config ?? {};
|
||||
for (const key of ["token", "user", "password", "value"]) {
|
||||
const v = cfg[key];
|
||||
if (typeof v === "string") literals[key] = v;
|
||||
}
|
||||
return {
|
||||
id: internal.id,
|
||||
name: internal.name,
|
||||
type: internal.type,
|
||||
literals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHttpAuths() {
|
||||
const rows = await db("http_auths").select("*").orderBy("name", "asc");
|
||||
return rows.map((r) => publicAuth(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthById(id) {
|
||||
let authId;
|
||||
try {
|
||||
authId = assertAuthId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id?: string | null,
|
||||
* name: string,
|
||||
* type: string,
|
||||
* config?: unknown,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertHttpAuth({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
unauthorized_status,
|
||||
unauthorized_response,
|
||||
}) {
|
||||
const authName = assertAuthName(name);
|
||||
const authType = assertAuthType(type);
|
||||
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
let existing = null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
const authId = assertAuthId(id);
|
||||
existing = await db("http_auths").where({ id: authId }).first();
|
||||
if (!existing) {
|
||||
const err = new Error("auth not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||
const err = new Error(`auth name "${authName}" already exists`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||
const normalized = normalizeAuthConfig(authType, config, {
|
||||
keepLiteralsFrom: prevConfig,
|
||||
});
|
||||
|
||||
let unauthStatus = null;
|
||||
if (unauthorized_status != null && unauthorized_status !== "") {
|
||||
unauthStatus = assertHttpStatus(unauthorized_status, 401);
|
||||
}
|
||||
let unauthResponse = null;
|
||||
if (
|
||||
unauthorized_response != null &&
|
||||
String(unauthorized_response).length > 0
|
||||
) {
|
||||
unauthResponse = String(unauthorized_response);
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const configJson = JSON.stringify(normalized);
|
||||
|
||||
if (existing) {
|
||||
await db("http_auths")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||
}
|
||||
|
||||
const newId = randomUUID();
|
||||
await db("http_auths").insert({
|
||||
id: newId,
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(newId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteHttpAuth(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const n = await db("http_auths").where({ id: authId }).del();
|
||||
return n > 0;
|
||||
}
|
||||
export * from "./src/stores/http-auths-store.js";
|
||||
|
||||
+1
-399
@@ -1,399 +1 @@
|
||||
import { db } from "./db.js";
|
||||
|
||||
const MAX_KEY_LENGTH = 512;
|
||||
const MAX_NAMESPACE_LENGTH = 512;
|
||||
const MAX_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
const MAX_LIST_LIMIT = 500;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function valuesEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function assertString(label, value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
* @param {number} max
|
||||
*/
|
||||
function assertMaxLength(label, value, max) {
|
||||
if (value.length > max) {
|
||||
throw new Error(`${label} must be at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
*/
|
||||
function assertNamespace(namespace) {
|
||||
assertString("namespace", namespace);
|
||||
assertMaxLength("namespace", namespace, MAX_NAMESPACE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function assertKey(key) {
|
||||
assertString("key", key);
|
||||
assertMaxLength("key", key, MAX_KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeKvValue(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw new Error("value must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf8") > MAX_VALUE_BYTES) {
|
||||
throw new Error(`value exceeds ${MAX_VALUE_BYTES} byte limit`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function deserializeKvValue(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ expires_at?: string | null }} row
|
||||
*/
|
||||
function isExpired(row) {
|
||||
if (!row.expires_at) return false;
|
||||
return Date.parse(row.expires_at) <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export async function kvGet(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const row = await db("script_state").where({ namespace, key }).first();
|
||||
if (!row) return null;
|
||||
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key }).del();
|
||||
return null;
|
||||
}
|
||||
|
||||
return deserializeKvValue(row.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
export async function kvSet(namespace, key, value, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
const json = serializeKvValue(value);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
await db("script_state")
|
||||
.insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
})
|
||||
.onConflict(["namespace", "key"])
|
||||
.merge({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function kvDelete(namespace, key) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
const deleted = await db("script_state").where({ namespace, key }).del();
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ expiresAt?: string | Date | null }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, previous: unknown }>}
|
||||
*/
|
||||
export async function kvCompareAndSet(namespace, key, expected, next, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
assertKey(key);
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx("script_state").where({ namespace, key }).first();
|
||||
|
||||
if (row && isExpired(row)) {
|
||||
await trx("script_state").where({ namespace, key }).del();
|
||||
}
|
||||
|
||||
const currentRow =
|
||||
row && !isExpired(row)
|
||||
? row
|
||||
: await trx("script_state").where({ namespace, key }).first();
|
||||
const previous = currentRow ? deserializeKvValue(currentRow.value) : null;
|
||||
|
||||
if (!valuesEqual(previous, expected)) {
|
||||
return { ok: false, previous };
|
||||
}
|
||||
|
||||
const json = serializeKvValue(next);
|
||||
const updated_at = nowIso();
|
||||
let expires_at = null;
|
||||
if (opts.expiresAt != null) {
|
||||
expires_at =
|
||||
opts.expiresAt instanceof Date
|
||||
? opts.expiresAt.toISOString()
|
||||
: String(opts.expiresAt);
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await trx("script_state").where({ namespace, key }).update({
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
} else {
|
||||
await trx("script_state").insert({
|
||||
namespace,
|
||||
key,
|
||||
value: json,
|
||||
updated_at,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, previous };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} namespace
|
||||
* @param {{ limit?: number }} [opts]
|
||||
*/
|
||||
export async function kvList(namespace, opts = {}) {
|
||||
assertNamespace(namespace);
|
||||
const limit = Math.min(
|
||||
Math.max(opts.limit ?? DEFAULT_LIST_LIMIT, 1),
|
||||
MAX_LIST_LIMIT,
|
||||
);
|
||||
|
||||
const rows = await db("script_state")
|
||||
.where({ namespace })
|
||||
.orderBy("updated_at", "desc")
|
||||
.limit(limit);
|
||||
|
||||
const items = [];
|
||||
for (const row of rows) {
|
||||
if (isExpired(row)) {
|
||||
await db("script_state").where({ namespace, key: row.key }).del();
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function escapeLike(value) {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
async function pruneExpiredKv() {
|
||||
await db("script_state")
|
||||
.whereNotNull("expires_at")
|
||||
.andWhere("expires_at", "<=", nowIso())
|
||||
.del();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* namespace?: string,
|
||||
* q?: string,
|
||||
* limit?: number,
|
||||
* offset?: number,
|
||||
* }} [opts]
|
||||
*/
|
||||
export async function kvQuery(opts = {}) {
|
||||
await pruneExpiredKv();
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
const offset = Math.max(Number(opts.offset) || 0, 0);
|
||||
|
||||
let q = db("script_state");
|
||||
if (opts.namespace) {
|
||||
assertNamespace(opts.namespace);
|
||||
q = q.where({ namespace: opts.namespace });
|
||||
}
|
||||
if (typeof opts.q === "string" && opts.q.length > 0) {
|
||||
const like = `%${escapeLike(opts.q)}%`;
|
||||
q = q.where(function likeSearch() {
|
||||
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
|
||||
"value LIKE ? ESCAPE '\\'",
|
||||
[like],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const countRow = await q.clone().count({ count: "*" }).first();
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
const rows = await q
|
||||
.clone()
|
||||
.orderBy("updated_at", "desc")
|
||||
.orderBy("namespace", "asc")
|
||||
.orderBy("key", "asc")
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
namespace: row.namespace,
|
||||
key: row.key,
|
||||
value: deserializeKvValue(row.value),
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function kvNamespaces() {
|
||||
await pruneExpiredKv();
|
||||
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
|
||||
return rows.map((row) => row.namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultNamespace
|
||||
*/
|
||||
export function createKvApi(defaultNamespace) {
|
||||
assertNamespace(defaultNamespace);
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
function resolveNamespace(opts = {}) {
|
||||
const namespace = opts.namespace ?? defaultNamespace;
|
||||
assertNamespace(namespace);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: defaultNamespace,
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
get(key, opts) {
|
||||
return kvGet(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} value
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
set(key, value, opts) {
|
||||
const { namespace, expiresAt } = opts ?? {};
|
||||
return kvSet(resolveNamespace(opts), key, value, { expiresAt });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {{ namespace?: string }} [opts]
|
||||
*/
|
||||
delete(key, opts) {
|
||||
return kvDelete(resolveNamespace(opts), key);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {unknown} expected
|
||||
* @param {unknown} next
|
||||
* @param {{ namespace?: string, expiresAt?: string | Date | null }} [opts]
|
||||
*/
|
||||
compareAndSet(key, expected, next, opts) {
|
||||
const { expiresAt } = opts ?? {};
|
||||
return kvCompareAndSet(resolveNamespace(opts), key, expected, next, {
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ namespace?: string, limit?: number }} [opts]
|
||||
*/
|
||||
list(opts) {
|
||||
const { namespace, limit } = opts ?? {};
|
||||
return kvList(resolveNamespace(opts), { limit });
|
||||
},
|
||||
};
|
||||
}
|
||||
export * from "./src/stores/kv-store.js";
|
||||
|
||||
@@ -1,244 +1 @@
|
||||
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;
|
||||
}
|
||||
export * from "./src/stores/profiles-store.js";
|
||||
|
||||
+17
-120
@@ -23,15 +23,13 @@ import {
|
||||
storedEnvelope,
|
||||
} from "./step-result.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
import {
|
||||
checkAnyHttpAuth,
|
||||
resolveAuthMechanisms,
|
||||
resolveUnauthorizedSpec,
|
||||
sendHttpPageOrJson,
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
import { 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 {
|
||||
buildFailureAlertData,
|
||||
@@ -69,7 +67,14 @@ export function createRegistry(server, opts = {}) {
|
||||
let pruneTask = null;
|
||||
/** @type {Map<string, HttpRouteEntry>} */
|
||||
const httpRoutes = new Map();
|
||||
let httpDispatcherRegistered = false;
|
||||
const httpDispatcherState = { registered: false };
|
||||
const dispatchHttpTrigger = createHttpTriggerHandler({
|
||||
httpRoutes,
|
||||
workflows,
|
||||
namespacedPath,
|
||||
enqueueWorkflow: (...args) => enqueueWorkflow(...args),
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a same-owner workflow that opts in with `type: workflow`.
|
||||
@@ -175,118 +180,10 @@ export function createRegistry(server, opts = {}) {
|
||||
* Fastify route once so path/method changes apply on reregister without restart.
|
||||
*/
|
||||
function registerHttpTriggers() {
|
||||
httpRoutes.clear();
|
||||
|
||||
for (const [key, { owner, workflow }] of workflows) {
|
||||
if (workflow.enabled === false) {
|
||||
log.debug(`Skipping disabled workflow HTTP triggers (${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const trigger of workflow.triggers ?? []) {
|
||||
if (trigger.type !== "HTTP") continue;
|
||||
|
||||
const method = String(trigger.method ?? "POST").toUpperCase();
|
||||
const url = namespacedPath(owner, trigger.path);
|
||||
const routeKey = `${method} ${url}`;
|
||||
|
||||
if (httpRoutes.has(routeKey)) {
|
||||
log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`);
|
||||
continue;
|
||||
}
|
||||
httpRoutes.set(routeKey, { key, owner, trigger });
|
||||
log.debug(`Mapped HTTP trigger ${routeKey} (${key})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!httpDispatcherRegistered) {
|
||||
httpDispatcherRegistered = true;
|
||||
server.route({
|
||||
method: HTTP_METHODS,
|
||||
url: "/u/*",
|
||||
handler: dispatchHttpTrigger,
|
||||
rebuildHttpRoutes(workflows, httpRoutes, { namespacedPath, log });
|
||||
ensureHttpWildcardRoute(server, dispatchHttpTrigger, httpDispatcherState, {
|
||||
log,
|
||||
});
|
||||
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() {
|
||||
|
||||
@@ -486,7 +486,7 @@ const inspectLog = pino({ level: "silent" });
|
||||
* @param {unknown} fn
|
||||
* @returns {{ meta: Record<string, unknown> | null, metaError: string | null }}
|
||||
*/
|
||||
export function extractScriptMeta(fn) {
|
||||
function extractScriptMeta(fn) {
|
||||
if (typeof fn !== "function") {
|
||||
return { meta: null, metaError: "default export must be a function" };
|
||||
}
|
||||
|
||||
@@ -1,144 +1 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
const err = new Error("value is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
export * from "./src/stores/secrets-store.js";
|
||||
|
||||
@@ -25,7 +25,7 @@ let cachedKey = null;
|
||||
/**
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function getMasterKey() {
|
||||
function getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
const raw = resolveSecretsKeyMaterial();
|
||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||
|
||||
@@ -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";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_STRING_BYTES = 64 * 1024;
|
||||
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertVariableName(name) {
|
||||
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid variable name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} type
|
||||
* @returns {"string" | "number" | "boolean"}
|
||||
*/
|
||||
export function assertVariableType(type) {
|
||||
if (type !== "string" && type !== "number" && type !== "boolean") {
|
||||
throw httpError("type must be string, number, or boolean");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeVariableValue(type, value) {
|
||||
if (type === "string") {
|
||||
if (typeof value !== "string") {
|
||||
throw httpError("value must be a string");
|
||||
}
|
||||
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
|
||||
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === "number") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw httpError("value must be a finite number");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw httpError("value must be a boolean");
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"string" | "number" | "boolean"} type
|
||||
* @param {string} stored
|
||||
* @returns {string | number | boolean}
|
||||
*/
|
||||
export function decodeVariableValue(type, stored) {
|
||||
if (type === "string") return stored;
|
||||
if (type === "number") {
|
||||
const n = Number(stored);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicVariable(row) {
|
||||
const type = assertVariableType(row.type);
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type,
|
||||
value: decodeVariableValue(type, String(row.value ?? "")),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listVariables(filters = {}) {
|
||||
let q = db("variables")
|
||||
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicVariable(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getVariableById(id) {
|
||||
const row = await db("variables").where({ id }).first();
|
||||
return row ? publicVariable(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
|
||||
*/
|
||||
export async function upsertVariable({ owner, name, type, value }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const variableType = assertVariableType(type);
|
||||
const encoded = encodeVariableValue(variableType, value);
|
||||
const now = nowIso();
|
||||
const existing = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("variables")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("variables").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: variableName,
|
||||
type: variableType,
|
||||
value: encoded,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getVariableById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteVariable(id) {
|
||||
const n = await db("variables").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed primitive for an owner/name. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | number | boolean | null>}
|
||||
*/
|
||||
export async function getVariablePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const variableName = assertVariableName(name);
|
||||
const row = await db("variables")
|
||||
.where({ owner: ownerName, name: variableName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
|
||||
}
|
||||
export * from "./src/stores/variables-store.js";
|
||||
|
||||
@@ -0,0 +1,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
|
||||
*/
|
||||
export function trashAgeMs(deletedAtIso) {
|
||||
function trashAgeMs(deletedAtIso) {
|
||||
return Date.now() - Date.parse(deletedAtIso);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,761 +1 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, opsApi } from "./client.js";
|
||||
|
||||
export function useBootstrap() {
|
||||
return useQuery({
|
||||
queryKey: ["bootstrap"],
|
||||
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await api.get("/auth/me")).data;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) return { user: null };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["me"], data);
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["me"], data);
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await api.post("/auth/logout")).data,
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(["me"], null);
|
||||
qc.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: async () => (await api.get("/dashboard")).data,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.running?.length ? 4000 : 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScripts() {
|
||||
return useQuery({
|
||||
queryKey: ["scripts"],
|
||||
queryFn: async () => (await api.get("/scripts")).data.scripts,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScript(name, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["scripts", name],
|
||||
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
|
||||
enabled: Boolean(name) && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveScript() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, content }) =>
|
||||
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, content, description }) =>
|
||||
(await api.post("/plugins/create", { id, content, description })).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useForkScript() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, id, description }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
|
||||
id,
|
||||
description,
|
||||
})
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallPlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteScript() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (name) =>
|
||||
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDryRunScript() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, content, data, context, config, owner }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
||||
content,
|
||||
data,
|
||||
context,
|
||||
config,
|
||||
owner,
|
||||
})
|
||||
).data,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflows(owner) {
|
||||
return useQuery({
|
||||
queryKey: ["workflows", owner ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = owner ? { owner } : {};
|
||||
return (await api.get("/workflows", { params })).data.workflows;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflow(owner, file, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["workflows", owner, file],
|
||||
queryFn: async () =>
|
||||
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
|
||||
.data,
|
||||
enabled: Boolean(owner && file) && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOwners() {
|
||||
return useQuery({
|
||||
queryKey: ["owners"],
|
||||
queryFn: async () => (await api.get("/owners")).data.owners,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, content, saveAnyway }) =>
|
||||
(
|
||||
await api.put(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
|
||||
)
|
||||
).data,
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetWorkflowEnabled() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, enabled }) =>
|
||||
(
|
||||
await api.patch(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||
{ enabled },
|
||||
)
|
||||
).data,
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file }) =>
|
||||
(
|
||||
await api.delete(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
|
||||
)
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, destOwner, destFile }) =>
|
||||
(
|
||||
await api.post(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
|
||||
{
|
||||
...(destOwner ? { owner: destOwner } : {}),
|
||||
...(destFile ? { file: destFile } : {}),
|
||||
},
|
||||
)
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunWorkflow() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ owner, file, data }) =>
|
||||
(
|
||||
await api.post(
|
||||
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
|
||||
data !== undefined ? { data } : {},
|
||||
)
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["runs"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReregisterWorkflows() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await api.post("/workflows/reregister")).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["owners"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRuns(filters = {}) {
|
||||
const { owner, workflow, status, 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from "./hooks/index.js";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../client.js";
|
||||
|
||||
export function useBootstrap() {
|
||||
return useQuery({
|
||||
queryKey: ["bootstrap"],
|
||||
queryFn: async () => (await api.get("/auth/bootstrap")).data,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await api.get("/auth/me")).data;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) return { user: null };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["me"], data);
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["me"], data);
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await api.post("/auth/logout")).data,
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(["me"], null);
|
||||
qc.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => (await api.get("/users")).data.users,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.post("/users", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...body }) =>
|
||||
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../client.js";
|
||||
|
||||
export function useHttpPages() {
|
||||
return useQuery({
|
||||
queryKey: ["http-pages"],
|
||||
queryFn: async () => (await api.get("/http-pages")).data.pages,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertHttpPage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteHttpPage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useHttpAuths() {
|
||||
return useQuery({
|
||||
queryKey: ["http-auths"],
|
||||
queryFn: async () => (await api.get("/http-auths")).data.auths,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertHttpAuth() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteHttpAuth() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||
|
||||
/** Fetch plaintext literals only (not encrypted secrets). */
|
||||
export async function fetchHttpAuthLiterals(id) {
|
||||
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from "./auth.js";
|
||||
export * from "./scripts.js";
|
||||
export * from "./workflows.js";
|
||||
export * from "./runs.js";
|
||||
export * from "./secrets.js";
|
||||
export * from "./variables.js";
|
||||
export * from "./profiles.js";
|
||||
export * from "./kv.js";
|
||||
export * from "./http.js";
|
||||
export * from "./ops.js";
|
||||
@@ -0,0 +1,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 { HeartbeatsToolbar } from "./HeartbeatsToolbar.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;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { levelName } from "../lib/format.jsx";
|
||||
import { levelName } from "../lib/format";
|
||||
|
||||
export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) {
|
||||
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 }) {
|
||||
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 { Link } from "react-router-dom";
|
||||
import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { useVariables } from "../../api/hooks.js";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
|
||||
import { FieldLabel } from "./FieldHelp.jsx";
|
||||
import { ConfigRefHint } from "./ConfigRefHint.jsx";
|
||||
|
||||
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) {
|
||||
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 { FormInput, FormSelect, FormTextarea } from "../FormControls.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 { ConfigFields } from "./ConfigFields.jsx";
|
||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||
@@ -390,7 +390,7 @@ function needsMode(needs) {
|
||||
return "map";
|
||||
}
|
||||
|
||||
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||
const existing = useScript(script);
|
||||
const dryRun = useDryRunScript();
|
||||
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import cronstrue from "cronstrue";
|
||||
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
|
||||
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
||||
import { FormInput, FormSelect } from "../FormControls.jsx";
|
||||
import { AuthPicker } from "./AuthPicker.jsx";
|
||||
|
||||
export function TriggerCard({
|
||||
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 }) {
|
||||
const preset = matchCronPreset(trigger.schedule);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useRevertWorkflowRevision,
|
||||
useWorkflowRevisions,
|
||||
} from "../../api/hooks.js";
|
||||
import { formatTime } from "../../lib/format.jsx";
|
||||
import { formatTime } from "../../lib/format";
|
||||
import { useNotifications } from "../../notifications.jsx";
|
||||
import {
|
||||
SaveWorkflowWarningsDialog,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { LuHistory } from "react-icons/lu";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
import { useRevertWorkflowRevision } from "../../api/hooks.js";
|
||||
import { formatTime } from "../../lib/format.jsx";
|
||||
import { formatTime } from "../../lib/format";
|
||||
import { useNotifications } from "../../notifications.jsx";
|
||||
import { ConfirmDialog } from "../ConfirmDialog.jsx";
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||
import { api, errorMessage } from "../../api/client.js";
|
||||
import { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
import { StatusBadge } from "../../lib/format";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import {
|
||||
overlayWorkflowTestData,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TriggersTab } from "./TriggersTab.jsx";
|
||||
import { YamlTab } from "./YamlTab.jsx";
|
||||
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
|
||||
|
||||
export function useYamlPreview(content) {
|
||||
function useYamlPreview(content) {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
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 { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
||||
import { useRun } from "../api/hooks.js";
|
||||
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||
import { formatTime, StatusBadge } from "../lib/format";
|
||||
|
||||
function stepLabel(s) {
|
||||
if (s.script === "set") return "set";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
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 DEFAULT_LIMIT = 50;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useConsecutiveFailures } from "../api/hooks.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
export function FailuresPage() {
|
||||
const { data, isLoading, error } = useConsecutiveFailures();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
LuTriangleAlert,
|
||||
} from "react-icons/lu";
|
||||
import { useDashboard } from "../api/hooks.js";
|
||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||
import { formatTime, StatusBadge } from "../lib/format";
|
||||
|
||||
export function HomePage() {
|
||||
const { data, isLoading, error } = useDashboard();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useKv, useKvNamespaces } from "../api/hooks.js";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Modal } from "../components/Modal.jsx";
|
||||
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
function previewConfig(config) {
|
||||
if (!config || typeof config !== "object") return "";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useUpsertHttpPage,
|
||||
} from "../api/hooks.js";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { LogViewer } from "../components/LogViewer.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { StatusBadge } from "../lib/format.jsx";
|
||||
import { StatusBadge } from "../lib/format";
|
||||
import {
|
||||
DEFAULT_INPUT_CONTEXT,
|
||||
NEW_SCRIPT_TEMPLATE,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
export function SecretsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { FormSelect } from "../components/FormControls.jsx";
|
||||
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
|
||||
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
|
||||
function displayValue(value) {
|
||||
if (typeof value === "string") return value === "" ? '""' : value;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useWorkflowTrash,
|
||||
} from "../api/hooks.js";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
import { formatTime } from "../lib/format";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
function formatAge(ms) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
|
||||
import { formatTime, WorkflowStatusBadge } from "../lib/format";
|
||||
|
||||
export function WorkflowsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
Reference in New Issue
Block a user