feat(profiles): add typed live step config profiles

Profiles store script + default config per owner. Workflow steps reference
them with profile:, overlay keys win, and the UI marks overrides. Profile
name is immutable after create so YAML refs stay stable.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-21 06:12:49 +00:00
co-authored by nsrb
parent 210fdb2244
commit e84432a492
25 changed files with 1512 additions and 123 deletions
@@ -0,0 +1,25 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("profiles", (t) => {
t.text("id").primary();
t.text("owner").notNullable();
t.text("name").notNullable();
t.text("script").notNullable();
t.text("config").notNullable();
t.text("description").notNullable().defaultTo("");
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
t.unique(["owner", "name"]);
});
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)");
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("profiles");
}
+2 -1
View File
@@ -13,7 +13,8 @@
"start:control": "node control.js",
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "node test/workflow-history-smoke.js"
"test:workflow-history": "node test/workflow-history-smoke.js",
"test:profiles": "node test/profiles-smoke.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
+31
View File
@@ -0,0 +1,31 @@
/**
* Shallow merge: step overlay keys replace profile defaults (including "").
* Nested objects/arrays are replaced, not deep-merged.
*
* @param {unknown} profileConfig
* @param {unknown} stepConfig
* @returns {Record<string, unknown>}
*/
export function mergeProfileConfig(profileConfig, stepConfig) {
const base =
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
? { ...profileConfig }
: {};
if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) {
return base;
}
return { ...base, ...stepConfig };
}
/**
* @param {unknown} config
* @returns {boolean}
*/
export function configHasOverlay(config) {
return (
config != null &&
typeof config === "object" &&
!Array.isArray(config) &&
Object.keys(config).length > 0
);
}
+244
View File
@@ -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;
}
+21 -3
View File
@@ -31,6 +31,8 @@ import {
sendSuccessPage,
} from "./http-trigger-auth.js";
import { resolveConfigRefs } from "./config-refs.js";
import { mergeProfileConfig } from "./profile-config.js";
import { getProfilePlain } from "./profiles-store.js";
import {
buildFailureAlertData,
resolveFailureTriggerConfig,
@@ -593,8 +595,21 @@ export function createRegistry(server, opts = {}) {
owner,
depth,
) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const unresolvedConfig = parsed.config;
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
let unresolvedConfig = parsed.config;
if (parsed.kind === "script" && parsed.profile) {
const profile = await getProfilePlain(owner, parsed.profile);
if (!profile) {
throw new Error(`profile "${parsed.profile}" not found`);
}
if (parsed.script && parsed.script !== profile.script) {
throw new Error(
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
);
}
script = profile.script;
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
}
const incomingContext = normalizeContext(ctx.context);
const step = await store.startStep({
runId,
@@ -904,7 +919,10 @@ export function createRegistry(server, opts = {}) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
if (parsed.kind === "script") refs.add(parsed.script);
if (parsed.kind === "script") {
if (parsed.script) refs.add(parsed.script);
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
}
} catch {
// skip invalid steps
}
+82
View File
@@ -0,0 +1,82 @@
import * as fsStore from "../../fs-store.js";
import {
assertProfileName,
deleteProfile,
getProfileById,
getProfilePlain,
listProfileUsages,
listProfiles,
upsertProfile,
} from "../../profiles-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function profilesPlugin(fastify) {
fastify.get("/profiles", async (req, reply) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
try {
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
const profiles = await listProfiles({ owner });
const withUsage = profiles.map((profile) => ({
...profile,
usageCount: listProfileUsages(profile.owner, profile.name).length,
}));
return { profiles: withUsage };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.get("/profiles/:id/usage", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
return { usages: listProfileUsages(existing.owner, existing.name) };
});
fastify.put("/profiles", async (req, reply) => {
const body = /** @type {{
owner?: string,
name?: string,
script?: unknown,
config?: unknown,
description?: unknown,
}} */ (req.body ?? {});
try {
fsStore.assertOwner(String(body.owner ?? ""));
assertProfileName(String(body.name ?? ""));
const profile = await upsertProfile({
owner: String(body.owner),
name: String(body.name),
script: body.script,
config: body.config,
description: body.description,
});
return reply.send({ profile });
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
});
fastify.delete("/profiles/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const q = /** @type {{ force?: string }} */ (req.query ?? {});
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
const usages = listProfileUsages(existing.owner, existing.name);
const force = q.force === "1" || q.force === "true";
if (usages.length > 0 && !force) {
return reply.code(409).send({
error: "profile is used by workflows",
usages,
});
}
await deleteProfile(id);
return { ok: true, forced: force && usages.length > 0, usages };
});
}
+63 -3
View File
@@ -26,6 +26,8 @@ import {
collectWorkflowWarnings,
parseWorkflowDocument,
} from "../../workflow-validate-warnings.js";
import { getProfilePlain } from "../../profiles-store.js";
import { resolveScriptRef } from "../../plugin-store.js";
import {
recordRevision,
listRevisions,
@@ -80,7 +82,9 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? "set" : parsed.script);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
} catch {
names.push(null);
}
@@ -88,6 +92,59 @@ function scriptNames(workflow) {
return names;
}
/**
* @param {unknown} parsed
* @param {string} owner
*/
async function collectProfileWarnings(parsed, owner) {
/** @type {Array<{ code: string, message: string, path?: string }>} */
const warnings = [];
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
return warnings;
}
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
const profileName = raw.profile;
if (typeof profileName !== "string" || !profileName) continue;
const pathKey = `scripts[${i}]`;
let profile;
try {
profile = await getProfilePlain(owner, profileName);
} catch {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" is not a valid name`,
path: pathKey,
});
continue;
}
if (!profile) {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" not found`,
path: pathKey,
});
continue;
}
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
warnings.push({
code: "profile_script_mismatch",
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
path: pathKey,
});
}
const resolved = resolveScriptRef(profile.script);
if (resolved.error) {
warnings.push({
code: "unknown_script",
message: resolved.error,
path: `${pathKey}.profile`,
});
}
}
return warnings;
}
/**
* @param {unknown} parsed
*/
@@ -109,8 +166,11 @@ async function validateStrictWorkflow(parsed) {
* }} opts
*/
async function saveWorkflowContent(opts) {
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
const saveAnyway = Boolean(opts.saveAnyway);
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
if (parsed) {
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
}
const saveAnyway = Boolean(opts.saveAnyway);
if (!saveAnyway) {
if (parseError) {
+2
View File
@@ -19,6 +19,7 @@ import dashboardPluginFactory from "./src/api/dashboard.js";
import secretsPlugin from "./src/api/secrets.js";
import kvPlugin from "./src/api/kv.js";
import variablesPlugin from "./src/api/variables.js";
import profilesPlugin from "./src/api/profiles.js";
import httpPagesPlugin from "./src/api/http-pages.js";
import httpAuthsPlugin from "./src/api/http-auths.js";
import { WEB_DIST } from "./paths.js";
@@ -169,6 +170,7 @@ export async function startApp(opts = {}) {
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(variablesPlugin);
await api.register(profilesPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
+101
View File
@@ -0,0 +1,101 @@
import { migrate, db } from "../db.js";
import {
assertProfileName,
deleteProfile,
encodeProfileConfig,
getProfilePlain,
listProfileUsages,
upsertProfile,
} from "../profiles-store.js";
import { mergeProfileConfig } from "../profile-config.js";
import { parseScriptStep } from "../workflow-parse.js";
await migrate();
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function assertThrows(fn, match) {
try {
await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (match && !message.includes(match)) {
throw new Error(`threw "${message}", expected to include "${match}"`);
}
return;
}
throw new Error(`expected to throw (${match ?? "any error"})`);
}
const merged = mergeProfileConfig(
{ url: "https://n.example/ops", fingerprint: true },
{ fingerprint: "comic-rss" },
);
assert(merged.url === "https://n.example/ops", "profile url kept");
assert(merged.fingerprint === "comic-rss", "overlay wins");
const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {});
assert(emptyOverlay.url === "https://n.example/ops", "empty overlay");
const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" });
assert(emptyWins.url === "", "empty string overlay wins");
const profileOnly = parseScriptStep({
profile: "ops-ntfy",
config: { fingerprint: "x" },
});
assert(profileOnly.kind === "script", "profile step kind");
assert(profileOnly.script === "", "script supplied by profile at runtime");
assert(profileOnly.profile === "ops-ntfy", "profile name");
const both = parseScriptStep({
script: "ntfy.js",
profile: "ops-ntfy",
});
assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile");
await assertThrows(
() => parseScriptStep({ profile: "ops", set: { expression: "1" } }),
"profile and set",
);
assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name");
await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name");
await assertThrows(() => encodeProfileConfig([]), "config must be an object");
const owner = "default";
const name = `profiles_smoke_${Date.now()}`;
const created = await upsertProfile({
owner,
name,
script: "ntfy.js",
config: { url: "$VAR_ntfy_channel" },
description: "smoke",
});
assert(created.name === name, "created");
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip");
assert(created.script === "ntfy.js", "script locked on profile");
const fetched = await getProfilePlain(owner, name);
assert(fetched?.id === created.id, "get by owner/name");
const updated = await upsertProfile({
owner,
name,
script: "send-email.js",
config: { service: "Gmail" },
description: "now mail",
});
assert(updated.id === created.id, "upsert same row");
assert(updated.script === "send-email.js", "script may change");
const usages = listProfileUsages(owner, name);
assert(Array.isArray(usages) && usages.length === 0, "unused profile");
await deleteProfile(created.id);
assert((await getProfilePlain(owner, name)) == null, "deleted");
await db.destroy();
console.log("profiles-smoke: ok");
+30 -17
View File
@@ -6,20 +6,22 @@ export const SET_STEP_SCRIPT = "set";
* @typedef {{ alias: string, from: string }} NeedEdge
* @typedef {{
* kind: "script",
* script: string,
* config: unknown | null,
* expression?: undefined,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
* when: string | null,
* }} ParsedScriptStep
* script: string,
* profile: string | null,
* config: unknown | null,
* expression?: undefined,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
* when: string | null,
* }} ParsedScriptStep
* @typedef {{
* kind: "set",
* script: typeof SET_STEP_SCRIPT,
* config: { expression: string },
* expression: string,
* id: string | null,
* script: typeof SET_STEP_SCRIPT,
* profile: null,
* config: { expression: string },
* expression: string,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
* when: string | null,
@@ -85,6 +87,7 @@ export function parseScriptStep(step) {
return {
kind: "script",
script: step,
profile: null,
config: null,
id: null,
needsKind: "none",
@@ -97,24 +100,33 @@ export function parseScriptStep(step) {
}
const hasScript = step.script != null && step.script !== "";
const hasProfile = step.profile != null && step.profile !== "";
const hasSet = step.set != null;
if (hasScript && hasSet) {
throw new Error("Step cannot have both script and set");
}
if (hasProfile && hasSet) {
throw new Error("Step cannot have both profile and set");
}
if (hasSet) {
return parseSetStep(step);
}
if (hasScript) {
if (typeof step.script !== "string") {
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
if (hasProfile && typeof step.profile !== "string") {
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
}
if (hasScript && typeof step.script !== "string") {
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
}
if (hasScript || hasProfile) {
const { needsKind, needs } = parseNeeds(step.needs);
return {
kind: "script",
script: step.script,
script: hasScript ? step.script : "",
profile: hasProfile ? step.profile : null,
config: step.config ?? null,
id: parseOptionalId(step.id),
needsKind,
@@ -265,6 +277,7 @@ function parseSetStep(step) {
return {
kind: "set",
script: SET_STEP_SCRIPT,
profile: null,
config: { expression },
expression,
id: parseOptionalId(step.id),
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
try {
const step = parseScriptStep(raw);
if (step.kind === "set") continue;
if (step.profile && !step.script) continue;
const resolved = resolveScriptRef(step.script);
if (resolved.error) {
warnings.push({