Merge branch 'main' of https://github.com/nasyarobby/jerapah-flow into cursor/ftp-sftp-script-3038
This commit is contained in:
@@ -16,7 +16,7 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- API: http://localhost:9000
|
- API: http://localhost:8700
|
||||||
- UI (dev): http://localhost:5173
|
- UI (dev): http://localhost:5173
|
||||||
|
|
||||||
The first account created becomes **admin**. Later accounts are created from Users.
|
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 + Vite together |
|
||||||
| `pnpm dev:server` | API/runner only |
|
| `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 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 |
|
| `pnpm migrate` | Apply SQLite migrations |
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
@@ -67,7 +67,7 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
| `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 |
|
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
### Manual trigger (default owner)
|
### Manual trigger (default owner)
|
||||||
POST http://localhost:9000/u/default/mt
|
POST http://localhost:8700/u/default/mt
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
0
|
0
|
||||||
|
|
||||||
###
|
###
|
||||||
POST http://localhost:9000/u/default/time-to-ntfy
|
POST http://localhost:8700/u/default/time-to-ntfy
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
###
|
||||||
### Auth bootstrap
|
GET http://localhost:8700/api/auth/bootstrap
|
||||||
GET http://localhost:9000/api/auth/bootstrap
|
###
|
||||||
|
POST http://localhost:8700/api/auth/login
|
||||||
### Login
|
|
||||||
POST http://localhost:9000/api/auth/login
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "changeme1"
|
"password": "changeme1"
|
||||||
}
|
}
|
||||||
|
###
|
||||||
### Dashboard
|
GET http://localhost:8700/api/dashboard
|
||||||
GET http://localhost:9000/api/dashboard
|
###
|
||||||
|
GET http://localhost:8700/api/runs?owner=default&limit=20
|
||||||
### Runs
|
###
|
||||||
GET http://localhost:9000/api/runs?owner=default&limit=20
|
POST http://localhost:8700/api/workflows/reregister
|
||||||
|
|
||||||
### Reregister
|
|
||||||
POST http://localhost:9000/api/workflows/reregister
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
###
|
||||||
### Run workflow manually
|
POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run
|
||||||
POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ async function shutdown() {
|
|||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 9000);
|
const port = Number(process.env.PORT ?? 8700);
|
||||||
|
|
||||||
server
|
server
|
||||||
.listen({
|
.listen({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inspect } from "node:util";
|
import { inspect } from "node:util";
|
||||||
|
|
||||||
export const REDACTED = "[secret]";
|
export const REDACTED = "[secret]";
|
||||||
|
/** Short values are stored, but skipped in log redaction to avoid false positives. */
|
||||||
export const MIN_SECRET_LENGTH = 8;
|
export const MIN_SECRET_LENGTH = 8;
|
||||||
|
|
||||||
/** @type {Set<string>} */
|
/** @type {Set<string>} */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { assertOwner } from "./fs-store.js";
|
import { assertOwner } from "./fs-store.js";
|
||||||
import { decryptSecret, encryptSecret } from "./secrets.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 MAX_NAME_LENGTH = 128;
|
||||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
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
|
* @param {{ owner: string, name: string, value: string }} opts
|
||||||
*/
|
*/
|
||||||
export async function upsertSecret({ owner, name, value }) {
|
export async function upsertSecret({ owner, name, value }) {
|
||||||
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
|
const err = new Error("value is required");
|
||||||
err.statusCode = 400;
|
err.statusCode = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
import { isBinary, summarizeBinary } from "../../json-preview.js";
|
||||||
import { redactString } from "../../secret-value.js";
|
import { redactString } from "../../secret-value.js";
|
||||||
|
|
||||||
const LEVEL_TO_NUM = {
|
const LEVEL_TO_NUM = {
|
||||||
@@ -64,7 +65,9 @@ export function safeSerialize(value) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
redactString(
|
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 === "bigint") return v.toString();
|
||||||
if (typeof v === "object" && v !== null) {
|
if (typeof v === "object" && v !== null) {
|
||||||
if (seen.has(v)) return "[Circular]";
|
if (seen.has(v)) return "[Circular]";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
listSecrets,
|
listSecrets,
|
||||||
upsertSecret,
|
upsertSecret,
|
||||||
} from "../../secrets-store.js";
|
} from "../../secrets-store.js";
|
||||||
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
@@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = String(body.value ?? "");
|
const value = String(body.value ?? "");
|
||||||
if (value.length < MIN_SECRET_LENGTH) {
|
if (value.length === 0) {
|
||||||
return reply
|
return reply.code(400).send({ error: "value is required" });
|
||||||
.code(400)
|
|
||||||
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return {
|
return {
|
||||||
runId: result.runId,
|
runId: result.runId,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
result: result.result,
|
result: store.toDisplayValue(result.result),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
|
import { jsonPreviewReplacer } from "./json-preview.js";
|
||||||
import { redactString } from "./secret-value.js";
|
import { redactString } from "./secret-value.js";
|
||||||
|
|
||||||
const MAX_JSON_BYTES = 64 * 1024;
|
const MAX_JSON_BYTES = 64 * 1024;
|
||||||
@@ -12,7 +13,7 @@ export function serialize(value) {
|
|||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
let json;
|
let json;
|
||||||
try {
|
try {
|
||||||
json = JSON.stringify(value);
|
json = JSON.stringify(value, jsonPreviewReplacer);
|
||||||
} catch {
|
} catch {
|
||||||
json = JSON.stringify({ truncated: true, reason: "unserializable" });
|
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
|
* @param {string | null} value
|
||||||
* @returns {unknown}
|
* @returns {unknown}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -10,3 +10,4 @@ scripts:
|
|||||||
- test-send-gmail.yaml
|
- test-send-gmail.yaml
|
||||||
- track.yaml
|
- track.yaml
|
||||||
- rss-devto-to-ntfy.yaml
|
- rss-devto-to-ntfy.yaml
|
||||||
|
- 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
|
||||||
@@ -180,11 +180,11 @@ export function SecretsPage() {
|
|||||||
value={form.value}
|
value={form.value}
|
||||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||||
required
|
required
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs opacity-60 mt-1">
|
<p className="text-xs opacity-60 mt-1">
|
||||||
Values are encrypted at rest and never shown again after save.
|
Values are encrypted at rest and never shown again after save.
|
||||||
|
Values shorter than 8 characters are not redacted from logs.
|
||||||
</p>
|
</p>
|
||||||
{upsert.isError ? (
|
{upsert.isError ? (
|
||||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { 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:9000", changeOrigin: true },
|
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user