feat(server): enhance secret management and logging functionality

- Added SCRUNNER_SECRETS_KEY to README as a required variable for production.
- Implemented redaction of sensitive information in logs across various components.
- Enhanced script execution functions to include an owner parameter for better secret management.
- Introduced a secrets API in the script sandbox for retrieving and managing secrets.
- Updated UI components to support owner selection for script execution and secret management.
This commit is contained in:
2026-08-14 22:11:03 +07:00
parent 94aa5b2efa
commit 3e4d5f443d
19 changed files with 886 additions and 39 deletions
+2 -1
View File
@@ -35,6 +35,7 @@ The first account created becomes **admin**. Later accounts are created from Use
| Variable | Default | Notes |
|---|---|---|
| `SCRUNNER_JWT_SECRET` | `scrunner-dev-secret` (dev only) | **Required in production** |
| `SCRUNNER_SECRETS_KEY` | `scrunner-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
| `SCRUNNER_DB_PATH` | `packages/server/data/scrunner.db` | SQLite file |
| `SCRUNNER_LOG_LEVEL` | `debug` | Pino level |
| `SCRUNNER_RETENTION_DAYS` | `30` | Run history prune |
@@ -47,7 +48,7 @@ The first account created becomes **admin**. Later accounts are created from Use
```bash
pnpm install
pnpm build
SCRUNNER_JWT_SECRET=... NODE_ENV=production pnpm start
SCRUNNER_JWT_SECRET=... SCRUNNER_SECRETS_KEY=... NODE_ENV=production pnpm start
```
The server serves `packages/web/dist` when that folder exists.
+16 -4
View File
@@ -3,6 +3,18 @@ import path from "path";
import pino from "pino";
import * as store from "./store.js";
import { LOGS_DIR } from "./paths.js";
import { redactString } from "./secret-value.js";
/**
* @param {{ write: (line: string) => unknown }} dest
*/
function redactStream(dest) {
return {
write(line) {
dest.write(redactString(typeof line === "string" ? line : String(line)));
},
};
}
const LEVEL_TO_NUM = {
trace: 10,
@@ -27,7 +39,7 @@ let timer = null;
function enqueueLine(line) {
let record;
try {
record = JSON.parse(line);
record = JSON.parse(redactString(line));
} catch {
return;
}
@@ -103,9 +115,9 @@ const rollingFile = pino.transport({
export const log = pino(
{ level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
pino.multistream([
{ level: "debug", stream: process.stdout },
{ level: "debug", stream: rollingFile },
{ level: "debug", stream: sqliteStream },
{ level: "debug", stream: redactStream(process.stdout) },
{ level: "debug", stream: redactStream(rollingFile) },
{ level: "debug", stream: redactStream(sqliteStream) },
]),
);
@@ -0,0 +1,27 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("secrets", (t) => {
t.text("id").primary();
t.text("owner").notNullable();
t.text("name").notNullable();
t.text("ciphertext").notNullable();
t.text("iv").notNullable();
t.text("auth_tag").notNullable();
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
t.unique(["owner", "name"]);
});
await knex.schema.raw(
"CREATE INDEX secrets_owner_name_idx ON secrets (owner, name)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("secrets");
}
+11 -6
View File
@@ -207,12 +207,13 @@ export function createRegistry(server) {
* @param {string} runId
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
*/
async function runLinearSteps(compiled, ctx, runId, runLog, key) {
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner) {
let next = ctx;
for (const index of compiled.order) {
const parsed = compiled.steps[index];
next = await runCompiledStep(parsed, next, index, runId, runLog, key);
next = await runCompiledStep(parsed, next, index, runId, runLog, key, owner);
}
return next;
}
@@ -223,8 +224,9 @@ export function createRegistry(server) {
* @param {string} runId
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
*/
async function runDagSteps(compiled, ctx, runId, runLog, key) {
async function runDagSteps(compiled, ctx, runId, runLog, key, owner) {
const triggerData = ctx.data;
/** @type {Map<string, unknown>} */
const outputsById = new Map();
@@ -240,6 +242,7 @@ export function createRegistry(server) {
runId,
runLog,
key,
owner,
);
if (parsed.id) {
outputsById.set(parsed.id, last);
@@ -255,8 +258,9 @@ export function createRegistry(server) {
* @param {string} runId
* @param {import("pino").Logger} runLog
* @param {string} key
* @param {string} owner
*/
async function runCompiledStep(parsed, ctx, index, runId, runLog, key) {
async function runCompiledStep(parsed, ctx, index, runId, runLog, key, owner) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const config = parsed.config;
const step = await store.startStep({
@@ -287,6 +291,7 @@ export function createRegistry(server) {
const result = await runScript(script, { ...ctx, config }, {
log: stepLog,
workflowName: key,
owner,
});
await store.finishStep(step.id, "success", result);
return result;
@@ -327,9 +332,9 @@ export function createRegistry(server) {
try {
const compiled = compileWorkflowScripts(workflow.scripts);
if (compiled.dagMode) {
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key);
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key, owner);
} else {
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key);
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key, owner);
}
await store.finishRun(run.id, "success", ctx);
return { runId: run.id, status: "success", result: ctx };
+10
View File
@@ -15,7 +15,9 @@ import scriptsPluginFactory from "./src/api/scripts.js";
import workflowsPluginFactory from "./src/api/workflows.js";
import runsPlugin from "./src/api/runs.js";
import dashboardPluginFactory from "./src/api/dashboard.js";
import secretsPlugin from "./src/api/secrets.js";
import { WEB_DIST } from "./paths.js";
import { resolveSecretsKeyMaterial } from "./secrets.js";
await migrate();
enableLogPersistence();
@@ -29,6 +31,13 @@ if (!jwtSecret) {
process.exit(1);
}
try {
resolveSecretsKeyMaterial();
} catch (err) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const server = fastify({ loggerInstance: log });
await server.register(cookie);
@@ -78,6 +87,7 @@ await server.register(
});
await api.register(authPlugin);
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin);
+62 -13
View File
@@ -6,6 +6,8 @@ import axios from "axios";
import pino from "pino";
import { createKvApi } from "./kv-store.js";
import { SCRIPTS_DIR } from "./paths.js";
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
import { getSecretPlaintext } from "./secrets-store.js";
const hostRequire = createRequire(import.meta.url);
@@ -256,7 +258,26 @@ function createScreenedAxios(log) {
screenRequestUrl(url, log);
return config;
});
return instance;
for (const method of [
"request",
"get",
"delete",
"head",
"options",
"post",
"put",
"patch",
]) {
const orig = instance[method].bind(instance);
instance[method] = (...args) => orig(...unwrapSecretsDeep(args));
}
return new Proxy(instance, {
apply(_target, _thisArg, args) {
return instance.request(...args);
},
});
}
function createRestrictedRequire(screenedAxios) {
@@ -272,18 +293,45 @@ function createRestrictedRequire(screenedAxios) {
}
/**
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
* @param {string} owner
*/
function createScriptSandbox({ log, script, workflowName }) {
function createSecretsApi(owner) {
return {
/**
* @param {string} name
*/
async get(name) {
const value = await getSecretPlaintext(owner, name);
if (value == null) {
throw new Error(`secret "${name}" not found`);
}
return new Secret(value);
},
/**
* @param {unknown} value
*/
reveal(value) {
if (isSecret(value)) return value.reveal();
throw new Error("reveal() expects a Secret from $secrets.get()");
},
};
}
/**
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
*/
function createScriptSandbox({ log, script, workflowName, owner = "default" }) {
const scriptLog = log.child({ workflow: workflowName, script });
const $axios = createScreenedAxios(scriptLog);
const $kv = createKvApi(workflowName);
const $secrets = createSecretsApi(owner);
const sandbox = {
...pickBuiltins(),
log: scriptLog,
console: createConsole(scriptLog),
$axios,
$kv,
$secrets,
require: createRestrictedRequire($axios),
};
@@ -328,10 +376,10 @@ export function extractScriptMeta(fn) {
/**
* @param {import("node:vm").Script} compiled
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
*/
function instantiateCompiled(compiled, { log, script, workflowName }) {
const sandbox = createScriptSandbox({ log, script, workflowName });
function instantiateCompiled(compiled, { log, script, workflowName, owner }) {
const sandbox = createScriptSandbox({ log, script, workflowName, owner });
return compiled.runInContext(sandbox);
}
@@ -341,7 +389,7 @@ function instantiateCompiled(compiled, { log, script, workflowName }) {
*
* @param {string} script
* @param {string} source
* @param {{ log?: import("pino").Logger, workflowName?: string }} [opts]
* @param {{ log?: import("pino").Logger, workflowName?: string, owner?: string }} [opts]
*/
export function instantiateScriptSource(script, source, opts = {}) {
const compiled = compileScriptSource(source, script);
@@ -349,6 +397,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
log: opts.log ?? inspectLog,
script,
workflowName: opts.workflowName ?? "inspect",
owner: opts.owner ?? "default",
});
return { fn, ...extractScriptMeta(fn) };
}
@@ -390,11 +439,11 @@ function loadCompiledScript(script) {
*
* @param {string} script
* @param {unknown} ctx
* @param {{ log: import("pino").Logger, workflowName: string }} opts
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
*/
export async function runScript(script, ctx, { log, workflowName }) {
export async function runScript(script, ctx, { log, workflowName, owner }) {
const compiled = loadCompiledScript(script);
const fn = instantiateCompiled(compiled, { log, script, workflowName });
const fn = instantiateCompiled(compiled, { log, script, workflowName, owner });
return await fn(ctx);
}
@@ -404,9 +453,9 @@ export async function runScript(script, ctx, { log, workflowName }) {
* @param {string} script
* @param {string} source
* @param {unknown} ctx
* @param {{ log: import("pino").Logger, workflowName: string }} opts
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
*/
export async function runScriptSource(script, source, ctx, { log, workflowName }) {
const { fn } = instantiateScriptSource(script, source, { log, workflowName });
export async function runScriptSource(script, source, ctx, { log, workflowName, owner }) {
const { fn } = instantiateScriptSource(script, source, { log, workflowName, owner });
return await fn(ctx);
}
+50
View File
@@ -0,0 +1,50 @@
async function getSecret(ctx) {
const name = ctx.config?.name;
if (typeof name !== "string" || name.length === 0) {
throw new Error("config.name is required");
}
const as =
typeof ctx.config?.as === "string" && ctx.config.as.length > 0
? ctx.config.as
: name;
const value = await $secrets.get(name);
const base =
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {};
const data =
base.data != null && typeof base.data === "object" && !Array.isArray(base.data)
? { ...base.data }
: {};
data[as] = value;
return { ...base, data };
}
getSecret.meta = {
description:
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
config: {
name: {
type: "string",
required: true,
description: "Secret name (per owner)",
},
as: {
type: "string",
required: false,
description: "ctx.data field to write (defaults to name)",
},
},
input: {},
output: {
data: {
type: "object",
description: "Previous ctx.data plus the retrieved Secret at [as]",
},
},
example: {
data: {},
config: { name: "ntfy_token", as: "ntfyToken" },
},
};
export default getSecret;
+101
View File
@@ -0,0 +1,101 @@
import { inspect } from "node:util";
export const REDACTED = "[secret]";
export const MIN_SECRET_LENGTH = 8;
/** @type {Set<string>} */
const plaintextValues = new Set();
/**
* @param {string} value
*/
export function registerPlaintext(value) {
if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) {
plaintextValues.add(value);
}
}
/**
* Replace registered secret strings in text (raw and JSON-escaped forms).
* @param {string} text
*/
export function redactString(text) {
if (typeof text !== "string" || plaintextValues.size === 0) return text;
let out = text;
for (const secret of plaintextValues) {
if (out.includes(secret)) {
out = out.split(secret).join("***");
}
const escaped = JSON.stringify(secret).slice(1, -1);
if (escaped !== secret && out.includes(escaped)) {
out = out.split(escaped).join("***");
}
}
return out;
}
export class Secret {
/** @type {string} */
#value;
/**
* @param {string} value
*/
constructor(value) {
if (typeof value !== "string") {
throw new Error("secret value must be a string");
}
this.#value = value;
registerPlaintext(value);
}
reveal() {
return this.#value;
}
toString() {
return REDACTED;
}
toJSON() {
return REDACTED;
}
[inspect.custom]() {
return REDACTED;
}
[Symbol.toPrimitive]() {
return REDACTED;
}
}
/**
* @param {unknown} value
*/
export function isSecret(value) {
return value instanceof Secret;
}
/**
* @param {unknown} value
* @param {WeakSet<object>} [seen]
*/
export function unwrapSecretsDeep(value, seen = new WeakSet()) {
if (isSecret(value)) return value.reveal();
if (value == null || typeof value !== "object") return value;
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return value;
if (seen.has(value)) return value;
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => unwrapSecretsDeep(item, seen));
}
/** @type {Record<string, unknown>} */
const out = {};
for (const [key, child] of Object.entries(value)) {
out[key] = unwrapSecretsDeep(child, seen);
}
return out;
}
+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 { MIN_SECRET_LENGTH, 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 < MIN_SECRET_LENGTH) {
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
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}"`);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
const DEV_DEFAULT = "scrunner-dev-secrets-key";
const SCRYPT_SALT = Buffer.from("scrunner-secrets-v1");
const KEY_LEN = 32;
const IV_LEN = 12;
const AUTH_TAG_LEN = 16;
/**
* @returns {string}
*/
export function resolveSecretsKeyMaterial() {
const raw =
process.env.SCRUNNER_SECRETS_KEY ??
(process.env.NODE_ENV === "production" ? "" : DEV_DEFAULT);
if (!raw) {
throw new Error("SCRUNNER_SECRETS_KEY is required in production");
}
return raw;
}
/** @type {Buffer | null} */
let cachedKey = null;
/**
* @returns {Buffer}
*/
export function getMasterKey() {
if (cachedKey) return cachedKey;
const raw = resolveSecretsKeyMaterial();
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
? Buffer.from(raw, "hex")
: scryptSync(raw, SCRYPT_SALT, KEY_LEN);
return cachedKey;
}
/**
* @param {string} plaintext
* @returns {{ ciphertext: string, iv: string, authTag: string }}
*/
export function encryptSecret(plaintext) {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv("aes-256-gcm", getMasterKey(), iv, {
authTagLength: AUTH_TAG_LEN,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
return {
ciphertext: encrypted.toString("base64"),
iv: iv.toString("base64"),
authTag: cipher.getAuthTag().toString("base64"),
};
}
/**
* @param {{ ciphertext: string, iv: string, authTag: string }} row
* @returns {string}
*/
export function decryptSecret(row) {
const decipher = createDecipheriv(
"aes-256-gcm",
getMasterKey(),
Buffer.from(row.iv, "base64"),
{ authTagLength: AUTH_TAG_LEN },
);
decipher.setAuthTag(Buffer.from(row.authTag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(row.ciphertext, "base64")),
decipher.final(),
]).toString("utf8");
}
+7 -2
View File
@@ -1,4 +1,5 @@
import pino from "pino";
import { redactString } from "../../secret-value.js";
const LEVEL_TO_NUM = {
trace: 10,
@@ -22,7 +23,9 @@ export function createDryRunLogger() {
write(line) {
let record;
try {
record = JSON.parse(typeof line === "string" ? line : String(line));
record = JSON.parse(
redactString(typeof line === "string" ? line : String(line)),
);
} catch {
return;
}
@@ -43,7 +46,7 @@ export function createDryRunLogger() {
logs.push({
ts,
level,
msg,
msg: typeof msg === "string" ? redactString(msg) : msg,
payload: Object.keys(rest).length ? rest : null,
});
},
@@ -60,6 +63,7 @@ export function safeSerialize(value) {
const seen = new WeakSet();
try {
return JSON.parse(
redactString(
JSON.stringify(value, (_key, v) => {
if (typeof v === "bigint") return v.toString();
if (typeof v === "object" && v !== null) {
@@ -68,6 +72,7 @@ export function safeSerialize(value) {
}
return v;
}),
),
);
} catch (err) {
return {
+11 -1
View File
@@ -85,13 +85,22 @@ export default function scriptsPluginFactory(registry) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const body = /** @type {{ content?: string, data?: unknown, config?: unknown }} */ (
const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {}
);
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
let owner = "default";
if (body.owner != null && body.owner !== "") {
try {
owner = fsStore.assertOwner(String(body.owner));
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
}
const ctx = {
data: body.data ?? null,
config: body.config ?? null,
@@ -104,6 +113,7 @@ export default function scriptsPluginFactory(registry) {
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
log,
workflowName: "dry-run",
owner,
});
const output = await fn(ctx);
return {
+67
View File
@@ -0,0 +1,67 @@
import * as fsStore from "../../fs-store.js";
import {
assertSecretName,
deleteSecret,
getSecretById,
listSecrets,
upsertSecret,
} from "../../secrets-store.js";
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function secretsPlugin(fastify) {
fastify.addHook("onRequest", fastify.requireAdmin);
fastify.get("/secrets", async (req, reply) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
try {
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
const secrets = await listSecrets({ owner });
return { secrets };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.put("/secrets", async (req, reply) => {
const body = /** @type {{ owner?: string, name?: string, value?: string }} */ (
req.body ?? {}
);
try {
fsStore.assertOwner(String(body.owner ?? ""));
assertSecretName(String(body.name ?? ""));
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const value = String(body.value ?? "");
if (value.length < MIN_SECRET_LENGTH) {
return reply
.code(400)
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
}
try {
const secret = await upsertSecret({
owner: String(body.owner),
name: String(body.name),
value,
});
return reply.send({ secret });
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.delete("/secrets/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getSecretById(id);
if (!existing) {
return reply.code(404).send({ error: "secret not found" });
}
await deleteSecret(id);
return { ok: true };
});
}
+2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { redactString } from "./secret-value.js";
const MAX_JSON_BYTES = 64 * 1024;
@@ -15,6 +16,7 @@ export function serialize(value) {
} catch {
json = JSON.stringify({ truncated: true, reason: "unserializable" });
}
json = redactString(json);
if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json;
return JSON.stringify({
truncated: true,
+7
View File
@@ -13,6 +13,7 @@ import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"
import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx";
export function App() {
const qc = useQueryClient();
@@ -57,9 +58,15 @@ export function App() {
<Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
{user.role === "admin" ? (
<>
<Route path="/users" element={<UsersPage />} />
<Route path="/secrets" element={<SecretsPage />} />
</>
) : (
<>
<Route path="/users" element={<Navigate to="/" replace />} />
<Route path="/secrets" element={<Navigate to="/" replace />} />
</>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+29 -1
View File
@@ -107,12 +107,13 @@ export function useDeleteScript() {
export function useDryRunScript() {
return useMutation({
mutationFn: async ({ name, content, data, config }) =>
mutationFn: async ({ name, content, data, config, owner }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content,
data,
config,
owner,
})
).data,
});
@@ -267,3 +268,30 @@ export function useDeleteUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useSecrets(owner) {
return useQuery({
queryKey: ["secrets", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/secrets", { params })).data.secrets;
},
});
}
export function useUpsertSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/secrets", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
export function useDeleteSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
+5 -1
View File
@@ -4,6 +4,7 @@ import {
LuCode,
LuGitBranch,
LuHouse,
LuKey,
LuLogOut,
LuMenu,
LuMoon,
@@ -28,7 +29,10 @@ export function Layout({ user, children }) {
const navItems = [
...links,
...(user.role === "admin"
? [{ to: "/users", label: "Users", icon: LuUsers }]
? [
{ to: "/secrets", label: "Secrets", icon: LuKey },
{ to: "/users", label: "Users", icon: LuUsers },
]
: []),
];
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDryRunScript,
useOwners,
useSaveScript,
useScript,
} from "../api/hooks.js";
@@ -27,6 +28,8 @@ export function ScriptDryRunPage() {
const existing = useScript(name, stateContent == null);
const dryRun = useDryRunScript();
const save = useSaveScript();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("default");
const [content, setContent] = useState("");
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
@@ -116,6 +119,7 @@ export function ScriptDryRunPage() {
content,
data: ctx.data,
config: ctx.config,
owner,
});
}
@@ -136,6 +140,21 @@ export function ScriptDryRunPage() {
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
) : null}
<div className="flex-1" />
<label className="flex items-center gap-1 text-sm">
<span className="opacity-60 hidden sm:inline">Owner</span>
<select
className="select select-sm"
value={owner}
onChange={(e) => setOwner(e.target.value)}
>
{owners.includes(owner) ? null : <option value={owner}>{owner}</option>}
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-primary btn-sm"
+233
View File
@@ -0,0 +1,233 @@
import { useState } from "react";
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteSecret,
useOwners,
useSecrets,
useUpsertSecret,
} from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
export function SecretsPage() {
const { data: owners = [] } = useOwners();
const [ownerFilter, setOwnerFilter] = useState("");
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
const upsert = useUpsertSecret();
const del = useDeleteSecret();
const [mode, setMode] = useState(null);
const [form, setForm] = useState({
owner: "",
name: "",
value: "",
});
const [confirmDelete, setConfirmDelete] = useState(null);
function openAdd() {
setMode("add");
setForm({
owner: ownerFilter || owners[0] || "default",
name: "",
value: "",
});
}
function openReplace(s) {
setMode("replace");
setForm({ owner: s.owner, name: s.name, value: "" });
}
function closeForm() {
setMode(null);
setForm({ owner: "", name: "", value: "" });
}
function onSubmit(e) {
e.preventDefault();
upsert.mutate(
{ owner: form.owner, name: form.name, value: form.value },
{ onSuccess: closeForm },
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Secrets</h1>
<div className="flex gap-2">
<select
className="select select-sm"
value={ownerFilter}
onChange={(e) => setOwnerFilter(e.target.value)}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<LuPlus className="size-4" />
Add
</button>
</div>
</div>
{isLoading ? (
<span className="loading loading-spinner" />
) : secrets.length === 0 ? (
<p className="text-sm opacity-60">No secrets yet.</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Owner</th>
<th>Name</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{secrets.map((s) => (
<tr key={s.id} className="hover">
<td className="font-mono">{s.owner}</td>
<td className="font-mono">{s.name}</td>
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Replace value"
onClick={() => openReplace(s)}
>
<LuPencil className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete(s)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{mode ? (
<form
onSubmit={onSubmit}
className="fieldset bg-base-100 border-base-300 rounded-box max-w-md border p-4"
>
<div className="flex items-center justify-between">
<legend className="fieldset-legend">
{mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`}
</legend>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
onClick={closeForm}
aria-label="Close"
>
<LuX className="size-4" />
</button>
</div>
{mode === "add" ? (
<>
<label className="label">Owner</label>
{owners.length > 0 ? (
<select
className="select w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : (
<input
className="input w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
/>
)}
<label className="label">Name</label>
<input
className="input w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</>
) : null}
<label className="label">Value</label>
<input
type="password"
className="input w-full"
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
required
minLength={8}
autoComplete="new-password"
/>
<p className="text-xs opacity-60 mt-1">
Values are encrypted at rest and never shown again after save.
</p>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
) : null}
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
Save
</button>
</form>
) : null}
{confirmDelete ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">
Delete {confirmDelete.owner}/{confirmDelete.name}?
</h3>
<p className="text-sm mt-2">This cannot be undone. Workflows that retrieve this name will fail.</p>
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
Cancel
</button>
<button
type="button"
className="btn btn-error"
disabled={del.isPending}
onClick={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
>
Delete
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setConfirmDelete(null)}>
close
</button>
</form>
</dialog>
) : null}
</div>
);
}