From a6d83eb7cb2b0b41223aa0edd5ed3b93bc1ac13f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 00:07:04 +0000 Subject: [PATCH 1/5] feat(scripts): add s3 script for S3-compatible object storage Co-authored-by: Nasyarobby Putra --- packages/server/package.json | 2 + packages/server/script-sandbox.js | 2 + packages/server/scripts/s3.js | 508 ++++++++++++++++++++++++++++++ pnpm-lock.yaml | 318 +++++++++++++++++++ 4 files changed, 830 insertions(+) create mode 100644 packages/server/scripts/s3.js diff --git a/packages/server/package.json b/packages/server/package.json index c3c1eb5..8a8b4c5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,6 +10,8 @@ "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1111.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", "@fastify/jwt": "^9.1.0", diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index 4b7c7c7..d02cdc1 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -15,6 +15,8 @@ import { getVariablePlain } from "./variables-store.js"; const hostRequire = createRequire(import.meta.url); const ALLOWED_MODULES = new Set([ + "@aws-sdk/client-s3", + "@aws-sdk/s3-request-presigner", "axios", "jsonata", "mustache", diff --git a/packages/server/scripts/s3.js b/packages/server/scripts/s3.js new file mode 100644 index 0000000..f152699 --- /dev/null +++ b/packages/server/scripts/s3.js @@ -0,0 +1,508 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const ACTIONS = new Set(["list", "read", "write", "delete", "stat", "presign", "copy"]); + +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; + } + return {}; +} + +function mergeData(data) { + if (data != null && typeof data === "object" && !Array.isArray(data)) { + return { ...data }; + } + return {}; +} + +function resolveAction(ctx) { + const raw = ctx.config?.action ?? ctx.data?.action ?? "list"; + if (typeof raw !== "string" || raw.length === 0) { + throw new Error("s3: action must be a non-empty string"); + } + const action = raw.toLowerCase(); + if (!ACTIONS.has(action)) { + throw new Error(`s3: unsupported action "${raw}"`); + } + return action; +} + +function resolveBucket(ctx) { + const bucket = ctx.config?.bucket ?? ctx.data?.bucket; + if (typeof bucket !== "string" || bucket.length === 0) { + throw new Error("s3: bucket is required (ctx.config.bucket or ctx.data.bucket)"); + } + return bucket; +} + +function resolveKey(ctx, { required = false } = {}) { + const key = ctx.config?.key ?? ctx.data?.key; + if (key == null || key === "") { + if (required) throw new Error("s3: key is required for this action"); + return undefined; + } + if (typeof key !== "string") { + throw new Error("s3: key must be a string"); + } + return key; +} + +async function resolveSecretValue(secretName, label) { + if (typeof secretName !== "string" || secretName.length === 0) { + throw new Error(`s3: ${label} is required`); + } + return $secrets.reveal(await $secrets.get(secretName)); +} + +async function resolveCredentials(ctx) { + const accessKeyIdSecret = ctx.config?.accessKeyIdSecret; + const secretAccessKeySecret = ctx.config?.secretAccessKeySecret; + + if ( + typeof accessKeyIdSecret === "string" && + accessKeyIdSecret.length > 0 && + typeof secretAccessKeySecret === "string" && + secretAccessKeySecret.length > 0 + ) { + return { + accessKeyId: await resolveSecretValue(accessKeyIdSecret, "accessKeyIdSecret"), + secretAccessKey: await resolveSecretValue(secretAccessKeySecret, "secretAccessKeySecret"), + }; + } + + const accessKeyId = ctx.config?.accessKeyId ?? ctx.data?.accessKeyId; + const secretAccessKey = ctx.config?.secretAccessKey ?? ctx.data?.secretAccessKey; + if (typeof accessKeyId === "string" && typeof secretAccessKey === "string") { + return { accessKeyId, secretAccessKey }; + } + + throw new Error( + "s3: credentials are required (accessKeyIdSecret + secretAccessKeySecret, or accessKeyId + secretAccessKey)", + ); +} + +function createS3Client(ctx, credentials) { + const endpoint = ctx.config?.endpoint ?? ctx.data?.endpoint; + const region = + typeof ctx.config?.region === "string" && ctx.config.region.length > 0 + ? ctx.config.region + : typeof ctx.data?.region === "string" && ctx.data.region.length > 0 + ? ctx.data.region + : "us-east-1"; + + /** @type {import("@aws-sdk/client-s3").S3ClientConfig} */ + const options = { + region, + credentials, + }; + + if (typeof endpoint === "string" && endpoint.length > 0) { + options.endpoint = endpoint; + } + if (ctx.config?.forcePathStyle === true || ctx.data?.forcePathStyle === true) { + options.forcePathStyle = true; + } + + return new S3Client(options); +} + +function toIsoDate(value) { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +function normalizeListedObject(item) { + return { + key: item.Key ?? null, + size: typeof item.Size === "number" ? item.Size : null, + modified: toIsoDate(item.LastModified), + etag: item.ETag ?? null, + storageClass: item.StorageClass ?? null, + }; +} + +async function readObjectBody(body) { + if (body == null) return Buffer.alloc(0); + if (typeof body.transformToByteArray === "function") { + return Buffer.from(await body.transformToByteArray()); + } + /** @type {Buffer[]} */ + const chunks = []; + for await (const chunk of body) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function resolveWriteBody(ctx) { + if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) { + return ctx.config.body; + } + if (ctx.data?.file != null) { + const file = ctx.data.file; + if (Buffer.isBuffer(file) || file instanceof Uint8Array) { + return Buffer.from(file); + } + } + if (ctx.data?.body != null) { + const body = ctx.data.body; + if (Buffer.isBuffer(body) || body instanceof Uint8Array) { + return Buffer.from(body); + } + if (typeof body === "string") { + return Buffer.from(body, "utf8"); + } + return Buffer.from(JSON.stringify(body), "utf8"); + } + throw new Error("s3: write requires ctx.config.body, ctx.data.body, or ctx.data.file"); +} + +async function s3(ctx) { + const action = resolveAction(ctx); + const bucket = resolveBucket(ctx); + const credentials = await resolveCredentials(ctx); + const client = createS3Client(ctx, credentials); + + log.info({ action, bucket }, "s3: starting action"); + + /** @type {Record} */ + let result = { action, bucket }; + + switch (action) { + case "list": { + const prefix = ctx.config?.prefix ?? ctx.data?.prefix ?? ""; + const delimiter = ctx.config?.delimiter ?? ctx.data?.delimiter; + const maxKeys = Number(ctx.config?.maxKeys ?? ctx.data?.maxKeys ?? 1000); + const response = await client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: typeof prefix === "string" ? prefix : "", + Delimiter: typeof delimiter === "string" && delimiter.length > 0 ? delimiter : undefined, + MaxKeys: Number.isFinite(maxKeys) && maxKeys > 0 ? maxKeys : 1000, + }), + ); + const objects = (response.Contents ?? []).map(normalizeListedObject); + const prefixes = (response.CommonPrefixes ?? []) + .map((entry) => entry.Prefix) + .filter((value) => typeof value === "string"); + result = { + ...result, + prefix: typeof prefix === "string" ? prefix : "", + objects, + prefixes, + count: objects.length, + isTruncated: response.IsTruncated === true, + nextContinuationToken: response.NextContinuationToken ?? null, + }; + break; + } + case "read": { + const key = resolveKey(ctx, { required: true }); + const outputVar = + typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0 + ? ctx.config.outputVar + : "file"; + const response = await client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key, + }), + ); + const file = await readObjectBody(response.Body); + const contentType = response.ContentType ?? "application/octet-stream"; + const encoding = ctx.config?.encoding ?? ctx.data?.encoding; + result = { + ...result, + key, + [outputVar]: file, + contentType, + contentLength: file.length, + etag: response.ETag ?? null, + lastModified: toIsoDate(response.LastModified), + }; + if (encoding === "utf8" || encoding === "text") { + result.text = file.toString("utf8"); + } else if (encoding === "base64") { + result.base64 = file.toString("base64"); + } + break; + } + case "write": { + const key = resolveKey(ctx, { required: true }); + const body = resolveWriteBody(ctx); + const contentType = + ctx.config?.contentType ?? + ctx.data?.contentType ?? + (typeof ctx.data?.body === "string" ? "text/plain; charset=utf-8" : "application/octet-stream"); + const response = await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: typeof contentType === "string" ? contentType : undefined, + }), + ); + result = { + ...result, + key, + etag: response.ETag ?? null, + contentLength: body.length, + written: true, + }; + break; + } + case "delete": { + const key = resolveKey(ctx, { required: true }); + await client.send( + new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + }), + ); + result = { + ...result, + key, + deleted: true, + }; + break; + } + case "stat": { + const key = resolveKey(ctx, { required: true }); + const response = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key, + }), + ); + result = { + ...result, + key, + contentType: response.ContentType ?? null, + contentLength: typeof response.ContentLength === "number" ? response.ContentLength : null, + etag: response.ETag ?? null, + lastModified: toIsoDate(response.LastModified), + metadata: response.Metadata ?? {}, + }; + break; + } + case "presign": { + const key = resolveKey(ctx, { required: true }); + const method = String(ctx.config?.presignMethod ?? ctx.data?.presignMethod ?? "get").toLowerCase(); + const expiresIn = Number(ctx.config?.expiresIn ?? ctx.data?.expiresIn ?? 3600); + const command = + method === "put" + ? new PutObjectCommand({ Bucket: bucket, Key: key }) + : new GetObjectCommand({ Bucket: bucket, Key: key }); + const url = await getSignedUrl(client, command, { + expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600, + }); + result = { + ...result, + key, + method, + expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600, + url, + }; + break; + } + case "copy": { + const key = resolveKey(ctx, { required: true }); + const sourceKey = ctx.config?.sourceKey ?? ctx.data?.sourceKey; + const sourceBucket = ctx.config?.sourceBucket ?? ctx.data?.sourceBucket ?? bucket; + if (typeof sourceKey !== "string" || sourceKey.length === 0) { + throw new Error("s3: copy requires sourceKey (ctx.config.sourceKey or ctx.data.sourceKey)"); + } + const response = await client.send( + new CopyObjectCommand({ + Bucket: bucket, + Key: key, + CopySource: `${sourceBucket}/${sourceKey}`, + }), + ); + result = { + ...result, + key, + sourceBucket, + sourceKey, + etag: response.CopyObjectResult?.ETag ?? null, + copied: true, + }; + break; + } + default: + throw new Error(`s3: unsupported action "${action}"`); + } + + log.info({ action, bucket, key: result.key ?? null }, "s3: action complete"); + return { + output: { ...mergeData(ctx.data), ...result }, + context: { ...passContext(ctx), ...result }, + }; +} + +s3.meta = { + description: "Access S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, etc.)", + previewConfigKey: "action", + tags: ["S3", "storage"], + config: { + action: { + type: "string", + default: "list", + enum: ["list", "read", "write", "delete", "stat", "presign", "copy"], + description: "Operation to perform", + }, + endpoint: { + type: "string", + required: false, + description: "Custom S3 endpoint URL (required for MinIO and most non-AWS providers)", + }, + region: { + type: "string", + default: "us-east-1", + description: "AWS region (still required by many S3-compatible APIs)", + }, + bucket: { + type: "string", + required: true, + description: "Bucket name", + }, + key: { + type: "string", + required: false, + description: "Object key (required for read, write, delete, stat, presign, copy)", + }, + prefix: { + type: "string", + required: false, + description: "List only keys under this prefix", + }, + delimiter: { + type: "string", + required: false, + description: "List folder delimiter, usually /", + }, + maxKeys: { + type: "number", + default: 1000, + description: "Maximum objects returned by list", + }, + forcePathStyle: { + type: "boolean", + default: false, + description: "Use path-style URLs (often required for MinIO)", + }, + accessKeyIdSecret: { + type: "string", + required: false, + description: "Named secret for the access key id", + }, + secretAccessKeySecret: { + type: "string", + required: false, + description: "Named secret for the secret access key", + }, + accessKeyId: { + type: "string", + required: false, + description: "Plain access key id (prefer secrets in production)", + }, + secretAccessKey: { + type: "string", + required: false, + description: "Plain secret access key (prefer secrets in production)", + }, + contentType: { + type: "string", + required: false, + description: "Content-Type for write", + }, + body: { + type: "any", + required: false, + description: "Body for write when not passed via data", + }, + outputVar: { + type: "string", + default: "file", + description: "Output key for read action bytes", + }, + encoding: { + type: "string", + required: false, + enum: ["utf8", "text", "base64"], + description: "Optional read decoding helper (adds text or base64 field)", + }, + expiresIn: { + type: "number", + default: 3600, + description: "Presigned URL lifetime in seconds", + }, + presignMethod: { + type: "string", + default: "get", + enum: ["get", "put"], + description: "Presign a download (get) or upload (put) URL", + }, + sourceBucket: { + type: "string", + required: false, + description: "Source bucket for copy (defaults to bucket)", + }, + sourceKey: { + type: "string", + required: false, + description: "Source key for copy", + }, + }, + input: { + action: { type: "string", required: false }, + bucket: { type: "string", required: false }, + key: { type: "string", required: false }, + prefix: { type: "string", required: false }, + body: { type: "any", required: false, description: "Write payload" }, + file: { type: "buffer", required: false, description: "Binary write payload" }, + sourceKey: { type: "string", required: false }, + sourceBucket: { type: "string", required: false }, + }, + output: { + action: { type: "string" }, + bucket: { type: "string" }, + objects: { type: "array", required: false, description: "list results" }, + file: { type: "buffer", required: false, description: "read bytes (or outputVar)" }, + url: { type: "string", required: false, description: "presigned URL" }, + written: { type: "boolean", required: false }, + deleted: { type: "boolean", required: false }, + copied: { type: "boolean", required: false }, + }, + context: { + action: { type: "string" }, + bucket: { type: "string" }, + }, + example: { + data: {}, + config: { + action: "list", + endpoint: "https://minio.example.com", + region: "us-east-1", + bucket: "backups", + prefix: "daily/", + forcePathStyle: true, + accessKeyIdSecret: "minio_access_key", + secretAccessKeySecret: "minio_secret_key", + }, + }, +}; + +export default s3; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caa1251..cc69c89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: packages/server: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1111.0 + version: 3.1111.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1111.0 + version: 3.1111.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.1.2 @@ -132,6 +138,82 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-sdk/checksums@3.1000.28': + resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1111.0': + resolution: {integrity: sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.74': + resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1111.0': + resolution: {integrity: sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -618,6 +700,30 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.33.2': + resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.11.2': + resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.2': + resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -903,6 +1009,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2038,6 +2147,180 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.3.0 + '@aws-sdk/checksums@3.1000.28': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1111.0': + dependencies: + '@aws-sdk/checksums': 3.1000.28 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/middleware-sdk-s3': 3.972.74 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.8': + dependencies: + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.80': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.13': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.43': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.45': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.4': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.39': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2447,6 +2730,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@smithy/core@3.33.2': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2749,6 +3065,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 From 14f6331fce6a9e8a640a271fbe73945a55dff135 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Wed, 19 Aug 2026 15:12:54 +0700 Subject: [PATCH 2/5] chore(server): change default HTTP port from 9000 to 8700 Avoid colliding with MinIO's default S3 API port. Co-authored-by: Cursor --- README.md | 8 ++++---- packages/server/docs/mt.http | 37 +++++++++++++++--------------------- packages/server/runner.js | 2 +- packages/web/vite.config.js | 4 ++-- 4 files changed, 22 insertions(+), 29 deletions(-) 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/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/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 }, }, }, }); From 284597167083aeba01ce4ecf48685f7059f3a0b1 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Wed, 19 Aug 2026 15:13:03 +0700 Subject: [PATCH 3/5] fix(secrets): allow storing values shorter than 8 characters Keep the 8-character floor only for log redaction so short MinIO keys can be saved. Co-authored-by: Cursor --- packages/server/secret-value.js | 1 + packages/server/secrets-store.js | 6 +++--- packages/server/src/api/secrets.js | 7 ++----- packages/web/src/pages/SecretsPage.jsx | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) 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/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/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)}

From f41e9dc23a7d3373923d5a2e6004fc3cb8d713f8 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Wed, 19 Aug 2026 15:13:12 +0700 Subject: [PATCH 4/5] fix(server): summarize Buffer values in stored and API JSON Stop dumping every byte as a number in event I/O, workflow test results, and script dry-runs. Co-authored-by: Cursor --- packages/server/json-preview.js | 43 +++++++++++++++++ packages/server/src/api/dry-run-logger.js | 5 +- packages/server/src/api/workflows.js | 2 +- packages/server/store.js | 17 ++++++- packages/server/test/json-preview-smoke.js | 56 ++++++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/server/json-preview.js create mode 100644 packages/server/test/json-preview-smoke.js 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/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/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"); From dc0dfadd44c6f8d00b9bc1ecac60ee1c124e8dad Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Wed, 19 Aug 2026 15:13:25 +0700 Subject: [PATCH 5/5] feat(workflows): add MinIO S3 test workflow Exercise fetch-binary plus s3 write against a local HTTP MinIO endpoint. Co-authored-by: Cursor --- .../server/workflows/default/registers.yaml | 1 + .../server/workflows/default/test-minio.yaml | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/server/workflows/default/test-minio.yaml 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