diff --git a/README.md b/README.md index 3a850ce..7ed7724 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ pnpm install pnpm dev ``` -- API: http://localhost:9000 +- API: http://localhost:8700 - UI (dev): http://localhost:5173 The first account created becomes **admin**. Later accounts are created from Users. @@ -52,9 +52,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / |---|---| | `pnpm dev` | Server + Vite together | | `pnpm dev:server` | API/runner only | -| `pnpm dev:web` | UI only (proxies `/api` to :9000) | +| `pnpm dev:web` | UI only (proxies `/api` to :8700) | | `pnpm build` | Production UI build | -| `pnpm start` | Serve API and built UI from :9000 | +| `pnpm start` | Serve API and built UI from :8700 | | `pnpm migrate` | Apply SQLite migrations | ## Environment @@ -67,7 +67,7 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / | `JFLOW_LOG_LEVEL` | `debug` | Pino level | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune | | `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev | -| `PORT` | `9000` | HTTP port | +| `PORT` | `8700` | HTTP port | | `NODE_ENV` | — | Set `production` for secure cookies | ## Production diff --git a/packages/server/docs/mt.http b/packages/server/docs/mt.http index 92f846c..d6d4597 100644 --- a/packages/server/docs/mt.http +++ b/packages/server/docs/mt.http @@ -1,41 +1,34 @@ ### Manual trigger (default owner) -POST http://localhost:9000/u/default/mt +POST http://localhost:8700/u/default/mt Content-Type: application/json 0 - ### -POST http://localhost:9000/u/default/time-to-ntfy +POST http://localhost:8700/u/default/time-to-ntfy Content-Type: application/json {} - -### Auth bootstrap -GET http://localhost:9000/api/auth/bootstrap - -### Login -POST http://localhost:9000/api/auth/login +### +GET http://localhost:8700/api/auth/bootstrap +### +POST http://localhost:8700/api/auth/login Content-Type: application/json { "username": "admin", "password": "changeme1" } - -### Dashboard -GET http://localhost:9000/api/dashboard - -### Runs -GET http://localhost:9000/api/runs?owner=default&limit=20 - -### Reregister -POST http://localhost:9000/api/workflows/reregister +### +GET http://localhost:8700/api/dashboard +### +GET http://localhost:8700/api/runs?owner=default&limit=20 +### +POST http://localhost:8700/api/workflows/reregister Content-Type: application/json {} - -### Run workflow manually -POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run +### +POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run Content-Type: application/json -{} +{} \ No newline at end of file diff --git a/packages/server/json-preview.js b/packages/server/json-preview.js new file mode 100644 index 0000000..c730632 --- /dev/null +++ b/packages/server/json-preview.js @@ -0,0 +1,43 @@ +const BUFFER_PREVIEW_BYTES = 16; + +/** + * @param {unknown} value + */ +export function isBinary(value) { + return ( + Buffer.isBuffer(value) || + ArrayBuffer.isView(value) || + value instanceof ArrayBuffer + ); +} + +/** + * Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number). + * @param {Buffer | ArrayBufferView | ArrayBuffer} value + */ +export function summarizeBinary(value) { + const buf = Buffer.isBuffer(value) + ? value + : value instanceof ArrayBuffer + ? Buffer.from(value) + : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES); + return { + type: "Buffer", + length: buf.length, + preview: buf.subarray(0, take).toString("hex"), + truncated: buf.length > take, + }; +} + +/** + * JSON.stringify replacer. Must be a real function so `this` is the holder: + * Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer. + * @param {string} key + * @param {unknown} value + */ +export function jsonPreviewReplacer(key, value) { + const raw = this[key]; + if (isBinary(raw)) return summarizeBinary(raw); + return value; +} diff --git a/packages/server/runner.js b/packages/server/runner.js index 87ca3c3..59a3984 100644 --- a/packages/server/runner.js +++ b/packages/server/runner.js @@ -174,7 +174,7 @@ async function shutdown() { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); -const port = Number(process.env.PORT ?? 9000); +const port = Number(process.env.PORT ?? 8700); server .listen({ diff --git a/packages/server/secret-value.js b/packages/server/secret-value.js index 149eda0..adc0a6c 100644 --- a/packages/server/secret-value.js +++ b/packages/server/secret-value.js @@ -1,6 +1,7 @@ import { inspect } from "node:util"; export const REDACTED = "[secret]"; +/** Short values are stored, but skipped in log redaction to avoid false positives. */ export const MIN_SECRET_LENGTH = 8; /** @type {Set} */ diff --git a/packages/server/secrets-store.js b/packages/server/secrets-store.js index 777f743..a23e949 100644 --- a/packages/server/secrets-store.js +++ b/packages/server/secrets-store.js @@ -2,7 +2,7 @@ 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"; +import { registerPlaintext } from "./secret-value.js"; const MAX_NAME_LENGTH = 128; const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; @@ -68,8 +68,8 @@ export async function getSecretById(id) { * @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`); + if (typeof value !== "string" || value.length === 0) { + const err = new Error("value is required"); err.statusCode = 400; throw err; } diff --git a/packages/server/src/api/dry-run-logger.js b/packages/server/src/api/dry-run-logger.js index 7f347c0..660322c 100644 --- a/packages/server/src/api/dry-run-logger.js +++ b/packages/server/src/api/dry-run-logger.js @@ -1,4 +1,5 @@ import pino from "pino"; +import { isBinary, summarizeBinary } from "../../json-preview.js"; import { redactString } from "../../secret-value.js"; const LEVEL_TO_NUM = { @@ -64,7 +65,9 @@ export function safeSerialize(value) { try { return JSON.parse( redactString( - JSON.stringify(value, (_key, v) => { + JSON.stringify(value, function (key, v) { + const raw = this[key]; + if (isBinary(raw)) return summarizeBinary(raw); if (typeof v === "bigint") return v.toString(); if (typeof v === "object" && v !== null) { if (seen.has(v)) return "[Circular]"; diff --git a/packages/server/src/api/secrets.js b/packages/server/src/api/secrets.js index d3f75e3..28147ef 100644 --- a/packages/server/src/api/secrets.js +++ b/packages/server/src/api/secrets.js @@ -6,7 +6,6 @@ import { listSecrets, upsertSecret, } from "../../secrets-store.js"; -import { MIN_SECRET_LENGTH } from "../../secret-value.js"; /** * @param {import("fastify").FastifyInstance} fastify @@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) { } 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` }); + if (value.length === 0) { + return reply.code(400).send({ error: "value is required" }); } try { diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index f017f58..116d79f 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -418,7 +418,7 @@ export default function workflowsPluginFactory(registry) { return { runId: result.runId, status: result.status, - result: result.result, + result: store.toDisplayValue(result.result), }; }); diff --git a/packages/server/store.js b/packages/server/store.js index d8510dd..6132725 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { db } from "./db.js"; +import { jsonPreviewReplacer } from "./json-preview.js"; import { redactString } from "./secret-value.js"; const MAX_JSON_BYTES = 64 * 1024; @@ -12,7 +13,7 @@ export function serialize(value) { if (value === undefined || value === null) return null; let json; try { - json = JSON.stringify(value); + json = JSON.stringify(value, jsonPreviewReplacer); } catch { json = JSON.stringify({ truncated: true, reason: "unserializable" }); } @@ -24,6 +25,20 @@ export function serialize(value) { }); } +/** + * Parsed JSON-safe copy for API / UI (buffers summarized, size-capped). + * @param {unknown} value + */ +export function toDisplayValue(value) { + const json = serialize(value); + if (json == null) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + /** * @param {string | null} value * @returns {unknown} diff --git a/packages/server/test/json-preview-smoke.js b/packages/server/test/json-preview-smoke.js new file mode 100644 index 0000000..bb87cda --- /dev/null +++ b/packages/server/test/json-preview-smoke.js @@ -0,0 +1,56 @@ +import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js"; +import { serialize, toDisplayValue } from "../store.js"; +import { safeSerialize } from "../src/api/dry-run-logger.js"; + +const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); +const summary = summarizeBinary(png); +if (summary.length !== png.length || summary.preview !== "89504e470d0a1a0a010203" || summary.truncated !== false) { + throw new Error(`summarizeBinary: ${JSON.stringify(summary)}`); +} + +const long = Buffer.alloc(32, 0xff); +const longSummary = summarizeBinary(long); +if (longSummary.length !== 32 || longSummary.preview.length !== 32 || longSummary.truncated !== true) { + throw new Error(`long summarizeBinary: ${JSON.stringify(longSummary)}`); +} + +const dumped = JSON.stringify({ file: png }); +if (!dumped.includes('"data":[')) { + throw new Error("expected default Buffer JSON to include data array"); +} + +const previewed = JSON.stringify({ file: png }, jsonPreviewReplacer); +if (previewed.includes('"data":[')) { + throw new Error(`replacer still dumped bytes: ${previewed}`); +} +if (!previewed.includes('"preview":"89504e470d0a1a0a010203"')) { + throw new Error(`replacer missing hex preview: ${previewed}`); +} + +const stored = serialize({ output: { file: png, filename: "test.png" } }); +if (stored.includes('"data":[')) { + throw new Error(`serialize dumped bytes: ${stored.slice(0, 200)}`); +} + +const display = toDisplayValue({ + output: { file: png }, + context: { file: png }, +}); +if (display.output.file.length !== png.length || display.context.file.truncated !== false) { + throw new Error(`toDisplayValue: ${JSON.stringify(display)}`); +} +if (Array.isArray(display.output.file.data)) { + throw new Error("toDisplayValue should not keep Buffer.data"); +} + +const dry = safeSerialize({ file: png, n: 1n }); +if (dry.n !== "1" || Array.isArray(dry.file.data)) { + throw new Error(`safeSerialize: ${JSON.stringify(dry)}`); +} + +const typed = safeSerialize({ file: new Uint8Array(png) }); +if (typed.file.length !== png.length || typed.file.type !== "Buffer") { + throw new Error(`Uint8Array: ${JSON.stringify(typed)}`); +} + +console.log("json-preview-smoke: ok"); diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index 7273056..945766e 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -10,3 +10,4 @@ scripts: - test-send-gmail.yaml - track.yaml - rss-devto-to-ntfy.yaml + - test-minio.yaml diff --git a/packages/server/workflows/default/test-minio.yaml b/packages/server/workflows/default/test-minio.yaml new file mode 100644 index 0000000..fc745f1 --- /dev/null +++ b/packages/server/workflows/default/test-minio.yaml @@ -0,0 +1,20 @@ +name: Test MinIO +scripts: + - script: fetch-binary.js + config: + outputVar: file + url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S + filename: test.png + - script: s3.js + config: + action: write + endpoint: http://localhost:9000 + bucket: default + forcePathStyle: true + accessKeyIdSecret: minio_user + secretAccessKeySecret: minio_pass + key: file.png +triggers: + - type: HTTP + method: POST + path: /new diff --git a/packages/web/src/pages/SecretsPage.jsx b/packages/web/src/pages/SecretsPage.jsx index c5456ae..dfff63c 100644 --- a/packages/web/src/pages/SecretsPage.jsx +++ b/packages/web/src/pages/SecretsPage.jsx @@ -180,11 +180,11 @@ export function SecretsPage() { value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} required - minLength={8} autoComplete="new-password" />

Values are encrypted at rest and never shown again after save. + Values shorter than 8 characters are not redacted from logs.

{upsert.isError ? (

{errorMessage(upsert.error)}

diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js index 0c6e009..e345a98 100644 --- a/packages/web/vite.config.js +++ b/packages/web/vite.config.js @@ -7,8 +7,8 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": { target: "http://127.0.0.1:9000", changeOrigin: true }, - "/admin": { target: "http://127.0.0.1:9000", changeOrigin: true }, + "/api": { target: "http://127.0.0.1:8700", changeOrigin: true }, + "/admin": { target: "http://127.0.0.1:8700", changeOrigin: true }, }, }, });