Merge branch 'main' into dev
Deploy to Raspberry Pi / deploy (push) Successful in 4m3s

This commit is contained in:
2026-08-22 21:48:51 +07:00
172 changed files with 11061 additions and 5442 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
import { kvNamespaces, kvQuery } from "../../kv-store.js";
import { kvDelete, kvNamespaces, kvQuery } from "../../kv-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -19,4 +19,17 @@ export default async function kvPlugin(fastify) {
offset: Number.isFinite(offset) ? offset : undefined,
});
});
fastify.delete("/kv", async (req, reply) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
try {
const deleted = await kvDelete(String(q.namespace ?? ""), String(q.key ?? ""));
if (!deleted) {
return reply.code(404).send({ error: "kv entry not found" });
}
return { ok: true };
} catch (err) {
return reply.code(400).send({ error: err.message });
}
});
}
+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 };
});
}
+28
View File
@@ -0,0 +1,28 @@
import * as store from "../../store.js";
/**
* @param {Record<string, string | undefined>} q
*/
export function parseRunQueryParams(q) {
const limit = q.limit != null ? Number(q.limit) : undefined;
const offset = q.offset != null ? Number(q.offset) : undefined;
return {
owner: q.owner || undefined,
workflow: q.workflow || undefined,
status: q.status || undefined,
trigger_type: q.trigger || undefined,
after: q.after || undefined,
before: q.before || undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined,
sort: q.sort || undefined,
order: q.order || undefined,
};
}
/**
* @param {Record<string, string | undefined>} q
*/
export async function queryRunsFromRequest(q) {
return store.queryRuns(parseRunQueryParams(q));
}
+2 -10
View File
@@ -1,20 +1,12 @@
import * as store from "../../store.js";
import { queryRunsFromRequest } from "./run-query.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function runsPlugin(fastify) {
fastify.get("/runs", async (req) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
const limit = q.limit ? Number(q.limit) : undefined;
const runs = await store.listRuns({
owner: q.owner,
workflow: q.workflow,
status: q.status,
limit: Number.isFinite(limit) ? limit : undefined,
before: q.before,
});
return { runs };
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (req.query ?? {}));
});
fastify.get("/consecutive-failures", async (req) => {
+94 -16
View File
@@ -8,7 +8,6 @@ import {
import * as fsStore from "../../fs-store.js";
import {
forkCoreScript,
getInstalledPlugin,
listCoreScriptNames,
listInstalledPlugins,
resolveScriptRef,
@@ -22,11 +21,16 @@ import {
installPluginFromZipBuffer,
} from "../../plugin-install.js";
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
import {
encodeBinaryForWire,
reviveBinaryFromWire,
} from "../../json-preview.js";
import { normalizeStepResult } from "../../step-result.js";
import { resolveConfigRefs } from "../../config-refs.js";
import { getAppVersion } from "../../app-version.js";
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
import { pluginScriptRef } from "../../plugin-manifest.js";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
/**
* @param {{ referencedScripts: () => Set<string> }} registry
@@ -250,14 +254,11 @@ export default function scriptsPluginFactory(registry) {
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
const body = /** @type {{ content?: string, expression?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {}
);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let owner = "default";
let owner = "local";
if (body.owner != null && body.owner !== "") {
try {
owner = fsStore.assertOwner(String(body.owner));
@@ -266,12 +267,92 @@ export default function scriptsPluginFactory(registry) {
}
}
const incomingContext =
const incomingContext = reviveBinaryFromWire(
body.context != null &&
typeof body.context === "object" &&
!Array.isArray(body.context)
typeof body.context === "object" &&
!Array.isArray(body.context)
? body.context
: {};
: {},
);
const incomingData = reviveBinaryFromWire(body.data ?? null);
const { log, logs } = createDryRunLogger();
const started = Date.now();
// Set steps are inline JSONata (no script file). Match registry runCompiledStep.
if (rawName === SET_STEP_SCRIPT || rawName === `${SET_STEP_SCRIPT}.js`) {
const configObj =
body.config != null &&
typeof body.config === "object" &&
!Array.isArray(body.config)
? /** @type {Record<string, unknown>} */ (body.config)
: null;
const expression =
typeof body.expression === "string"
? body.expression
: typeof configObj?.expression === "string"
? configObj.expression
: null;
if (expression == null || !expression.trim()) {
return reply.code(400).send({ error: "expression is required" });
}
try {
const config = await resolveConfigRefs(
{ ...(configObj ?? {}), expression },
{
owner,
workflowKey: "dry-run",
context: incomingContext,
},
);
const ctx = {
data: incomingData,
context: incomingContext,
config,
};
const value = await evaluateJsonata(expression, ctx);
const result = normalizeStepResult(
{
output: value,
context: incomingContext,
skipRemaining: false,
},
incomingContext,
SET_STEP_SCRIPT,
);
log.info({ expression }, "set: dry-run evaluated");
return {
status: "success",
output: safeSerialize(result.output),
context: safeSerialize(result.context),
wireOutput: encodeBinaryForWire(result.output),
wireContext: encodeBinaryForWire(result.context),
skipRemaining: result.skipRemaining,
error: null,
logs,
durationMs: Date.now() - started,
meta: null,
metaError: null,
};
} catch (err) {
return {
status: "failed",
output: null,
context: null,
skipRemaining: false,
error: err instanceof Error ? err.message : String(err),
logs,
durationMs: Date.now() - started,
meta: null,
metaError: null,
};
}
}
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
const resolved = resolveScriptRef(rawName);
const pluginDir =
@@ -279,9 +360,6 @@ export default function scriptsPluginFactory(registry) {
? resolved.pluginDir ?? null
: null;
const { log, logs } = createDryRunLogger();
const started = Date.now();
try {
const config = await resolveConfigRefs(body.config ?? null, {
owner,
@@ -289,7 +367,7 @@ export default function scriptsPluginFactory(registry) {
context: incomingContext,
});
const ctx = {
data: body.data ?? null,
data: incomingData,
context: incomingContext,
config,
};
@@ -313,6 +391,8 @@ export default function scriptsPluginFactory(registry) {
status: "success",
output: safeSerialize(result.output),
context: safeSerialize(result.context),
wireOutput: encodeBinaryForWire(result.output),
wireContext: encodeBinaryForWire(result.context),
skipRemaining: result.skipRemaining,
error: null,
logs,
@@ -475,7 +555,5 @@ export default function scriptsPluginFactory(registry) {
}
},
);
void getInstalledPlugin;
};
}
+92 -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,
@@ -43,6 +45,11 @@ import {
createWorkflowBackupBuffer,
restoreWorkflowBackup,
} from "../../workflow-backup.js";
import {
listExampleWorkflows,
readExampleWorkflow,
assertExampleWorkflowId,
} from "../../workflow-examples.js";
/**
* Reload this process and notify other HTTP/worker processes via Redis.
@@ -81,7 +88,9 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? "set" : parsed.script);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
} catch {
names.push(null);
}
@@ -89,6 +98,59 @@ function scriptNames(workflow) {
return names;
}
/**
* @param {unknown} parsed
* @param {string} owner
*/
async function collectProfileWarnings(parsed, owner) {
/** @type {Array<{ code: string, message: string, path?: string }>} */
const warnings = [];
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
return warnings;
}
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
const profileName = raw.profile;
if (typeof profileName !== "string" || !profileName) continue;
const pathKey = `scripts[${i}]`;
let profile;
try {
profile = await getProfilePlain(owner, profileName);
} catch {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" is not a valid name`,
path: pathKey,
});
continue;
}
if (!profile) {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" not found`,
path: pathKey,
});
continue;
}
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
warnings.push({
code: "profile_script_mismatch",
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
path: pathKey,
});
}
const resolved = resolveScriptRef(profile.script);
if (resolved.error) {
warnings.push({
code: "unknown_script",
message: resolved.error,
path: `${pathKey}.profile`,
});
}
}
return warnings;
}
/**
* @param {unknown} parsed
*/
@@ -110,8 +172,11 @@ async function validateStrictWorkflow(parsed) {
* }} opts
*/
async function saveWorkflowContent(opts) {
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
const saveAnyway = Boolean(opts.saveAnyway);
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
if (parsed) {
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
}
const saveAnyway = Boolean(opts.saveAnyway);
if (!saveAnyway) {
if (parseError) {
@@ -187,6 +252,22 @@ export default function workflowsPluginFactory(registry) {
return { owners: fsStore.listOwners() };
});
fastify.get("/workflow-examples", async () => {
return { examples: listExampleWorkflows() };
});
fastify.get("/workflow-examples/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
if (!assertExampleWorkflowId(id)) {
return reply.code(400).send({ error: "invalid example id" });
}
const example = readExampleWorkflow(id);
if (!example) {
return reply.code(404).send({ error: "example not found" });
}
return example;
});
fastify.get("/workflows/trash", async () => {
return { items: await listTrash() };
});
@@ -307,6 +388,7 @@ export default function workflowsPluginFactory(registry) {
enabled: parsed ? parsed.enabled !== false : false,
registered: registered.includes(file),
loadError: loadError ?? (parsed ? null : "unreadable"),
lastModifiedAt: fsStore.workflowLastModifiedAt(owner, file),
lastInvokedAt: st.lastInvokedAt,
lastStatus: st.lastStatus ?? null,
invocationCount: st.invocationCount,
@@ -315,6 +397,13 @@ export default function workflowsPluginFactory(registry) {
});
}
}
items.sort((a, b) => {
const byName = String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, {
sensitivity: "base",
});
if (byName !== 0) return byName;
return String(a.key ?? "").localeCompare(String(b.key ?? ""));
});
return { workflows: items };
});
@@ -0,0 +1,390 @@
import { randomUUID } from "node:crypto";
import { db } from "../../db.js";
import { assertHttpStatus } from "../../http-pages-store.js";
const MAX_NAME_LENGTH = 128;
const NAME_RE = /^[A-Za-z0-9._-]+$/;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
function nowIso() {
return new Date().toISOString();
}
/**
* @param {unknown} id
* @returns {string}
*/
export function assertAuthId(id) {
if (typeof id !== "string" || !UUID_RE.test(id)) {
const err = new Error("invalid auth id");
err.statusCode = 400;
throw err;
}
return id.toLowerCase();
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertAuthName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
const err = new Error("invalid auth name");
err.statusCode = 400;
throw err;
}
if (name.length > MAX_NAME_LENGTH) {
const err = new Error(`auth name must be at most ${MAX_NAME_LENGTH} characters`);
err.statusCode = 400;
throw err;
}
return name;
}
/**
* @param {unknown} type
* @returns {"bearer" | "basic" | "header"}
*/
export function assertAuthType(type) {
const t = String(type ?? "");
if (!ALLOWED_TYPES.has(t)) {
const err = new Error('auth type must be "bearer", "basic", or "header"');
err.statusCode = 400;
throw err;
}
return /** @type {"bearer" | "basic" | "header"} */ (t);
}
/**
* Detect value source without exposing literal values.
* @param {unknown} value
* @returns {"literal" | "kv" | "secret" | "missing"}
*/
export function valueSourceKind(value) {
if (value == null) return "missing";
if (typeof value === "string") return "literal";
if (typeof value === "object" && !Array.isArray(value)) {
if ("secret" in value) return "secret";
if ("kv" in value) return "kv";
}
return "literal";
}
/**
* Redact config for API responses: replace literal strings with source markers.
* @param {Record<string, unknown>} config
* @param {string} type
*/
export function publicConfig(config, type) {
/** @type {Record<string, unknown>} */
const out = {};
if (type === "bearer") {
out.token = redactField(config.token);
} else if (type === "basic") {
out.user = redactField(config.user);
out.password = redactField(config.password);
} else if (type === "header") {
out.header = typeof config.header === "string" ? config.header : null;
out.value = redactField(config.value);
}
return out;
}
/**
* @param {unknown} value
*/
function redactField(value) {
const kind = valueSourceKind(value);
if (kind === "missing") return { source: "missing" };
if (kind === "kv") {
const v = /** @type {{ kv: string, namespace?: string }} */ (value);
return {
source: "kv",
kv: v.kv,
...(v.namespace != null ? { namespace: v.namespace } : {}),
};
}
if (kind === "secret") {
const v = /** @type {{ secret: string }} */ (value);
return { source: "secret", secret: v.secret };
}
return { source: "literal", set: true };
}
/**
* Validate and normalize auth config for storage.
* @param {string} type
* @param {unknown} config
* @param {{ keepLiteralsFrom?: Record<string, unknown> }} [opts]
*/
export function normalizeAuthConfig(type, config, opts = {}) {
const raw = config && typeof config === "object" && !Array.isArray(config)
? /** @type {Record<string, unknown>} */ (config)
: {};
const keep = opts.keepLiteralsFrom ?? {};
if (type === "bearer") {
return {
token: normalizeCredentialField(raw.token, keep.token, "token"),
};
}
if (type === "basic") {
return {
user: normalizeCredentialField(raw.user, keep.user, "user"),
password: normalizeCredentialField(raw.password, keep.password, "password", {
allowEmpty: true,
}),
};
}
// header
if (typeof raw.header !== "string" || raw.header.length === 0) {
const err = new Error("header name must be a non-empty string");
err.statusCode = 400;
throw err;
}
return {
header: raw.header,
value: normalizeCredentialField(raw.value, keep.value, "value"),
};
}
/**
* @param {unknown} value
* @param {unknown} previous
* @param {string} label
* @param {{ allowEmpty?: boolean }} [opts]
*/
function normalizeCredentialField(value, previous, label, opts = {}) {
// Explicit "keep previous literal" marker from UI when editing without re-entering
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
/** @type {{ keep?: boolean }} */ (value).keep === true
) {
if (typeof previous === "string") return previous;
if (previous && typeof previous === "object") return previous;
const err = new Error(`${label} was not previously set`);
err.statusCode = 400;
throw err;
}
if (value == null || value === "") {
if (opts.allowEmpty && value === "") return "";
// Allow empty password for basic
if (opts.allowEmpty && (value === "" || value == null)) {
if (typeof previous === "string") return previous;
return "";
}
const err = new Error(`${label} is required`);
err.statusCode = 400;
throw err;
}
if (typeof value === "string") return value;
if (typeof value === "object" && !Array.isArray(value)) {
const v = /** @type {Record<string, unknown>} */ (value);
if (typeof v.secret === "string" && v.secret.length > 0) {
return { secret: v.secret };
}
if (typeof v.kv === "string" && v.kv.length > 0) {
/** @type {{ kv: string, namespace?: string }} */
const out = { kv: v.kv };
if (typeof v.namespace === "string" && v.namespace.length > 0) {
out.namespace = v.namespace;
}
return out;
}
}
const err = new Error(
`${label} must be a string, { kv }, { secret }, or { keep: true }`,
);
err.statusCode = 400;
throw err;
}
function parseConfig(raw) {
if (typeof raw !== "string") return raw ?? {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function publicAuth(row, { includeConfig = true } = {}) {
const type = row.type;
const config = parseConfig(row.config);
return {
id: row.id,
name: row.name,
type,
...(includeConfig ? { config: publicConfig(config, type) } : {}),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* Internal: full config including literals (for runtime auth checks).
* @param {string} id
*/
export async function getHttpAuthInternal(id) {
const authId = assertAuthId(id);
const row = await db("http_auths").where({ id: authId }).first();
if (!row) return null;
return {
id: row.id,
name: row.name,
type: row.type,
config: parseConfig(row.config),
unauthorized_status: row.unauthorized_status ?? null,
unauthorized_response: row.unauthorized_response ?? null,
};
}
/**
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
* @param {string} id
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
*/
export async function revealHttpAuthLiterals(id) {
const internal = await getHttpAuthInternal(id);
if (!internal) return null;
/** @type {Record<string, string>} */
const literals = {};
const cfg = internal.config ?? {};
for (const key of ["token", "user", "password", "value"]) {
const v = cfg[key];
if (typeof v === "string") literals[key] = v;
}
return {
id: internal.id,
name: internal.name,
type: internal.type,
literals,
};
}
export async function listHttpAuths() {
const rows = await db("http_auths").select("*").orderBy("name", "asc");
return rows.map((r) => publicAuth(r));
}
/**
* @param {string} id
*/
export async function getHttpAuthById(id) {
let authId;
try {
authId = assertAuthId(id);
} catch {
return null;
}
const row = await db("http_auths").where({ id: authId }).first();
return row ? publicAuth(row) : null;
}
/**
* @param {{
* id?: string | null,
* name: string,
* type: string,
* config?: unknown,
* unauthorized_status?: number | null,
* unauthorized_response?: string | null,
* }} opts
*/
export async function upsertHttpAuth({
id,
name,
type,
config,
unauthorized_status,
unauthorized_response,
}) {
const authName = assertAuthName(name);
const authType = assertAuthType(type);
/** @type {Record<string, unknown> | null} */
let existing = null;
if (id != null && String(id).length > 0) {
const authId = assertAuthId(id);
existing = await db("http_auths").where({ id: authId }).first();
if (!existing) {
const err = new Error("auth not found");
err.statusCode = 404;
throw err;
}
}
const nameClash = await db("http_auths").where({ name: authName }).first();
if (nameClash && (!existing || nameClash.id !== existing.id)) {
const err = new Error(`auth name "${authName}" already exists`);
err.statusCode = 409;
throw err;
}
const prevConfig = existing ? parseConfig(existing.config) : {};
const normalized = normalizeAuthConfig(authType, config, {
keepLiteralsFrom: prevConfig,
});
let unauthStatus = null;
if (unauthorized_status != null && unauthorized_status !== "") {
unauthStatus = assertHttpStatus(unauthorized_status, 401);
}
let unauthResponse = null;
if (
unauthorized_response != null &&
String(unauthorized_response).length > 0
) {
unauthResponse = String(unauthorized_response);
}
const now = nowIso();
const configJson = JSON.stringify(normalized);
if (existing) {
await db("http_auths")
.where({ id: existing.id })
.update({
name: authName,
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
updated_at: now,
});
return getHttpAuthById(/** @type {string} */ (existing.id));
}
const newId = randomUUID();
await db("http_auths").insert({
id: newId,
name: authName,
type: authType,
config: configJson,
unauthorized_status: unauthStatus,
unauthorized_response: unauthResponse,
created_at: now,
updated_at: now,
});
return getHttpAuthById(newId);
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function deleteHttpAuth(id) {
const authId = assertAuthId(id);
const n = await db("http_auths").where({ id: authId }).del();
return n > 0;
}
+399
View File
@@ -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;
}
+144
View File
@@ -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 ?? ""));
}