diff --git a/packages/server/config-refs.js b/packages/server/config-refs.js index 7ab8aea..aa3c81b 100644 --- a/packages/server/config-refs.js +++ b/packages/server/config-refs.js @@ -1,17 +1,16 @@ import { coerceCredentialString } from "./http-trigger-auth.js"; -import { kvGet } from "./kv-store.js"; import { assertSecretName, getSecretPlaintext } from "./secrets-store.js"; import { isSecret } from "./secret-value.js"; +import { assertVariableName, getVariablePlain } from "./variables-store.js"; -/** Longest prefix first so `$CONTEXT_` is not confused with `$KV_`. */ const PREFIXES = [ { kind: "context", prefix: "$CONTEXT_" }, { kind: "secret", prefix: "$SECRET_" }, - { kind: "kv", prefix: "$KV_" }, + { kind: "var", prefix: "$VAR_" }, ]; /** - * @typedef {{ kind: "secret" | "kv" | "context", name: string, raw: string }} ConfigRef + * @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef * @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx */ @@ -32,7 +31,7 @@ export function parseConfigRef(value) { } /** - * Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$KV_` / `$CONTEXT_` + * Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_` * strings. Does not walk trigger data. * * @param {unknown} value @@ -69,7 +68,7 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) { /** * @param {string} value * @param {ConfigRefCtx} ctx - * @returns {Promise} + * @returns {Promise} */ async function resolveStringRef(value, ctx) { const ref = parseConfigRef(value); @@ -78,8 +77,8 @@ async function resolveStringRef(value, ctx) { if (ref.kind === "secret") { return resolveSecretRef(ref, ctx); } - if (ref.kind === "kv") { - return resolveKvRef(ref, ctx); + if (ref.kind === "var") { + return resolveVarRef(ref, ctx); } return resolveContextRef(ref, ctx); } @@ -105,21 +104,22 @@ async function resolveSecretRef(ref, ctx) { /** * @param {ConfigRef} ref * @param {ConfigRefCtx} ctx - * @returns {Promise} + * @returns {Promise} */ -async function resolveKvRef(ref, ctx) { +async function resolveVarRef(ref, ctx) { if (ref.name.length === 0) { - throw new Error(`config ref ${ref.raw}: empty KV key`); + throw new Error(`config ref ${ref.raw}: empty variable name`); } - const raw = await kvGet(ctx.workflowKey, ref.name); - if (raw == null) { - throw new Error(`config ref ${ref.raw}: KV "${ref.name}" not found`); + try { + assertVariableName(ref.name); + } catch { + throw new Error(`config ref ${ref.raw}: invalid variable name`); } - const coerced = coerceCredentialString(raw); - if (coerced == null) { - throw new Error(`config ref ${ref.raw}: KV "${ref.name}" is not a scalar`); + const value = await getVariablePlain(ctx.owner, ref.name); + if (value == null) { + throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`); } - return coerced; + return value; } /** diff --git a/packages/server/migrations/20260816220000_variables.js b/packages/server/migrations/20260816220000_variables.js new file mode 100644 index 0000000..4228db4 --- /dev/null +++ b/packages/server/migrations/20260816220000_variables.js @@ -0,0 +1,26 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("variables", (t) => { + t.text("id").primary(); + t.text("owner").notNullable(); + t.text("name").notNullable(); + t.text("type").notNullable(); + t.text("value").notNullable(); + t.text("created_at").notNullable(); + t.text("updated_at").notNullable(); + t.unique(["owner", "name"]); + }); + + await knex.schema.raw( + "CREATE INDEX variables_owner_name_idx ON variables (owner, name)", + ); +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists("variables"); +} diff --git a/packages/server/runner.js b/packages/server/runner.js index 6ef41ce..c0a610b 100644 --- a/packages/server/runner.js +++ b/packages/server/runner.js @@ -17,6 +17,7 @@ import runsPlugin from "./src/api/runs.js"; import dashboardPluginFactory from "./src/api/dashboard.js"; import secretsPlugin from "./src/api/secrets.js"; import kvPlugin from "./src/api/kv.js"; +import variablesPlugin from "./src/api/variables.js"; import httpPagesPlugin from "./src/api/http-pages.js"; import httpAuthsPlugin from "./src/api/http-auths.js"; import { WEB_DIST } from "./paths.js"; @@ -92,6 +93,7 @@ await server.register( await api.register(authPlugin); await api.register(usersPlugin); await api.register(secretsPlugin); + await api.register(variablesPlugin); await api.register(kvPlugin); await api.register(httpPagesPlugin); await api.register(httpAuthsPlugin); diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index d9ab9c0..4b7c7c7 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -10,6 +10,7 @@ import { SCRIPTS_DIR } from "./paths.js"; import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js"; import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js"; import { getSecretPlaintext } from "./secrets-store.js"; +import { getVariablePlain } from "./variables-store.js"; const hostRequire = createRequire(import.meta.url); @@ -301,6 +302,24 @@ function createRestrictedRequire(screenedAxios) { }; } +/** + * @param {string} owner + */ +function createVarsApi(owner) { + return { + /** + * @param {string} name + */ + async get(name) { + const value = await getVariablePlain(owner, name); + if (value == null) { + throw new Error(`variable "${name}" not found`); + } + return value; + }, + }; +} + /** * @param {string} owner */ @@ -380,6 +399,7 @@ function createScriptSandbox({ const $kv = createKvApi(workflowName); const $fingerprint = createFingerprintApi($kv); const $secrets = createSecretsApi(owner); + const $vars = createVarsApi(owner); const $responses = createResponsesApi(); const sandbox = { ...pickBuiltins(), @@ -389,6 +409,7 @@ function createScriptSandbox({ $kv, $fingerprint, $secrets, + $vars, $responses, $workflows, require: createRestrictedRequire($axios), diff --git a/packages/server/src/api/variables.js b/packages/server/src/api/variables.js new file mode 100644 index 0000000..27f359a --- /dev/null +++ b/packages/server/src/api/variables.js @@ -0,0 +1,55 @@ +import * as fsStore from "../../fs-store.js"; +import { + assertVariableName, + assertVariableType, + deleteVariable, + getVariableById, + listVariables, + upsertVariable, +} from "../../variables-store.js"; + +/** + * @param {import("fastify").FastifyInstance} fastify + */ +export default async function variablesPlugin(fastify) { + fastify.get("/variables", async (req, reply) => { + const q = /** @type {{ owner?: string }} */ (req.query ?? {}); + try { + const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined; + const variables = await listVariables({ owner }); + return { variables }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + fastify.put("/variables", async (req, reply) => { + const body = /** @type {{ owner?: string, name?: string, type?: unknown, value?: unknown }} */ ( + req.body ?? {} + ); + try { + fsStore.assertOwner(String(body.owner ?? "")); + assertVariableName(String(body.name ?? "")); + assertVariableType(body.type); + const variable = await upsertVariable({ + owner: String(body.owner), + name: String(body.name), + type: body.type, + value: body.value, + }); + return reply.send({ variable }); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + }); + + fastify.delete("/variables/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const existing = await getVariableById(id); + if (!existing) { + return reply.code(404).send({ error: "variable not found" }); + } + await deleteVariable(id); + return { ok: true }; + }); +} diff --git a/packages/server/test/config-refs-smoke.js b/packages/server/test/config-refs-smoke.js index 774d1c4..c1c0eaf 100644 --- a/packages/server/test/config-refs-smoke.js +++ b/packages/server/test/config-refs-smoke.js @@ -1,6 +1,6 @@ import { migrate, db } from "../db.js"; -import { kvSet, kvDelete } from "../kv-store.js"; import { upsertSecret, deleteSecret } from "../secrets-store.js"; +import { deleteVariable, upsertVariable } from "../variables-store.js"; import { Secret } from "../secret-value.js"; import { parseConfigRef, resolveConfigRefs } from "../config-refs.js"; @@ -45,7 +45,8 @@ assertParse(" password123 ", null); assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" }); assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" }); assertParse("Bearer $SECRET_x", null); -assertParse("$KV_modem password", { kind: "kv", name: "modem password" }); +assertParse("$KV_modem password", null); +assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" }); assertParse("$CONTEXT_token", { kind: "context", name: "token" }); assertParse("$SECRET_", { kind: "secret", name: "" }); assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" }); @@ -55,6 +56,8 @@ assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" }); assert(literal === "password123", "literal passthrough"); const unknown = await resolveConfigRefs("$FOO_bar", ctx); assert(unknown === "$FOO_bar", "$FOO_bar stays literal"); + const kvLiteral = await resolveConfigRefs("$KV_modem_password", ctx); + assert(kvLiteral === "$KV_modem_password", "$KV_ stays literal"); const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx); assert(embedded === "Bearer $SECRET_x", "mid-string stays literal"); const number = await resolveConfigRefs(42, ctx); @@ -66,9 +69,24 @@ const secret = await upsertSecret({ name: "config_refs_smoke_token", value: "s3cret-ok", }); -await kvSet(workflowKey, "modem_password", "kv-pass-ok"); -await kvSet(workflowKey, "modem password", "kv-spaced-ok"); -await kvSet(workflowKey, "obj-key", { nested: true }); +const varUrl = await upsertVariable({ + owner, + name: "config_refs_smoke_url", + type: "string", + value: "https://example.test", +}); +const varRetry = await upsertVariable({ + owner, + name: "config_refs_smoke_retry", + type: "number", + value: 3, +}); +const varDebug = await upsertVariable({ + owner, + name: "config_refs_smoke_debug", + type: "boolean", + value: false, +}); try { { @@ -80,12 +98,18 @@ try { assert(resolved === "s3cret-ok", "secret resolve trimmed"); } { - const resolved = await resolveConfigRefs("$KV_modem_password", ctx); - assert(resolved === "kv-pass-ok", "kv resolve"); + const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx); + assert(resolved === "https://example.test", "var string"); } { - const resolved = await resolveConfigRefs("$KV_modem password", ctx); - assert(resolved === "kv-spaced-ok", "kv spaced key"); + const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx); + assert(resolved === 3, "var number stays number"); + assert(typeof resolved === "number", "var number type"); + } + { + const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx); + assert(resolved === false, "var boolean stays false"); + assert(typeof resolved === "boolean", "var boolean type"); } { const resolved = await resolveConfigRefs("$CONTEXT_token", { @@ -113,16 +137,20 @@ try { { const nested = await resolveConfigRefs( { - url: "http://example.test", + url: "$VAR_config_refs_smoke_url", + retry: "$VAR_config_refs_smoke_retry", + debug: "$VAR_config_refs_smoke_debug", password: "$SECRET_config_refs_smoke_token", headers: { Authorization: "$KV_modem_password" }, extra: ["$CONTEXT_token", "plain"], }, { ...ctx, context: { token: "ctx-token-ok" } }, ); - assert(nested.url === "http://example.test", "nested literal"); + assert(nested.url === "https://example.test", "nested var string"); + assert(nested.retry === 3, "nested var number"); + assert(nested.debug === false, "nested var boolean"); assert(nested.password === "s3cret-ok", "nested secret"); - assert(nested.headers.Authorization === "kv-pass-ok", "nested kv"); + assert(nested.headers.Authorization === "$KV_modem_password", "nested $KV_ stays literal"); assert(nested.extra[0] === "ctx-token-ok", "nested array context"); assert(nested.extra[1] === "plain", "nested array literal"); } @@ -146,18 +174,6 @@ try { () => resolveConfigRefs("$SECRET_", ctx), "invalid secret name", ); - await assertRejects( - () => resolveConfigRefs("$KV_missing-key", ctx), - 'KV "missing-key" not found', - ); - await assertRejects( - () => resolveConfigRefs("$KV_obj-key", ctx), - 'KV "obj-key" is not a scalar', - ); - await assertRejects( - () => resolveConfigRefs("$KV_", ctx), - "empty KV key", - ); await assertRejects( () => resolveConfigRefs("$CONTEXT_missing", ctx), 'context "missing" not found', @@ -174,11 +190,23 @@ try { () => resolveConfigRefs("$CONTEXT_", ctx), "empty context key", ); + await assertRejects( + () => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx), + 'variable "does_not_exist_xyz" not found', + ); + await assertRejects( + () => resolveConfigRefs("$VAR_not valid", ctx), + "invalid variable name", + ); + await assertRejects( + () => resolveConfigRefs("$VAR_", ctx), + "empty variable name", + ); } finally { await deleteSecret(secret.id); - await kvDelete(workflowKey, "modem_password"); - await kvDelete(workflowKey, "modem password"); - await kvDelete(workflowKey, "obj-key"); + await deleteVariable(varUrl.id); + await deleteVariable(varRetry.id); + await deleteVariable(varDebug.id); } console.log("config-refs smoke test passed"); diff --git a/packages/server/test/variables-smoke.js b/packages/server/test/variables-smoke.js new file mode 100644 index 0000000..18e5908 --- /dev/null +++ b/packages/server/test/variables-smoke.js @@ -0,0 +1,136 @@ +import { migrate, db } from "../db.js"; +import { runScriptSource } from "../script-sandbox.js"; +import { log } from "../logger.js"; +import { + deleteVariable, + encodeVariableValue, + getVariablePlain, + upsertVariable, +} from "../variables-store.js"; + +await migrate(); + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function assertThrows(fn, match) { + try { + await fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (match && !message.includes(match)) { + throw new Error(`threw "${message}", expected to include "${match}"`); + } + return; + } + throw new Error(`expected to throw (${match ?? "any error"})`); +} + +const owner = "default"; + +await assertThrows( + () => encodeVariableValue("number", "3"), + "finite number", +); +await assertThrows( + () => encodeVariableValue("number", NaN), + "finite number", +); +await assertThrows( + () => encodeVariableValue("boolean", "true"), + "boolean", +); +await assertThrows( + () => encodeVariableValue("string", 3), + "string", +); + +const created = []; +try { + const str = await upsertVariable({ + owner, + name: "variables_smoke_url", + type: "string", + value: "https://n.0dev.web.id/system", + }); + created.push(str.id); + assert(str.value === "https://n.0dev.web.id/system", "string stored"); + assert( + (await getVariablePlain(owner, "variables_smoke_url")) === str.value, + "string plain", + ); + + const num = await upsertVariable({ + owner, + name: "variables_smoke_retry", + type: "number", + value: 3, + }); + created.push(num.id); + assert(num.value === 3 && typeof num.value === "number", "number stored"); + + const flag = await upsertVariable({ + owner, + name: "variables_smoke_debug", + type: "boolean", + value: false, + }); + created.push(flag.id); + assert(flag.value === false, "boolean false stored"); + + const updated = await upsertVariable({ + owner, + name: "variables_smoke_retry", + type: "number", + value: 9, + }); + assert(updated.id === num.id, "upsert same id"); + assert(updated.value === 9, "upsert number"); + + await assertThrows( + () => + upsertVariable({ + owner, + name: "variables_smoke_bad", + type: "number", + value: "abc", + }), + "finite number", + ); + + const scriptOut = await runScriptSource( + "variables-smoke.js", + `export default async function () { + const url = await $vars.get("variables_smoke_url"); + const retry = await $vars.get("variables_smoke_retry"); + const debug = await $vars.get("variables_smoke_debug"); + return { url, retry, debug, retryType: typeof retry, debugType: typeof debug }; + }`, + { data: {} }, + { log, workflowName: "default/variables-smoke.yaml", owner }, + ); + assert(scriptOut.url === "https://n.0dev.web.id/system", "script $vars string"); + assert(scriptOut.retry === 9 && scriptOut.retryType === "number", "script $vars number"); + assert(scriptOut.debug === false && scriptOut.debugType === "boolean", "script $vars boolean"); + + await assertThrows( + () => + runScriptSource( + "variables-smoke.js", + `export default async function () { + return $vars.get("does_not_exist_xyz"); + }`, + { data: {} }, + { log, workflowName: "default/variables-smoke.yaml", owner }, + ), + 'variable "does_not_exist_xyz" not found', + ); +} finally { + for (const id of created) { + await deleteVariable(id); + } +} + +console.log("variables smoke test passed"); +await db.destroy(); diff --git a/packages/server/variables-store.js b/packages/server/variables-store.js new file mode 100644 index 0000000..2ed9f64 --- /dev/null +++ b/packages/server/variables-store.js @@ -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} 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} + */ +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} + */ +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 ?? "")); +} diff --git a/packages/server/workflows/default/cron-example.yaml b/packages/server/workflows/default/cron-example.yaml index ce1f57e..b829f36 100644 --- a/packages/server/workflows/default/cron-example.yaml +++ b/packages/server/workflows/default/cron-example.yaml @@ -2,9 +2,13 @@ name: cron example description: | this workflow triggered by cron scripts: + - script: get-current-time.js + - set: + expression: '{"message": context.datetime}' - script: ntfy.js config: - url: https://ntfy.sh/jerapah-flow + url: $VAR_ntfy_channel + title: $DATA triggers: - type: cron schedule: "*/20 * * * *" diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml index 3673194..d6b42cf 100644 --- a/packages/server/workflows/default/send-gmail.yaml +++ b/packages/server/workflows/default/send-gmail.yaml @@ -36,3 +36,4 @@ triggers: - type: HTTP method: POST path: /send-gmail + auth: basic-auth diff --git a/packages/server/workflows/default/time-to-ntfy-example.yaml b/packages/server/workflows/default/time-to-ntfy-example.yaml index 683d505..df6144c 100644 --- a/packages/server/workflows/default/time-to-ntfy-example.yaml +++ b/packages/server/workflows/default/time-to-ntfy-example.yaml @@ -3,6 +3,8 @@ description: | this workflow will send a message to ntfy with the current time scripts: - script: get-current-time.js + config: + key: "" - script: ntfy.js config: url: https://n.0dev.web.id/system diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 903c93d..6bf8baf 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -17,6 +17,7 @@ import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx"; import { ResponsesPage } from "./pages/ResponsesPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx"; +import { VariablesPage } from "./pages/VariablesPage.jsx"; export function App() { const qc = useQueryClient(); @@ -61,6 +62,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> {user.role === "admin" ? ( diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 0da8036..e4387f2 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -336,6 +336,33 @@ export function useDeleteSecret() { }); } +export function useVariables(owner) { + return useQuery({ + queryKey: ["variables", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/variables", { params })).data.variables; + }, + }); +} + +export function useUpsertVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/variables", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + +export function useDeleteVariable() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/variables/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }), + }); +} + export function useKvNamespaces() { return useQuery({ queryKey: ["kv", "namespaces"], diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index 6c7f777..501b400 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -12,6 +12,7 @@ import { LuMoon, LuShield, LuSun, + LuTags, LuUsers, } from "react-icons/lu"; import { useLogout } from "../api/hooks.js"; @@ -24,6 +25,7 @@ const links = [ { to: "/workflows", label: "Workflows", icon: LuGitBranch }, { to: "/events", label: "Events", icon: LuActivity }, { to: "/kv", label: "KV", icon: LuDatabase }, + { to: "/variables", label: "Variables", icon: LuTags }, { to: "/auth", label: "Auth", icon: LuShield }, { to: "/responses", label: "Responses", icon: LuFileText }, ]; diff --git a/packages/web/src/components/workflow/ConfigFields.jsx b/packages/web/src/components/workflow/ConfigFields.jsx index dc2eaac..365a4f4 100644 --- a/packages/web/src/components/workflow/ConfigFields.jsx +++ b/packages/web/src/components/workflow/ConfigFields.jsx @@ -9,7 +9,7 @@ const MULTILINE_KEYS = new Set(["expression", "jsonata"]); const CONFIG_REF_PREFIXES = [ { prefix: "$SECRET_", label: "secret" }, { prefix: "$CONTEXT_", label: "context" }, - { prefix: "$KV_", label: "KV" }, + { prefix: "$VAR_", label: "variable" }, ]; function describeConfigRef(value) { diff --git a/packages/web/src/pages/VariablesPage.jsx b/packages/web/src/pages/VariablesPage.jsx new file mode 100644 index 0000000..1b65f3b --- /dev/null +++ b/packages/web/src/pages/VariablesPage.jsx @@ -0,0 +1,312 @@ +import { useState } from "react"; +import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + useDeleteVariable, + useOwners, + useUpsertVariable, + useVariables, +} from "../api/hooks.js"; +import { formatTime } from "../lib/format.jsx"; + +const TYPES = ["string", "number", "boolean"]; + +function defaultValue(type) { + if (type === "boolean") return false; + if (type === "number") return ""; + return ""; +} + +function displayValue(value) { + if (typeof value === "string") return value === "" ? '""' : value; + return String(value); +} + +export function VariablesPage() { + const { data: owners = [] } = useOwners(); + const [ownerFilter, setOwnerFilter] = useState(""); + const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined); + const upsert = useUpsertVariable(); + const del = useDeleteVariable(); + const [mode, setMode] = useState(null); + const [form, setForm] = useState({ + owner: "", + name: "", + type: "string", + value: "", + }); + const [formError, setFormError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); + + function openAdd() { + setMode("add"); + setFormError(null); + setForm({ + owner: ownerFilter || owners[0] || "default", + name: "", + type: "string", + value: "", + }); + } + + function openEdit(row) { + setMode("edit"); + setFormError(null); + setForm({ + owner: row.owner, + name: row.name, + type: row.type, + value: row.type === "number" ? String(row.value) : row.value, + }); + } + + function closeForm() { + setMode(null); + setFormError(null); + setForm({ owner: "", name: "", type: "string", value: "" }); + } + + function onTypeChange(type) { + setForm({ ...form, type, value: defaultValue(type) }); + } + + function onSubmit(e) { + e.preventDefault(); + let value = form.value; + if (form.type === "number") { + value = Number(form.value); + if (!Number.isFinite(value)) { + setFormError("value must be a finite number"); + return; + } + } + if (form.type === "boolean") { + value = form.value === true; + } + setFormError(null); + upsert.mutate( + { owner: form.owner, name: form.name, type: form.type, value }, + { onSuccess: closeForm }, + ); + } + + return ( +
+
+

Variables

+
+ + +
+
+ + {isLoading ? ( + + ) : variables.length === 0 ? ( +

No variables yet.

+ ) : ( +
+ + + + + + + + + + + + {variables.map((row) => ( + + + + + + + + + ))} + +
OwnerNameTypeValueUpdated +
{row.owner}{row.name}{row.type} + {displayValue(row.value)} + {formatTime(row.updated_at)} + + +
+
+ )} + + {mode ? ( +
+
+ + {mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`} + + +
+ {mode === "add" ? ( + <> + + {owners.length > 0 ? ( + + ) : ( + setForm({ ...form, owner: e.target.value })} + required + /> + )} + + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + title="Letters, numbers, dots, underscores, hyphens" + /> + + ) : null} + + + + {form.type === "boolean" ? ( + + ) : form.type === "number" ? ( + setForm({ ...form, value: e.target.value })} + required + step="any" + /> + ) : ( +