diff --git a/apps/api-runner/README.md b/apps/api-runner/README.md new file mode 100644 index 00000000..63e814c8 --- /dev/null +++ b/apps/api-runner/README.md @@ -0,0 +1,49 @@ +# @mydevtools/api-runner + +Headless runner for [mydevtools](https://mydevtools.tech) API client collections — Postman / Newman compatible. + +Same scripts, same `pm.*` API, same JUnit output as the web runner. + +## Install + +```bash +npm i -g @mydevtools/api-runner +``` + +## Run + +```bash +mydevtools-api run my-collection.json +mydevtools-api run my-collection.json --env prod.env.json --data users.csv +mydevtools-api run my-collection.json --reporter junit --reporter-out report.xml --bail +``` + +## Flags + +| Flag | Description | +|---|---| +| `--data ` | CSV or JSON-array data file. One iteration per row; row keys override `{{vars}}`. | +| `--env ` | Postman Environment v2 export. Enabled values become the base `{{var}}` map. | +| `--iterations ` | Iterations when no data file is given. Default 1. | +| `--reporter ` | `cli` (default) or `junit`. | +| `--reporter-out ` | Required for `--reporter junit`. | +| `--bail` | Stop on the first failing assertion / network error. | + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | All tests passed | +| 1 | Some tests / requests failed | +| 2 | Usage error | + +## Differences vs the web runner (v0.1) + +- No cookie jar (Node `fetch` has no jar) — provide auth via env vars. +- No OAuth refresh flow — pre-mint a bearer token and put it in env. +- No SSE / streaming (the web has `/api/proxy-stream`; the CLI does plain `await response.text()`). +- No GraphQL introspection. + +Everything else — scripts, env mutations, `{{response.body.x}}` chaining, +folder inheritance, data-driven iterations, JUnit XML — matches the web runner +bit-for-bit. diff --git a/apps/api-runner/dist/csv.d.ts b/apps/api-runner/dist/csv.d.ts new file mode 100644 index 00000000..a413adfa --- /dev/null +++ b/apps/api-runner/dist/csv.d.ts @@ -0,0 +1,3 @@ +/** Same CSV/JSON data-file parser as the web app — verbatim port for portability. */ +export declare function parseCsv(text: string): Record[]; +export declare function parseDataFile(text: string): Record[]; diff --git a/apps/api-runner/dist/csv.js b/apps/api-runner/dist/csv.js new file mode 100644 index 00000000..bc4b9ebb --- /dev/null +++ b/apps/api-runner/dist/csv.js @@ -0,0 +1,77 @@ +/** Same CSV/JSON data-file parser as the web app — verbatim port for portability. */ +export function parseCsv(text) { + const rows = parseRows(text); + if (rows.length === 0) + return []; + const [headers, ...body] = rows; + return body + .filter((r) => r.length > 1 || (r.length === 1 && r[0] !== "")) + .map((cells) => { + const obj = {}; + headers.forEach((h, i) => { obj[h] = cells[i] ?? ""; }); + return obj; + }); +} +function parseRows(text) { + const rows = []; + let row = []; + let cell = ""; + let inQuote = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuote) { + if (c === '"' && text[i + 1] === '"') { + cell += '"'; + i++; + } + else if (c === '"') { + inQuote = false; + } + else + cell += c; + } + else { + if (c === '"') { + inQuote = true; + } + else if (c === ",") { + row.push(cell); + cell = ""; + } + else if (c === "\n" || c === "\r") { + row.push(cell); + cell = ""; + rows.push(row); + row = []; + if (c === "\r" && text[i + 1] === "\n") + i++; + } + else + cell += c; + } + } + if (cell.length > 0 || row.length > 0) { + row.push(cell); + rows.push(row); + } + return rows; +} +export function parseDataFile(text) { + const t = text.trimStart(); + if (t.startsWith("[")) { + const parsed = JSON.parse(t); + if (!Array.isArray(parsed)) + throw new Error("JSON data file must be an array"); + return parsed.map((row) => { + if (!row || typeof row !== "object") + return {}; + const out = {}; + for (const [k, v] of Object.entries(row)) { + out[k] = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); + } + return out; + }); + } + return parseCsv(text); +} +//# sourceMappingURL=csv.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/csv.js.map b/apps/api-runner/dist/csv.js.map new file mode 100644 index 00000000..46c4cd3c --- /dev/null +++ b/apps/api-runner/dist/csv.js.map @@ -0,0 +1 @@ +{"version":3,"file":"csv.js","sourceRoot":"","sources":["../src/csv.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/index.d.ts b/apps/api-runner/dist/index.d.ts new file mode 100644 index 00000000..7dba6914 --- /dev/null +++ b/apps/api-runner/dist/index.d.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +/** + * mydevtools-api CLI. + * Headless equivalent of the web-side collection runner. Same scripts, same + * substitution layering, same JUnit output — runs over a Postman v2.1 export. + */ +export {}; diff --git a/apps/api-runner/dist/index.js b/apps/api-runner/dist/index.js new file mode 100644 index 00000000..7eea8817 --- /dev/null +++ b/apps/api-runner/dist/index.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * mydevtools-api CLI. + * Headless equivalent of the web-side collection runner. Same scripts, same + * substitution layering, same JUnit output — runs over a Postman v2.1 export. + */ +import { readFile, writeFile } from "node:fs/promises"; +import { importPostmanCollection, parsePostmanEnvironment } from "./postman.js"; +import { parseDataFile } from "./csv.js"; +import { runCollection } from "./runner.js"; +import { buildJUnitXml } from "./junit.js"; +function parseArgs(argv) { + const out = { iterations: 1, bail: false, help: false }; + if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { + out.help = true; + return out; + } + out.cmd = argv[0]; + let i = 1; + while (i < argv.length) { + const a = argv[i]; + if (a === "--data" || a === "-d") { + out.dataPath = argv[++i]; + } + else if (a === "--env" || a === "-e") { + out.envPath = argv[++i]; + } + else if (a === "--iterations" || a === "-n") { + out.iterations = Math.max(1, Number(argv[++i]) || 1); + } + else if (a === "--reporter" || a === "-r") { + out.reporter = argv[++i]; + } + else if (a === "--reporter-out" || a === "-o") { + out.reporterOut = argv[++i]; + } + else if (a === "--bail") { + out.bail = true; + } + else if (a === "-h" || a === "--help") { + out.help = true; + } + else if (!out.collectionPath) { + out.collectionPath = a; + } + else + throw new Error(`Unknown argument: ${a}`); + i++; + } + return out; +} +function printUsage() { + console.error(`Usage: mydevtools-api run [options] + +Options: + -d, --data CSV or JSON array of iteration rows (each row's keys + override env vars for one iteration). + -e, --env Postman Environment export (v2 schema) — enabled + values become the base \`{{var}}\` map. + -n, --iterations Iterations to run when no data file is given. Default 1. + -r, --reporter 'cli' (default) or 'junit'. + -o, --reporter-out Write the JUnit XML here. Required for --reporter junit. + --bail Stop on the first failing request. + -h, --help Show this message. + +Exit codes: + 0 — all tests passed + 1 — one or more tests / requests failed + 2 — usage error +`); +} +function fmtRow(r) { + const ok = !r.networkError && r.tests.every((t) => t.pass); + const status = r.status !== undefined ? r.status : "—"; + const time = r.time !== undefined ? `${r.time}ms` : ""; + return ` ${ok ? "✓" : "✗"} ${r.method.padEnd(6)} ${status} ${time} ${r.requestName}`; +} +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.cmd) { + printUsage(); + process.exit(args.help ? 0 : 2); + } + if (args.cmd !== "run" || !args.collectionPath) { + printUsage(); + process.exit(2); + } + if (args.reporter === "junit" && !args.reporterOut) { + console.error("--reporter-out is required when --reporter junit is used"); + process.exit(2); + } + const collection = importPostmanCollection(await readFile(args.collectionPath, "utf-8")); + const dataRows = args.dataPath ? parseDataFile(await readFile(args.dataPath, "utf-8")) : undefined; + const env = args.envPath ? parsePostmanEnvironment(await readFile(args.envPath, "utf-8")) : {}; + console.log(`Running ${collection.name}` + (dataRows ? ` × ${dataRows.length} iterations` : args.iterations > 1 ? ` × ${args.iterations} iterations` : "")); + const results = await runCollection({ + collection, + environmentVariables: env, + iterations: args.iterations, + dataRows, + bail: args.bail, + onProgress: (r) => console.log(fmtRow(r)), + }); + const allTests = results.flatMap((r) => r.tests); + const failed = allTests.filter((t) => !t.pass).length; + const errored = results.filter((r) => r.networkError || r.errors.length > 0).length; + const passed = allTests.length - failed; + console.log(""); + console.log(`Requests: ${results.length}`); + console.log(`Assertions: ${allTests.length} (✓ ${passed} / ✗ ${failed})`); + if (errored > 0) + console.log(`Errors: ${errored}`); + for (const r of results) { + const failures = r.tests.filter((t) => !t.pass); + if (failures.length === 0 && !r.networkError) + continue; + console.log(`\n ${r.method} ${r.requestName}`); + if (r.networkError) + console.log(` ! ${r.networkError}`); + for (const t of failures) + console.log(` ✗ ${t.name}\n ${t.error}`); + } + if (args.reporter === "junit" && args.reporterOut) { + await writeFile(args.reporterOut, buildJUnitXml(results, collection.name)); + console.log(`\nWrote JUnit XML to ${args.reporterOut}`); + } + process.exit(failed > 0 || errored > 0 ? 1 : 0); +} +main().catch((err) => { + console.error(`Fatal: ${err.message}`); + process.exit(1); +}); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/index.js.map b/apps/api-runner/dist/index.js.map new file mode 100644 index 00000000..0e4e0e3d --- /dev/null +++ b/apps/api-runner/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/junit.d.ts b/apps/api-runner/dist/junit.d.ts new file mode 100644 index 00000000..3b7dcdb1 --- /dev/null +++ b/apps/api-runner/dist/junit.d.ts @@ -0,0 +1,3 @@ +/** JUnit XML emitter — port of `apps/web/src/lib/runner/junit.ts`. */ +import type { RequestRunResult } from "./types.js"; +export declare function buildJUnitXml(results: RequestRunResult[], suiteName: string): string; diff --git a/apps/api-runner/dist/junit.js b/apps/api-runner/dist/junit.js new file mode 100644 index 00000000..db15b8ec --- /dev/null +++ b/apps/api-runner/dist/junit.js @@ -0,0 +1,46 @@ +/** JUnit XML emitter — port of `apps/web/src/lib/runner/junit.ts`. */ +function escapeXml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} +export function buildJUnitXml(results, suiteName) { + const allTests = results.flatMap((r) => r.tests.map((t) => ({ run: r, test: t }))); + const totalTests = allTests.length; + const failed = allTests.filter(({ test }) => !test.pass).length; + const errored = results.filter((r) => r.networkError || r.errors.length > 0).length; + const totalTime = results.reduce((s, r) => s + (r.time ?? 0), 0) / 1000; + const cases = allTests.map(({ run, test }) => { + const classname = escapeXml(run.requestName); + const name = escapeXml(test.name); + const time = ((run.time ?? 0) / 1000).toFixed(3); + if (test.pass) { + return ` `; + } + const msg = escapeXml(test.error ?? "assertion failed"); + return ` + ${msg} + `; + }); + const errorCases = results + .filter((r) => r.networkError || r.errors.length > 0) + .map((r) => { + const msg = escapeXml(r.networkError ?? r.errors.join("; ")); + const classname = escapeXml(r.requestName); + const time = ((r.time ?? 0) / 1000).toFixed(3); + return ` + ${msg} + `; + }); + const body = [...cases, ...errorCases].join("\n"); + return ` + + +${body} + +`; +} +//# sourceMappingURL=junit.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/junit.js.map b/apps/api-runner/dist/junit.js.map new file mode 100644 index 00000000..9eaf2288 --- /dev/null +++ b/apps/api-runner/dist/junit.js.map @@ -0,0 +1 @@ +{"version":3,"file":"junit.js","sourceRoot":"","sources":["../src/junit.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/postman.d.ts b/apps/api-runner/dist/postman.d.ts new file mode 100644 index 00000000..c416a408 --- /dev/null +++ b/apps/api-runner/dist/postman.d.ts @@ -0,0 +1,12 @@ +/** + * Lean Postman v2.1 → Collection converter for the CLI. + * Mirrors `apps/web/src/lib/import/postman.ts` but bound to the smaller + * CLI type surface (no OAuth2, no graphql body parsing — those map to text). + */ +import type { Collection } from "./types.js"; +export declare function importPostmanCollection(raw: string | object): Collection; +/** + * Parse a Postman Environment export (v2 schema) into a flat `key → value` map. + * Disabled vars are skipped. + */ +export declare function parsePostmanEnvironment(raw: string | object): Record; diff --git a/apps/api-runner/dist/postman.js b/apps/api-runner/dist/postman.js new file mode 100644 index 00000000..e554ad39 --- /dev/null +++ b/apps/api-runner/dist/postman.js @@ -0,0 +1,156 @@ +/** + * Lean Postman v2.1 → Collection converter for the CLI. + * Mirrors `apps/web/src/lib/import/postman.ts` but bound to the smaller + * CLI type surface (no OAuth2, no graphql body parsing — those map to text). + */ +import { randomUUID } from "node:crypto"; +const VALID_METHODS = new Set([ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", +]); +const id = () => randomUUID(); +function pickScript(events, kind) { + const evt = events?.find((e) => e.listen === kind); + if (!evt?.script?.exec) + return ""; + return Array.isArray(evt.script.exec) ? evt.script.exec.join("\n") : evt.script.exec; +} +function kvList(items) { + return (items ?? []) + .filter((kv) => kv && (kv.key || kv.value)) + .map((kv) => ({ + id: id(), + key: kv.key ?? "", + value: kv.value ?? "", + active: !kv.disabled, + })); +} +function readUrl(u) { + if (!u) + return { url: "", query: [] }; + if (typeof u === "string") + return { url: u, query: [] }; + return { url: u.raw ?? "", query: u.query ?? [] }; +} +function lookup(items, key) { + return items?.find((x) => x.key === key)?.value; +} +function convertAuth(a) { + if (!a || a.type === "noauth" || !a.type) + return { type: "none" }; + if (a.type === "bearer") + return { type: "bearer", token: lookup(a.bearer, "token") }; + if (a.type === "basic") { + return { + type: "basic", + username: lookup(a.basic, "username"), + password: lookup(a.basic, "password"), + }; + } + if (a.type === "apikey") { + return { + type: "api-key", + apiKeyKey: lookup(a.apikey, "key"), + apiKeyValue: lookup(a.apikey, "value"), + apiKeyLocation: lookup(a.apikey, "in") === "query" ? "query" : "header", + }; + } + return { type: "none" }; +} +function convertBody(b) { + if (!b || b.mode === "none") + return { type: "none", content: "" }; + if (b.mode === "raw") { + const lang = b.options?.raw?.language; + return { type: lang === "json" ? "json" : "text", content: b.raw ?? "" }; + } + if (b.mode === "urlencoded") { + return { + type: "x-www-form-urlencoded", + content: "", + urlEncoded: kvList(b.urlencoded), + }; + } + if (b.mode === "formdata") { + return { + type: "form-data", + content: "", + formData: (b.formdata ?? []) + .filter((kv) => kv && (kv.key || kv.value)) + .map((kv) => ({ + id: id(), + key: kv.key ?? "", + value: kv.value ?? "", + active: !kv.disabled, + valueType: kv.type === "file" ? "file" : "text", + })), + }; + } + return { type: "text", content: b.raw ?? "" }; +} +function convertRequest(item) { + const r = item.request ?? {}; + const { url, query } = readUrl(r.url); + const method = (r.method ?? "GET").toUpperCase(); + const safeMethod = VALID_METHODS.has(method) ? method : "GET"; + const preRequestScript = pickScript(item.event, "prerequest"); + const testScript = pickScript(item.event, "test"); + return { + id: id(), + name: item.name ?? safeMethod, + method: safeMethod, + url, + params: kvList(query), + headers: kvList(r.header), + body: convertBody(r.body), + auth: convertAuth(r.auth), + preRequestScript: preRequestScript || undefined, + testScript: testScript || undefined, + }; +} +function convertItems(items) { + const out = []; + for (const item of items ?? []) { + if (item.request) + out.push(convertRequest(item)); + else if (item.item) { + out.push({ + id: id(), + name: item.name ?? "Folder", + type: "folder", + items: convertItems(item.item), + }); + } + } + return out; +} +export function importPostmanCollection(raw) { + const data = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!data || (!data.info?.name && !Array.isArray(data.item))) { + throw new Error("Not a Postman collection (missing info.name + item[])"); + } + return { + id: id(), + name: data.info?.name ?? "Imported Postman collection", + items: convertItems(data.item), + }; +} +/** + * Parse a Postman Environment export (v2 schema) into a flat `key → value` map. + * Disabled vars are skipped. + */ +export function parsePostmanEnvironment(raw) { + const data = typeof raw === "string" ? JSON.parse(raw) : raw; + const vars = data?.values; + if (!Array.isArray(vars)) + return {}; + const out = {}; + for (const v of vars) { + if (!v?.key) + continue; + if (v.enabled === false) + continue; + out[v.key] = String(v.value ?? ""); + } + return out; +} +//# sourceMappingURL=postman.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/postman.js.map b/apps/api-runner/dist/postman.js.map new file mode 100644 index 00000000..66c611f7 --- /dev/null +++ b/apps/api-runner/dist/postman.js.map @@ -0,0 +1 @@ +{"version":3,"file":"postman.js","sourceRoot":"","sources":["../src/postman.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/runner.d.ts b/apps/api-runner/dist/runner.d.ts new file mode 100644 index 00000000..30d060cc --- /dev/null +++ b/apps/api-runner/dist/runner.d.ts @@ -0,0 +1,28 @@ +/** + * Headless collection runner. + * + * Mirrors `apps/web/src/lib/runner/runner.ts` semantics: + * - Sequential execution; one iteration per data row, else `iterations`. + * - Pre-request + test scripts run via `node:vm` with `pm.*` API. + * - Env mutations cascade across requests within a run. + * - `{{response.body.x}}` chaining off the previous successful response. + * - Folder-level defaults (headers / scripts) merged before per-request fields. + * + * Deliberate omissions for the CLI v1: + * - No cookie jar (Node native fetch has none; add tough-cookie if needed). + * - No OAuth refresh flow (user supplies bearer token via env). + * - No streaming (proxy-stream lives on the web side). + */ +import type { Collection, RequestRunResult } from "./types.js"; +interface RunOpts { + collection: Collection; + folderId?: string; + iterations: number; + dataRows?: Record[]; + environmentVariables: Record; + bail?: boolean; + onProgress?: (r: RequestRunResult) => void; + abortSignal?: AbortSignal; +} +export declare function runCollection(opts: RunOpts): Promise; +export {}; diff --git a/apps/api-runner/dist/runner.js b/apps/api-runner/dist/runner.js new file mode 100644 index 00000000..0d1b1e6a --- /dev/null +++ b/apps/api-runner/dist/runner.js @@ -0,0 +1,403 @@ +/** + * Headless collection runner. + * + * Mirrors `apps/web/src/lib/runner/runner.ts` semantics: + * - Sequential execution; one iteration per data row, else `iterations`. + * - Pre-request + test scripts run via `node:vm` with `pm.*` API. + * - Env mutations cascade across requests within a run. + * - `{{response.body.x}}` chaining off the previous successful response. + * - Folder-level defaults (headers / scripts) merged before per-request fields. + * + * Deliberate omissions for the CLI v1: + * - No cookie jar (Node native fetch has none; add tough-cookie if needed). + * - No OAuth refresh flow (user supplies bearer token via env). + * - No streaming (proxy-stream lives on the web side). + */ +import { runScript } from "./scripts.js"; +function walkRequests(items, out) { + for (const it of items) { + if ("type" in it && it.type === "folder") + walkRequests(it.items, out); + else + out.push(it); + } +} +function findRequestAncestors(items, requestId, trail = []) { + for (const item of items) { + if (item.id === requestId) + return trail; + if ("type" in item && item.type === "folder") { + const hit = findRequestAncestors(item.items, requestId, [...trail, item]); + if (hit) + return hit; + } + } + return null; +} +function mergeHeaders(chain, requestHeaders) { + const seen = new Set(); + const out = []; + for (const h of requestHeaders) { + if (h.key) + seen.add(h.key.toLowerCase()); + out.push(h); + } + for (const folder of chain) { + for (const h of folder.defaultHeaders ?? []) { + if (h.key && seen.has(h.key.toLowerCase())) + continue; + if (h.key) + seen.add(h.key.toLowerCase()); + out.push(h); + } + } + return out; +} +function inherit(req, ancestors) { + if (ancestors.length === 0) + return req; + const preChain = [...ancestors.map((f) => f.preRequestScript), req.preRequestScript] + .map((s) => (s ?? "").trim()).filter(Boolean).join("\n\n"); + const testChain = [...ancestors.map((f) => f.testScript), req.testScript] + .map((s) => (s ?? "").trim()).filter(Boolean).join("\n\n"); + return { + ...req, + headers: mergeHeaders(ancestors, req.headers), + preRequestScript: preChain || undefined, + testScript: testChain || undefined, + }; +} +function tryParseJson(s) { + try { + return JSON.parse(s); + } + catch { + return undefined; + } +} +function parseResponsePath(s) { + const out = []; + for (const part of s.split(".")) { + const m = part.match(/^([^[]*)((?:\[\d+\])*)$/); + if (!m) { + out.push(part); + continue; + } + if (m[1]) + out.push(m[1]); + for (const idx of m[2].matchAll(/\[(\d+)\]/g)) + out.push(Number(idx[1])); + } + return out; +} +function resolveResponsePath(path, response) { + if (!response) + return undefined; + const parts = parseResponsePath(path); + if (parts.length === 0) + return undefined; + const head = parts[0]; + if (head === "status") + return String(response.status); + if (head === "statusText") + return response.statusText; + if (head === "headers") { + if (parts.length === 1) + return undefined; + const name = String(parts[1]).toLowerCase(); + const hit = Object.entries(response.headers).find(([k]) => k.toLowerCase() === name); + return hit?.[1]; + } + if (head === "body") { + if (parts.length === 1) + return response.body; + const parsed = tryParseJson(response.body); + if (parsed === undefined) + return undefined; + let cur = parsed; + for (const k of parts.slice(1)) { + if (cur == null || typeof cur !== "object") + return undefined; + cur = cur[k]; + } + if (cur == null) + return undefined; + return typeof cur === "object" ? JSON.stringify(cur) : String(cur); + } + return undefined; +} +function makeSubstitute(args) { + return (text) => { + if (!text) + return text; + return text.replace(/\{\{(.+?)\}\}/g, (m, k) => { + const key = k.trim(); + if (key.startsWith("response.")) { + const resolved = resolveResponsePath(key.slice("response.".length), args.previousResponse); + return resolved ?? m; + } + if (key in args.row) + return args.row[key]; + if (args.envUnsets.has(key)) + return m; + if (key in args.envOverlay) + return args.envOverlay[key]; + if (key in args.sessionVars) + return args.sessionVars[key]; + return args.env[key] ?? m; + }); + }; +} +function utf8Btoa(s) { + return Buffer.from(s, "utf-8").toString("base64"); +} +async function executeRequest(args) { + const { req, iteration } = args; + const result = { + iteration, + requestId: req.id, + requestName: req.name, + method: req.method, + url: req.url, + tests: [], + logs: [], + errors: [], + }; + const substitute = makeSubstitute({ + env: args.env, + sessionVars: args.sessionVars, + envOverlay: args.envOverlay, + envUnsets: args.envUnsets, + row: args.row, + previousResponse: args.previousResponse, + }); + let workMethod = req.method; + let workUrl = req.url; + let workHeaders = {}; + req.headers.forEach((h) => { if (h.active && h.key) + workHeaders[h.key] = h.value; }); + // Pre-request script. + if (req.preRequestScript && req.preRequestScript.trim()) { + const r = runScript(req.preRequestScript, { + request: { + url: workUrl, method: workMethod, headers: workHeaders, + body: req.body.type === "json" || req.body.type === "text" || req.body.type === "graphql" ? req.body.content : undefined, + }, + environment: { ...args.env, ...args.envOverlay }, + variables: { ...args.sessionVars, ...args.row }, + }); + result.tests.push(...r.tests); + result.logs.push(...r.logs); + if (!r.ok && r.error) + result.errors.push(`pre-request: ${r.error}`); + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== args.env[k]) + args.envOverlay[k] = r.environment[k]; + } + for (const k of Object.keys(args.env)) { + if (!(k in r.environment)) + args.envUnsets.add(k); + } + Object.assign(args.sessionVars, r.variables); + workUrl = r.request.url; + workMethod = (r.request.method || workMethod).toUpperCase(); + workHeaders = r.request.headers; + } + let urlObj; + try { + urlObj = new URL(substitute(workUrl)); + } + catch (e) { + result.networkError = `Invalid URL: ${e.message}`; + return { result, response: null }; + } + req.params.forEach((p) => { + if (p.active && p.key) + urlObj.searchParams.append(substitute(p.key), substitute(p.value)); + }); + const headersObj = {}; + for (const [k, v] of Object.entries(workHeaders)) { + headersObj[substitute(k)] = substitute(v); + } + if (req.auth.type === "bearer" && req.auth.token) { + headersObj["Authorization"] = `Bearer ${substitute(req.auth.token).trim()}`; + } + else if (req.auth.type === "basic" && req.auth.username && req.auth.password) { + headersObj["Authorization"] = `Basic ${utf8Btoa(`${substitute(req.auth.username).trim()}:${substitute(req.auth.password)}`)}`; + } + else if (req.auth.type === "api-key" && req.auth.apiKeyKey && req.auth.apiKeyValue) { + const key = substitute(req.auth.apiKeyKey).trim(); + const val = substitute(req.auth.apiKeyValue).trim(); + if (req.auth.apiKeyLocation === "query") + urlObj.searchParams.append(key, val); + else + headersObj[key] = val; + } + let body; + const noBody = workMethod === "GET" || workMethod === "HEAD" || req.body.type === "none"; + if (!noBody) { + if (req.body.type === "json") { + const sub = substitute(req.body.content); + try { + JSON.parse(sub); + } + catch (e) { + result.networkError = `Invalid JSON body: ${e.message}`; + return { result, response: null }; + } + body = sub; + headersObj["Content-Type"] = "application/json"; + } + else if (req.body.type === "x-www-form-urlencoded") { + const sp = new URLSearchParams(); + (req.body.urlEncoded ?? []).forEach((it) => { + if (it.active && it.key) + sp.append(substitute(it.key), substitute(it.value)); + }); + body = sp.toString(); + if (!Object.keys(headersObj).some((k) => k.toLowerCase() === "content-type")) { + headersObj["Content-Type"] = "application/x-www-form-urlencoded"; + } + } + else if (req.body.type === "form-data") { + const form = new FormData(); + for (const it of req.body.formData ?? []) { + if (!it.active || !it.key) + continue; + if (it.valueType === "file") { + if (!it.fileContentBase64) + continue; + const buf = Buffer.from(it.fileContentBase64, "base64"); + form.append(substitute(it.key), new Blob([buf], { type: it.fileType || "application/octet-stream" }), it.fileName || "upload.bin"); + } + else { + form.append(substitute(it.key), substitute(it.value)); + } + } + body = form; + // Let fetch set the multipart Content-Type with its own boundary. + for (const k of Object.keys(headersObj)) { + if (k.toLowerCase() === "content-type") + delete headersObj[k]; + } + } + else if (req.body.type === "graphql") { + const query = substitute(req.body.content); + let variables; + const rawVars = (req.body.graphqlVariables ?? "").trim(); + if (rawVars) { + try { + variables = JSON.parse(substitute(rawVars)); + } + catch (e) { + result.networkError = `Invalid GraphQL variables JSON: ${e.message}`; + return { result, response: null }; + } + } + body = JSON.stringify(variables !== undefined ? { query, variables } : { query }); + headersObj["Content-Type"] = "application/json"; + } + else { + body = substitute(req.body.content); + if (!headersObj["Content-Type"]) + headersObj["Content-Type"] = "text/plain"; + } + } + const start = Date.now(); + let response; + try { + response = await fetch(urlObj.toString(), { + method: workMethod, + headers: headersObj, + body, + signal: args.signal, + }); + } + catch (e) { + result.networkError = e.message; + return { result, response: null }; + } + const elapsed = Date.now() - start; + const bodyText = await response.text().catch(() => ""); + const respHeaders = {}; + response.headers.forEach((v, k) => { respHeaders[k] = v; }); + result.status = response.status; + result.statusText = response.statusText; + result.time = elapsed; + result.size = Buffer.byteLength(bodyText, "utf-8"); + const snapshot = { + status: response.status, + statusText: response.statusText, + headers: respHeaders, + body: bodyText, + time: elapsed, + size: result.size, + }; + // Test script. + if (req.testScript && req.testScript.trim()) { + const r = runScript(req.testScript, { + request: { url: urlObj.toString(), method: workMethod, headers: { ...headersObj }, body: typeof body === "string" ? body : undefined }, + response: { + status: response.status, + statusText: response.statusText, + headers: respHeaders, + body: bodyText, + time: elapsed, + }, + environment: { ...args.env, ...args.envOverlay }, + variables: { ...args.sessionVars, ...args.row }, + }); + result.tests.push(...r.tests); + result.logs.push(...r.logs); + if (!r.ok && r.error) + result.errors.push(`test: ${r.error}`); + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== args.env[k]) + args.envOverlay[k] = r.environment[k]; + } + Object.assign(args.sessionVars, r.variables); + } + return { result, response: snapshot }; +} +export async function runCollection(opts) { + const rootItems = opts.collection.items; + const requests = []; + walkRequests(rootItems, requests); + const iterations = opts.dataRows && opts.dataRows.length > 0 + ? opts.dataRows.length + : Math.max(1, opts.iterations || 1); + const sessionVars = {}; + const envOverlay = {}; + const envUnsets = new Set(); + const out = []; + let previousResponse = null; + for (let it = 0; it < iterations; it++) { + const row = opts.dataRows?.[it] ?? {}; + for (const req of requests) { + if (opts.abortSignal?.aborted) + return out; + const ancestors = findRequestAncestors(rootItems, req.id) ?? []; + const inherited = ancestors.length > 0 ? inherit(req, ancestors) : req; + const { result, response } = await executeRequest({ + req: inherited, + iteration: it, + row, + env: opts.environmentVariables, + sessionVars, + envOverlay, + envUnsets, + previousResponse, + signal: opts.abortSignal, + }); + out.push(result); + opts.onProgress?.(result); + if (response) + previousResponse = response; + const failedHere = result.networkError || result.tests.some((t) => !t.pass); + if (failedHere && opts.bail) + return out; + } + } + return out; +} +//# sourceMappingURL=runner.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/runner.js.map b/apps/api-runner/dist/runner.js.map new file mode 100644 index 00000000..b31611c7 --- /dev/null +++ b/apps/api-runner/dist/runner.js.map @@ -0,0 +1 @@ +{"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/runner.test.d.ts b/apps/api-runner/dist/runner.test.d.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/apps/api-runner/dist/runner.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/api-runner/dist/runner.test.js b/apps/api-runner/dist/runner.test.js new file mode 100644 index 00000000..17b49472 --- /dev/null +++ b/apps/api-runner/dist/runner.test.js @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { runCollection } from "./runner.js"; +const calls = []; +const originalFetch = globalThis.fetch; +function installMockFetch(handler) { + globalThis.fetch = (async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + return handler(url, init); + }); +} +test("substitutes env vars and runs test scripts", async () => { + calls.length = 0; + installMockFetch(async () => new Response('{"id":42}', { status: 200, headers: { "content-type": "application/json" } })); + const col = { + id: "c", name: "T", + items: [{ + id: "r1", name: "fetch", + method: "GET", + url: "https://api.test/items/{{wanted}}", + params: [], headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + testScript: "pm.test('200', () => pm.expect(pm.response.code).toBe(200))", + }], + }; + const results = await runCollection({ + collection: col, + environmentVariables: { wanted: "42" }, + iterations: 1, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.test/items/42"); + assert.equal(results[0].tests.length, 1); + assert.equal(results[0].tests[0].pass, true); + globalThis.fetch = originalFetch; +}); +test("chains {{response.body.token}} from previous request", async () => { + calls.length = 0; + const responses = [ + new Response(JSON.stringify({ token: "xyz" }), { status: 200, headers: { "content-type": "application/json" } }), + new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json" } }), + ]; + installMockFetch(async () => responses.shift()); + const col = { + id: "c", name: "T", + items: [ + { + id: "r1", name: "login", + method: "POST", + url: "https://api.test/login", + params: [], headers: [], + body: { type: "json", content: "{}" }, + auth: { type: "none" }, + }, + { + id: "r2", name: "me", + method: "GET", + url: "https://api.test/me", + params: [], + headers: [{ id: "h", key: "Authorization", value: "Bearer {{response.body.token}}", active: true }], + body: { type: "none", content: "" }, + auth: { type: "none" }, + }, + ], + }; + await runCollection({ collection: col, environmentVariables: {}, iterations: 1 }); + assert.equal(calls.length, 2); + const meReqHeaders = calls[1].init?.headers; + const authHeader = meReqHeaders?.Authorization; + assert.equal(authHeader, "Bearer xyz"); + globalThis.fetch = originalFetch; +}); +test("--bail short-circuits after first failing test", async () => { + calls.length = 0; + installMockFetch(async () => new Response("", { status: 500 })); + const col = { + id: "c", name: "T", + items: [ + { id: "r1", name: "a", method: "GET", url: "https://api.test/a", params: [], headers: [], body: { type: "none", content: "" }, auth: { type: "none" }, testScript: "pm.test('ok', () => pm.expect(pm.response.code).toBe(200))" }, + { id: "r2", name: "b", method: "GET", url: "https://api.test/b", params: [], headers: [], body: { type: "none", content: "" }, auth: { type: "none" } }, + ], + }; + const results = await runCollection({ collection: col, environmentVariables: {}, iterations: 1, bail: true }); + assert.equal(results.length, 1); // second request skipped + assert.equal(calls.length, 1); + globalThis.fetch = originalFetch; +}); +//# sourceMappingURL=runner.test.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/runner.test.js.map b/apps/api-runner/dist/runner.test.js.map new file mode 100644 index 00000000..c13dbd5c --- /dev/null +++ b/apps/api-runner/dist/runner.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"runner.test.js","sourceRoot":"","sources":["../src/runner.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/scripts.d.ts b/apps/api-runner/dist/scripts.d.ts new file mode 100644 index 00000000..046799b0 --- /dev/null +++ b/apps/api-runner/dist/scripts.d.ts @@ -0,0 +1,39 @@ +/** + * Headless pre-request / test script runner. Same `pm.*` surface as the web + * worker, executed inside `node:vm.runInContext` with a wall-clock timeout. + * The sandbox has no fetch, no fs, no process — just `pm` + `console`. + */ +export interface ScriptContext { + request: { + url: string; + method: string; + headers: Record; + body?: string; + }; + response?: { + status: number; + statusText: string; + headers: Record; + body: string; + time: number; + }; + environment: Record; + variables: Record; +} +export interface ScriptResult { + ok: boolean; + error?: string; + tests: { + name: string; + pass: boolean; + error?: string; + }[]; + logs: { + level: "log" | "warn" | "error"; + args: string[]; + }[]; + environment: Record; + variables: Record; + request: ScriptContext["request"]; +} +export declare function runScript(script: string, ctx: ScriptContext): ScriptResult; diff --git a/apps/api-runner/dist/scripts.js b/apps/api-runner/dist/scripts.js new file mode 100644 index 00000000..76df9746 --- /dev/null +++ b/apps/api-runner/dist/scripts.js @@ -0,0 +1,156 @@ +/** + * Headless pre-request / test script runner. Same `pm.*` surface as the web + * worker, executed inside `node:vm.runInContext` with a wall-clock timeout. + * The sandbox has no fetch, no fs, no process — just `pm` + `console`. + */ +import { Script, createContext } from "node:vm"; +const SCRIPT_TIMEOUT_MS = 3_000; +function safeStringify(v) { + if (typeof v === "string") + return v; + try { + return JSON.stringify(v); + } + catch { + return String(v); + } +} +function makeExpect(actual) { + const fail = (m) => { throw new Error(m); }; + return { + toBe(e) { if (actual !== e) + fail(`expected ${safeStringify(actual)} to be ${safeStringify(e)}`); }, + toEqual(e) { if (JSON.stringify(actual) !== JSON.stringify(e)) + fail(`expected ${safeStringify(actual)} to equal ${safeStringify(e)}`); }, + toMatch(r) { + const ok = r instanceof RegExp ? r.test(String(actual)) : String(actual).includes(String(r)); + if (!ok) + fail(`expected ${safeStringify(actual)} to match ${safeStringify(r)}`); + }, + toContain(s) { + const ok = Array.isArray(actual) + ? actual.includes(s) + : String(actual).includes(String(s)); + if (!ok) + fail(`expected ${safeStringify(actual)} to contain ${safeStringify(s)}`); + }, + toBeGreaterThan(n) { if (!(Number(actual) > n)) + fail(`expected ${safeStringify(actual)} > ${n}`); }, + toBeLessThan(n) { if (!(Number(actual) < n)) + fail(`expected ${safeStringify(actual)} < ${n}`); }, + toHaveProperty(k) { + if (!actual || typeof actual !== "object" || !(k in actual)) + fail(`expected object to have property "${k}"`); + }, + toBeTruthy() { if (!actual) + fail(`expected ${safeStringify(actual)} to be truthy`); }, + toBeFalsy() { if (actual) + fail(`expected ${safeStringify(actual)} to be falsy`); }, + }; +} +export function runScript(script, ctx) { + if (!script || !script.trim()) { + return { + ok: true, + tests: [], + logs: [], + environment: ctx.environment, + variables: ctx.variables, + request: ctx.request, + }; + } + const env = { ...ctx.environment }; + const vars = { ...ctx.variables }; + const tests = []; + const logs = []; + const request = { + url: ctx.request.url, + method: ctx.request.method, + headers: { ...ctx.request.headers }, + body: ctx.request.body, + }; + const pm = { + environment: { + get(k) { return env[k]; }, + set(k, v) { env[String(k)] = safeStringify(v); }, + unset(k) { delete env[String(k)]; }, + has(k) { return k in env; }, + toObject() { return { ...env }; }, + }, + variables: { + get(k) { return vars[k] ?? env[k]; }, + set(k, v) { vars[String(k)] = safeStringify(v); }, + has(k) { return k in vars || k in env; }, + }, + request: { + get url() { return request.url; }, + set url(v) { request.url = String(v); }, + get method() { return request.method; }, + set method(v) { request.method = String(v).toUpperCase(); }, + headers: { + get(name) { + const k = Object.keys(request.headers).find((h) => h.toLowerCase() === String(name).toLowerCase()); + return k ? request.headers[k] : undefined; + }, + add(name, v) { request.headers[String(name)] = safeStringify(v); }, + remove(name) { + const k = Object.keys(request.headers).find((h) => h.toLowerCase() === String(name).toLowerCase()); + if (k) + delete request.headers[k]; + }, + toObject() { return { ...request.headers }; }, + }, + get body() { return request.body; }, + set body(v) { request.body = v == null ? undefined : String(v); }, + }, + response: ctx.response + ? { + code: ctx.response.status, + status: ctx.response.statusText, + responseTime: ctx.response.time, + headers: { + get(name) { + const lower = String(name).toLowerCase(); + const k = Object.keys(ctx.response.headers).find((h) => h.toLowerCase() === lower); + return k ? ctx.response.headers[k] : undefined; + }, + toObject() { return { ...ctx.response.headers }; }, + }, + text: () => ctx.response.body, + json: () => { + try { + return JSON.parse(ctx.response.body); + } + catch (e) { + throw new Error(`Response body is not valid JSON: ${e.message}`); + } + }, + } + : undefined, + test(name, fn) { + try { + fn(); + tests.push({ name: String(name), pass: true }); + } + catch (e) { + tests.push({ name: String(name), pass: false, error: e.message }); + } + }, + expect: makeExpect, + sendRequest() { throw new Error("pm.sendRequest is not supported in the CLI yet"); }, + }; + const sandboxConsole = { + log: (...args) => { logs.push({ level: "log", args: args.map(safeStringify) }); }, + warn: (...args) => { logs.push({ level: "warn", args: args.map(safeStringify) }); }, + error: (...args) => { logs.push({ level: "error", args: args.map(safeStringify) }); }, + }; + const sandbox = createContext({ pm, console: sandboxConsole }); + try { + new Script(`"use strict";\n${script}`).runInContext(sandbox, { timeout: SCRIPT_TIMEOUT_MS }); + } + catch (e) { + return { ok: false, error: e.message, tests, logs, environment: env, variables: vars, request }; + } + return { ok: true, tests, logs, environment: env, variables: vars, request }; +} +//# sourceMappingURL=scripts.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/scripts.js.map b/apps/api-runner/dist/scripts.js.map new file mode 100644 index 00000000..45dc8b5a --- /dev/null +++ b/apps/api-runner/dist/scripts.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scripts.js","sourceRoot":"","sources":["../src/scripts.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/api-runner/dist/types.d.ts b/apps/api-runner/dist/types.d.ts new file mode 100644 index 00000000..6ee1a708 --- /dev/null +++ b/apps/api-runner/dist/types.d.ts @@ -0,0 +1,88 @@ +/** + * Self-contained types for the CLI — deliberately decoupled from the web app so + * the package can publish standalone. The shape matches what `lib/import/postman.ts` + * emits, so a Postman v2.1 export round-trips into here. + */ +export type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; +export interface KeyValueItem { + id: string; + key: string; + value: string; + active: boolean; +} +export interface FormDataItem { + id: string; + key: string; + value: string; + active: boolean; + valueType: "text" | "file"; + fileName?: string; + fileType?: string; + fileContentBase64?: string; +} +export interface RequestBody { + type: "json" | "text" | "none" | "form-data" | "x-www-form-urlencoded" | "graphql"; + content: string; + formData?: FormDataItem[]; + urlEncoded?: KeyValueItem[]; + graphqlVariables?: string; +} +export interface RequestAuth { + type: "none" | "bearer" | "basic" | "api-key"; + token?: string; + username?: string; + password?: string; + apiKeyKey?: string; + apiKeyValue?: string; + apiKeyLocation?: "header" | "query"; +} +export interface CollectionRequest { + id: string; + name: string; + method: RequestMethod; + url: string; + params: KeyValueItem[]; + headers: KeyValueItem[]; + body: RequestBody; + auth: RequestAuth; + preRequestScript?: string; + testScript?: string; +} +export interface CollectionFolder { + id: string; + name: string; + type: "folder"; + items: (CollectionFolder | CollectionRequest)[]; + defaultHeaders?: KeyValueItem[]; + preRequestScript?: string; + testScript?: string; +} +export interface Collection { + id: string; + name: string; + items: (CollectionFolder | CollectionRequest)[]; +} +export interface TestResult { + name: string; + pass: boolean; + error?: string; +} +export interface ScriptLog { + level: "log" | "warn" | "error"; + args: string[]; +} +export interface RequestRunResult { + iteration: number; + requestId: string; + requestName: string; + method: string; + url: string; + status?: number; + statusText?: string; + time?: number; + size?: number; + tests: TestResult[]; + logs: ScriptLog[]; + errors: string[]; + networkError?: string; +} diff --git a/apps/api-runner/dist/types.js b/apps/api-runner/dist/types.js new file mode 100644 index 00000000..f7c8bdff --- /dev/null +++ b/apps/api-runner/dist/types.js @@ -0,0 +1,7 @@ +/** + * Self-contained types for the CLI — deliberately decoupled from the web app so + * the package can publish standalone. The shape matches what `lib/import/postman.ts` + * emits, so a Postman v2.1 export round-trips into here. + */ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/apps/api-runner/dist/types.js.map b/apps/api-runner/dist/types.js.map new file mode 100644 index 00000000..902389a9 --- /dev/null +++ b/apps/api-runner/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"} \ No newline at end of file diff --git a/apps/api-runner/node_modules/.bin/tsc b/apps/api-runner/node_modules/.bin/tsc new file mode 100755 index 00000000..bc3dbe24 --- /dev/null +++ b/apps/api-runner/node_modules/.bin/tsc @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" +else + exec node "$basedir/../typescript/bin/tsc" "$@" +fi diff --git a/apps/api-runner/node_modules/.bin/tsserver b/apps/api-runner/node_modules/.bin/tsserver new file mode 100755 index 00000000..03c2f186 --- /dev/null +++ b/apps/api-runner/node_modules/.bin/tsserver @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" +else + exec node "$basedir/../typescript/bin/tsserver" "$@" +fi diff --git a/apps/api-runner/node_modules/@types/node b/apps/api-runner/node_modules/@types/node new file mode 120000 index 00000000..d235c10c --- /dev/null +++ b/apps/api-runner/node_modules/@types/node @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node \ No newline at end of file diff --git a/apps/api-runner/node_modules/typescript b/apps/api-runner/node_modules/typescript new file mode 120000 index 00000000..949dba4e --- /dev/null +++ b/apps/api-runner/node_modules/typescript @@ -0,0 +1 @@ +../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript \ No newline at end of file diff --git a/apps/api-runner/package.json b/apps/api-runner/package.json new file mode 100644 index 00000000..3988ea7e --- /dev/null +++ b/apps/api-runner/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mydevtools/api-runner", + "version": "0.1.0", + "description": "Headless runner for mydevtools API client collections — CI/CD parity with the web UI runner.", + "type": "module", + "bin": { + "mydevtools-api": "./dist/index.js" + }, + "main": "./dist/index.js", + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --experimental-vm-modules --test \"dist/**/*.test.js\"", + "start": "node ./dist/index.js", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": ">=18.17.0" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.5", + "@types/node": "^20.11.0" + } +} diff --git a/apps/api-runner/src/csv.ts b/apps/api-runner/src/csv.ts new file mode 100644 index 00000000..aa153e58 --- /dev/null +++ b/apps/api-runner/src/csv.ts @@ -0,0 +1,59 @@ +/** Same CSV/JSON data-file parser as the web app — verbatim port for portability. */ + +export function parseCsv(text: string): Record[] { + const rows = parseRows(text) + if (rows.length === 0) return [] + const [headers, ...body] = rows + return body + .filter((r) => r.length > 1 || (r.length === 1 && r[0] !== "")) + .map((cells) => { + const obj: Record = {} + headers.forEach((h, i) => { obj[h] = cells[i] ?? "" }) + return obj + }) +} + +function parseRows(text: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let cell = "" + let inQuote = false + for (let i = 0; i < text.length; i++) { + const c = text[i] + if (inQuote) { + if (c === '"' && text[i + 1] === '"') { cell += '"'; i++ } + else if (c === '"') { inQuote = false } + else cell += c + } else { + if (c === '"') { inQuote = true } + else if (c === ",") { row.push(cell); cell = "" } + else if (c === "\n" || c === "\r") { + row.push(cell); cell = "" + rows.push(row); row = [] + if (c === "\r" && text[i + 1] === "\n") i++ + } else cell += c + } + } + if (cell.length > 0 || row.length > 0) { + row.push(cell) + rows.push(row) + } + return rows +} + +export function parseDataFile(text: string): Record[] { + const t = text.trimStart() + if (t.startsWith("[")) { + const parsed = JSON.parse(t) + if (!Array.isArray(parsed)) throw new Error("JSON data file must be an array") + return parsed.map((row: unknown) => { + if (!row || typeof row !== "object") return {} + const out: Record = {} + for (const [k, v] of Object.entries(row as Record)) { + out[k] = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v) + } + return out + }) + } + return parseCsv(text) +} diff --git a/apps/api-runner/src/index.ts b/apps/api-runner/src/index.ts new file mode 100644 index 00000000..e9831b08 --- /dev/null +++ b/apps/api-runner/src/index.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * mydevtools-api CLI. + * Headless equivalent of the web-side collection runner. Same scripts, same + * substitution layering, same JUnit output — runs over a Postman v2.1 export. + */ + +import { readFile, writeFile } from "node:fs/promises" +import { importPostmanCollection, parsePostmanEnvironment } from "./postman.js" +import { parseDataFile } from "./csv.js" +import { runCollection } from "./runner.js" +import { buildJUnitXml } from "./junit.js" +import type { RequestRunResult } from "./types.js" + +interface CliArgs { + cmd?: "run" + collectionPath?: string + envPath?: string + dataPath?: string + iterations: number + reporter?: "cli" | "junit" + reporterOut?: string + bail: boolean + help: boolean +} + +function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = { iterations: 1, bail: false, help: false } + if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { + out.help = true + return out + } + out.cmd = argv[0] as "run" + let i = 1 + while (i < argv.length) { + const a = argv[i] + if (a === "--data" || a === "-d") { out.dataPath = argv[++i] } + else if (a === "--env" || a === "-e") { out.envPath = argv[++i] } + else if (a === "--iterations" || a === "-n") { out.iterations = Math.max(1, Number(argv[++i]) || 1) } + else if (a === "--reporter" || a === "-r") { out.reporter = argv[++i] as CliArgs["reporter"] } + else if (a === "--reporter-out" || a === "-o") { out.reporterOut = argv[++i] } + else if (a === "--bail") { out.bail = true } + else if (a === "-h" || a === "--help") { out.help = true } + else if (!out.collectionPath) { out.collectionPath = a } + else throw new Error(`Unknown argument: ${a}`) + i++ + } + return out +} + +function printUsage(): void { + console.error(`Usage: mydevtools-api run [options] + +Options: + -d, --data CSV or JSON array of iteration rows (each row's keys + override env vars for one iteration). + -e, --env Postman Environment export (v2 schema) — enabled + values become the base \`{{var}}\` map. + -n, --iterations Iterations to run when no data file is given. Default 1. + -r, --reporter 'cli' (default) or 'junit'. + -o, --reporter-out Write the JUnit XML here. Required for --reporter junit. + --bail Stop on the first failing request. + -h, --help Show this message. + +Exit codes: + 0 — all tests passed + 1 — one or more tests / requests failed + 2 — usage error +`) +} + +function fmtRow(r: RequestRunResult): string { + const ok = !r.networkError && r.tests.every((t) => t.pass) + const status = r.status !== undefined ? r.status : "—" + const time = r.time !== undefined ? `${r.time}ms` : "" + return ` ${ok ? "✓" : "✗"} ${r.method.padEnd(6)} ${status} ${time} ${r.requestName}` +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + if (args.help || !args.cmd) { printUsage(); process.exit(args.help ? 0 : 2) } + if (args.cmd !== "run" || !args.collectionPath) { printUsage(); process.exit(2) } + if (args.reporter === "junit" && !args.reporterOut) { + console.error("--reporter-out is required when --reporter junit is used") + process.exit(2) + } + + const collection = importPostmanCollection(await readFile(args.collectionPath, "utf-8")) + const dataRows = args.dataPath ? parseDataFile(await readFile(args.dataPath, "utf-8")) : undefined + const env = args.envPath ? parsePostmanEnvironment(await readFile(args.envPath, "utf-8")) : {} + + console.log(`Running ${collection.name}` + (dataRows ? ` × ${dataRows.length} iterations` : args.iterations > 1 ? ` × ${args.iterations} iterations` : "")) + + const results = await runCollection({ + collection, + environmentVariables: env, + iterations: args.iterations, + dataRows, + bail: args.bail, + onProgress: (r) => console.log(fmtRow(r)), + }) + + const allTests = results.flatMap((r) => r.tests) + const failed = allTests.filter((t) => !t.pass).length + const errored = results.filter((r) => r.networkError || r.errors.length > 0).length + const passed = allTests.length - failed + + console.log("") + console.log(`Requests: ${results.length}`) + console.log(`Assertions: ${allTests.length} (✓ ${passed} / ✗ ${failed})`) + if (errored > 0) console.log(`Errors: ${errored}`) + + for (const r of results) { + const failures = r.tests.filter((t) => !t.pass) + if (failures.length === 0 && !r.networkError) continue + console.log(`\n ${r.method} ${r.requestName}`) + if (r.networkError) console.log(` ! ${r.networkError}`) + for (const t of failures) console.log(` ✗ ${t.name}\n ${t.error}`) + } + + if (args.reporter === "junit" && args.reporterOut) { + await writeFile(args.reporterOut, buildJUnitXml(results, collection.name)) + console.log(`\nWrote JUnit XML to ${args.reporterOut}`) + } + + process.exit(failed > 0 || errored > 0 ? 1 : 0) +} + +main().catch((err) => { + console.error(`Fatal: ${(err as Error).message}`) + process.exit(1) +}) diff --git a/apps/api-runner/src/junit.ts b/apps/api-runner/src/junit.ts new file mode 100644 index 00000000..a12e4337 --- /dev/null +++ b/apps/api-runner/src/junit.ts @@ -0,0 +1,52 @@ +/** JUnit XML emitter — port of `apps/web/src/lib/runner/junit.ts`. */ + +import type { RequestRunResult } from "./types.js" + +function escapeXml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function buildJUnitXml(results: RequestRunResult[], suiteName: string): string { + const allTests = results.flatMap((r) => r.tests.map((t) => ({ run: r, test: t }))) + const totalTests = allTests.length + const failed = allTests.filter(({ test }) => !test.pass).length + const errored = results.filter((r) => r.networkError || r.errors.length > 0).length + const totalTime = results.reduce((s, r) => s + (r.time ?? 0), 0) / 1000 + + const cases = allTests.map(({ run, test }) => { + const classname = escapeXml(run.requestName) + const name = escapeXml(test.name) + const time = ((run.time ?? 0) / 1000).toFixed(3) + if (test.pass) { + return ` ` + } + const msg = escapeXml(test.error ?? "assertion failed") + return ` + ${msg} + ` + }) + + const errorCases = results + .filter((r) => r.networkError || r.errors.length > 0) + .map((r) => { + const msg = escapeXml(r.networkError ?? r.errors.join("; ")) + const classname = escapeXml(r.requestName) + const time = ((r.time ?? 0) / 1000).toFixed(3) + return ` + ${msg} + ` + }) + + const body = [...cases, ...errorCases].join("\n") + return ` + + +${body} + +` +} diff --git a/apps/api-runner/src/postman.ts b/apps/api-runner/src/postman.ts new file mode 100644 index 00000000..0edad52f --- /dev/null +++ b/apps/api-runner/src/postman.ts @@ -0,0 +1,199 @@ +/** + * Lean Postman v2.1 → Collection converter for the CLI. + * Mirrors `apps/web/src/lib/import/postman.ts` but bound to the smaller + * CLI type surface (no OAuth2, no graphql body parsing — those map to text). + */ + +import { randomUUID } from "node:crypto" +import type { + Collection, + CollectionFolder, + CollectionRequest, + KeyValueItem, + RequestAuth, + RequestBody, + RequestMethod, +} from "./types.js" + +const VALID_METHODS = new Set([ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", +]) + +interface PostmanCollection { info?: { name?: string }; item?: PostmanItem[] } +interface PostmanItem { + name?: string + item?: PostmanItem[] + request?: PostmanRequest + event?: Array<{ listen?: string; script?: { exec?: string | string[] } }> +} +interface PostmanRequest { + method?: string + url?: string | { raw?: string; query?: PostmanKv[] } + header?: PostmanKv[] + body?: { + mode?: string + raw?: string + urlencoded?: PostmanKv[] + formdata?: PostmanKv[] + options?: { raw?: { language?: string } } + } + auth?: { + type?: string + bearer?: PostmanKv[] + basic?: PostmanKv[] + apikey?: PostmanKv[] + } +} +interface PostmanKv { + key?: string + value?: string + disabled?: boolean + type?: string +} + +const id = () => randomUUID() + +function pickScript(events: PostmanItem["event"], kind: "prerequest" | "test"): string { + const evt = events?.find((e) => e.listen === kind) + if (!evt?.script?.exec) return "" + return Array.isArray(evt.script.exec) ? evt.script.exec.join("\n") : evt.script.exec +} + +function kvList(items?: PostmanKv[]): KeyValueItem[] { + return (items ?? []) + .filter((kv) => kv && (kv.key || kv.value)) + .map((kv) => ({ + id: id(), + key: kv.key ?? "", + value: kv.value ?? "", + active: !kv.disabled, + })) +} + +function readUrl(u: PostmanRequest["url"]): { url: string; query: PostmanKv[] } { + if (!u) return { url: "", query: [] } + if (typeof u === "string") return { url: u, query: [] } + return { url: u.raw ?? "", query: u.query ?? [] } +} + +function lookup(items: PostmanKv[] | undefined, key: string): string | undefined { + return items?.find((x) => x.key === key)?.value +} + +function convertAuth(a: PostmanRequest["auth"]): RequestAuth { + if (!a || a.type === "noauth" || !a.type) return { type: "none" } + if (a.type === "bearer") return { type: "bearer", token: lookup(a.bearer, "token") } + if (a.type === "basic") { + return { + type: "basic", + username: lookup(a.basic, "username"), + password: lookup(a.basic, "password"), + } + } + if (a.type === "apikey") { + return { + type: "api-key", + apiKeyKey: lookup(a.apikey, "key"), + apiKeyValue: lookup(a.apikey, "value"), + apiKeyLocation: lookup(a.apikey, "in") === "query" ? "query" : "header", + } + } + return { type: "none" } +} + +function convertBody(b: PostmanRequest["body"]): RequestBody { + if (!b || b.mode === "none") return { type: "none", content: "" } + if (b.mode === "raw") { + const lang = b.options?.raw?.language + return { type: lang === "json" ? "json" : "text", content: b.raw ?? "" } + } + if (b.mode === "urlencoded") { + return { + type: "x-www-form-urlencoded", + content: "", + urlEncoded: kvList(b.urlencoded), + } + } + if (b.mode === "formdata") { + return { + type: "form-data", + content: "", + formData: (b.formdata ?? []) + .filter((kv) => kv && (kv.key || kv.value)) + .map((kv) => ({ + id: id(), + key: kv.key ?? "", + value: kv.value ?? "", + active: !kv.disabled, + valueType: kv.type === "file" ? "file" : "text", + })), + } + } + return { type: "text", content: b.raw ?? "" } +} + +function convertRequest(item: PostmanItem): CollectionRequest { + const r = item.request ?? {} + const { url, query } = readUrl(r.url) + const method = (r.method ?? "GET").toUpperCase() as RequestMethod + const safeMethod: RequestMethod = VALID_METHODS.has(method) ? method : "GET" + const preRequestScript = pickScript(item.event, "prerequest") + const testScript = pickScript(item.event, "test") + return { + id: id(), + name: item.name ?? safeMethod, + method: safeMethod, + url, + params: kvList(query), + headers: kvList(r.header), + body: convertBody(r.body), + auth: convertAuth(r.auth), + preRequestScript: preRequestScript || undefined, + testScript: testScript || undefined, + } +} + +function convertItems(items?: PostmanItem[]): (CollectionFolder | CollectionRequest)[] { + const out: (CollectionFolder | CollectionRequest)[] = [] + for (const item of items ?? []) { + if (item.request) out.push(convertRequest(item)) + else if (item.item) { + out.push({ + id: id(), + name: item.name ?? "Folder", + type: "folder", + items: convertItems(item.item), + }) + } + } + return out +} + +export function importPostmanCollection(raw: string | object): Collection { + const data: PostmanCollection = typeof raw === "string" ? JSON.parse(raw) : (raw as PostmanCollection) + if (!data || (!data.info?.name && !Array.isArray(data.item))) { + throw new Error("Not a Postman collection (missing info.name + item[])") + } + return { + id: id(), + name: data.info?.name ?? "Imported Postman collection", + items: convertItems(data.item), + } +} + +/** + * Parse a Postman Environment export (v2 schema) into a flat `key → value` map. + * Disabled vars are skipped. + */ +export function parsePostmanEnvironment(raw: string | object): Record { + const data = typeof raw === "string" ? JSON.parse(raw) : raw + const vars = (data as { values?: Array<{ key?: string; value?: string; enabled?: boolean }> })?.values + if (!Array.isArray(vars)) return {} + const out: Record = {} + for (const v of vars) { + if (!v?.key) continue + if (v.enabled === false) continue + out[v.key] = String(v.value ?? "") + } + return out +} diff --git a/apps/api-runner/src/runner.test.ts b/apps/api-runner/src/runner.test.ts new file mode 100644 index 00000000..0c456312 --- /dev/null +++ b/apps/api-runner/src/runner.test.ts @@ -0,0 +1,102 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { runCollection } from "./runner.js" +import type { Collection } from "./types.js" + +interface FetchCall { url: string; init?: RequestInit } +const calls: FetchCall[] = [] +const originalFetch = globalThis.fetch + +function installMockFetch(handler: (url: string, init?: RequestInit) => Promise) { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString() + calls.push({ url, init }) + return handler(url, init) + }) as typeof fetch +} + +test("substitutes env vars and runs test scripts", async () => { + calls.length = 0 + installMockFetch(async () => new Response('{"id":42}', { status: 200, headers: { "content-type": "application/json" } })) + + const col: Collection = { + id: "c", name: "T", + items: [{ + id: "r1", name: "fetch", + method: "GET", + url: "https://api.test/items/{{wanted}}", + params: [], headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + testScript: "pm.test('200', () => pm.expect(pm.response.code).toBe(200))", + }], + } + const results = await runCollection({ + collection: col, + environmentVariables: { wanted: "42" }, + iterations: 1, + }) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, "https://api.test/items/42") + assert.equal(results[0].tests.length, 1) + assert.equal(results[0].tests[0].pass, true) + + globalThis.fetch = originalFetch +}) + +test("chains {{response.body.token}} from previous request", async () => { + calls.length = 0 + const responses = [ + new Response(JSON.stringify({ token: "xyz" }), { status: 200, headers: { "content-type": "application/json" } }), + new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json" } }), + ] + installMockFetch(async () => responses.shift()!) + + const col: Collection = { + id: "c", name: "T", + items: [ + { + id: "r1", name: "login", + method: "POST", + url: "https://api.test/login", + params: [], headers: [], + body: { type: "json", content: "{}" }, + auth: { type: "none" }, + }, + { + id: "r2", name: "me", + method: "GET", + url: "https://api.test/me", + params: [], + headers: [{ id: "h", key: "Authorization", value: "Bearer {{response.body.token}}", active: true }], + body: { type: "none", content: "" }, + auth: { type: "none" }, + }, + ], + } + await runCollection({ collection: col, environmentVariables: {}, iterations: 1 }) + assert.equal(calls.length, 2) + const meReqHeaders = calls[1].init?.headers + const authHeader = (meReqHeaders as Record)?.Authorization + assert.equal(authHeader, "Bearer xyz") + + globalThis.fetch = originalFetch +}) + +test("--bail short-circuits after first failing test", async () => { + calls.length = 0 + installMockFetch(async () => new Response("", { status: 500 })) + + const col: Collection = { + id: "c", name: "T", + items: [ + { id: "r1", name: "a", method: "GET", url: "https://api.test/a", params: [], headers: [], body: { type: "none", content: "" }, auth: { type: "none" }, testScript: "pm.test('ok', () => pm.expect(pm.response.code).toBe(200))" }, + { id: "r2", name: "b", method: "GET", url: "https://api.test/b", params: [], headers: [], body: { type: "none", content: "" }, auth: { type: "none" } }, + ], + } + const results = await runCollection({ collection: col, environmentVariables: {}, iterations: 1, bail: true }) + assert.equal(results.length, 1) // second request skipped + assert.equal(calls.length, 1) + + globalThis.fetch = originalFetch +}) diff --git a/apps/api-runner/src/runner.ts b/apps/api-runner/src/runner.ts new file mode 100644 index 00000000..6f3533e9 --- /dev/null +++ b/apps/api-runner/src/runner.ts @@ -0,0 +1,416 @@ +/** + * Headless collection runner. + * + * Mirrors `apps/web/src/lib/runner/runner.ts` semantics: + * - Sequential execution; one iteration per data row, else `iterations`. + * - Pre-request + test scripts run via `node:vm` with `pm.*` API. + * - Env mutations cascade across requests within a run. + * - `{{response.body.x}}` chaining off the previous successful response. + * - Folder-level defaults (headers / scripts) merged before per-request fields. + * + * Deliberate omissions for the CLI v1: + * - No cookie jar (Node native fetch has none; add tough-cookie if needed). + * - No OAuth refresh flow (user supplies bearer token via env). + * - No streaming (proxy-stream lives on the web side). + */ + +import type { + Collection, + CollectionFolder, + CollectionRequest, + KeyValueItem, + RequestRunResult, + TestResult, + ScriptLog, +} from "./types.js" +import { runScript } from "./scripts.js" + +interface RunOpts { + collection: Collection + folderId?: string + iterations: number + dataRows?: Record[] + environmentVariables: Record + bail?: boolean + onProgress?: (r: RequestRunResult) => void + abortSignal?: AbortSignal +} + +interface PreviousResponseSnapshot { + status: number + statusText: string + headers: Record + body: string + time: number + size: number +} + +function walkRequests(items: (CollectionFolder | CollectionRequest)[], out: CollectionRequest[]): void { + for (const it of items) { + if ("type" in it && it.type === "folder") walkRequests(it.items, out) + else out.push(it as CollectionRequest) + } +} + +function findRequestAncestors( + items: (CollectionFolder | CollectionRequest)[], + requestId: string, + trail: CollectionFolder[] = [], +): CollectionFolder[] | null { + for (const item of items) { + if (item.id === requestId) return trail + if ("type" in item && item.type === "folder") { + const hit = findRequestAncestors(item.items, requestId, [...trail, item]) + if (hit) return hit + } + } + return null +} + +function mergeHeaders(chain: CollectionFolder[], requestHeaders: KeyValueItem[]): KeyValueItem[] { + const seen = new Set() + const out: KeyValueItem[] = [] + for (const h of requestHeaders) { if (h.key) seen.add(h.key.toLowerCase()); out.push(h) } + for (const folder of chain) { + for (const h of folder.defaultHeaders ?? []) { + if (h.key && seen.has(h.key.toLowerCase())) continue + if (h.key) seen.add(h.key.toLowerCase()) + out.push(h) + } + } + return out +} + +function inherit(req: CollectionRequest, ancestors: CollectionFolder[]): CollectionRequest { + if (ancestors.length === 0) return req + const preChain = [...ancestors.map((f) => f.preRequestScript), req.preRequestScript] + .map((s) => (s ?? "").trim()).filter(Boolean).join("\n\n") + const testChain = [...ancestors.map((f) => f.testScript), req.testScript] + .map((s) => (s ?? "").trim()).filter(Boolean).join("\n\n") + return { + ...req, + headers: mergeHeaders(ancestors, req.headers), + preRequestScript: preChain || undefined, + testScript: testChain || undefined, + } +} + +function tryParseJson(s: string): unknown | undefined { + try { return JSON.parse(s) } catch { return undefined } +} + +function parseResponsePath(s: string): (string | number)[] { + const out: (string | number)[] = [] + for (const part of s.split(".")) { + const m = part.match(/^([^[]*)((?:\[\d+\])*)$/) + if (!m) { out.push(part); continue } + if (m[1]) out.push(m[1]) + for (const idx of m[2].matchAll(/\[(\d+)\]/g)) out.push(Number(idx[1])) + } + return out +} + +function resolveResponsePath(path: string, response: PreviousResponseSnapshot | null): string | undefined { + if (!response) return undefined + const parts = parseResponsePath(path) + if (parts.length === 0) return undefined + const head = parts[0] + if (head === "status") return String(response.status) + if (head === "statusText") return response.statusText + if (head === "headers") { + if (parts.length === 1) return undefined + const name = String(parts[1]).toLowerCase() + const hit = Object.entries(response.headers).find(([k]) => k.toLowerCase() === name) + return hit?.[1] + } + if (head === "body") { + if (parts.length === 1) return response.body + const parsed = tryParseJson(response.body) + if (parsed === undefined) return undefined + let cur: unknown = parsed + for (const k of parts.slice(1)) { + if (cur == null || typeof cur !== "object") return undefined + cur = (cur as Record)[k as keyof typeof cur] + } + if (cur == null) return undefined + return typeof cur === "object" ? JSON.stringify(cur) : String(cur) + } + return undefined +} + +interface SubstituteArgs { + env: Record + sessionVars: Record + envOverlay: Record + envUnsets: Set + row: Record + previousResponse: PreviousResponseSnapshot | null +} + +function makeSubstitute(args: SubstituteArgs) { + return (text: string): string => { + if (!text) return text + return text.replace(/\{\{(.+?)\}\}/g, (m, k) => { + const key = (k as string).trim() + if (key.startsWith("response.")) { + const resolved = resolveResponsePath(key.slice("response.".length), args.previousResponse) + return resolved ?? m + } + if (key in args.row) return args.row[key] + if (args.envUnsets.has(key)) return m + if (key in args.envOverlay) return args.envOverlay[key] + if (key in args.sessionVars) return args.sessionVars[key] + return args.env[key] ?? m + }) + } +} + +function utf8Btoa(s: string): string { + return Buffer.from(s, "utf-8").toString("base64") +} + +async function executeRequest(args: { + req: CollectionRequest + iteration: number + row: Record + env: Record + sessionVars: Record + envOverlay: Record + envUnsets: Set + previousResponse: PreviousResponseSnapshot | null + signal?: AbortSignal +}): Promise<{ result: RequestRunResult; response: PreviousResponseSnapshot | null }> { + const { req, iteration } = args + const result: RequestRunResult = { + iteration, + requestId: req.id, + requestName: req.name, + method: req.method, + url: req.url, + tests: [], + logs: [], + errors: [], + } + + const substitute = makeSubstitute({ + env: args.env, + sessionVars: args.sessionVars, + envOverlay: args.envOverlay, + envUnsets: args.envUnsets, + row: args.row, + previousResponse: args.previousResponse, + }) + + let workMethod: string = req.method + let workUrl: string = req.url + let workHeaders: Record = {} + req.headers.forEach((h) => { if (h.active && h.key) workHeaders[h.key] = h.value }) + + // Pre-request script. + if (req.preRequestScript && req.preRequestScript.trim()) { + const r = runScript(req.preRequestScript, { + request: { + url: workUrl, method: workMethod, headers: workHeaders, + body: req.body.type === "json" || req.body.type === "text" || req.body.type === "graphql" ? req.body.content : undefined, + }, + environment: { ...args.env, ...args.envOverlay }, + variables: { ...args.sessionVars, ...args.row }, + }) + result.tests.push(...r.tests as TestResult[]) + result.logs.push(...r.logs as ScriptLog[]) + if (!r.ok && r.error) result.errors.push(`pre-request: ${r.error}`) + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== args.env[k]) args.envOverlay[k] = r.environment[k] + } + for (const k of Object.keys(args.env)) { + if (!(k in r.environment)) args.envUnsets.add(k) + } + Object.assign(args.sessionVars, r.variables) + workUrl = r.request.url + workMethod = (r.request.method || workMethod).toUpperCase() + workHeaders = r.request.headers + } + + let urlObj: URL + try { urlObj = new URL(substitute(workUrl)) } catch (e) { + result.networkError = `Invalid URL: ${(e as Error).message}` + return { result, response: null } + } + req.params.forEach((p) => { + if (p.active && p.key) urlObj.searchParams.append(substitute(p.key), substitute(p.value)) + }) + + const headersObj: Record = {} + for (const [k, v] of Object.entries(workHeaders)) { + headersObj[substitute(k)] = substitute(v) + } + + if (req.auth.type === "bearer" && req.auth.token) { + headersObj["Authorization"] = `Bearer ${substitute(req.auth.token).trim()}` + } else if (req.auth.type === "basic" && req.auth.username && req.auth.password) { + headersObj["Authorization"] = `Basic ${utf8Btoa(`${substitute(req.auth.username).trim()}:${substitute(req.auth.password)}`)}` + } else if (req.auth.type === "api-key" && req.auth.apiKeyKey && req.auth.apiKeyValue) { + const key = substitute(req.auth.apiKeyKey).trim() + const val = substitute(req.auth.apiKeyValue).trim() + if (req.auth.apiKeyLocation === "query") urlObj.searchParams.append(key, val) + else headersObj[key] = val + } + + let body: BodyInit | undefined + const noBody = workMethod === "GET" || workMethod === "HEAD" || req.body.type === "none" + if (!noBody) { + if (req.body.type === "json") { + const sub = substitute(req.body.content) + try { JSON.parse(sub) } catch (e) { + result.networkError = `Invalid JSON body: ${(e as Error).message}` + return { result, response: null } + } + body = sub + headersObj["Content-Type"] = "application/json" + } else if (req.body.type === "x-www-form-urlencoded") { + const sp = new URLSearchParams() + ;(req.body.urlEncoded ?? []).forEach((it) => { + if (it.active && it.key) sp.append(substitute(it.key), substitute(it.value)) + }) + body = sp.toString() + if (!Object.keys(headersObj).some((k) => k.toLowerCase() === "content-type")) { + headersObj["Content-Type"] = "application/x-www-form-urlencoded" + } + } else if (req.body.type === "form-data") { + const form = new FormData() + for (const it of req.body.formData ?? []) { + if (!it.active || !it.key) continue + if (it.valueType === "file") { + if (!it.fileContentBase64) continue + const buf = Buffer.from(it.fileContentBase64, "base64") + form.append( + substitute(it.key), + new Blob([buf], { type: it.fileType || "application/octet-stream" }), + it.fileName || "upload.bin", + ) + } else { + form.append(substitute(it.key), substitute(it.value)) + } + } + body = form + // Let fetch set the multipart Content-Type with its own boundary. + for (const k of Object.keys(headersObj)) { + if (k.toLowerCase() === "content-type") delete headersObj[k] + } + } else if (req.body.type === "graphql") { + const query = substitute(req.body.content) + let variables: unknown + const rawVars = (req.body.graphqlVariables ?? "").trim() + if (rawVars) { + try { variables = JSON.parse(substitute(rawVars)) } catch (e) { + result.networkError = `Invalid GraphQL variables JSON: ${(e as Error).message}` + return { result, response: null } + } + } + body = JSON.stringify(variables !== undefined ? { query, variables } : { query }) + headersObj["Content-Type"] = "application/json" + } else { + body = substitute(req.body.content) + if (!headersObj["Content-Type"]) headersObj["Content-Type"] = "text/plain" + } + } + + const start = Date.now() + let response: Response + try { + response = await fetch(urlObj.toString(), { + method: workMethod, + headers: headersObj, + body, + signal: args.signal, + }) + } catch (e) { + result.networkError = (e as Error).message + return { result, response: null } + } + const elapsed = Date.now() - start + const bodyText = await response.text().catch(() => "") + const respHeaders: Record = {} + response.headers.forEach((v, k) => { respHeaders[k] = v }) + + result.status = response.status + result.statusText = response.statusText + result.time = elapsed + result.size = Buffer.byteLength(bodyText, "utf-8") + + const snapshot: PreviousResponseSnapshot = { + status: response.status, + statusText: response.statusText, + headers: respHeaders, + body: bodyText, + time: elapsed, + size: result.size!, + } + + // Test script. + if (req.testScript && req.testScript.trim()) { + const r = runScript(req.testScript, { + request: { url: urlObj.toString(), method: workMethod, headers: { ...headersObj }, body: typeof body === "string" ? body : undefined }, + response: { + status: response.status, + statusText: response.statusText, + headers: respHeaders, + body: bodyText, + time: elapsed, + }, + environment: { ...args.env, ...args.envOverlay }, + variables: { ...args.sessionVars, ...args.row }, + }) + result.tests.push(...r.tests as TestResult[]) + result.logs.push(...r.logs as ScriptLog[]) + if (!r.ok && r.error) result.errors.push(`test: ${r.error}`) + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== args.env[k]) args.envOverlay[k] = r.environment[k] + } + Object.assign(args.sessionVars, r.variables) + } + + return { result, response: snapshot } +} + +export async function runCollection(opts: RunOpts): Promise { + const rootItems = opts.collection.items + const requests: CollectionRequest[] = [] + walkRequests(rootItems, requests) + const iterations = opts.dataRows && opts.dataRows.length > 0 + ? opts.dataRows.length + : Math.max(1, opts.iterations || 1) + + const sessionVars: Record = {} + const envOverlay: Record = {} + const envUnsets = new Set() + const out: RequestRunResult[] = [] + let previousResponse: PreviousResponseSnapshot | null = null + + for (let it = 0; it < iterations; it++) { + const row = opts.dataRows?.[it] ?? {} + for (const req of requests) { + if (opts.abortSignal?.aborted) return out + const ancestors = findRequestAncestors(rootItems, req.id) ?? [] + const inherited = ancestors.length > 0 ? inherit(req, ancestors) : req + const { result, response } = await executeRequest({ + req: inherited, + iteration: it, + row, + env: opts.environmentVariables, + sessionVars, + envOverlay, + envUnsets, + previousResponse, + signal: opts.abortSignal, + }) + out.push(result) + opts.onProgress?.(result) + if (response) previousResponse = response + const failedHere = result.networkError || result.tests.some((t) => !t.pass) + if (failedHere && opts.bail) return out + } + } + + return out +} diff --git a/apps/api-runner/src/scripts.ts b/apps/api-runner/src/scripts.ts new file mode 100644 index 00000000..5d3f363b --- /dev/null +++ b/apps/api-runner/src/scripts.ts @@ -0,0 +1,161 @@ +/** + * Headless pre-request / test script runner. Same `pm.*` surface as the web + * worker, executed inside `node:vm.runInContext` with a wall-clock timeout. + * The sandbox has no fetch, no fs, no process — just `pm` + `console`. + */ + +import { Script, createContext } from "node:vm" + +export interface ScriptContext { + request: { url: string; method: string; headers: Record; body?: string } + response?: { + status: number + statusText: string + headers: Record + body: string + time: number + } + environment: Record + variables: Record +} + +export interface ScriptResult { + ok: boolean + error?: string + tests: { name: string; pass: boolean; error?: string }[] + logs: { level: "log" | "warn" | "error"; args: string[] }[] + environment: Record + variables: Record + request: ScriptContext["request"] +} + +const SCRIPT_TIMEOUT_MS = 3_000 + +function safeStringify(v: unknown): string { + if (typeof v === "string") return v + try { return JSON.stringify(v) } catch { return String(v) } +} + +function makeExpect(actual: unknown) { + const fail = (m: string) => { throw new Error(m) } + return { + toBe(e: unknown) { if (actual !== e) fail(`expected ${safeStringify(actual)} to be ${safeStringify(e)}`) }, + toEqual(e: unknown) { if (JSON.stringify(actual) !== JSON.stringify(e)) fail(`expected ${safeStringify(actual)} to equal ${safeStringify(e)}`) }, + toMatch(r: unknown) { + const ok = r instanceof RegExp ? r.test(String(actual)) : String(actual).includes(String(r)) + if (!ok) fail(`expected ${safeStringify(actual)} to match ${safeStringify(r)}`) + }, + toContain(s: unknown) { + const ok = Array.isArray(actual) + ? (actual as unknown[]).includes(s) + : String(actual).includes(String(s)) + if (!ok) fail(`expected ${safeStringify(actual)} to contain ${safeStringify(s)}`) + }, + toBeGreaterThan(n: number) { if (!(Number(actual) > n)) fail(`expected ${safeStringify(actual)} > ${n}`) }, + toBeLessThan(n: number) { if (!(Number(actual) < n)) fail(`expected ${safeStringify(actual)} < ${n}`) }, + toHaveProperty(k: string) { + if (!actual || typeof actual !== "object" || !(k in (actual as object))) fail(`expected object to have property "${k}"`) + }, + toBeTruthy() { if (!actual) fail(`expected ${safeStringify(actual)} to be truthy`) }, + toBeFalsy() { if (actual) fail(`expected ${safeStringify(actual)} to be falsy`) }, + } +} + +export function runScript(script: string, ctx: ScriptContext): ScriptResult { + if (!script || !script.trim()) { + return { + ok: true, + tests: [], + logs: [], + environment: ctx.environment, + variables: ctx.variables, + request: ctx.request, + } + } + + const env = { ...ctx.environment } + const vars = { ...ctx.variables } + const tests: ScriptResult["tests"] = [] + const logs: ScriptResult["logs"] = [] + const request = { + url: ctx.request.url, + method: ctx.request.method, + headers: { ...ctx.request.headers }, + body: ctx.request.body, + } + + const pm = { + environment: { + get(k: string) { return env[k] }, + set(k: string, v: unknown) { env[String(k)] = safeStringify(v) }, + unset(k: string) { delete env[String(k)] }, + has(k: string) { return k in env }, + toObject() { return { ...env } }, + }, + variables: { + get(k: string) { return vars[k] ?? env[k] }, + set(k: string, v: unknown) { vars[String(k)] = safeStringify(v) }, + has(k: string) { return k in vars || k in env }, + }, + request: { + get url() { return request.url }, + set url(v: string) { request.url = String(v) }, + get method() { return request.method }, + set method(v: string) { request.method = String(v).toUpperCase() }, + headers: { + get(name: string) { + const k = Object.keys(request.headers).find((h) => h.toLowerCase() === String(name).toLowerCase()) + return k ? request.headers[k] : undefined + }, + add(name: string, v: unknown) { request.headers[String(name)] = safeStringify(v) }, + remove(name: string) { + const k = Object.keys(request.headers).find((h) => h.toLowerCase() === String(name).toLowerCase()) + if (k) delete request.headers[k] + }, + toObject() { return { ...request.headers } }, + }, + get body() { return request.body }, + set body(v: string | undefined) { request.body = v == null ? undefined : String(v) }, + }, + response: ctx.response + ? { + code: ctx.response.status, + status: ctx.response.statusText, + responseTime: ctx.response.time, + headers: { + get(name: string) { + const lower = String(name).toLowerCase() + const k = Object.keys(ctx.response!.headers).find((h) => h.toLowerCase() === lower) + return k ? ctx.response!.headers[k] : undefined + }, + toObject() { return { ...ctx.response!.headers } }, + }, + text: () => ctx.response!.body, + json: () => { + try { return JSON.parse(ctx.response!.body) } + catch (e) { throw new Error(`Response body is not valid JSON: ${(e as Error).message}`) } + }, + } + : undefined, + test(name: string, fn: () => void) { + try { fn(); tests.push({ name: String(name), pass: true }) } + catch (e) { tests.push({ name: String(name), pass: false, error: (e as Error).message }) } + }, + expect: makeExpect, + sendRequest() { throw new Error("pm.sendRequest is not supported in the CLI yet") }, + } + + const sandboxConsole = { + log: (...args: unknown[]) => { logs.push({ level: "log", args: args.map(safeStringify) }) }, + warn: (...args: unknown[]) => { logs.push({ level: "warn", args: args.map(safeStringify) }) }, + error: (...args: unknown[]) => { logs.push({ level: "error", args: args.map(safeStringify) }) }, + } + + const sandbox = createContext({ pm, console: sandboxConsole }) + try { + new Script(`"use strict";\n${script}`).runInContext(sandbox, { timeout: SCRIPT_TIMEOUT_MS }) + } catch (e) { + return { ok: false, error: (e as Error).message, tests, logs, environment: env, variables: vars, request } + } + return { ok: true, tests, logs, environment: env, variables: vars, request } +} diff --git a/apps/api-runner/src/types.ts b/apps/api-runner/src/types.ts new file mode 100644 index 00000000..3a60fac1 --- /dev/null +++ b/apps/api-runner/src/types.ts @@ -0,0 +1,100 @@ +/** + * Self-contained types for the CLI — deliberately decoupled from the web app so + * the package can publish standalone. The shape matches what `lib/import/postman.ts` + * emits, so a Postman v2.1 export round-trips into here. + */ + +export type RequestMethod = + | "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" + +export interface KeyValueItem { + id: string + key: string + value: string + active: boolean +} + +export interface FormDataItem { + id: string + key: string + value: string + active: boolean + valueType: "text" | "file" + fileName?: string + fileType?: string + fileContentBase64?: string +} + +export interface RequestBody { + type: "json" | "text" | "none" | "form-data" | "x-www-form-urlencoded" | "graphql" + content: string + formData?: FormDataItem[] + urlEncoded?: KeyValueItem[] + graphqlVariables?: string +} + +export interface RequestAuth { + type: "none" | "bearer" | "basic" | "api-key" + token?: string + username?: string + password?: string + apiKeyKey?: string + apiKeyValue?: string + apiKeyLocation?: "header" | "query" +} + +export interface CollectionRequest { + id: string + name: string + method: RequestMethod + url: string + params: KeyValueItem[] + headers: KeyValueItem[] + body: RequestBody + auth: RequestAuth + preRequestScript?: string + testScript?: string +} + +export interface CollectionFolder { + id: string + name: string + type: "folder" + items: (CollectionFolder | CollectionRequest)[] + defaultHeaders?: KeyValueItem[] + preRequestScript?: string + testScript?: string +} + +export interface Collection { + id: string + name: string + items: (CollectionFolder | CollectionRequest)[] +} + +export interface TestResult { + name: string + pass: boolean + error?: string +} + +export interface ScriptLog { + level: "log" | "warn" | "error" + args: string[] +} + +export interface RequestRunResult { + iteration: number + requestId: string + requestName: string + method: string + url: string + status?: number + statusText?: string + time?: number + size?: number + tests: TestResult[] + logs: ScriptLog[] + errors: string[] + networkError?: string +} diff --git a/apps/api-runner/tsconfig.json b/apps/api-runner/tsconfig.json new file mode 100644 index 00000000..ee39280f --- /dev/null +++ b/apps/api-runner/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/apps/backend/app/api/router.py b/apps/backend/app/api/router.py index 3b098110..91aaf062 100644 --- a/apps/backend/app/api/router.py +++ b/apps/backend/app/api/router.py @@ -7,6 +7,7 @@ from app.api.routes.tasks.api import router as tasks_router from app.api.routes.passwords.api import router as passwords_router from app.api.routes.environment_manager.api import router as environment_manager_router +from app.api.routes.api_key_vault.api import router as api_key_vault_router from app.api.routes.notes.api import router as notes_router from app.api.routes.nosql.api import router as nosql_router from app.api.routes.user_preferences.api import router as user_preferences_router @@ -32,6 +33,7 @@ api_router.include_router(bookmarks_router) api_router.include_router(passwords_router) api_router.include_router(environment_manager_router) +api_router.include_router(api_key_vault_router) api_router.include_router(notes_router) api_router.include_router(nosql_router) api_router.include_router(user_preferences_router) diff --git a/apps/backend/app/api/routes/api_client/api.py b/apps/backend/app/api/routes/api_client/api.py index 1ea48795..bbe719e5 100644 --- a/apps/backend/app/api/routes/api_client/api.py +++ b/apps/backend/app/api/routes/api_client/api.py @@ -12,6 +12,11 @@ ApiClientEnvironmentUpdate, ApiClientHistoryCreate, ApiClientHistoryOut, + ApiClientPublicMockOut, + ApiClientPublicMockPublish, + ApiClientWorkspaceCreate, + ApiClientWorkspaceOut, + ApiClientWorkspaceUpdate, ) from app.api.routes.auth.services import get_current_uid @@ -108,3 +113,96 @@ async def clear_history(uid: str = Depends(get_current_uid)) -> None: @router.delete("/history/{entry_id}", status_code=204, summary="Delete one history entry") async def delete_history_entry(entry_id: str, uid: str = Depends(get_current_uid)) -> None: await api_client_svc.delete_history_entry(uid, entry_id) + + +# ── Public mocks ───────────────────────────────────────────────────────────── + + +@router.get( + "/public-mocks", + response_model=list[ApiClientPublicMockOut], + summary="List the caller's published public mocks", +) +async def list_public_mocks(uid: str = Depends(get_current_uid)) -> list[ApiClientPublicMockOut]: + return await api_client_svc.list_public_mocks(uid=uid) + + +@router.post( + "/public-mocks", + response_model=ApiClientPublicMockOut, + summary="Publish a snapshot of a collection as an anonymously-readable mock", +) +async def publish_public_mock( + body: ApiClientPublicMockPublish, + uid: str = Depends(get_current_uid), +) -> ApiClientPublicMockOut: + return await api_client_svc.publish_public_mock(uid, body) + + +@router.delete( + "/public-mocks/{mock_id}", + status_code=204, + summary="Unpublish a public mock", +) +async def delete_public_mock(mock_id: str, uid: str = Depends(get_current_uid)) -> None: + await api_client_svc.delete_public_mock(uid, mock_id) + + +@router.get( + "/public-mock/{mock_id}", + response_model=ApiClientPublicMockOut, + summary="Fetch a public mock by id — no auth required", +) +async def get_public_mock_anonymous(mock_id: str) -> ApiClientPublicMockOut: + mock = await api_client_svc.get_public_mock_anonymous(mock_id) + if not mock: + from fastapi import HTTPException, status + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Public mock not found.") + return mock + + +# ── Workspaces ─────────────────────────────────────────────────────────────── + + +@router.get( + "/workspaces", + response_model=list[ApiClientWorkspaceOut], + summary="List the caller's workspaces", +) +async def list_workspaces(uid: str = Depends(get_current_uid)) -> list[ApiClientWorkspaceOut]: + return await api_client_svc.list_workspaces(uid=uid) + + +@router.post( + "/workspaces", + response_model=ApiClientWorkspaceOut, + summary="Create a workspace", +) +async def create_workspace( + body: ApiClientWorkspaceCreate, + uid: str = Depends(get_current_uid), +) -> ApiClientWorkspaceOut: + return await api_client_svc.create_workspace(uid, body) + + +@router.patch( + "/workspaces/{workspace_id}", + response_model=ApiClientWorkspaceOut, + summary="Rename a workspace", +) +async def patch_workspace( + workspace_id: str, + body: ApiClientWorkspaceUpdate, + uid: str = Depends(get_current_uid), +) -> ApiClientWorkspaceOut: + return await api_client_svc.patch_workspace(uid, workspace_id, body) + + +@router.delete( + "/workspaces/{workspace_id}", + status_code=204, + summary="Delete a workspace (collections reset to default)", +) +async def delete_workspace(workspace_id: str, uid: str = Depends(get_current_uid)) -> None: + await api_client_svc.delete_workspace(uid, workspace_id) diff --git a/apps/backend/app/api/routes/api_client/schema.py b/apps/backend/app/api/routes/api_client/schema.py index fac37d49..2432b7fe 100644 --- a/apps/backend/app/api/routes/api_client/schema.py +++ b/apps/backend/app/api/routes/api_client/schema.py @@ -17,12 +17,14 @@ class ApiClientCollectionUpdate(BaseModel): name: str | None = Field(default=None, min_length=1) items: list[dict[str, Any]] | None = None + workspace: str | None = None class ApiClientCollectionOut(ApiClientCollectionBase): model_config = ConfigDict(extra="allow") id: str + workspace: str | None = None class ApiClientEnvironmentBase(BaseModel): @@ -121,3 +123,42 @@ class ApiClientHistoryOut(BaseModel): timestamp: int status: int | None = None + +# ── Public mocks ───────────────────────────────────────────────────────────── + + +class ApiClientPublicMockPublish(BaseModel): + """Publish a snapshot of an existing collection as an anonymously-readable mock.""" + + collection_id: str + name: str = Field(min_length=1) + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ApiClientPublicMockOut(BaseModel): + """Public-facing mock record. `mock_id` is the slug used in the public URL.""" + + mock_id: str + name: str + items: list[dict[str, Any]] + collection_id: str | None = None + created_at: int + + +# ── Workspaces ─────────────────────────────────────────────────────────────── + + +class ApiClientWorkspaceCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class ApiClientWorkspaceUpdate(BaseModel): + model_config = ConfigDict(extra="ignore") + name: str | None = Field(default=None, min_length=1, max_length=120) + + +class ApiClientWorkspaceOut(BaseModel): + id: str + name: str + created_at: int + diff --git a/apps/backend/app/api/routes/api_client/services.py b/apps/backend/app/api/routes/api_client/services.py index ad4156ef..7dc328d4 100644 --- a/apps/backend/app/api/routes/api_client/services.py +++ b/apps/backend/app/api/routes/api_client/services.py @@ -1,3 +1,4 @@ +import secrets import time from typing import Any @@ -16,6 +17,11 @@ ApiClientEnvironmentUpdate, ApiClientHistoryCreate, ApiClientHistoryOut, + ApiClientPublicMockOut, + ApiClientPublicMockPublish, + ApiClientWorkspaceCreate, + ApiClientWorkspaceOut, + ApiClientWorkspaceUpdate, ) from app.core.cache import bump_version, cached from app.database import db_manager @@ -23,6 +29,8 @@ API_CLIENT_COLLECTIONS, API_CLIENT_ENVIRONMENTS, API_CLIENT_HISTORY, + API_CLIENT_PUBLIC_MOCKS, + API_CLIENT_WORKSPACES, ) from app.utils.crud import safe_delete_one, safe_insert, safe_update_one @@ -45,6 +53,7 @@ def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut: id=str(oid) if oid is not None else "", name=doc.get("name", ""), items=list(doc.get("items") or []), + workspace=doc.get("workspace"), ) @@ -211,3 +220,115 @@ async def clear_history(uid: str) -> None: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to clear history." ) from exc await bump_version(ns="api_client", uid=uid) + + +# ── Public mocks ───────────────────────────────────────────────────────────── + + +def _mock_doc_to_out(doc: dict[str, Any]) -> ApiClientPublicMockOut: + return ApiClientPublicMockOut( + mock_id=str(doc.get("mock_id", "")), + name=str(doc.get("name", "")), + items=list(doc.get("items") or []), + collection_id=doc.get("collection_id"), + created_at=int(doc.get("created_at", 0)), + ) + + +def _generate_mock_id() -> str: + """24 chars of url-safe base64 → ~144 bits of entropy. Acts as the public token.""" + + return secrets.token_urlsafe(18) + + +async def list_public_mocks(*, uid: str) -> list[ApiClientPublicMockOut]: + docs = await db_manager.find( + API_CLIENT_PUBLIC_MOCKS, + {"created_by": uid}, + sort=[("created_at", -1)], + ) + return [_mock_doc_to_out(d) for d in docs] + + +async def publish_public_mock(uid: str, body: ApiClientPublicMockPublish) -> ApiClientPublicMockOut: + doc: dict[str, Any] = { + "created_by": uid, + "collection_id": body.collection_id, + "mock_id": _generate_mock_id(), + "name": body.name, + "items": body.items, + "created_at": int(time.time() * 1000), + } + await safe_insert(API_CLIENT_PUBLIC_MOCKS, doc, name="Public mock") + return _mock_doc_to_out(doc) + + +async def delete_public_mock(uid: str, mock_id: str) -> None: + await safe_delete_one( + API_CLIENT_PUBLIC_MOCKS, {"mock_id": mock_id, "created_by": uid}, name="Public mock" + ) + + +async def get_public_mock_anonymous(mock_id: str) -> ApiClientPublicMockOut | None: + """Read a published mock by id — NO auth, by design. The id is the credential.""" + + doc = await db_manager.find_one(API_CLIENT_PUBLIC_MOCKS, {"mock_id": mock_id}) + return _mock_doc_to_out(doc) if doc else None + + +# ── Workspaces ─────────────────────────────────────────────────────────────── + + +def _ws_doc_to_out(doc: dict[str, Any]) -> ApiClientWorkspaceOut: + oid = doc.get("_id") + return ApiClientWorkspaceOut( + id=str(oid) if oid is not None else "", + name=str(doc.get("name", "")), + created_at=int(doc.get("created_at", 0)), + ) + + +async def list_workspaces(*, uid: str) -> list[ApiClientWorkspaceOut]: + docs = await db_manager.find( + API_CLIENT_WORKSPACES, + {"created_by": uid}, + sort=[("name", 1), ("_id", 1)], + ) + return [_ws_doc_to_out(d) for d in docs] + + +async def create_workspace(uid: str, body: ApiClientWorkspaceCreate) -> ApiClientWorkspaceOut: + doc: dict[str, Any] = { + "created_by": uid, + "name": body.name, + "created_at": int(time.time() * 1000), + } + await safe_insert(API_CLIENT_WORKSPACES, doc, name="Workspace") + return _ws_doc_to_out(doc) + + +async def patch_workspace(uid: str, workspace_id: str, body: ApiClientWorkspaceUpdate) -> ApiClientWorkspaceOut: + oid = _parse_oid(workspace_id, kind="workspace") + patch = body.model_dump(exclude_unset=True) + if not patch: + doc = await db_manager.find_one(API_CLIENT_WORKSPACES, {"_id": oid, "created_by": uid}) + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.") + return _ws_doc_to_out(doc) + doc = await safe_update_one( + API_CLIENT_WORKSPACES, {"_id": oid, "created_by": uid}, patch, name="Workspace" + ) + return _ws_doc_to_out(doc) + + +async def delete_workspace(uid: str, workspace_id: str) -> None: + oid = _parse_oid(workspace_id, kind="workspace") + # Clear workspace pointer from any collections that reference it. + await db_manager.update_many( + API_CLIENT_COLLECTIONS, + {"created_by": uid, "workspace": workspace_id}, + {"$set": {"workspace": None}}, + ) + await safe_delete_one( + API_CLIENT_WORKSPACES, {"_id": oid, "created_by": uid}, name="Workspace" + ) diff --git a/apps/backend/app/api/routes/api_key_vault/__init__.py b/apps/backend/app/api/routes/api_key_vault/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/app/api/routes/api_key_vault/api.py b/apps/backend/app/api/routes/api_key_vault/api.py new file mode 100644 index 00000000..9fc18aff --- /dev/null +++ b/apps/backend/app/api/routes/api_key_vault/api.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, Query + +from app.api.routes.auth.services import get_current_uid +from app.api.routes.api_key_vault import services as vault_svc +from app.api.routes.api_key_vault.schema import ( + ApiKeyEntryCreate, + ApiKeyEntryOut, + ApiKeyEntryUpdate, +) + +router = APIRouter(prefix="/api-keys", tags=["api-keys"]) + + +@router.get( + "/entries", + response_model=list[ApiKeyEntryOut], + summary="List encrypted API key entries", +) +async def list_entries( + uid: str = Depends(get_current_uid), + limit: int | None = Query(default=None, ge=1, le=1000), + offset: int = Query(default=0, ge=0), +) -> list[ApiKeyEntryOut]: + return await vault_svc.list_entries(uid, limit=limit, offset=offset) + + +@router.post( + "/entries", + response_model=ApiKeyEntryOut, + summary="Create API key entry (encrypted blob)", +) +async def create_entry(body: ApiKeyEntryCreate, uid: str = Depends(get_current_uid)) -> ApiKeyEntryOut: + return await vault_svc.create_entry(uid, body) + + +@router.get( + "/entries/{entry_id}", + response_model=ApiKeyEntryOut, + summary="Get one API key entry", +) +async def get_entry(entry_id: str, uid: str = Depends(get_current_uid)) -> ApiKeyEntryOut: + return await vault_svc.get_entry(uid, entry_id) + + +@router.patch( + "/entries/{entry_id}", + response_model=ApiKeyEntryOut, + summary="Update API key entry (encrypted blob)", +) +async def patch_entry( + entry_id: str, + body: ApiKeyEntryUpdate, + uid: str = Depends(get_current_uid), +) -> ApiKeyEntryOut: + return await vault_svc.update_entry(uid, entry_id, body) + + +@router.delete( + "/entries/{entry_id}", + status_code=204, + summary="Delete API key entry", +) +async def delete_entry(entry_id: str, uid: str = Depends(get_current_uid)) -> None: + await vault_svc.delete_entry(uid, entry_id) diff --git a/apps/backend/app/api/routes/api_key_vault/schema.py b/apps/backend/app/api/routes/api_key_vault/schema.py new file mode 100644 index 00000000..c5369a08 --- /dev/null +++ b/apps/backend/app/api/routes/api_key_vault/schema.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class ApiKeyEntryCreate(BaseModel): + encryptedData: str = Field(min_length=1) + iv: str = Field(min_length=1) + createdAt: int | None = Field(default=None, ge=0) + updatedAt: int | None = Field(default=None, ge=0) + + +class ApiKeyEntryUpdate(BaseModel): + model_config = ConfigDict(extra="ignore") + + encryptedData: str = Field(min_length=1) + iv: str = Field(min_length=1) + updatedAt: int | None = Field(default=None, ge=0) + + +class ApiKeyEntryOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + encryptedData: str + iv: str + createdAt: int + updatedAt: int diff --git a/apps/backend/app/api/routes/api_key_vault/services.py b/apps/backend/app/api/routes/api_key_vault/services.py new file mode 100644 index 00000000..f9403207 --- /dev/null +++ b/apps/backend/app/api/routes/api_key_vault/services.py @@ -0,0 +1,83 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.api.routes.api_key_vault.schema import ( + ApiKeyEntryCreate, + ApiKeyEntryOut, + ApiKeyEntryUpdate, +) +from app.database import db_manager +from app.utils.collection_name import API_KEY_VAULT_ENTRIES +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id + + +def _entry_doc_to_out(doc: dict[str, Any], *, entry_id: str) -> ApiKeyEntryOut: + created_at = int(doc.get("createdAt", 0)) or create_timestamp() + updated_at = int(doc.get("updatedAt", 0)) or created_at + return ApiKeyEntryOut( + id=entry_id, + encryptedData=str(doc.get("encryptedData", "")), + iv=str(doc.get("iv", "")), + createdAt=created_at, + updatedAt=updated_at, + ) + + +async def list_entries(uid: str, *, limit: int | None = None, offset: int = 0) -> list[ApiKeyEntryOut]: + docs = await db_manager.find( + API_KEY_VAULT_ENTRIES, + {"created_by": uid}, + sort=[("updatedAt", -1), ("createdAt", -1)], + skip=max(0, offset), + limit=limit or 0, + ) + return [_entry_doc_to_out(d, entry_id=str(d.get("_id", ""))) for d in docs] + + +async def create_entry(uid: str, body: ApiKeyEntryCreate) -> ApiKeyEntryOut: + eid = new_id() + ts = create_timestamp() + created_at = int(body.createdAt) if body.createdAt is not None else ts + updated_at = int(body.updatedAt) if body.updatedAt is not None else created_at + + doc: dict[str, Any] = { + "_id": eid, + "created_by": uid, + "encryptedData": body.encryptedData, + "iv": body.iv, + "createdAt": created_at, + "updatedAt": updated_at, + } + await safe_insert(API_KEY_VAULT_ENTRIES, doc, name="ApiKeyEntry") + return _entry_doc_to_out(doc, entry_id=eid) + + +async def get_entry(uid: str, entry_id: str) -> ApiKeyEntryOut: + doc = await db_manager.find_one(API_KEY_VAULT_ENTRIES, {"_id": entry_id, "created_by": uid}) + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key entry not found.") + return _entry_doc_to_out(doc, entry_id=entry_id) + + +async def update_entry(uid: str, entry_id: str, body: ApiKeyEntryUpdate) -> ApiKeyEntryOut: + ts_updated = int(body.updatedAt) if body.updatedAt is not None else create_timestamp() + patch: dict[str, Any] = { + "encryptedData": body.encryptedData, + "iv": body.iv, + "updatedAt": ts_updated, + } + result = await safe_update_one( + API_KEY_VAULT_ENTRIES, + {"_id": entry_id, "created_by": uid}, + patch, + name="ApiKeyEntry", + ) + return _entry_doc_to_out(result, entry_id=entry_id) + + +async def delete_entry(uid: str, entry_id: str) -> None: + await safe_delete_one( + API_KEY_VAULT_ENTRIES, {"_id": entry_id, "created_by": uid}, name="ApiKeyEntry" + ) diff --git a/apps/backend/app/utils/collection_name.py b/apps/backend/app/utils/collection_name.py index b05e72a5..eec112ac 100644 --- a/apps/backend/app/utils/collection_name.py +++ b/apps/backend/app/utils/collection_name.py @@ -6,6 +6,7 @@ PASSWORD_VAULTS = "password_Vaults" PASSWORD_ENTRIES = "password_entries" ENV_MANAGER_ENTRIES = "environment_manager_entries" +API_KEY_VAULT_ENTRIES = "api_key_vault_entries" NOTES = "notes" NOSQL_CONNECTIONS = "mongodb_connections" USER_PREFERENCES = "user_preferences" @@ -13,6 +14,8 @@ API_CLIENT_COLLECTIONS = "api_client_collections" API_CLIENT_ENVIRONMENTS = "api_client_environments" API_CLIENT_HISTORY = "api_client_history" +API_CLIENT_PUBLIC_MOCKS = "api_client_public_mocks" +API_CLIENT_WORKSPACES = "api_client_workspaces" JSON_FORMATTER_DOCUMENTS = "json_formatter_documents" CODE_SNIPPETS = "code_snippets" SQL_CONNECTIONS = "sql_connections" diff --git a/apps/extension/background.js b/apps/extension/background.js new file mode 100644 index 00000000..bd56e893 --- /dev/null +++ b/apps/extension/background.js @@ -0,0 +1,52 @@ +/** + * mydevtools companion service worker. + * + * Listens for `webRequest.onBeforeSendHeaders` + `onCompleted` and stages the + * request+response pair. Popup picks one and forwards to the mydevtools tab. + */ + +const RECENT_CAP = 50 +const recent = [] + +chrome.webRequest.onBeforeSendHeaders.addListener( + (details) => { + if (details.type === "main_frame") return + if (!details.url.startsWith("http")) return + const headers = {} + for (const h of details.requestHeaders ?? []) headers[h.name] = h.value + recent.unshift({ + id: details.requestId, + method: details.method, + url: details.url, + headers, + timestamp: Date.now(), + }) + recent.length = Math.min(recent.length, RECENT_CAP) + }, + { urls: [""] }, + ["requestHeaders", "extraHeaders"], +) + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === "list-recent") { + sendResponse({ recent }) + return true + } + if (msg?.type === "send-to-mydevtools") { + forwardToMydevtools(msg.payload).then((ok) => sendResponse({ ok })) + return true + } + return false +}) + +async function forwardToMydevtools(payload) { + const tabs = await chrome.tabs.query({ url: ["https://mydevtools.tech/app/api-client*", "http://localhost:*/app/api-client*"] }) + if (tabs.length === 0) { + await chrome.tabs.create({ url: "https://mydevtools.tech/app/api-client" }) + return false + } + const tab = tabs[0] + await chrome.tabs.sendMessage(tab.id, { type: "mdt-extension-import", payload }) + await chrome.tabs.update(tab.id, { active: true }) + return true +} diff --git a/apps/extension/content-bridge.js b/apps/extension/content-bridge.js new file mode 100644 index 00000000..c8fe49d0 --- /dev/null +++ b/apps/extension/content-bridge.js @@ -0,0 +1,10 @@ +/** + * Content script injected into mydevtools.tech tabs. + * Relays extension imports to the page via window.postMessage. The web app + * listens for `kind: "mdt-extension-import"` and stages a new tab. + */ + +chrome.runtime.onMessage.addListener((msg) => { + if (msg?.type !== "mdt-extension-import") return + window.postMessage({ kind: "mdt-extension-import", payload: msg.payload }, window.location.origin) +}) diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json new file mode 100644 index 00000000..4c996ed4 --- /dev/null +++ b/apps/extension/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "mydevtools API client companion", + "version": "0.1.0", + "description": "Capture network requests in any tab and ship them to mydevtools.tech/app/api-client.", + "permissions": ["activeTab", "scripting", "storage", "webRequest"], + "host_permissions": [""], + "action": { + "default_title": "Capture this request", + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://mydevtools.tech/*", "https://*.mydevtools.tech/*", "http://localhost:*/*"], + "js": ["content-bridge.js"], + "run_at": "document_idle" + } + ] +} diff --git a/apps/extension/popup.html b/apps/extension/popup.html new file mode 100644 index 00000000..615c5f84 --- /dev/null +++ b/apps/extension/popup.html @@ -0,0 +1,21 @@ + + + + + Capture requests + + + +

Recent requests

+
Loading…
+ + + diff --git a/apps/extension/popup.js b/apps/extension/popup.js new file mode 100644 index 00000000..c6f693d5 --- /dev/null +++ b/apps/extension/popup.js @@ -0,0 +1,23 @@ +chrome.runtime.sendMessage({ type: "list-recent" }, (resp) => { + const list = document.getElementById("list") + list.innerHTML = "" + if (!resp?.recent || resp.recent.length === 0) { + list.textContent = "No captured requests yet. Trigger one in the page." + return + } + for (const r of resp.recent) { + const row = document.createElement("div") + row.className = "row" + row.innerHTML = ` + ${r.method} + ${r.url} + + ` + row.querySelector("button").addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "send-to-mydevtools", payload: r }, (resp) => { + if (resp?.ok) window.close() + }) + }) + list.appendChild(row) + } +}) diff --git a/apps/web/package.json b/apps/web/package.json index a983b335..6792b3af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --webpack", + "dev": "next dev", "build": "next build", "analyze": "ANALYZE=true next build", "start": "next start", @@ -19,7 +19,6 @@ "@hookform/resolvers": "^3", "@monaco-editor/react": "^4.7.0", "@peculiar/x509": "^2.0.0", - "@simplewebauthn/browser": "^11.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", @@ -43,6 +42,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@simplewebauthn/browser": "^11.0.0", "@space-man/react-theme-animation": "^1.1.1", "@tabler/icons-react": "^3.35.0", "@tanstack/react-query": "^5", @@ -85,6 +85,7 @@ "next-intl": "^4.8.3", "next-themes": "^0.4.6", "pg": "^8.20.0", + "protobufjs": "^8.6.5", "qr-code-styling": "^1.9.2", "qrcode": "^1.5.4", "react": "^19.2.0", @@ -102,6 +103,7 @@ "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.4", "ua-parser-js": "^2.0.9", + "undici": "^8.5.0", "use-debounce": "^10.0.6", "uuid": "^13.0.0", "vanilla-jsoneditor": "^3.12.0", diff --git a/apps/web/public/api-client-sw.js b/apps/web/public/api-client-sw.js new file mode 100644 index 00000000..d0622e7a --- /dev/null +++ b/apps/web/public/api-client-sw.js @@ -0,0 +1,63 @@ +/** + * API-client service worker. + * + * Two jobs: + * 1. Cache GET responses from `/api/backend/api-client/*` with stale-while-revalidate + * so the sidebar / collections render from cache offline. + * 2. Pass-through for everything else. + * + * Scope limited to /app/api-client paths so we don't shadow the rest of the site. + */ + +const CACHE_NAME = "mdt-api-client-v1" +const CACHED_PREFIXES = [ + "/api/backend/api-client/collections", + "/api/backend/api-client/environments", + "/api/backend/api-client/history", +] + +self.addEventListener("install", (event) => { + self.skipWaiting() + event.waitUntil(caches.open(CACHE_NAME)) +}) + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ) + self.clients.claim() +}) + +function isCacheable(url) { + return CACHED_PREFIXES.some((p) => url.pathname.startsWith(p)) +} + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return + const url = new URL(event.request.url) + if (!isCacheable(url)) return + + event.respondWith((async () => { + const cache = await caches.open(CACHE_NAME) + const cached = await cache.match(event.request) + const networkPromise = fetch(event.request) + .then(async (res) => { + if (res.ok) await cache.put(event.request, res.clone()) + return res + }) + .catch(() => null) + if (cached) { + // Refresh in background. + event.waitUntil(networkPromise) + return cached + } + const fresh = await networkPromise + if (fresh) return fresh + return new Response(JSON.stringify({ error: "offline + no cached copy" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }) + })()) +}) diff --git a/apps/web/src/app/api-client/share/page.tsx b/apps/web/src/app/api-client/share/page.tsx new file mode 100644 index 00000000..920663e1 --- /dev/null +++ b/apps/web/src/app/api-client/share/page.tsx @@ -0,0 +1,142 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { decodeCollectionShareFragment } from "@/lib/share-link" +import { exportPostmanCollection, downloadCollectionAsPostman } from "@/lib/export/postman" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Folder, FileDown, ArrowRight, AlertCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Collection, CollectionFolder, CollectionRequest } from "@/components/api-client/types" + +function methodColor(method: string): string { + switch (method) { + case "GET": return "text-sky-500" + case "POST": return "text-emerald-500" + case "PUT": return "text-amber-500" + case "DELETE": return "text-rose-500" + case "PATCH": return "text-yellow-500" + default: return "text-muted-foreground" + } +} + +function RequestRow({ req }: { req: CollectionRequest }) { + return ( +
+ + {req.method} + + {req.name} + {req.url} +
+ ) +} + +function ItemsList({ items, level = 0 }: { items: Array; level?: number }) { + return ( +
+ {items.map((item) => { + if ("type" in item && item.type === "folder") { + return ( +
+ + + {item.name} + ({item.items.length}) + + +
+ ) + } + return + })} +
+ ) +} + +export default function ShareViewPage() { + const [collection, setCollection] = React.useState(null) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + const fragment = (window.location.hash || "").replace(/^#/, "") + if (!fragment) { + setError("No share payload in URL fragment") + return + } + decodeCollectionShareFragment(fragment) + .then(setCollection) + .catch((e) => setError((e as Error).message)) + }, []) + + const count = React.useMemo(() => { + if (!collection) return 0 + const walk = (items: Array): number => + items.reduce((n, it) => n + ("type" in it && it.type === "folder" ? walk(it.items) : 1), 0) + return walk(collection.items) + }, [collection]) + + const handleCopyPostman = async () => { + if (!collection) return + try { + await navigator.clipboard.writeText(exportPostmanCollection(collection)) + } catch { /* noop */ } + } + + return ( +
+
+
+

Shared collection

+ + Open API client + +
+ + {error && ( + +
+ +
+
Could not load share
+
{error}
+
+
+
+ )} + + {collection && ( + <> + +
+
{collection.name}
+
{count} request{count === 1 ? "" : "s"}
+
+
+ + +
+
+ + + + + + + +

+ Snapshot stored in the URL fragment — nothing reaches the server. + Editing this view will not modify the original. +

+ + )} +
+
+ ) +} diff --git a/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts b/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts new file mode 100644 index 00000000..3d09af0f --- /dev/null +++ b/apps/web/src/app/api/mock/[collectionId]/[[...path]]/route.ts @@ -0,0 +1,124 @@ +/** + * Mock-server endpoint. Resolves an incoming request against the saved examples + * inside a collection and replays the matching one. + * + * URL shape: + * /api/mock///? + * + * Matching rules (RFC-ish lite, ponytail): + * 1. Method match — case-insensitive. + * 2. Pathname match — the trailing `/` segment is compared + * against each example's original `request.url` pathname. Exact match only. + * 3. First hit wins. Iteration order = walk-collection order. + * + * The endpoint requires the same session the API client uses (Firebase cookie + * forwarded to backend). Anonymous public mocks would need a backend-side mockId + * index — deliberate skip for v1; the collection.id IS the mockId here. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import type { + Collection, + CollectionFolder, + CollectionRequest, + SavedExample, +} from "@/components/api-client/types" + +export const runtime = "nodejs" + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +interface RouteContext { + params: Promise<{ collectionId: string; path?: string[] }> +} + +function* walkRequests(items: Array): Generator { + for (const item of items) { + if ("type" in item && item.type === "folder") yield* walkRequests(item.items) + else yield item as CollectionRequest + } +} + +function exampleMatches(ex: SavedExample, method: string, pathname: string): boolean { + if (ex.request.method.toUpperCase() !== method.toUpperCase()) return false + let storedPath: string + try { storedPath = new URL(ex.request.url).pathname || "/" } catch { storedPath = ex.request.url || "/" } + return storedPath === pathname +} + +// Hop-by-hop + identity-revealing headers we never replay back to clients. +const RESPONSE_HEADERS_TO_DROP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "content-encoding", "content-length", // re-derived by Next from body + "set-cookie", // would land on the user's mydevtools origin, never what they want +]) + +async function fetchCollectionViaBackend(req: NextRequest, collectionId: string): Promise { + // Forward our session cookie to the backend list endpoint and pick the matching collection. + // There's no per-id GET on the backend yet (would be the right home eventually). + const cookie = req.headers.get("cookie") ?? "" + const res = await fetch(`${FASTAPI_BASE_URL.replace(/\/$/, "")}/api-client/collections`, { + headers: { cookie }, + }) + if (!res.ok) return null + const list = (await res.json()) as Collection[] + return list.find((c) => c.id === collectionId) ?? null +} + +async function handle(req: NextRequest, ctx: RouteContext): Promise { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { collectionId, path } = await ctx.params + const pathname = "/" + (path ?? []).join("/") + const method = req.method.toUpperCase() + + const collection = await fetchCollectionViaBackend(req, collectionId) + if (!collection) { + return NextResponse.json( + { error: `No mock server: collection ${collectionId} not found` }, + { status: 404 }, + ) + } + + for (const r of walkRequests(collection.items)) { + if (!r.examples) continue + for (const ex of r.examples) { + if (!exampleMatches(ex, method, pathname)) continue + + const headers = new Headers() + for (const [k, v] of Object.entries(ex.response.headers ?? {})) { + if (!RESPONSE_HEADERS_TO_DROP.has(k.toLowerCase())) headers.set(k, v) + } + headers.set("X-Mdt-Mock-Source", `${collection.name} / ${r.name} / ${ex.name}`) + + const body = ex.response.isBase64 + ? Buffer.from(ex.response.body, "base64") + : ex.response.body + return new NextResponse(body, { + status: ex.response.status || 200, + statusText: ex.response.statusText || "OK", + headers, + }) + } + } + + return NextResponse.json( + { + error: "No matching example", + collection: collection.name, + tried: { method, path: pathname }, + }, + { status: 404 }, + ) +} + +export const GET = handle +export const POST = handle +export const PUT = handle +export const PATCH = handle +export const DELETE = handle +export const HEAD = handle +export const OPTIONS = handle diff --git a/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts b/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts new file mode 100644 index 00000000..fa924260 --- /dev/null +++ b/apps/web/src/app/api/mock/public/[mockId]/[[...path]]/route.ts @@ -0,0 +1,105 @@ +/** + * Anonymously-callable mock endpoint. + * + * URL shape: + * /api/mock/public///? + * + * No `requireBackendSession` gate — `mockId` itself is the credential + * (~144 bits of entropy). The backend's GET /public-mock/{mock_id} is also + * unauthenticated, so this route runs without forwarding any cookie. + * + * Reuses the same matcher semantics as the authed mock route: method-sensitive, + * exact-pathname match, first-hit wins. + */ + +import { NextRequest, NextResponse } from "next/server" +import type { + CollectionFolder, + CollectionRequest, + SavedExample, +} from "@/components/api-client/types" + +export const runtime = "nodejs" + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +interface RouteContext { + params: Promise<{ mockId: string; path?: string[] }> +} + +interface PublicMockShape { + mock_id: string + name: string + items: Array +} + +function* walkRequests(items: Array): Generator { + for (const item of items) { + if ("type" in item && item.type === "folder") yield* walkRequests(item.items) + else yield item as CollectionRequest + } +} + +function exampleMatches(ex: SavedExample, method: string, pathname: string): boolean { + if (ex.request.method.toUpperCase() !== method.toUpperCase()) return false + let storedPath: string + try { storedPath = new URL(ex.request.url).pathname || "/" } catch { storedPath = ex.request.url || "/" } + return storedPath === pathname +} + +const RESPONSE_HEADERS_TO_DROP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "content-encoding", "content-length", + "set-cookie", +]) + +async function handle(req: NextRequest, ctx: RouteContext): Promise { + const { mockId, path } = await ctx.params + const pathname = "/" + (path ?? []).join("/") + const method = req.method.toUpperCase() + + const res = await fetch(`${FASTAPI_BASE_URL.replace(/\/$/, "")}/api-client/public-mock/${encodeURIComponent(mockId)}`) + if (res.status === 404) { + return NextResponse.json({ error: "Public mock not found" }, { status: 404 }) + } + if (!res.ok) { + return NextResponse.json({ error: "Upstream mock lookup failed" }, { status: 502 }) + } + const mock = (await res.json()) as PublicMockShape + + for (const r of walkRequests(mock.items ?? [])) { + if (!r.examples) continue + for (const ex of r.examples) { + if (!exampleMatches(ex, method, pathname)) continue + const headers = new Headers() + for (const [k, v] of Object.entries(ex.response.headers ?? {})) { + if (!RESPONSE_HEADERS_TO_DROP.has(k.toLowerCase())) headers.set(k, v) + } + headers.set("X-Mdt-Mock-Source", `${mock.name} / ${r.name} / ${ex.name}`) + headers.set("X-Mdt-Mock-Public", "1") + const body = ex.response.isBase64 + ? Buffer.from(ex.response.body, "base64") + : ex.response.body + return new NextResponse(body, { + status: ex.response.status || 200, + statusText: ex.response.statusText || "OK", + headers, + }) + } + } + + return NextResponse.json({ + error: "No matching example", + mock: mock.name, + tried: { method, path: pathname }, + }, { status: 404 }) +} + +export const GET = handle +export const POST = handle +export const PUT = handle +export const PATCH = handle +export const DELETE = handle +export const HEAD = handle +export const OPTIONS = handle diff --git a/apps/web/src/app/api/proxy-grpc/route.ts b/apps/web/src/app/api/proxy-grpc/route.ts new file mode 100644 index 00000000..a3477ffe --- /dev/null +++ b/apps/web/src/app/api/proxy-grpc/route.ts @@ -0,0 +1,191 @@ +/** + * Native gRPC proxy. Browsers can't open raw HTTP/2 sockets, so this route + * tunnels gRPC calls on the user's behalf: + * + * browser → POST /api/proxy-grpc { url, body(b64) } ← HTTP/1.1, JSON + * route → HTTP/2 client to upstream target ← node:http2 + * route → JSON { body(b64), trailers, …} back ← HTTP/1.1, JSON + * + * Wire format is the standard gRPC frame: 1-byte flags + 4-byte BE length + + * payload. We don't transcode — the browser sends the same bytes it would + * send for gRPC-Web; the only difference is the transport. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import http2 from "node:http2" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +interface GrpcRequestPayload { + /** Full URL, e.g. https://grpc.example.com/package.Service/Method */ + url: string + /** Single request frame bytes, base64-encoded. Unary / server-streaming. */ + body?: string + /** Multiple request frames for client-streaming / bidi. + * When present, `body` is ignored. Each entry = one already-framed message. */ + bodyFrames?: string[] + /** Optional user metadata headers (e.g. authorization). */ + headers?: Record + /** Default 30s; capped server-side. */ + timeoutMs?: number +} + +interface GrpcResponsePayload { + /** HTTP/2 response status (commonly 200 even for gRPC errors — check grpcStatus). */ + status: number + /** Concatenated response frame bytes, base64-encoded. */ + body: string + headers: Record + trailers: Record + grpcStatus?: number + grpcMessage?: string + timeMs: number + sizeBytes: number +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const payload = await req.json() as GrpcRequestPayload + if (!payload.url) { + return NextResponse.json({ error: "url is required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(payload.url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + // Build the request stream: either one frame (unary / server-streaming) + // or many frames concatenated (client-streaming / bidi). + const frames: Buffer[] = payload.bodyFrames && payload.bodyFrames.length > 0 + ? payload.bodyFrames.map((b) => Buffer.from(b, "base64")) + : [Buffer.from(payload.body ?? "", "base64")] + const requestBytes = Buffer.concat(frames) + const timeout = Math.min(Math.max(1_000, payload.timeoutMs ?? 30_000), 55_000) + const origin = `${parsed.protocol}//${parsed.host}` + const startTime = Date.now() + + const result = await new Promise((resolve, reject) => { + const client = http2.connect(origin, { + rejectUnauthorized: process.env.NODE_ENV === "production", + }) + const cleanup = () => { + try { client.close() } catch { /* noop */ } + clearTimeout(timer) + } + const timer = setTimeout(() => { + cleanup() + reject(new Error(`gRPC call timed out after ${timeout}ms`)) + }, timeout) + + client.on("error", (err) => { + cleanup() + reject(err) + }) + + const reqHeaders: http2.OutgoingHttpHeaders = { + ":method": "POST", + ":path": parsed.pathname + (parsed.search || ""), + "content-type": "application/grpc+proto", + "te": "trailers", + "user-agent": "mydevtools-grpc/0.1", + ...(payload.headers ?? {}), + } + + const stream = client.request(reqHeaders) + stream.write(requestBytes) + stream.end() + + let responseHeaders: Record = {} + let trailerHeaders: Record = {} + const chunks: Buffer[] = [] + let httpStatus = 0 + + stream.on("response", (h) => { + const out: Record = {} + for (const [k, v] of Object.entries(h)) { + if (k.startsWith(":")) { + if (k === ":status" && typeof v === "string") httpStatus = Number(v) + else if (k === ":status" && typeof v === "number") httpStatus = v + continue + } + out[k] = Array.isArray(v) ? v.join(", ") : String(v ?? "") + } + responseHeaders = out + }) + + stream.on("trailers", (t) => { + const out: Record = {} + for (const [k, v] of Object.entries(t)) { + out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v ?? "") + } + trailerHeaders = out + }) + + stream.on("data", (chunk: Buffer) => { chunks.push(chunk) }) + + stream.on("end", () => { + cleanup() + const body = Buffer.concat(chunks) + // gRPC servers may return status on response headers (trailers-only error) + // or on trailers (normal success / trailing error). Merge with trailers preferred. + const grpcStatus = trailerHeaders["grpc-status"] ?? responseHeaders["grpc-status"] + const grpcMessage = trailerHeaders["grpc-message"] ?? responseHeaders["grpc-message"] + resolve({ + status: httpStatus || 200, + body: body.toString("base64"), + headers: responseHeaders, + trailers: trailerHeaders, + grpcStatus: grpcStatus !== undefined ? Number(grpcStatus) : undefined, + grpcMessage, + timeMs: Date.now() - startTime, + sizeBytes: body.length, + }) + }) + + stream.on("error", (err) => { + cleanup() + reject(err) + }) + }) + + return NextResponse.json(result) + } catch (error) { + const err = error as Error + return NextResponse.json({ + error: err.message, + status: 0, + body: "", + headers: {}, + trailers: {}, + }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy-ntlm/route.ts b/apps/web/src/app/api/proxy-ntlm/route.ts new file mode 100644 index 00000000..3c77659e --- /dev/null +++ b/apps/web/src/app/api/proxy-ntlm/route.ts @@ -0,0 +1,178 @@ +/** + * NTLMv2-aware proxy. Runs the three-step handshake against an upstream HTTP + * endpoint and returns the final response. + * + * Standard NTLM HTTP exchange (RFC 4559 + [MS-NLMP]): + * 1. Client → proxy (this route) with credentials in the body + * 2. Proxy → upstream: original request + `Authorization: NTLM ` + * 3. Upstream → proxy: `401 Unauthorized` + `WWW-Authenticate: NTLM ` + * 4. Proxy → upstream: same request + `Authorization: NTLM ` ON THE SAME TCP CONNECTION + * 5. Upstream → proxy: real response (200/etc.) + * + * Step 4's connection affinity is critical — IIS / SharePoint enforce it. + * We use undici's `Agent` with `pipelining: 1, connections: 1` so both fetches + * land on the same dispatcher and (in practice) the same keepalive socket. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" +import { Agent, fetch as undiciFetch } from "undici" +import { createType1Message, parseType2Message, createType3Message } from "@/lib/auth/ntlm" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +function parseNtlmChallenge(wwwAuth: string | null): string | null { + if (!wwwAuth) return null + // Header may carry several schemes: `Negotiate, NTLM `. + for (const part of wwwAuth.split(",")) { + const trimmed = part.trim() + if (/^NTLM\s+/i.test(trimmed)) { + return trimmed.replace(/^NTLM\s+/i, "").trim() + } + } + return null +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body, ntlm } = await req.json() as { + url: string + method: string + headers?: Record + body?: string + ntlm: { username: string; password: string; domain?: string; workstation?: string } + } + + if (!url) return NextResponse.json({ error: "URL is required" }, { status: 400 }) + if (!ntlm?.username || !ntlm?.password) { + return NextResponse.json({ error: "ntlm.username and ntlm.password are required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + // One agent for the whole handshake — pins both fetches to the same TCP socket. + const agent = new Agent({ + keepAliveTimeout: 30_000, + keepAliveMaxTimeout: 60_000, + pipelining: 1, + connections: 1, + }) + + const baseHeaders: Record = { ...(headers ?? {}) } + // Drop any Authorization the user pre-filled — NTLM owns this header. + for (const k of Object.keys(baseHeaders)) { + if (k.toLowerCase() === "authorization") delete baseHeaders[k] + } + + const startTime = performance.now() + + // ── Step 1: send Type1 ── + const type1 = createType1Message(ntlm.domain ?? "", ntlm.workstation ?? "") + const res1 = await undiciFetch(url, { + method, + headers: { ...baseHeaders, Authorization: `NTLM ${type1}` }, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + dispatcher: agent, + }) + + if (res1.status !== 401) { + // Server did not challenge — return what we got. + const respHeaders: Record = {} + res1.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + return NextResponse.json({ + status: res1.status, + statusText: res1.statusText, + headers: respHeaders, + body: await res1.text(), + time: Math.round(performance.now() - startTime), + size: 0, + handshake: "skipped", + }) + } + + // ── Step 2: parse Type2 ── + const wwwAuth = res1.headers.get("www-authenticate") + const type2B64 = parseNtlmChallenge(wwwAuth) + if (!type2B64) { + return NextResponse.json({ + error: "Server returned 401 but no NTLM challenge in WWW-Authenticate", + wwwAuthenticate: wwwAuth, + }, { status: 502 }) + } + // Drain the Type2 response body so the connection is ready for the next request. + await res1.body?.cancel().catch(() => { /* noop */ }) + + const type2 = parseType2Message(type2B64) + + // ── Step 3: send Type3 on same dispatcher ── + const type3 = createType3Message({ + type2, + username: ntlm.username, + password: ntlm.password, + domain: ntlm.domain, + workstation: ntlm.workstation, + }) + const res2 = await undiciFetch(url, { + method, + headers: { ...baseHeaders, Authorization: `NTLM ${type3}` }, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + dispatcher: agent, + }) + + const respHeaders: Record = {} + res2.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + const text = await res2.text() + const elapsed = Math.round(performance.now() - startTime) + + await agent.close().catch(() => { /* noop */ }) + + return NextResponse.json({ + status: res2.status, + statusText: res2.statusText, + headers: respHeaders, + body: text, + time: elapsed, + size: text.length, + handshake: "ntlmv2", + }) + } catch (error) { + const err = error as Error + return NextResponse.json({ + status: 0, + statusText: "Error", + headers: {}, + body: err.message, + error: err.message, + }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/proxy-spnego/route.ts b/apps/web/src/app/api/proxy-spnego/route.ts new file mode 100644 index 00000000..001bc37a --- /dev/null +++ b/apps/web/src/app/api/proxy-spnego/route.ts @@ -0,0 +1,111 @@ +/** + * SPNEGO / Kerberos passthrough proxy. + * + * Browsers can't speak Kerberos directly. This route forwards the request + * unchanged but adds an `Authorization: Negotiate ` header built from a + * pre-acquired SPNEGO token (the user obtains it via `kinit` + a separate tool + * like `klist` / `kerberos-token`, then pastes it into the auth panel). + * + * Limitations vs full GSS-API integration: + * - No mutual authentication parsing (we don't validate the server's reply). + * - No token cache / TGT renewal — the user re-acquires when expired. + * - No replay protection beyond what SPNEGO + TLS already provide. + * + * Real GSS-API would need a native binding (libkrb5 / SSPI) and is out of scope + * for the browser. This route gets users 90% of the way: a working call when + * they have a token in hand. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" + +export const runtime = "nodejs" +export const maxDuration = 60 + +function isSSRFTarget(hostname: string): boolean { + const hl = hostname.toLowerCase() + if (process.env.NODE_ENV !== "production") { + return ["169.254.169.254", "metadata.google.internal"].includes(hl) + } + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = Number(parts[0]), b = Number(parts[1]) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + } + return false +} + +interface SpnegoPayload { + url: string + method: string + headers?: Record + body?: string + /** Pre-acquired SPNEGO/Kerberos token (base64). */ + token: string +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body, token } = await req.json() as SpnegoPayload + if (!url || !token) { + return NextResponse.json({ error: "url and token are required" }, { status: 400 }) + } + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs allowed" }, { status: 403 }) + } + if (isSSRFTarget(parsed.hostname)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + + const reqHeaders: Record = { ...(headers ?? {}) } + // Drop any pre-set Authorization — SPNEGO owns this. + for (const k of Object.keys(reqHeaders)) { + if (k.toLowerCase() === "authorization") delete reqHeaders[k] + } + reqHeaders["Authorization"] = `Negotiate ${token}` + + const startTime = Date.now() + const upstream = await fetch(url, { + method, + headers: reqHeaders, + body: body && method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? body : undefined, + }) + const elapsed = Date.now() - startTime + + const respHeaders: Record = {} + upstream.headers.forEach((v: string, k: string) => { respHeaders[k] = v }) + + const text = await upstream.text() + return NextResponse.json({ + status: upstream.status, + statusText: upstream.statusText, + headers: respHeaders, + body: text, + time: elapsed, + size: text.length, + // Surface server's mutual-auth reply token if present (not validated). + mutualReply: respHeaders["www-authenticate"]?.startsWith("Negotiate ") + ? respHeaders["www-authenticate"].slice("Negotiate ".length) + : undefined, + }) + } catch (error) { + const err = error as Error + return NextResponse.json({ + error: err.message, + status: 0, + body: err.message, + }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy-stream/route.ts b/apps/web/src/app/api/proxy-stream/route.ts new file mode 100644 index 00000000..485988ac --- /dev/null +++ b/apps/web/src/app/api/proxy-stream/route.ts @@ -0,0 +1,117 @@ +/** + * Streaming proxy for SSE and other long-lived response bodies. + * + * Same SSRF + auth guards as the JSON proxy, but the upstream response body + * is forwarded directly as a ReadableStream — no `await response.text()` that + * would coalesce the whole thing into a single buffer (and block the live + * `text/event-stream` semantics). + * + * Upstream status/headers ride along on `X-Mdt-Upstream-*` response headers so + * the client can render them without having to peek into the body stream. + */ + +import { requireBackendSession } from "@/lib/require-backend-session" +import { NextRequest, NextResponse } from "next/server" + +export const runtime = "nodejs" +export const maxDuration = 60 + +const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" + +function getAllowedHost(): string { + try { return new URL(FASTAPI_BASE_URL).host } catch { return "localhost:8000" } +} + +const ALLOWED_HOST = getAllowedHost() + +function allowPrivateProxyTargets(): boolean { + return ( + (process.env.ALLOW_PRIVATE_PROXY_TARGETS || "").toLowerCase() === "true" || + process.env.NODE_ENV !== "production" + ) +} + +function isBlockedRequestTarget(hostname: string, host: string): boolean { + if (host === ALLOWED_HOST) return false + const hl = hostname.toLowerCase() + if (allowPrivateProxyTargets()) { + const meta = ["169.254.169.254", "metadata.google.internal", "metadata.google", "100.100.100.200"] + return meta.includes(hl) + } + const ipv6Bare = hl.replace(/^\[|\]$/g, "") + const probablyIpv6 = hl.includes(":") + if (hl === "localhost" || hl.endsWith(".localhost")) return true + if (hl.endsWith(".local") || hl.endsWith(".internal")) return true + if (ipv6Bare === "::1") return true + if (probablyIpv6 && (ipv6Bare.startsWith("fe80:") || ipv6Bare.startsWith("fc") || ipv6Bare.startsWith("fd"))) return true + const parts = hostname.split(".") + if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) { + const a = parseInt(parts[0]!), b = parseInt(parts[1]!) + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + if (a === 0) return true + } + return false +} + +export async function POST(req: NextRequest) { + try { + const authError = await requireBackendSession(req) + if (authError) return authError + + const { url, method, headers, body } = await req.json() + if (!url) return NextResponse.json({ error: "URL is required" }, { status: 400 }) + + let parsed: URL + try { parsed = new URL(url) } catch { + return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }) + } + if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { + return NextResponse.json({ error: "Blocked by SSRF protection" }, { status: 403 }) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return NextResponse.json({ error: "Only HTTP(S) URLs are allowed" }, { status: 403 }) + } + + const proxyController = new AbortController() + // Tear the upstream connection down if the client gives up (tab close, navigation). + req.signal.addEventListener("abort", () => proxyController.abort(), { once: true }) + + const upstreamRes = await fetch(url, { + method, + headers: headers ?? {}, + body: body && typeof body === "string" ? body : undefined, + signal: proxyController.signal, + }) + + // Squash upstream headers into JSON we can ship on a single response header. + const upstreamHeaders: Record = {} + upstreamRes.headers.forEach((v, k) => { upstreamHeaders[k] = v }) + // Keep encoded header header under the 16KB typical limit. + const headersBlob = JSON.stringify(upstreamHeaders) + const headersForClient = headersBlob.length < 12_000 ? headersBlob : "{}" + + const out: Record = { + "Content-Type": upstreamRes.headers.get("content-type") ?? "application/octet-stream", + "X-Mdt-Upstream-Status": String(upstreamRes.status), + "X-Mdt-Upstream-Status-Text": upstreamRes.statusText, + "X-Mdt-Upstream-Headers": headersForClient, + // Disable buffering on Nginx + similar reverse proxies so SSE stays live. + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache, no-transform", + } + + return new NextResponse(upstreamRes.body, { + status: 200, + headers: out, + }) + } catch (error) { + const err = error as Error + if (req.signal.aborted || err?.name === "AbortError") { + return new NextResponse(null, { status: 499 }) + } + return NextResponse.json({ error: err.message }, { status: 502 }) + } +} diff --git a/apps/web/src/app/api/proxy/route.ts b/apps/web/src/app/api/proxy/route.ts index 45ef1a9e..492296af 100644 --- a/apps/web/src/app/api/proxy/route.ts +++ b/apps/web/src/app/api/proxy/route.ts @@ -1,6 +1,11 @@ import { requireBackendSession } from "@/lib/require-backend-session" import { NextRequest, NextResponse } from "next/server" +// Node runtime + raised wall-clock budget so big multipart bodies and slow upstreams +// don't get cut off by the platform's default 10s edge limit. +export const runtime = "nodejs" +export const maxDuration = 60 + // ── SSRF Protection: only allow proxying to the configured backend ──────────── const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000" @@ -15,6 +20,38 @@ function getAllowedHost(): string { const ALLOWED_HOST = getAllowedHost() +const DEFAULT_PROXY_TIMEOUT_MS = 30_000 +const MAX_PROXY_TIMEOUT_MS = 55_000 // stay under `maxDuration` so we surface a timeout, not a 504 +const MAX_REDIRECTS = Number(process.env.PROXY_MAX_REDIRECTS ?? 5) + +/** Pick an effective per-request timeout, clamped to the platform budget. */ +function resolveTimeoutMs(userValue: unknown): number { + const n = Number(userValue) + if (!Number.isFinite(n) || n <= 0) return DEFAULT_PROXY_TIMEOUT_MS + return Math.min(Math.floor(n), MAX_PROXY_TIMEOUT_MS) +} + +/** + * Heuristic: is this content-type safe to decode as UTF-8 text? + * Default to base64 for anything else — zip/xlsx/fonts/octet-stream were previously + * returned as garbled text strings. + */ +function isTextualContentType(ct: string): boolean { + if (!ct) return false + const lower = ct.toLowerCase() + if (lower.startsWith("text/")) return true + if (lower.includes("json")) return true + if (lower.includes("xml")) return true + if (lower.includes("javascript") || lower.includes("ecmascript")) return true + if (lower.includes("html")) return true + if (lower.includes("yaml")) return true + if (lower.includes("csv")) return true + if (lower.includes("urlencoded")) return true + if (lower.includes("graphql")) return true + if (lower.includes("x-ndjson")) return true + return false +} + /** In `next dev`, NODE_ENV is `development` — allow localhost/private targets without extra env (metadata still blocked). */ function allowPrivateProxyTargets(): boolean { return ( @@ -83,12 +120,122 @@ function isBlockedRequestTarget(hostname: string, host: string): boolean { return false } +/** Apply both guards (SSRF + scheme) consistently for every hop. Throws on block. */ +function assertHopAllowed(parsed: URL): void { + if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { + throw new ProxyHopBlockedError(`Blocked target: ${parsed.hostname}`) + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new ProxyHopBlockedError(`Blocked scheme: ${parsed.protocol}`) + } +} + +class ProxyHopBlockedError extends Error { + readonly isProxyBlock = true +} + +interface RedirectHop { + url: string + status: number +} + +interface FetchWithRedirectsResult { + response: Response + finalUrl: string + /** All hops walked BEFORE the final response. Final hop not included. */ + chain: RedirectHop[] +} + +/** + * Manual redirect follower so each hop runs through `isBlockedRequestTarget`. + * Default `fetch` (redirect: "follow") resolves redirects inside undici without + * giving us a chance to inspect — an open-redirect on the target host could land + * us on `169.254.169.254` or another internal IP. + */ +async function fetchFollowingRedirects(args: { + initialUrl: string + method: string + headers: Record + buildBody: () => BodyInit | undefined + signal: AbortSignal +}): Promise { + const { initialUrl, headers, buildBody, signal } = args + const chain: RedirectHop[] = [] + let currentUrl = initialUrl + let currentMethod = args.method + let dropBody = false + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const parsed = new URL(currentUrl) + assertHopAllowed(parsed) + + const useBody = + !dropBody && currentMethod !== "GET" && currentMethod !== "HEAD" + ? buildBody() + : undefined + + const response = await fetch(currentUrl, { + method: currentMethod, + headers, + body: useBody, + redirect: "manual", + signal, + }) + + const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304 + if (!isRedirect) { + return { response, finalUrl: currentUrl, chain } + } + + const location = response.headers.get("location") + if (!location) { + // 30x without Location — treat as terminal. + return { response, finalUrl: currentUrl, chain } + } + + chain.push({ url: currentUrl, status: response.status }) + + const nextUrl = new URL(location, currentUrl).toString() + + // RFC 7231 §6.4.4: 303 always switches to GET and drops the body. + // 301/302 historically (and matching browser fetch) switch POST/PUT/… to GET. + // 307/308 preserve both method and body. + if (response.status === 303) { + currentMethod = "GET" + dropBody = true + } else if ( + (response.status === 301 || response.status === 302) && + currentMethod !== "GET" && + currentMethod !== "HEAD" + ) { + currentMethod = "GET" + dropBody = true + } + + currentUrl = nextUrl + } + + throw new ProxyHopBlockedError(`Too many redirects (>${MAX_REDIRECTS})`) +} + +/** Pull every Set-Cookie header as a discrete entry — Fetch joins them with ", " otherwise. */ +function readSetCookies(headers: Headers): string[] { + type WithGetSetCookie = Headers & { getSetCookie?: () => string[] } + const h = headers as WithGetSetCookie + if (typeof h.getSetCookie === "function") { + return h.getSetCookie() + } + const joined = headers.get("set-cookie") + // Fallback for older runtimes — best-effort, single entry. + return joined ? [joined] : [] +} + export async function POST(req: NextRequest) { try { const authError = await requireBackendSession(req) if (authError) return authError - const { url, method, headers, body } = await req.json() + const { url, method, headers, body, timeoutMs } = await req.json() if (!url) { return NextResponse.json({ @@ -117,37 +264,28 @@ export async function POST(req: NextRequest) { }) } - // ── SSRF guard: block internal/metadata IPs ────────────────────────── - if (isBlockedRequestTarget(parsed.hostname, parsed.host)) { - return NextResponse.json({ - status: 403, - statusText: "Forbidden", - headers: {}, - body: "Requests to internal/private addresses are not allowed", - time: 0, - size: 0, - error: "Blocked by SSRF protection", - }) - } - - // ── Only allow file:// and other dangerous schemes to be blocked ───── - if (!["http:", "https:"].includes(parsed.protocol)) { + try { + assertHopAllowed(parsed) + } catch (e) { + const msg = (e as Error).message return NextResponse.json({ status: 403, statusText: "Forbidden", headers: {}, - body: "Only HTTP(S) URLs are allowed", + body: msg.startsWith("Blocked scheme") + ? "Only HTTP(S) URLs are allowed" + : "Requests to internal/private addresses are not allowed", time: 0, size: 0, - error: "Blocked protocol", + error: msg, }) } const startTime = performance.now() - const PROXY_TIMEOUT_MS = 30_000 + const effectiveTimeoutMs = resolveTimeoutMs(timeoutMs) const proxyController = new AbortController() - const proxyTimeout = setTimeout(() => proxyController.abort(), PROXY_TIMEOUT_MS) + const proxyTimeout = setTimeout(() => proxyController.abort(), effectiveTimeoutMs) // Propagate client disconnect (Strict Mode unmount, navigation) to upstream so // we don't keep reading a response no one will receive. @@ -175,14 +313,17 @@ export async function POST(req: NextRequest) { if (forwardedFor && !hasHeader("x-forwarded-for")) requestHeaders["x-forwarded-for"] = forwardedFor } - let requestBody: BodyInit | undefined = body || undefined + // Body builder: called per hop so 307/308 redirects can re-emit the same payload + // (FormData streams are consumed after one fetch and can't be reused directly). + const isMultipart = + body && typeof body === "object" && body.mode === "form-data" && Array.isArray(body.entries) - if (body && typeof body === "object" && body.mode === "form-data" && Array.isArray(body.entries)) { + const buildBody = (): BodyInit | undefined => { + if (!body) return undefined + if (!isMultipart) return body as BodyInit const form = new FormData() - for (const entry of body.entries) { if (!entry?.key) continue - if (entry.type === "file") { if (!entry.fileContentBase64) continue const fileBuffer = Buffer.from(entry.fileContentBase64, "base64") @@ -192,24 +333,30 @@ export async function POST(req: NextRequest) { form.append(entry.key, entry.value || "") } } + return form + } - requestBody = form + if (isMultipart) { + // Let undici set the multipart Content-Type with its own boundary. const contentTypeKey = Object.keys(requestHeaders).find((key) => key.toLowerCase() === "content-type") - if (contentTypeKey) { - delete requestHeaders[contentTypeKey] - } + if (contentTypeKey) delete requestHeaders[contentTypeKey] } - const response = await fetch(url, { - method, - headers: requestHeaders, - body: requestBody, - signal: proxyController.signal, - }).finally(() => { + let walked: FetchWithRedirectsResult + try { + walked = await fetchFollowingRedirects({ + initialUrl: url, + method, + headers: requestHeaders, + buildBody, + signal: proxyController.signal, + }) + } finally { clearTimeout(proxyTimeout) req.signal.removeEventListener("abort", onClientAbort) - }) + } + const { response, chain: redirectChain } = walked const endTime = performance.now() const time = Math.round(endTime - startTime) @@ -218,24 +365,36 @@ export async function POST(req: NextRequest) { responseHeaders[key] = value }) + const setCookies = readSetCookies(response.headers) + const contentType = response.headers.get("content-type") || "" let responseBody: string let isBase64 = false - if (contentType.includes("image/") || contentType.includes("application/pdf") || contentType.includes("audio/") || contentType.includes("video/")) { + if (isTextualContentType(contentType)) { + responseBody = await response.text() + } else { + // Everything non-textual (octet-stream, zip/xlsx/font/protobuf/binary, also + // missing content-type) goes through base64 so the client gets faithful bytes + // it can preview-or-download instead of UTF-8-mangled garbage. const buffer = await response.arrayBuffer() responseBody = Buffer.from(buffer).toString("base64") isBase64 = true - } else { - responseBody = await response.text() } - const size = Number(response.headers.get("content-length")) || (isBase64 ? Buffer.from(responseBody, "base64").length : responseBody.length) + const declaredLength = Number(response.headers.get("content-length")) + const size = Number.isFinite(declaredLength) && declaredLength > 0 + ? declaredLength + : isBase64 + ? Buffer.from(responseBody, "base64").length + : Buffer.byteLength(responseBody, "utf8") return NextResponse.json({ status: response.status, statusText: response.statusText, headers: responseHeaders, + setCookies, + redirectChain, body: responseBody, isBase64, time, @@ -249,6 +408,17 @@ export async function POST(req: NextRequest) { if (req.signal.aborted || err?.name === "AbortError") { return new NextResponse(null, { status: 499 }) } + if (err instanceof ProxyHopBlockedError) { + return NextResponse.json({ + status: 403, + statusText: "Forbidden", + headers: {}, + body: err.message, + time: 0, + size: 0, + error: err.message, + }) + } return NextResponse.json({ status: 0, statusText: "Error", diff --git a/apps/web/src/app/app/api-keys/layout.tsx b/apps/web/src/app/app/api-keys/layout.tsx new file mode 100644 index 00000000..96fbbe0b --- /dev/null +++ b/apps/web/src/app/app/api-keys/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('api-keys') + +export default function ApiKeysLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/api-keys/page.tsx b/apps/web/src/app/app/api-keys/page.tsx new file mode 100644 index 00000000..dc859374 --- /dev/null +++ b/apps/web/src/app/app/api-keys/page.tsx @@ -0,0 +1,160 @@ +"use client" + +import { useEffect, useRef } from "react" +import { AddApiKeyDialog } from "@/components/api-key-vault/add-api-key-dialog" +import { ApiKeyList } from "@/components/api-key-vault/api-key-list" +import { useApiKeyVaultStore, type ApiKeyEntry, type ApiKeyEnv } from "@/store/api-key-vault-store" +import { ShieldCheck } from "lucide-react" +import { useMasterKeyStore } from "@/store/master-key-store" +import { useVaultGuard } from "@/hooks/use-vault-guard" +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" +import useAuth from "@/utils/useAuth" +import { useIsMobile } from "@/components/hooks/use-mobile" +import { listApiKeyEntries } from "@/lib/api-key-vault-api" +import { decryptData } from "@/lib/encryption" +import { toast } from "sonner" +import { Skeleton } from "@/components/ui/skeleton" + +// ponytail: inline parser — one place uses it, no utils file +function parseApiKeyPayload(plain: string): Omit | null { + try { + const o = JSON.parse(plain) + if (typeof o !== "object" || o === null) return null + const env: ApiKeyEnv = + o.env === "staging" || o.env === "production" ? o.env : "development" + return { + name: typeof o.name === "string" ? o.name : "", + apiKey: typeof o.apiKey === "string" ? o.apiKey : "", + secret: typeof o.secret === "string" ? o.secret : "", + env, + notes: typeof o.notes === "string" ? o.notes : "", + } + } catch { + return null + } +} + +export default function ApiKeyVaultPage() { + const { user, loading } = useAuth(true) + const { encryptionKey } = useMasterKeyStore() + const { isUnlocked, isRestoring } = useVaultGuard() + const { entries, setEntries, setLoading, clearEntries } = useApiKeyVaultStore() + const isMobile = useIsMobile() + const loadedRef = useRef(false) + + useEffect(() => { + if (!encryptionKey || loadedRef.current) return + loadedRef.current = true + let cancelled = false + loadEntries(encryptionKey, () => cancelled) + + return () => { + cancelled = true + clearEntries() + loadedRef.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [encryptionKey]) + + const loadEntries = async (key: CryptoKey, isCancelled: () => boolean) => { + setLoading(true) + try { + const rows = await listApiKeyEntries() + if (isCancelled()) return + const decrypted = await Promise.all( + rows.map(async (row) => { + try { + const plain = await decryptData(key, row.encryptedData, row.iv) + const parsed = parseApiKeyPayload(plain) + if (!parsed) return null + return { + id: row.id, + ...parsed, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } satisfies ApiKeyEntry + } catch { + return null + } + }) + ) + if (isCancelled()) return + setEntries(decrypted.filter((x): x is ApiKeyEntry => x !== null)) + } catch { + if (!isCancelled()) toast.error("Failed to load API keys") + } finally { + if (!isCancelled()) setLoading(false) + } + } + + if (isRestoring) return + if (!isUnlocked) return + + if (loading) { + return ( +
+
+ + +
+
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+ ) + } + + if (!user) return null + + return ( +
+ {!isMobile && ( +
+
+
+

API Keys

+ {entries.length > 0 && ( + + {entries.length} + + )} +
+

+ + AES-256-GCM encrypted on your device — server never sees plaintext. +

+
+ +
+ )} + + {isMobile && ( +
+
+

API Keys

+ {entries.length > 0 && ( + + {entries.length} stored + + )} +
+
+ )} + +
+ +
+ + {isMobile && } +
+ ) +} diff --git a/apps/web/src/app/app/app-content.tsx b/apps/web/src/app/app/app-content.tsx index 48c503fa..52a5099f 100644 --- a/apps/web/src/app/app/app-content.tsx +++ b/apps/web/src/app/app/app-content.tsx @@ -1,47 +1,61 @@ 'use client'; -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ClientLayout } from '../../components/sidebar/client-layout'; import { RequireAuth } from '@/components/require-auth'; import { MasterPasswordGate } from '@/components/master-password-gate'; import { useMasterKeyStore } from '@/store/master-key-store'; -import { loadMasterKey } from '@/lib/key-storage'; +import { loadMasterKey, clearMasterKey } from '@/lib/key-storage'; import { getMasterVaultOrNull } from '@/lib/global-vault-api'; import { verifyKey } from '@/lib/encryption'; +import { restoreVault } from '@/lib/restore-vault'; import useAuth from '@/utils/useAuth'; -// Silently restores the encryption key from IndexedDB on login so critical -// apps that are visited after a page refresh don't need to re-enter the password. +// Single restoration path. Runs once per signed-in user mount. Mutates the +// store with the final state — modal and pages read from store only. function VaultKeyRestorer() { const { user } = useAuth(false); - const { isUnlocked, setKey, setVaultStatus } = useMasterKeyStore(); + const { vaultStatus, setKey, setVaultStatus, setVault, setRestoreError } = + useMasterKeyStore(); + const ranRef = useRef(false); useEffect(() => { - if (!user || isUnlocked) return; + if (vaultStatus !== 'restoring') { + ranRef.current = false; + return; + } + if (!user || ranRef.current) return; + ranRef.current = true; - async function tryRestoreKey() { - try { - const savedKey = await loadMasterKey(); - if (!savedKey) return; + (async () => { + const result = await restoreVault({ + loadMasterKey, + getMasterVaultOrNull, + verifyKey, + clearMasterKey, + }); - const vaultData = await getMasterVaultOrNull(); - if (!vaultData) { + switch (result.status) { + case 'not-configured': + setVault(null); setVaultStatus('not-configured'); return; - } - - const valid = await verifyKey(savedKey, vaultData.verifier.encrypted, vaultData.verifier.iv); - if (valid) { - setKey(savedKey); - } - } catch { - // Silent — modal will handle errors when user navigates to a critical app + case 'unlocked': + setVault(result.vault); + setKey(result.key); + return; + case 'locked': + setVault(result.vault); + setVaultStatus('locked'); + return; + case 'error': + setRestoreError(result.message); + setVaultStatus('locked'); + return; } - } - - tryRestoreKey(); + })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]); + }, [user, vaultStatus]); return null; } @@ -49,9 +63,7 @@ function VaultKeyRestorer() { export function AppContent({ children }: { children: React.ReactNode }) { return ( - {/* Modal renders when a critical app calls openVaultGate() */} - {/* Silent key restorer — no UI, no blocking */} {children} diff --git a/apps/web/src/app/app/database-explorer/page.tsx b/apps/web/src/app/app/database-explorer/page.tsx index 2a1f6881..058838fa 100644 --- a/apps/web/src/app/app/database-explorer/page.tsx +++ b/apps/web/src/app/app/database-explorer/page.tsx @@ -12,6 +12,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { getConnections } from "@/components/nosql-explorer/connection-service"; import { cn } from "@/lib/utils"; import { IconDatabase, IconServer, IconBrandMongodb, IconSearch, IconPlus, IconArrowLeft, IconMenu2 } from "@tabler/icons-react"; @@ -40,7 +41,7 @@ export default function NoSQLExplorerPage() { const t = useTranslations("NoSqlExplorer.page"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); // We still keep some state for the "active" context if needed, but mostly driven by tabs now const [state, setState] = useState({ isConnected: false, @@ -628,6 +629,7 @@ export default function NoSQLExplorerPage() { const isDesktop = useMediaQuery("(min-width: 768px)"); + if (isRestoring) return ; if (!isUnlocked) return return ( diff --git a/apps/web/src/app/app/environment-manager/page.tsx b/apps/web/src/app/app/environment-manager/page.tsx index b1a9842b..cd484cc1 100644 --- a/apps/web/src/app/app/environment-manager/page.tsx +++ b/apps/web/src/app/app/environment-manager/page.tsx @@ -7,6 +7,7 @@ import { useEnvironmentManagerStore, type EnvSetEntry } from "@/store/environmen import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import useAuth from "@/utils/useAuth" import { useIsMobile } from "@/components/hooks/use-mobile" import { useTranslations } from "next-intl" @@ -20,7 +21,7 @@ export default function EnvironmentManagerPage() { const t = useTranslations("EnvironmentManager.page") const { user, loading } = useAuth(true) const { encryptionKey } = useMasterKeyStore() - const { isUnlocked } = useVaultGuard() + const { isUnlocked, isRestoring } = useVaultGuard() const { setSets, setLoading, clearSets } = useEnvironmentManagerStore() const isMobile = useIsMobile() const loadedRef = useRef(false) @@ -70,6 +71,7 @@ export default function EnvironmentManagerPage() { } } + if (isRestoring) return if (!isUnlocked) return if (loading) { diff --git a/apps/web/src/app/app/password-manager/page.tsx b/apps/web/src/app/app/password-manager/page.tsx index cb2a3d3a..1e5ab93b 100644 --- a/apps/web/src/app/app/password-manager/page.tsx +++ b/apps/web/src/app/app/password-manager/page.tsx @@ -7,6 +7,7 @@ import { usePasswordStore, type PasswordEntry } from "@/store/password-store" import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import useAuth from "@/utils/useAuth" import { useIsMobile } from "@/components/hooks/use-mobile" import { useTranslations } from "next-intl" @@ -22,7 +23,7 @@ export default function PasswordManagerPage() { const t = useTranslations("PasswordManager.page") const { user, loading } = useAuth(true) const { encryptionKey } = useMasterKeyStore() - const { isUnlocked } = useVaultGuard() + const { isUnlocked, isRestoring } = useVaultGuard() const { setPasswords, setLoading, clearPasswords } = usePasswordStore() const isMobile = useIsMobile() const loadedRef = useRef(false) @@ -73,6 +74,7 @@ export default function PasswordManagerPage() { } } + if (isRestoring) return if (!isUnlocked) return if (loading) { diff --git a/apps/web/src/app/app/redis-commander/page.tsx b/apps/web/src/app/app/redis-commander/page.tsx index 8e13f052..160e4c31 100644 --- a/apps/web/src/app/app/redis-commander/page.tsx +++ b/apps/web/src/app/app/redis-commander/page.tsx @@ -45,6 +45,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { useMediaQuery } from "@/hooks/use-media-query"; import { ConnectionForm } from "@/components/redis-commander/connection-form"; @@ -69,7 +70,7 @@ function newTabId() { export default function RedisCommanderPage() { const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); const isDesktop = useMediaQuery("(min-width: 768px)"); const [connections, setConnections] = useState([]); @@ -172,6 +173,7 @@ export default function RedisCommanderPage() { } } + if (isRestoring) return ; if (!isUnlocked) { return ( diff --git a/apps/web/src/app/app/s3-drive/page.tsx b/apps/web/src/app/app/s3-drive/page.tsx index e23a635b..5085f263 100644 --- a/apps/web/src/app/app/s3-drive/page.tsx +++ b/apps/web/src/app/app/s3-drive/page.tsx @@ -6,6 +6,7 @@ import useAuth from "@/utils/useAuth" import { useMasterKeyStore } from "@/store/master-key-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton" import { useS3DriveStore } from "@/store/s3-drive-store" import { listConnections } from "@/lib/s3-drive-api" import { decryptData } from "@/lib/encryption" @@ -17,8 +18,8 @@ import { cn } from "@/lib/utils" export default function S3DrivePage() { const { user, loading: authLoading } = useAuth(true) - const { encryptionKey, isUnlocked } = useMasterKeyStore() - useVaultGuard() + const { encryptionKey } = useMasterKeyStore() + const { isUnlocked, isRestoring } = useVaultGuard() const { connections, activeConnectionId, setConnections } = useS3DriveStore() const [booting, setBooting] = useState(true) const loadedRef = useRef(false) @@ -104,6 +105,7 @@ export default function S3DrivePage() { ) } + if (isRestoring) return if (!isUnlocked || !encryptionKey) return const activeConn = connections.find((c) => c.id === activeConnectionId) ?? null diff --git a/apps/web/src/app/app/sql-client/page.tsx b/apps/web/src/app/app/sql-client/page.tsx index b24a592f..7c18022a 100644 --- a/apps/web/src/app/app/sql-client/page.tsx +++ b/apps/web/src/app/app/sql-client/page.tsx @@ -20,6 +20,7 @@ import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useTranslations } from "next-intl"; @@ -37,7 +38,7 @@ export default function SqlClientPage() { const t = useTranslations("SqlClient.page"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); - const { isUnlocked } = useVaultGuard(); + const { isUnlocked, isRestoring } = useVaultGuard(); const isDesktop = useMediaQuery("(min-width: 768px)"); const [connections, setConnections] = useState([]); @@ -162,6 +163,7 @@ export default function SqlClientPage() { /> ); + if (isRestoring) return ; if (!isUnlocked) return return ( diff --git a/apps/web/src/app/oauth/callback/page.tsx b/apps/web/src/app/oauth/callback/page.tsx new file mode 100644 index 00000000..b50ab45c --- /dev/null +++ b/apps/web/src/app/oauth/callback/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import * as React from "react" + +/** + * OAuth 2.0 authorization-code callback page. + * + * This page is the `redirect_uri` registered with the OAuth provider. The popup + * lands here with `?code=...&state=...` (or `?error=...&error_description=...`), + * relays the values back to the opening tab via postMessage, and closes itself. + * + * Same-origin is enforced on the receiving side; we still hard-code the target + * origin here as a belt-and-braces precaution. + */ +export default function OAuthCallbackPage() { + const [status, setStatus] = React.useState<"posting" | "done" | "no-opener">("posting") + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search) + const message = { + kind: "oauth-callback" as const, + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + error: params.get("error") ?? undefined, + description: params.get("error_description") ?? undefined, + } + + if (window.opener && !window.opener.closed) { + try { + window.opener.postMessage(message, window.location.origin) + } catch { + // Cross-origin opener (extremely unusual here) — surface to the user instead of failing silently. + setStatus("no-opener") + return + } + setStatus("done") + const t = setTimeout(() => { + try { window.close() } catch { /* noop */ } + }, 300) + return () => clearTimeout(t) + } + + setStatus("no-opener") + }, []) + + return ( +
+
+

OAuth callback

+ {status === "posting" && ( +

Returning the authorization code to the API client…

+ )} + {status === "done" && ( +

Done — this window will close.

+ )} + {status === "no-opener" && ( +

+ Could not reach the opener tab. Make sure the OAuth flow was started from the API + client and that popups are allowed for this site. +

+ )} +
+
+ ) +} diff --git a/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts b/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts new file mode 100644 index 00000000..a8632130 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/scripts-runner.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for the sandboxed script runner (phase 2 batch 1). + * + * Same pattern as use-json-formatter.test.ts: the worker module's pure logic is + * exercised directly. Web Worker / Comlink round-trip needs a worker-aware env + * which Jest's jsdom doesn't provide. + */ + +type RunnerApi = { + run: (script: string, ctx: ScriptContext) => { + ok: boolean + error?: string + tests: { name: string; pass: boolean; error?: string }[] + logs: { level: string; args: string[] }[] + environment: Record + variables: Record + request: { url: string; method: string; headers: Record; body?: string } + } +} + +// Comlink's `expose` is mocked to stash whatever the module hands it onto the +// mock object itself — that gives the test a way to call the worker's pure +// `api.run(...)` directly, with no MessageChannel plumbing. +jest.mock("comlink", () => { + const captured = { current: null as RunnerApi | null } + return { + expose: (a: RunnerApi) => { captured.current = a }, + __captured: captured, + } +}) + +// Importing the worker module triggers its `Comlink.expose(api)` side-effect, +// which the mock above captures. +import "../workers/scripts-runner.worker" +import type { ScriptContext } from "../workers/scripts-runner.worker" + +const comlinkMock = jest.requireMock("comlink") as { __captured: { current: RunnerApi | null } } +const api: RunnerApi = (() => { + const a = comlinkMock.__captured.current + if (!a) throw new Error("scripts-runner worker did not call expose()") + return a +})() + +const baseCtx = (over: Partial = {}): ScriptContext => ({ + request: { url: "https://example.com", method: "GET", headers: {} }, + environment: {}, + variables: {}, + ...over, +}) + +describe("scripts-runner: basic Postman API", () => { + it("pm.test passes when assertion holds", () => { + const r = api.run( + `pm.test("ok", () => pm.expect(1 + 1).toBe(2))`, + baseCtx(), + ) + expect(r.ok).toBe(true) + expect(r.tests).toEqual([{ name: "ok", pass: true }]) + }) + + it("pm.test fails when assertion throws", () => { + const r = api.run( + `pm.test("nope", () => pm.expect(1).toBe(2))`, + baseCtx(), + ) + expect(r.tests[0].pass).toBe(false) + expect(r.tests[0].error).toMatch(/to be 2/) + }) + + it("pm.environment.set mutates the env passed back to the caller", () => { + const r = api.run( + `pm.environment.set("token", "abc123")`, + baseCtx({ environment: { existing: "v" } }), + ) + expect(r.environment).toEqual({ existing: "v", token: "abc123" }) + }) + + it("pm.environment.unset removes a key", () => { + const r = api.run( + `pm.environment.unset("dropme")`, + baseCtx({ environment: { dropme: "x", keep: "y" } }), + ) + expect(r.environment).toEqual({ keep: "y" }) + }) + + it("pm.variables.set is session-only (kept separate from environment)", () => { + const r = api.run( + `pm.variables.set("session_x", "s")`, + baseCtx({ environment: { e: "1" } }), + ) + expect(r.variables).toEqual({ session_x: "s" }) + // Env should not be touched. + expect(r.environment).toEqual({ e: "1" }) + }) + + it("pm.request mutations propagate to caller", () => { + const r = api.run( + ` + pm.request.headers.add("Authorization", "Bearer " + pm.environment.get("tok")) + pm.request.url = "https://override.example.com" + `, + baseCtx({ environment: { tok: "secret" } }), + ) + expect(r.request.headers["Authorization"]).toBe("Bearer secret") + expect(r.request.url).toBe("https://override.example.com") + }) + + it("pm.response.json parses the response body in test scripts", () => { + const r = api.run( + `pm.test("body has id", () => pm.expect(pm.response.json()).toHaveProperty("id"))`, + baseCtx({ + response: { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + body: '{"id": 42}', + time: 12, + }, + }), + ) + expect(r.tests[0].pass).toBe(true) + }) + + it("syntax errors are reported in `error`, not thrown out", () => { + const r = api.run(`this is not valid javascript ===`, baseCtx()) + expect(r.ok).toBe(false) + expect(r.error).toBeDefined() + }) + + it("console.log calls land in logs", () => { + const r = api.run(`console.log("hello", { a: 1 })`, baseCtx()) + expect(r.logs[0].args).toEqual(["hello", '{"a":1}']) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch1.test.ts b/apps/web/src/components/api-client/__tests__/security-batch1.test.ts new file mode 100644 index 00000000..ae10d294 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch1.test.ts @@ -0,0 +1,78 @@ +import { encodeBasicCredentials } from "@/lib/basic-auth" +import { generateCode } from "../generate-code" +import type { ApiRequestState } from "../types" + +const baseRequest = ( + overrides: Partial = {}, +): ApiRequestState => ({ + id: "t1", + name: "test", + method: "GET", + url: "https://example.com/path", + params: [], + headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + response: null, + isLoading: false, + ...overrides, +}) + +describe("encodeBasicCredentials", () => { + it("encodes ASCII creds the same as btoa", () => { + expect(encodeBasicCredentials("alice", "hunter2")).toBe(btoa("alice:hunter2")) + }) + + it("handles UTF-8 in passwords without throwing", () => { + // raw btoa would throw on this — InvalidCharacterError + const encoded = encodeBasicCredentials("user", "pässwörd🔒") + // Decode back through TextDecoder and compare bytes. + const bin = atob(encoded) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + const decoded = new TextDecoder().decode(bytes) + expect(decoded).toBe("user:pässwörd🔒") + }) +}) + +describe("generateCode curl shell-escaping", () => { + it("escapes single quotes inside header values", () => { + const code = generateCode( + baseRequest({ + headers: [ + { id: "h1", key: "X-Note", value: "it's mine", active: true }, + ], + }), + "curl", + ) + expect(code).toContain("'X-Note: it'\\''s mine'") + }) + + it("escapes single quotes inside form-data text values", () => { + const code = generateCode( + baseRequest({ + method: "POST", + body: { + type: "form-data", + content: "", + formData: [ + { id: "f1", key: "note", value: "can't stop", active: true, valueType: "text" }, + ], + }, + }), + "curl", + ) + expect(code).toContain("'note=can'\\''t stop'") + }) + + it("emits utf-8 safe Basic header", () => { + const code = generateCode( + baseRequest({ + auth: { type: "basic", username: "user", password: "pässwörd" }, + }), + "curl", + ) + const expected = `Basic ${encodeBasicCredentials("user", "pässwörd")}` + expect(code).toContain(expected) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch3.test.ts b/apps/web/src/components/api-client/__tests__/security-batch3.test.ts new file mode 100644 index 00000000..096e8014 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch3.test.ts @@ -0,0 +1,81 @@ +import type { HistoryRequest } from "../types" + +/** + * The history persist helper isn't exported. Reach it via a CJS require of the + * source module — Jest's hoisted babel-jest run rewrites the import path the + * same way. We re-implement the two pure helpers inline for direct testing + * since they encapsulate the bug-fix logic for B29. + */ + +function stripHistoryFileBytes(items: HistoryRequest[]): HistoryRequest[] { + return items.map((item) => { + const formData = item.body?.formData + if (!formData?.some((f) => f.valueType === "file" && f.fileContentBase64)) { + return item + } + return { + ...item, + body: { + ...item.body, + formData: formData.map((f) => + f.valueType === "file" && f.fileContentBase64 + ? { ...f, fileContentBase64: "" } + : f + ), + }, + } + }) +} + +describe("stripHistoryFileBytes (B29 quota guard)", () => { + const baseEntry: HistoryRequest = { + id: "h1", + name: "POST /upload", + method: "POST", + url: "https://example.com/upload", + params: [], + headers: [], + body: { type: "form-data", content: "", formData: [] }, + auth: { type: "none" }, + timestamp: 1, + } + + it("strips fileContentBase64 from file entries", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { + type: "form-data", + content: "", + formData: [ + { id: "a", key: "doc", value: "doc.pdf", active: true, valueType: "file", fileContentBase64: "BIG-BASE64" }, + ], + }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0].body.formData?.[0].fileContentBase64).toBe("") + }) + + it("leaves text entries untouched", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { + type: "form-data", + content: "", + formData: [ + { id: "a", key: "name", value: "alice", active: true, valueType: "text" }, + ], + }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0].body.formData?.[0].value).toBe("alice") + }) + + it("returns the same reference when no files to strip", () => { + const input: HistoryRequest[] = [{ + ...baseEntry, + body: { type: "json", content: '{"a":1}' }, + }] + const out = stripHistoryFileBytes(input) + expect(out[0]).toBe(input[0]) + }) +}) diff --git a/apps/web/src/components/api-client/__tests__/security-batch4.test.ts b/apps/web/src/components/api-client/__tests__/security-batch4.test.ts new file mode 100644 index 00000000..824483e2 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/security-batch4.test.ts @@ -0,0 +1,58 @@ +import { truncateBody } from "../truncate-body" +import { generateCode } from "../generate-code" +import type { ApiRequestState } from "../types" + +const baseRequest = ( + overrides: Partial = {}, +): ApiRequestState => ({ + id: "t1", + name: "test", + method: "POST", + url: "https://example.com", + params: [], + headers: [], + body: { type: "text", content: "" }, + auth: { type: "none" }, + response: null, + isLoading: false, + ...overrides, +}) + +describe("truncateBody — UTF-16 surrogate safety (B35)", () => { + it("does not split a surrogate pair", () => { + // 🔒 is two code units (D83D DD12). 100 ASCII chars + emoji = length 102. + const body = "a".repeat(100) + "🔒b" + // Force the cut to land between the two halves of the emoji. + const { inline } = truncateBody(body, 101) + // Should back off by 1 so we don't leave a lonely high surrogate. + expect(inline.length).toBe(100) + expect(inline.endsWith("a")).toBe(true) + // No replacement char after re-encode round-trip. + expect(JSON.stringify(inline)).not.toMatch(/\\ud83d$/) + }) + + it("returns the full body when shorter than max", () => { + const body = "hello" + const { inline, truncated } = truncateBody(body, 10) + expect(inline).toBe(body) + expect(truncated).toBe(false) + }) +}) + +describe("generateCode Go body escape (B19)", () => { + it("escapes CR, TAB, and other control characters", () => { + const code = generateCode( + baseRequest({ + body: { type: "text", content: "line1\r\nline2\tcol2" }, + }), + "go", + ) + // The Go source string must show \r, \n, \t — not the raw control chars. + expect(code).toContain('"line1\\r\\nline2\\tcol2"') + // And must not contain a bare CR / TAB inside the quoted string literal. + // [\s\S] avoids the `s` flag — same effect, broader engine compatibility. + const literalMatch = code.match(/strings\.NewReader\("([\s\S]+?)"\)/) + expect(literalMatch).not.toBeNull() + expect(literalMatch?.[1]).not.toMatch(/[\r\t]/) + }) +}) diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 5f930c35..726484b9 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -7,7 +7,27 @@ import { RequestPanel } from "./request-panel" import { RequestTabs } from "./request-tabs" import { ResponsePanel } from "./response-panel" import { TabBar } from "./tab-bar" -import { ImportCurlDialog } from "./import-curl-dialog" +import { ImportCurlDialog, type ImportCurlTarget } from "./import-curl-dialog" +import { ImportDialog } from "./import-dialog" +import { CookieJarDialog } from "./cookie-jar-dialog" +import { WebSocketPanel } from "./websocket-panel" +import { GrpcPanel } from "./grpc-panel" +import { SaveExampleDialog } from "./save-example-dialog" +import { PerfRunDialog } from "./perf-run-dialog" +import { PublicMocksDialog } from "./public-mocks-dialog" +import { PluginsDialog } from "./plugins-dialog" +import { loadPlugins, instantiatePlugin, applyBeforeSend, applyAfterResponse, type PluginInstance } from "@/lib/plugins/plugin-runtime" +import { MetricsDialog } from "./metrics-dialog" +import { recordMetric, recordLog } from "@/lib/observability/metrics" +import { OfflineIndicator } from "./offline-indicator" +import { putCachedResponse, getCachedResponse } from "@/lib/cache/response-cache" +import { registerApiClientServiceWorker } from "@/lib/sw/register-api-client-sw" +import { P2pSyncDialog } from "./p2p-sync-dialog" +import { FuzzRunDialog } from "./fuzz-run-dialog" +import { RecorderDialog } from "./recorder-dialog" +import { listenForExtensionImports, capturedToTab } from "@/lib/extension/listen" +import { recordExchange } from "@/lib/recorder/traffic-recorder" +import type { SavedExample } from "./types" import { HelpShortcutsDialog } from "./help-shortcuts-dialog" import { SaveRequestDialog } from "./collections/save-request-dialog" import { parseCurlCommand } from "@/utils/curl-parser" @@ -20,19 +40,34 @@ import { API_CLIENT_DEFAULT_TAB_NAME, API_CLIENT_IMPORTED_TAB_NAME, API_CLIENT_ERROR_STATUS_TEXT, + ScriptTestResult, + ScriptLog, } from "./types" +import type { ScriptContext } from "./workers/scripts-runner.worker" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useIsMobile } from "@/components/hooks/use-mobile" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { FolderOpen, PanelRight, MoreVertical } from "lucide-react" +import { FolderOpen, PanelRight, MoreVertical, Cookie, Download, Gauge } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { IconCode, IconSettings } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" +import { encodeBasicCredentials } from "@/lib/basic-auth" +import { ensureFreshToken } from "@/lib/oauth2" +import { signAwsSigV4 } from "@/lib/auth/aws-sigv4" +import { signHawk } from "@/lib/auth/hawk" +import { signDigest } from "@/lib/auth/digest" +import { signJwt } from "@/lib/auth/jwt-bearer" +import { cookieHeaderForUrl, storeCookiesFromResponse } from "@/lib/cookie-jar" +import { resolveResponsePath } from "@/lib/response-path" +import { applyFolderInheritance, findRequestAncestors } from "@/lib/folder-inheritance" +import { streamSseRequest } from "@/lib/sse-client" import { useJsonFormatter } from "./workers/use-json-formatter" +import { useScriptsRunner } from "./workers/use-scripts-runner" +import { useJsonBodyValidation } from "./use-json-validation" import { useTabs, useTabsActions, createNewTab } from "./context/tabs-context" import { useCollectionsState, useCollectionsActions } from "./context/collections-context" import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" @@ -47,6 +82,15 @@ const CodeGenerator = dynamic( { ssr: false, loading: () => null } ) +/** + * Tag history entries with the HTTP method when falling back to URL, so the list + * can distinguish `GET /users` from `POST /users` at a glance. + */ +function historyName(tabName: string, method: string, url: string): string { + if (tabName && tabName !== API_CLIENT_DEFAULT_TAB_NAME) return tabName + return url ? `${method} ${url}` : method +} + /** `new URL()` requires a scheme; host-only URLs (e.g. `api.example.com/v1`) are common in API clients. */ function buildRequestUrl(raw: string): URL { const trimmed = raw.trim() @@ -75,12 +119,16 @@ function ApiClientInner() { React.useEffect(() => () => abortControllerRef.current?.abort(), []) const { format: formatJson } = useJsonFormatter() + const { run: runScript } = useScriptsRunner() const { collections } = useCollectionsState() const { saveRequest } = useCollectionsActions() const { history } = useHistoryState() const { addHistoryItem } = useHistoryActions() const { environments, activeEnvId, activeEnvironmentVariables } = useEnvironmentsState() - const { substituteVariables, setActiveEnvId } = useEnvironmentsActions() + const { setActiveEnvId, updateEnvironment } = useEnvironmentsActions() + // Session-only variables — set by `pm.variables.set` in scripts and consumed by + // the next request's variable substitution. Cleared on tab close. + const sessionVarsRef = React.useRef>({}) const isMobile = useIsMobile() const [collectionsOpen, setCollectionsOpen] = React.useState(false) @@ -91,6 +139,100 @@ function ApiClientInner() { const [importCurlOpen, setImportCurlOpen] = React.useState(false) const [helpOpen, setHelpOpen] = React.useState(false) const [saveOpen, setSaveOpen] = React.useState(false) + const [cookieJarOpen, setCookieJarOpen] = React.useState(false) + const [importOpen, setImportOpen] = React.useState(false) + const [saveExampleOpen, setSaveExampleOpen] = React.useState(false) + const [perfOpen, setPerfOpen] = React.useState(false) + const [publicMocksOpen, setPublicMocksOpen] = React.useState(false) + const [pluginsOpen, setPluginsOpen] = React.useState(false) + const [metricsOpen, setMetricsOpen] = React.useState(false) + const [p2pOpen, setP2pOpen] = React.useState(false) + const [fuzzOpen, setFuzzOpen] = React.useState(false) + const [recorderOpen, setRecorderOpen] = React.useState(false) + + /** Register the offline-cache service worker on mount. */ + React.useEffect(() => { void registerApiClientServiceWorker() }, []) + + /** Browser extension companion: spawn a new tab whenever the extension forwards a captured request. */ + React.useEffect(() => listenForExtensionImports((captured) => { + const tab = createNewTab() + appendTab({ ...tab, name: API_CLIENT_IMPORTED_TAB_NAME, ...capturedToTab(captured) }) + toast.success(`Imported ${captured.method} ${captured.url} from extension`) + }), [appendTab]) + + /** Rehydrate the active tab's response from IndexedDB if missing. + * Bodies are stripped from localStorage tabs to dodge the 5MB quota; this + * effect restores them transparently when the user reloads the page. */ + React.useEffect(() => { + if (activeTab.response || !activeTab.id) return + let cancelled = false + void getCachedResponse(activeTab.id).then((cached) => { + if (cancelled || !cached) return + updateActiveTab({ response: cached }) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab.id]) + + /** Compiled plugin instances; reloaded when the dialog closes. */ + const pluginInstancesRef = React.useRef([]) + React.useEffect(() => { + const compile = () => { + pluginInstancesRef.current = loadPlugins() + .filter((p) => p.enabled) + .map((p) => instantiatePlugin(p)) + .filter((r) => r.instance) + .map((r) => r.instance!) + } + compile() + if (!pluginsOpen) compile() // pick up edits after dialog closed + }, [pluginsOpen]) + + const handleSaveExample = (name: string) => { + if (!activeTab.response) return + const example: SavedExample = { + id: crypto.randomUUID(), + name, + capturedAt: Date.now(), + request: { + method: activeTab.method, + url: activeTab.url, + headers: activeTab.headers, + body: activeTab.body, + }, + response: { + status: activeTab.response.status, + statusText: activeTab.response.statusText, + headers: activeTab.response.headers, + body: activeTab.response.body, + isBase64: activeTab.response.isBase64, + contentType: Object.entries(activeTab.response.headers ?? {}) + .find(([k]) => k.toLowerCase() === "content-type")?.[1], + }, + } + updateActiveTab({ examples: [...(activeTab.examples ?? []), example] }) + toast.success(`Saved example "${name}"`) + } + + const handleDeleteExample = (id: string) => { + updateActiveTab({ examples: (activeTab.examples ?? []).filter((e) => e.id !== id) }) + } + + const handleLoadExample = (example: SavedExample) => { + updateActiveTab({ + response: { + status: example.response.status, + statusText: example.response.statusText, + headers: example.response.headers, + body: example.response.body, + isBase64: example.response.isBase64, + time: 0, + size: example.response.body?.length ?? 0, + }, + isLoading: false, + }) + toast.success(`Loaded example "${example.name}"`) + } // Scroll position memory for mobile panel toggle const scrollMemory = React.useRef<{ request: number; response: number }>({ request: 0, response: 0 }) @@ -114,11 +256,8 @@ function ApiClientInner() { return urls }, [history]) - const isBodyInvalid = React.useMemo(() => { - if (activeTab.body.type !== "json") return false - if (!activeTab.body.content.trim()) return false - try { JSON.parse(activeTab.body.content); return false } catch { return true } - }, [activeTab.body]) + const jsonValidation = useJsonBodyValidation(activeTab.body) + const isBodyInvalid = !jsonValidation.valid const replaceUrlWithEnvBaseUrl = React.useCallback((url: string | undefined) => { if (!url || !activeEnvId) return url @@ -147,19 +286,27 @@ function ApiClientInner() { updateActiveTab({ url, name: url || API_CLIENT_DEFAULT_TAB_NAME }) }, [updateActiveTab]) - const handleImportCurl = (curl: string) => { + const handleImportCurl = (curl: string, target: ImportCurlTarget = "new-tab") => { try { const parsed = parseCurlCommand(curl) const resolvedUrl = replaceUrlWithEnvBaseUrl(parsed.url) - const newTab: ApiRequestState = { - ...createNewTab(), - ...parsed, - url: resolvedUrl || "", - name: resolvedUrl || API_CLIENT_IMPORTED_TAB_NAME, - id: crypto.randomUUID(), + if (target === "replace-current") { + updateActiveTab({ + ...parsed, + url: resolvedUrl || "", + name: resolvedUrl || activeTab.name, + }) + } else { + const newTab: ApiRequestState = { + ...createNewTab(), + ...parsed, + url: resolvedUrl || "", + name: resolvedUrl || API_CLIENT_IMPORTED_TAB_NAME, + id: crypto.randomUUID(), + } + appendTab(newTab) } - appendTab(newTab) toast.success(t("toasts.curlImported")) } catch (error) { console.error(error) @@ -183,9 +330,20 @@ function ApiClientInner() { } const handleLoadRequest = (request: CollectionRequest) => { + // Locate the request inside any collection so we can fold ancestor folders' + // defaults (headers / auth / scripts) into the materialised tab. A request + // edited in a tab is a snapshot — later folder-default edits don't propagate. + let effective = request + for (const col of collections) { + const ancestors = findRequestAncestors(col.items, request.id) + if (ancestors !== null) { + effective = applyFolderInheritance(request, ancestors) + break + } + } const newTab: ApiRequestState = { ...createNewTab(), - ...request, + ...effective, id: crypto.randomUUID(), // New ID for the tab instance response: null, isLoading: false, @@ -206,44 +364,184 @@ function ApiClientInner() { const controller = new AbortController() abortControllerRef.current = controller - updateActiveTab({ isLoading: true, response: null }) + updateActiveTab({ isLoading: true, response: null, scriptResults: undefined }) if (isMobile) setMobilePanel('response') const startTime = performance.now() + // Aggregated script output across pre-request + tests. + const scriptTests: ScriptTestResult[] = [] + const scriptLogs: ScriptLog[] = [] + const scriptErrors: string[] = [] + + // Env mutations made by scripts — applied locally to substitution and + // persisted to the active environment after the request completes. + const scriptEnvOverlay: Record = {} + const scriptEnvUnsets = new Set() + + // Previous response on the same tab — what `{{response.body.token}}` chains against. + const previousResponse = activeTab.response + + const substituteAll = (text: string): string => { + if (!text) return text + return text.replace(/\{\{(.+?)\}\}/g, (m, k) => { + const key = (k as string).trim() + if (key.startsWith("response.")) { + const resolved = resolveResponsePath(key.slice("response.".length), previousResponse) + return resolved ?? m + } + if (scriptEnvUnsets.has(key)) return m + if (key in scriptEnvOverlay) return scriptEnvOverlay[key] + if (key in sessionVarsRef.current) return sessionVarsRef.current[key] + return activeEnvironmentVariables[key] ?? m + }) + } + try { + // Working request state. Pre-request script may rewrite url/method/headers/body. + let workMethod: string = activeTab.method + let workUrl: string = activeTab.url + let workHeaders: Record = {} + activeTab.headers.forEach((h) => { + if (h.active && h.key) workHeaders[h.key] = h.value + }) + + // ── Pre-request script ──────────────────────────────────────── + if (activeTab.preRequestScript && activeTab.preRequestScript.trim()) { + const preCtx: ScriptContext = { + request: { + url: workUrl, + method: workMethod, + headers: workHeaders, + body: activeTab.body.type === "json" || activeTab.body.type === "text" + ? activeTab.body.content + : undefined, + }, + environment: { ...activeEnvironmentVariables }, + variables: { ...sessionVarsRef.current }, + } + const r = await runScript(activeTab.preRequestScript, preCtx) + scriptTests.push(...r.tests) + scriptLogs.push(...r.logs) + if (!r.ok && r.error) scriptErrors.push(`pre-request: ${r.error}`) + + // Diff env: anything different goes into overlay; anything dropped goes into unsets. + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== activeEnvironmentVariables[k]) { + scriptEnvOverlay[k] = r.environment[k] + } + } + for (const k of Object.keys(activeEnvironmentVariables)) { + if (!(k in r.environment)) scriptEnvUnsets.add(k) + } + sessionVarsRef.current = r.variables + workUrl = r.request.url + workMethod = (r.request.method || workMethod).toUpperCase() + workHeaders = r.request.headers + } + // Substitute variables in URL - const finalUrl = substituteVariables(activeTab.url) + const finalUrl = substituteAll(workUrl) // Construct URL with params const urlObj = buildRequestUrl(finalUrl) activeTab.params.forEach((p) => { if (p.active && p.key) { - urlObj.searchParams.append(substituteVariables(p.key), substituteVariables(p.value)) + urlObj.searchParams.append(substituteAll(p.key), substituteAll(p.value)) } }) - // Construct headers + // Construct headers (start with pre-script's worked headers) const headersObj: Record = {} - activeTab.headers.forEach((h) => { - if (h.active && h.key) { - headersObj[substituteVariables(h.key)] = substituteVariables(h.value) - } + Object.entries(workHeaders).forEach(([k, v]) => { + headersObj[substituteAll(k)] = substituteAll(v) }) - // Add Auth + // Add Auth — credentials are trimmed: copy-pasted tokens routinely carry leading/trailing + // whitespace which most servers reject as malformed. if (activeTab.auth.type === "bearer" && activeTab.auth.token) { - headersObj["Authorization"] = `Bearer ${substituteVariables(activeTab.auth.token)}` + headersObj["Authorization"] = `Bearer ${substituteAll(activeTab.auth.token).trim()}` } else if (activeTab.auth.type === "basic" && activeTab.auth.username && activeTab.auth.password) { - const credentials = btoa(`${substituteVariables(activeTab.auth.username)}:${substituteVariables(activeTab.auth.password)}`) + const credentials = encodeBasicCredentials( + substituteAll(activeTab.auth.username).trim(), + substituteAll(activeTab.auth.password), + ) headersObj["Authorization"] = `Basic ${credentials}` } else if (activeTab.auth.type === "api-key" && activeTab.auth.apiKeyKey && activeTab.auth.apiKeyValue) { - const key = substituteVariables(activeTab.auth.apiKeyKey) - const val = substituteVariables(activeTab.auth.apiKeyValue) + const key = substituteAll(activeTab.auth.apiKeyKey).trim() + const val = substituteAll(activeTab.auth.apiKeyValue).trim() if (activeTab.auth.apiKeyLocation === "query") { urlObj.searchParams.append(key, val) } else { headersObj[key] = val } + } else if (activeTab.auth.type === "jwt-bearer" && activeTab.auth.jwtBearer) { + try { + const cfg = activeTab.auth.jwtBearer + let extra: Record | undefined + if (cfg.extraClaimsJson && cfg.extraClaimsJson.trim()) { + extra = JSON.parse(cfg.extraClaimsJson) as Record + } + const jwt = await signJwt({ + algorithm: cfg.algorithm, + secret: cfg.secret, + privateKeyPem: cfg.privateKeyPem, + ttlSeconds: cfg.ttlSeconds, + claims: { + iss: cfg.issuer || undefined, + sub: cfg.subject || undefined, + aud: cfg.audience || undefined, + extra, + }, + }) + headersObj["Authorization"] = `Bearer ${jwt}` + } catch (err) { + toast.error(`JWT signing failed: ${(err as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } else if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + // Defer signing until after the body is built — it has to hash the payload. + // We mark the intent here and apply at the end of body assembly. + } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + // Same — deferred to post-body assembly. + } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + // Digest with qop=auth-int also needs body. Deferred. + } else if (activeTab.auth.type === "oauth2" && activeTab.auth.oauth2) { + // Refresh / re-fetch as needed. The fresh config is persisted back + // into tab state so subsequent sends reuse the same access token. + try { + const fresh = await ensureFreshToken(activeTab.auth.oauth2) + if ( + fresh.accessToken !== activeTab.auth.oauth2.accessToken || + fresh.refreshToken !== activeTab.auth.oauth2.refreshToken || + fresh.expiresAt !== activeTab.auth.oauth2.expiresAt + ) { + updateActiveTab({ auth: { ...activeTab.auth, oauth2: fresh } }) + } + if (fresh.accessToken) { + const tokenType = fresh.tokenType || "Bearer" + headersObj["Authorization"] = `${tokenType} ${fresh.accessToken}` + } + } catch (err) { + toast.error(`OAuth token refresh failed: ${(err as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } + + // Cookie jar: prepend stored cookies to the Cookie header for this URL. + // User-provided Cookie header takes precedence by appearing later in the merge + // (browsers/curl typically take the merged value as-sent — undici will combine). + if (activeTab.useCookieJar !== false) { + const jarHeader = cookieHeaderForUrl(urlObj.toString()) + if (jarHeader) { + const existingKey = Object.keys(headersObj).find((k) => k.toLowerCase() === "cookie") + if (existingKey) { + headersObj[existingKey] = `${jarHeader}; ${headersObj[existingKey]}` + } else { + headersObj["Cookie"] = jarHeader + } + } } // Prepare body @@ -255,17 +553,23 @@ function ApiClientInner() { delete headersMap[existingKey] } } - if (activeTab.method !== "GET" && activeTab.method !== "HEAD" && activeTab.body.type !== "none") { + if (workMethod !== "GET" && workMethod !== "HEAD" && activeTab.body.type !== "none") { if (activeTab.body.type === "json") { try { - const substitutedBody = substituteVariables(activeTab.body.content) + const substitutedBody = substituteAll(activeTab.body.content) // Validate JSON JSON.parse(substitutedBody) bodyContent = substitutedBody bodyPayload = substitutedBody headersObj["Content-Type"] = "application/json" } catch (e) { - toast.error(t("toasts.invalidJsonBody")) + const parseErr = (e as Error).message + const hasUnresolvedVar = activeTab.body.content.includes("{{") + toast.error( + hasUnresolvedVar + ? `Invalid JSON after env substitution: ${parseErr}` + : `Invalid JSON body: ${parseErr}`, + ) updateActiveTab({ isLoading: false }) return } @@ -273,7 +577,7 @@ function ApiClientInner() { const params = new URLSearchParams() ;(activeTab.body.urlEncoded ?? []).forEach((item) => { if (item.active && item.key) { - params.append(substituteVariables(item.key), substituteVariables(item.value)) + params.append(substituteAll(item.key), substituteAll(item.value)) } }) bodyContent = params.toString() @@ -287,7 +591,7 @@ function ApiClientInner() { .map((item) => { if (item.valueType === "file") { return { - key: substituteVariables(item.key), + key: substituteAll(item.key), type: "file" as const, fileName: item.fileName || "upload.bin", fileType: item.fileType || "application/octet-stream", @@ -296,9 +600,9 @@ function ApiClientInner() { } return { - key: substituteVariables(item.key), + key: substituteAll(item.key), type: "text" as const, - value: substituteVariables(item.value), + value: substituteAll(item.value), } }) @@ -307,8 +611,25 @@ function ApiClientInner() { entries, } deleteContentTypeHeader(headersObj) + } else if (activeTab.body.type === "graphql") { + // GraphQL is serialised as { query, variables } JSON and sent as application/json. + const query = substituteAll(activeTab.body.content) + let variables: unknown = undefined + const rawVars = (activeTab.body.graphqlVariables ?? "").trim() + if (rawVars) { + try { + variables = JSON.parse(substituteAll(rawVars)) + } catch (e) { + toast.error(`Invalid GraphQL variables JSON: ${(e as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + } + bodyContent = JSON.stringify(variables !== undefined ? { query, variables } : { query }) + bodyPayload = bodyContent + headersObj["Content-Type"] = "application/json" } else { - bodyContent = substituteVariables(activeTab.body.content) + bodyContent = substituteAll(activeTab.body.content) bodyPayload = bodyContent if (!headersObj["Content-Type"]) { headersObj["Content-Type"] = "text/plain" @@ -316,6 +637,189 @@ function ApiClientInner() { } } + // ── Payload-dependent auth signers ─────────────────────────── + // SigV4 / Hawk / Digest(auth-int) all hash the body — they have to + // run after body assembly but before the proxy fetch. + const bodyForSigning = typeof bodyPayload === "string" + ? bodyPayload + : (bodyContent ?? "") + try { + if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + await signAwsSigV4({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.awsSigV4, + }) + } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + await signHawk({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.hawk, + }) + } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + await signDigest({ + method: workMethod, + url: urlObj, + headers: headersObj, + body: bodyForSigning, + cfg: activeTab.auth.digest, + }) + } + } catch (signErr) { + toast.error(`Auth signing failed: ${(signErr as Error).message}`) + updateActiveTab({ isLoading: false }) + return + } + + // ── SPNEGO / Kerberos branch ───────────────────────────────── + if (activeTab.auth.type === "spnego" && activeTab.auth.spnego) { + const spnegoRes = await fetch("/api/proxy-spnego", { + method: "POST", + credentials: "include", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: bodyContent ?? undefined, + token: activeTab.auth.spnego.token, + }), + }) + const data = await spnegoRes.json() + updateActiveTab({ + response: { + status: data.status, + statusText: data.statusText ?? "", + headers: data.headers ?? {}, + body: data.body ?? "", + time: data.time ?? 0, + size: data.size ?? 0, + error: data.error, + }, + isLoading: false, + }) + return + } + + // ── NTLM branch ───────────────────────────────────────────── + // NTLM does its own 3-step handshake on a single keepalive socket; + // route the request through the NTLM proxy and skip the streaming + // branch (NTLM responses are bounded). + if (activeTab.auth.type === "ntlm" && activeTab.auth.ntlm) { + const ntlmRes = await fetch("/api/proxy-ntlm", { + method: "POST", + credentials: "include", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: bodyContent ?? undefined, + ntlm: { + username: substituteAll(activeTab.auth.ntlm.username), + password: substituteAll(activeTab.auth.ntlm.password), + domain: activeTab.auth.ntlm.domain ? substituteAll(activeTab.auth.ntlm.domain) : undefined, + workstation: activeTab.auth.ntlm.workstation ? substituteAll(activeTab.auth.ntlm.workstation) : undefined, + }, + }), + }) + const ntlmData = await ntlmRes.json() + updateActiveTab({ + response: { + status: ntlmData.status, + statusText: ntlmData.statusText ?? "", + headers: ntlmData.headers ?? {}, + body: ntlmData.body ?? "", + time: ntlmData.time ?? 0, + size: ntlmData.size ?? 0, + error: ntlmData.error, + }, + isLoading: false, + }) + addHistoryItem({ + method: workMethod as RequestMethod, + url: activeTab.url, + params: activeTab.params, + headers: activeTab.headers, + body: activeTab.body, + auth: activeTab.auth, + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), ntlmData.status) + return + } + + // ── Streaming branch (SSE / chunked) ───────────────────────── + // Auto-promote to streaming when `Accept: text/event-stream` is set. + const acceptKey = Object.keys(headersObj).find((k) => k.toLowerCase() === "accept") + const sseAutoDetect = acceptKey ? (headersObj[acceptKey] ?? "").toLowerCase().includes("text/event-stream") : false + if (activeTab.streamResponse || sseAutoDetect) { + updateActiveTab({ streamEvents: [] }) + let metaCaptured = false + await streamSseRequest({ + url: urlObj.toString(), + method: workMethod, + headers: headersObj, + body: typeof bodyPayload === "string" ? bodyPayload : (bodyContent ?? undefined), + signal: controller.signal, + onMeta: (meta) => { + if (metaCaptured) return + metaCaptured = true + updateActiveTab({ + response: { + status: meta.status, + statusText: meta.statusText, + headers: meta.headers, + body: "", + time: 0, + size: 0, + }, + }) + }, + onEvent: (ev) => { + updateActiveTab((tab) => ({ + streamEvents: [ + ...(tab.streamEvents ?? []), + { event: ev.event, data: ev.data, id: ev.id, timestamp: ev.timestamp }, + ], + })) + }, + onClose: () => { + updateActiveTab({ isLoading: false }) + }, + }) + + addHistoryItem({ + method: workMethod as RequestMethod, + url: activeTab.url, + params: activeTab.params, + headers: activeTab.headers, + body: activeTab.body, + auth: activeTab.auth, + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), 200) + return + } + + // Plugins: onBeforeSend lets installed plugins mutate URL / headers / body. + const pluginRequest = { + method: workMethod, + url: urlObj.toString(), + headers: { ...headersObj }, + body: typeof bodyPayload === "string" ? bodyPayload : (bodyContent ?? undefined), + } + const beforeApplied = applyBeforeSend(pluginInstancesRef.current, pluginRequest) + for (const k of Object.keys(headersObj)) delete headersObj[k] + Object.assign(headersObj, beforeApplied.req.headers) + const finalUrlFromPlugins = beforeApplied.req.url + // Send via Proxy const res = await fetch("/api/proxy", { method: "POST", @@ -325,15 +829,51 @@ function ApiClientInner() { "Content-Type": "application/json", }, body: JSON.stringify({ - url: urlObj.toString(), - method: activeTab.method, + url: finalUrlFromPlugins, + method: beforeApplied.req.method, headers: headersObj, - body: bodyPayload ?? bodyContent, + body: beforeApplied.req.body ?? bodyPayload ?? bodyContent, + timeoutMs: activeTab.timeoutMs, }), }) const proxyData = await res.json() + // Plugins: onAfterResponse hook gets the live response. + applyAfterResponse(pluginInstancesRef.current, { + request: pluginRequest, + response: { + status: proxyData.status, + statusText: proxyData.statusText ?? "", + headers: proxyData.headers ?? {}, + body: proxyData.body ?? "", + timeMs: proxyData.time ?? 0, + }, + }) + + // Observability: record metric for the metrics dashboard. + recordMetric({ + method: beforeApplied.req.method, + url: finalUrlFromPlugins, + status: proxyData.status ?? 0, + timeMs: proxyData.time ?? 0, + sizeBytes: proxyData.size ?? 0, + error: proxyData.error, + }) + + if (proxyData.error) { + recordLog({ level: "error", message: `${beforeApplied.req.method} ${finalUrlFromPlugins} → ${proxyData.error}` }) + } + + // Cookie jar: persist Set-Cookie headers from the response. + if ( + activeTab.useCookieJar !== false && + Array.isArray(proxyData.setCookies) && + proxyData.setCookies.length > 0 + ) { + storeCookiesFromResponse(urlObj.toString(), proxyData.setCookies) + } + let formattedBody = proxyData.body if (formattedBody && !proxyData.isBase64) { const responseContentType = (proxyData.headers as Record | undefined) @@ -343,13 +883,67 @@ function ApiClientInner() { if (rawCT.includes("application/json")) { const r = await formatJson(formattedBody) if (r.ok) formattedBody = r.formatted - } else { - // Non-JSON: attempt sync pretty-print as before (best-effort) - try { - formattedBody = JSON.stringify(JSON.parse(formattedBody), null, 2) - } catch { - // Not JSON, keep as text + } + // Non-JSON content-types are NOT pretty-printed here. The previous + // sync `JSON.stringify(JSON.parse(body))` blocked the main thread on + // large XML/HTML/text bodies for no benefit. Monaco's built-in format + // action (right-click → Format Document) handles XML on demand. + } + + // ── Test script (runs against the live response) ────────────── + if (activeTab.testScript && activeTab.testScript.trim()) { + const postCtx: ScriptContext = { + request: { + url: urlObj.toString(), + method: workMethod, + headers: { ...headersObj }, + body: bodyContent ?? undefined, + }, + response: { + status: proxyData.status, + statusText: proxyData.statusText, + headers: proxyData.headers, + body: proxyData.body, + time: proxyData.time, + }, + environment: { ...activeEnvironmentVariables, ...scriptEnvOverlay }, + variables: { ...sessionVarsRef.current }, + } + const r = await runScript(activeTab.testScript, postCtx) + scriptTests.push(...r.tests) + scriptLogs.push(...r.logs) + if (!r.ok && r.error) scriptErrors.push(`test: ${r.error}`) + for (const k of Object.keys(r.environment)) { + if (r.environment[k] !== activeEnvironmentVariables[k]) { + scriptEnvOverlay[k] = r.environment[k] + } + } + sessionVarsRef.current = r.variables + } + + // Persist script-induced env mutations to the active environment. + if ( + activeEnvId && + (Object.keys(scriptEnvOverlay).length > 0 || scriptEnvUnsets.size > 0) + ) { + const env = environments.find((e) => e.id === activeEnvId) + if (env) { + const updatedKeys = new Set() + const merged = env.variables + .filter((v) => !scriptEnvUnsets.has(v.key)) + .map((v) => { + if (scriptEnvOverlay[v.key] !== undefined) { + updatedKeys.add(v.key) + return { ...v, value: scriptEnvOverlay[v.key] } + } + return v + }) + for (const [k, val] of Object.entries(scriptEnvOverlay)) { + if (!updatedKeys.has(k)) { + merged.push({ id: crypto.randomUUID(), key: k, value: val, enabled: true }) + } } + updateEnvironment(activeEnvId, { variables: merged }) } } @@ -363,18 +957,55 @@ function ApiClientInner() { time: proxyData.time, size: proxyData.size, error: proxyData.error, + setCookies: proxyData.setCookies, + redirectChain: proxyData.redirectChain, }, + scriptResults: (scriptTests.length || scriptLogs.length || scriptErrors.length) + ? { tests: scriptTests, logs: scriptLogs, errors: scriptErrors } + : undefined, isLoading: false, }) + // Capture-replay recorder: append exchange to active session (no-op if none). + recordExchange({ + request: { + method: workMethod, + url: activeTab.url, + headers: Object.fromEntries(activeTab.headers.filter((h) => h.active && h.key).map((h) => [h.key, h.value])), + body: typeof activeTab.body?.content === "string" ? activeTab.body.content : undefined, + }, + response: { + status: proxyData.status, + statusText: proxyData.statusText, + timeMs: proxyData.time ?? 0, + bodyExcerpt: typeof formattedBody === "string" ? formattedBody.slice(0, 4096) : "", + }, + }) + + // IndexedDB cache: keep full response body across reloads (localStorage strips it). + void putCachedResponse(activeTab.id, { + status: proxyData.status, + statusText: proxyData.statusText, + headers: proxyData.headers, + body: formattedBody, + isBase64: proxyData.isBase64, + time: proxyData.time, + size: proxyData.size, + error: proxyData.error, + setCookies: proxyData.setCookies, + redirectChain: proxyData.redirectChain, + }) + addHistoryItem({ - method: activeTab.method, + method: workMethod as RequestMethod, url: activeTab.url, params: activeTab.params, headers: activeTab.headers, body: activeTab.body, auth: activeTab.auth, - }, activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : activeTab.url, proxyData.status) + preRequestScript: activeTab.preRequestScript, + testScript: activeTab.testScript, + }, historyName(activeTab.name, workMethod, activeTab.url), proxyData.status) } catch (error) { if ((error as Error).name === "AbortError") return @@ -400,9 +1031,21 @@ function ApiClientInner() { headers: activeTab.headers, body: activeTab.body, auth: activeTab.auth, - }, activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : activeTab.url, 0) + }, historyName(activeTab.name, activeTab.method, activeTab.url), 0) } - }, [activeTab, updateActiveTab, isMobile, substituteVariables, formatJson, addHistoryItem, t]) + }, [ + activeTab, + updateActiveTab, + isMobile, + formatJson, + runScript, + addHistoryItem, + activeEnvironmentVariables, + activeEnvId, + environments, + updateEnvironment, + t, + ]) const handleCurlPaste = (curl: string) => { try { @@ -421,16 +1064,19 @@ function ApiClientInner() { } } - // Keyboard shortcuts + // Keyboard shortcuts. + // Cmd/Ctrl+T and Cmd/Ctrl+W are hard-reserved by the browser (`preventDefault` + // does not override) so we bind Alt+T / Alt+W as the working equivalents. + // Cmd/Ctrl+Enter still works inside form inputs. React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMac = /Mac|iPhone|iPad/i.test(navigator.userAgent) const mod = isMac ? e.metaKey : e.ctrlKey - if (mod && e.key === "t") { + if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "t" || e.key === "T")) { e.preventDefault() addTab() - } else if (mod && e.key === "w") { + } else if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "w" || e.key === "W")) { e.preventDefault() closeTab(activeTabId) } else if (mod && e.key === "Enter") { @@ -490,11 +1136,40 @@ function ApiClientInner() { setImportCurlOpen(true)}> {t("toolbar.importCurl")} + setImportOpen(true)}> + Import collection + setEnvMgrOpen(true)}> {t("toolbar.environments")} + setCookieJarOpen(true)}> + + Cookies + + setPerfOpen(true)}> + + Perf run + + setPublicMocksOpen(true)}> + Public mocks + + setPluginsOpen(true)}> + Plugins + + setMetricsOpen(true)}> + Metrics + + setP2pOpen(true)}> + Peer sync (WebRTC) + + setFuzzOpen(true)}> + Fuzz run + + setRecorderOpen(true)}> + Capture & replay + setHelpOpen(true)}> {t("toolbar.shortcuts")} @@ -558,8 +1233,45 @@ function ApiClientInner() { defaultName={activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : ""} /> + + + +
+
@@ -646,7 +1377,21 @@ function ApiClientInner() { )}
- {isMobile ? ( + {activeTab.kind === "websocket" ? ( +
+ updateActiveTab(patch)} + /> +
+ ) : activeTab.kind === "grpc" ? ( +
+ updateActiveTab(patch)} + /> +
+ ) : isMobile ? ( /* Mobile: separate scroll containers per panel — scroll position preserved on toggle */ <>
updateActiveTab({ streamResponse: v })} /> updateActiveTab({ body })} auth={activeTab.auth} setAuth={(auth) => updateActiveTab({ auth })} + preRequestScript={activeTab.preRequestScript} + setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + testScript={activeTab.testScript} + setTestScript={(s) => updateActiveTab({ testScript: s })} + graphqlUrl={activeTab.url} + graphqlSchema={activeTab.graphqlSchema} + setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + examples={activeTab.examples} + onDeleteExample={handleDeleteExample} + onLoadExample={handleLoadExample} + comments={activeTab.comments} + setComments={(next) => updateActiveTab({ comments: next })} />
@@ -686,7 +1445,7 @@ function ApiClientInner() { onScroll={(e) => { scrollMemory.current.response = (e.target as HTMLDivElement).scrollTop }} >
- + setSaveExampleOpen(true) : undefined} />
@@ -707,6 +1466,8 @@ function ApiClientInner() { onPaste={handleCurlPaste} urlHistory={urlHistory} tabId={activeTab.id} + streamResponse={activeTab.streamResponse} + setStreamResponse={(v) => updateActiveTab({ streamResponse: v })} />
updateActiveTab({ body })} auth={activeTab.auth} setAuth={(auth) => updateActiveTab({ auth })} + preRequestScript={activeTab.preRequestScript} + setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + testScript={activeTab.testScript} + setTestScript={(s) => updateActiveTab({ testScript: s })} + graphqlUrl={activeTab.url} + graphqlSchema={activeTab.graphqlSchema} + setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + examples={activeTab.examples} + onDeleteExample={handleDeleteExample} + onLoadExample={handleLoadExample} + comments={activeTab.comments} + setComments={(next) => updateActiveTab({ comments: next })} />
@@ -727,7 +1500,7 @@ function ApiClientInner() {
- + setSaveExampleOpen(true) : undefined} />
diff --git a/apps/web/src/components/api-client/collection-runner-dialog.tsx b/apps/web/src/components/api-client/collection-runner-dialog.tsx new file mode 100644 index 00000000..11c52702 --- /dev/null +++ b/apps/web/src/components/api-client/collection-runner-dialog.tsx @@ -0,0 +1,277 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ScrollArea } from "@/components/ui/scroll-area" +import { CheckCircle2, AlertCircle, Loader2, Play, X, FileDown, Trash2 } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Collection } from "./types" +import { useScriptsRunner } from "./workers/use-scripts-runner" +import { useEnvironmentsState } from "./context/environments-context" +import { parseDataFile } from "@/lib/runner/csv" +import { runCollection } from "@/lib/runner/runner" +import { downloadJUnitXml } from "@/lib/runner/junit" +import type { RequestRunResult } from "@/lib/runner/types" +import { toast } from "sonner" + +interface CollectionRunnerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + collection: Collection | null +} + +export function CollectionRunnerDialog({ open, onOpenChange, collection }: CollectionRunnerDialogProps) { + const { activeEnvironmentVariables } = useEnvironmentsState() + const { run: runScript } = useScriptsRunner() + + const [iterations, setIterations] = React.useState(1) + const [dataRows, setDataRows] = React.useState[]>([]) + const [dataFileName, setDataFileName] = React.useState("") + const [results, setResults] = React.useState([]) + const [running, setRunning] = React.useState(false) + const [total, setTotal] = React.useState(0) + const abortRef = React.useRef(null) + + React.useEffect(() => { + if (!open) { + setResults([]) + setRunning(false) + setDataRows([]) + setDataFileName("") + setIterations(1) + abortRef.current?.abort() + abortRef.current = null + } + }, [open]) + + const handleDataFile = async (file: File | null) => { + if (!file) return + try { + const text = await file.text() + const rows = parseDataFile(text) + setDataRows(rows) + setDataFileName(file.name) + toast.success(`Loaded ${rows.length} rows from ${file.name}`) + } catch (e) { + toast.error(`Could not parse data file: ${(e as Error).message}`) + } + } + + const handleRun = async () => { + if (!collection) return + setResults([]) + setRunning(true) + abortRef.current = new AbortController() + try { + await runCollection({ + collection, + iterations, + dataRows: dataRows.length > 0 ? dataRows : undefined, + environmentVariables: activeEnvironmentVariables, + runScript, + abortSignal: abortRef.current.signal, + onProgress: (e) => { + if (e.kind === "started") setTotal(e.total) + if (e.kind === "request-done") { + setResults((prev) => [...prev, e.result]) + } + }, + }) + } catch (e) { + toast.error(`Run failed: ${(e as Error).message}`) + } finally { + setRunning(false) + abortRef.current = null + } + } + + const handleAbort = () => { + abortRef.current?.abort() + } + + const stats = React.useMemo(() => { + let totalTests = 0 + let failedTests = 0 + let networkErrors = 0 + for (const r of results) { + totalTests += r.tests.length + failedTests += r.tests.filter((t) => !t.pass).length + if (r.networkError) networkErrors++ + } + return { totalTests, failedTests, networkErrors, passed: totalTests - failedTests } + }, [results]) + + const handleExport = () => { + if (!collection) return + downloadJUnitXml(results, collection.name) + } + + return ( + { if (!running) onOpenChange(o) }}> + + + Run collection: {collection?.name ?? "—"} + + Sends every request in this collection sequentially. Pre-request + test scripts + + cookie jar + OAuth refresh + env substitution all behave exactly as a normal send. + + + +
+
+ + setIterations(Math.max(1, Number(e.target.value) || 1))} + disabled={dataRows.length > 0 || running} + className="h-9" + /> + {dataRows.length > 0 && ( +

+ Iterations forced to {dataRows.length} (from data file). +

+ )} +
+
+ +
+ + {dataFileName ? ( +
+ {dataFileName} + +
+ ) : ( + none + )} +
+
+
+ +
+ {!running ? ( + + ) : ( + + )} + {results.length > 0 && ( + + )} + {results.length > 0 && !running && ( + + )} +
+ + {(running || results.length > 0) && ( +
+ {running && ( + + + {results.length} / {total} + + )} + + + {stats.passed} pass + + {stats.failedTests > 0 && ( + + + {stats.failedTests} fail + + )} + {stats.networkErrors > 0 && ( + + + {stats.networkErrors} network + + )} +
+ )} + + +
+ {results.length === 0 && !running && ( +
+ Configure and click Run. +
+ )} + {results.map((r, i) => { + const ok = !r.networkError && (r.status ?? 0) >= 200 && (r.status ?? 0) < 400 + && r.tests.every((t) => t.pass) + const tone = r.networkError + ? "border-rose-500/30 bg-rose-500/[0.03]" + : ok + ? "border-emerald-500/20 bg-emerald-500/[0.02]" + : "border-amber-500/30 bg-amber-500/[0.03]" + return ( +
+
+ {r.method} + {r.requestName} + {r.status !== undefined && ( + + {r.status} + + )} + {r.time !== undefined && ( + {r.time}ms + )} +
+ {r.networkError && ( +
{r.networkError}
+ )} + {r.tests.length > 0 && ( +
    + {r.tests.map((t, j) => ( +
  • + {t.pass ? "✓" : "✗"} {t.name} + {!t.pass && t.error && — {t.error}} +
  • + ))} +
+ )} +
+ ) + })} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/api-client/collections/collection-item.tsx b/apps/web/src/components/api-client/collections/collection-item.tsx index 67991d05..9afc4445 100644 --- a/apps/web/src/components/api-client/collections/collection-item.tsx +++ b/apps/web/src/components/api-client/collections/collection-item.tsx @@ -22,6 +22,7 @@ interface CollectionItemProps { onAddFolder: (parentId: string) => void onLoadRequest: (request: CollectionRequest) => void onRenameFolder?: (folderId: string, newName: string) => void + onEditFolderDefaults?: (folder: CollectionFolder) => void } function arePropsEqual(prev: CollectionItemProps, next: CollectionItemProps) { @@ -46,6 +47,7 @@ function CollectionItemImpl({ onAddFolder, onLoadRequest, onRenameFolder, + onEditFolderDefaults, }: CollectionItemProps) { const t = useTranslations("ApiClient.collectionItem") const tRoot = useTranslations("ApiClient") @@ -162,6 +164,15 @@ function CollectionItemImpl({ Rename )} + {onEditFolderDefaults && ( + { + e.stopPropagation() + onEditFolderDefaults(item as CollectionFolder) + }}> + + Folder defaults + + )} )} ))} {(item as CollectionFolder).items.length === 0 && ( diff --git a/apps/web/src/components/api-client/collections/collections-sidebar.tsx b/apps/web/src/components/api-client/collections/collections-sidebar.tsx index 99d40ef7..46d618d2 100644 --- a/apps/web/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/web/src/components/api-client/collections/collections-sidebar.tsx @@ -6,7 +6,20 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X } from "lucide-react" +import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe } from "lucide-react" +import { buildShareUrl } from "@/lib/share-link" +import { backendFetch } from "@/lib/backend-auth" +import { toast } from "sonner" +import { downloadCollectionAsPostman } from "@/lib/export/postman" +import { downloadCollectionAsOpenApi } from "@/lib/export/openapi" +import { downloadCollectionAsHar } from "@/lib/export/har" +import { downloadCollectionAsInsomnia } from "@/lib/export/insomnia" +import { CollectionRunnerDialog } from "../collection-runner-dialog" +import { useWorkspacesContext } from "../context/workspaces-context" +import { WorkspacesDialog } from "../workspaces-dialog" +import { Briefcase } from "lucide-react" +import { FolderDefaultsDialog } from "../folder-defaults-dialog" +import type { CollectionFolder } from "../types" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { VirtualHistoryList } from "./virtual-history-list" import { cn } from "@/lib/utils" @@ -42,7 +55,7 @@ export function CollectionsSidebar({ onLoadRequest, }: CollectionsSidebarProps) { const { collections, isLoading } = useCollectionsState() - const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() + const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() const { history, isLoading: isHistoryLoading } = useHistoryState() const { clearHistory: onClearHistory, deleteHistoryItem: onDeleteHistoryItem } = useHistoryActions() const t = useTranslations("ApiClient.collectionsSidebar") @@ -87,6 +100,34 @@ export function CollectionsSidebar({ const [renameCollectionName, setRenameCollectionName] = React.useState("") const [targetParentId, setTargetParentId] = React.useState(null) const [targetCollectionId, setTargetCollectionId] = React.useState(null) + const [runnerCollection, setRunnerCollection] = React.useState(null) + const [folderDefaultsTarget, setFolderDefaultsTarget] = React.useState(null) + /** "" = show all workspaces; otherwise filter collections whose `workspace` matches. */ + const [workspaceFilter, setWorkspaceFilter] = React.useState("") + + const workspaceOptions = React.useMemo(() => { + const set = new Set() + for (const c of collections) { + if (c.workspace) set.add(c.workspace) + } + return Array.from(set).sort() + }, [collections]) + + const filteredCollections = React.useMemo(() => { + if (!workspaceFilter) return collections + return collections.filter((c) => (c.workspace ?? "") === workspaceFilter) + }, [collections, workspaceFilter]) + + // Multi-tenant filter: when an active workspace is set via the WorkspacesProvider, + // narrow to collections referencing that workspace id. Local chip filter still composes. + const { workspaces, activeId: activeWorkspaceId } = useWorkspacesContext() + const [workspaceDialogOpen, setWorkspaceDialogOpen] = React.useState(false) + const collectionsForActiveWs = React.useMemo(() => { + if (!activeWorkspaceId) return filteredCollections + return filteredCollections.filter((c) => c.workspace === activeWorkspaceId) + }, [filteredCollections, activeWorkspaceId]) + + const activeWorkspaceName = workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "All" const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) const [isDeleting, setIsDeleting] = React.useState(false) @@ -173,6 +214,35 @@ export function CollectionsSidebar({ +
+ +
+ {workspaceOptions.length > 0 && ( +
+ + {workspaceOptions.map((ws) => ( + + ))} +
+ )} {t("tabCollections")} {t("tabHistory")} @@ -198,7 +268,7 @@ export function CollectionsSidebar({ ) : ( - collections.map((collection) => ( + collectionsForActiveWs.map((collection) => (
@@ -238,6 +308,115 @@ export function CollectionsSidebar({ {t("rename")} + { + if (typeof window === "undefined") return + if (workspaces.length === 0) { + toast.error("Create a workspace first (sidebar → Workspace bar)") + return + } + const options = workspaces + .map((w, i) => `${i + 1}: ${w.name}`) + .join("\n") + const choice = window.prompt( + `Move "${collection.name}" to which workspace?\n0: default (no workspace)\n${options}`, + "0", + ) + if (choice === null) return + const idx = Number(choice) + const target = idx === 0 ? null : workspaces[idx - 1]?.id ?? null + try { + const res = await backendFetch(`/api/backend/api-client/collections/${collection.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspace: target }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + toast.success("Workspace updated. Reload to see filter.") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Set workspace… + + setRunnerCollection(collection)}> + + Run collection + + { + if (typeof window === "undefined") return + const baseUrl = `${window.location.origin}/api/mock/${collection.id}/` + try { + await navigator.clipboard.writeText(baseUrl) + toast.success("Mock URL copied — append the request path to invoke") + } catch { + toast.error("Could not copy to clipboard") + } + }} + > + + Copy mock URL + + { + if (typeof window === "undefined") return + try { + const url = await buildShareUrl(window.location.origin, collection) + await navigator.clipboard.writeText(url) + toast.success("Share link copied — recipient sees a read-only snapshot") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Copy share link + + { + if (typeof window === "undefined") return + try { + const res = await backendFetch("/api/backend/api-client/public-mocks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + collection_id: collection.id, + name: collection.name, + items: collection.items, + }), + }) + if (!res.ok) throw new Error(`Publish failed: ${res.status}`) + const data = await res.json() as { mock_id: string } + const baseUrl = `${window.location.origin}/api/mock/public/${data.mock_id}/` + await navigator.clipboard.writeText(baseUrl) + toast.success("Public mock URL copied — anyone with the link can call it") + } catch (e) { + toast.error((e as Error).message) + } + }} + > + + Publish as public mock + + downloadCollectionAsPostman(collection)}> + + Export (Postman v2.1) + + downloadCollectionAsOpenApi(collection)}> + + Export (OpenAPI 3.0) + + downloadCollectionAsHar(collection)}> + + Export (HAR 1.2) + + downloadCollectionAsInsomnia(collection)}> + + Export (Insomnia v4) + onDelete(collection.id)} @@ -260,6 +439,7 @@ export function CollectionsSidebar({ onAddFolder={openAddFolderDialog} onLoadRequest={onLoadRequest} onRenameFolder={onRenameFolder} + onEditFolderDefaults={setFolderDefaultsTarget} /> ))} {collection.items.length === 0 && ( @@ -520,6 +700,22 @@ export function CollectionsSidebar({ + { if (!o) setRunnerCollection(null) }} + collection={runnerCollection} + /> + + { if (!o) setFolderDefaultsTarget(null) }} + folder={folderDefaultsTarget} + onSave={(patch) => { + if (folderDefaultsTarget) { + void patchFolder(folderDefaultsTarget.id, patch) + } + }} + />
) } diff --git a/apps/web/src/components/api-client/collections/use-collections.ts b/apps/web/src/components/api-client/collections/use-collections.ts index ace28e81..2b23c908 100644 --- a/apps/web/src/components/api-client/collections/use-collections.ts +++ b/apps/web/src/components/api-client/collections/use-collections.ts @@ -6,6 +6,7 @@ import { toast } from "sonner" import { auth } from "@/database/firebase" import { useAuthState } from "react-firebase-hooks/auth" import { backendFetch } from "@/lib/backend-auth" +import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync" const STORAGE_KEY = "api-client-collections" @@ -42,6 +43,17 @@ export function useCollections() { [user] ) + const reload = React.useCallback(async () => { + if (!user) return + try { + const res = await authedFetch("/api/backend/api-client/collections", { method: "GET" }) + const cols = sortCollections((await res.json()) as Collection[]) + setCollections(cols) + } catch (error) { + console.error("Error fetching collections:", error) + } + }, [user, authedFetch]) + // Load collections from backend React.useEffect(() => { if (loading) return @@ -71,6 +83,9 @@ export function useCollections() { } }, [user, loading, authedFetch]) + // Refetch when another tab broadcasts a collections mutation. + useApiClientSyncListener("collections", () => { void reload() }) + // Migration: localStorage → backend once per browser (when server has no collections yet) React.useEffect(() => { const migrateData = async () => { @@ -99,9 +114,12 @@ export function useCollections() { migrated.push(created) } } - toast.success("Migrated local collections to cloud") - localStorage.removeItem(STORAGE_KEY) + // Order matters: reconcile state from the server-confirmed result first, + // THEN drop the local copy. If the tab closes mid-flight after removeItem + // but before the state update, the user loses their data. setCollections(sortCollections(migrated)) + localStorage.removeItem(STORAGE_KEY) + toast.success("Migrated local collections to cloud") } catch (e) { migrationRanRef.current = false console.error("Migration failed", e) @@ -160,6 +178,7 @@ export function useCollections() { { type: "add", parent_id: parentId, item: newFolder }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error adding folder", e) @@ -183,6 +202,7 @@ export function useCollections() { setCollections((cur) => cur.filter((c) => c.id !== itemId)) try { await authedFetch(`/api/backend/api-client/collections/${itemId}`, { method: "DELETE" }) + broadcastApiClientUpdate("collections") toast.success("Collection deleted") } catch (e) { setCollections(prev) @@ -207,6 +227,7 @@ export function useCollections() { { type: "delete", item_id: itemId }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error deleting item", e) @@ -239,6 +260,7 @@ export function useCollections() { { type: "add", parent_id: parentId, item: request }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") toast.success("Request saved") } catch (e) { setCollections(prev) @@ -271,12 +293,50 @@ export function useCollections() { { type: "update", item_id: folderId, patch: { isOpen: !folder.isOpen } }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error toggling folder", e) } } + /** + * Generic folder patch — used by the folder-defaults dialog to set + * defaultHeaders / preRequestScript / testScript / defaultAuth. + * Applies optimistically against state, reconciles from the delta result. + */ + const patchFolder = async (folderId: string, patch: Partial) => { + if (!user) return + const targetCollection = collections.find((c) => findItemInCollection(c.items, folderId)) + if (!targetCollection) return + const prev = collections + const patchInItems = (items: (CollectionFolder | CollectionRequest)[]): (CollectionFolder | CollectionRequest)[] => + items.map((item) => { + if ("type" in item && item.type === "folder") { + if (item.id === folderId) return { ...item, ...patch } + return { ...item, items: patchInItems(item.items) } + } + return item + }) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: patchInItems(c.items) } : c + )) + ) + try { + const updated = await applyDelta(targetCollection.id, [ + { type: "update", item_id: folderId, patch }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") + toast.success("Folder defaults saved") + } catch (e) { + setCollections(prev) + console.error("Error patching folder", e) + toast.error("Failed to save folder defaults") + } + } + const renameFolder = async (folderId: string, name: string) => { if (!user) return @@ -297,6 +357,7 @@ export function useCollections() { { type: "update", item_id: folderId, patch: { name } }, ]) setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") } catch (e) { setCollections(prev) console.error("Error renaming folder", e) @@ -385,6 +446,38 @@ export function useCollections() { }) } + /** + * Bulk-import a collection (Postman / HAR / OpenAPI conversion output). + * Creates the collection on the server, then PATCHes its items in one shot. + * Cheaper than fan-out applyDelta for hundreds of converted requests. + */ + const importCollection = async (incoming: Collection): Promise => { + if (!user) return null + try { + const res = await authedFetch("/api/backend/api-client/collections", { + method: "POST", + body: JSON.stringify({ name: incoming.name }), + }) + const created = (await res.json()) as Collection + let final = created + if (incoming.items.length > 0) { + const patchRes = await authedFetch(`/api/backend/api-client/collections/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ items: incoming.items }), + }) + final = (await patchRes.json()) as Collection + } + setCollections((prev) => sortCollections([...prev, final])) + broadcastApiClientUpdate("collections") + toast.success(`Imported "${incoming.name}"`) + return final + } catch (e) { + console.error("Error importing collection", e) + toast.error("Failed to import collection") + return null + } + } + // Add a way to create a new root collection const createCollection = async (name: string) => { if (!user) return @@ -395,6 +488,7 @@ export function useCollections() { }) const created = (await res.json()) as Collection setCollections((prev) => sortCollections([...prev, created])) + broadcastApiClientUpdate("collections") toast.success("Collection created") } catch (e) { console.error("Error creating collection", e) @@ -416,6 +510,7 @@ export function useCollections() { }) const updated = (await res.json()) as Collection setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) + broadcastApiClientUpdate("collections") toast.success("Collection renamed") } catch (e) { setCollections(prev) @@ -454,6 +549,7 @@ export function useCollections() { // Remove successfully deleted collections from state if (successfulIds.length > 0) { setCollections((prev) => prev.filter((c) => !successfulIds.includes(c.id))) + broadcastApiClientUpdate("collections") } // Handle results with appropriate feedback @@ -493,7 +589,9 @@ export function useCollections() { createCollection, renameCollection, renameFolder, + patchFolder, deleteMultipleCollections, + importCollection, isLoading } } diff --git a/apps/web/src/components/api-client/comments-panel.tsx b/apps/web/src/components/api-client/comments-panel.tsx new file mode 100644 index 00000000..b8302217 --- /dev/null +++ b/apps/web/src/components/api-client/comments-panel.tsx @@ -0,0 +1,101 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Trash2, MessageSquare } from "lucide-react" +import type { RequestComment } from "./types" +import { useAuthState } from "react-firebase-hooks/auth" +import { auth } from "@/database/firebase" + +/** Pulled out so the lint rule for impure render-time calls doesn't flag the inline use. */ +const nowMs = (): number => Date.now() + +interface CommentsPanelProps { + comments?: RequestComment[] + onChange: (next: RequestComment[]) => void +} + +export function CommentsPanel({ comments, onChange }: CommentsPanelProps) { + const [user] = useAuthState(auth) + const [draft, setDraft] = React.useState("") + const list = comments ?? [] + + const myName = user?.displayName ?? user?.email ?? "Anonymous" + + const handlePost = () => { + const text = draft.trim() + if (!text) return + const next: RequestComment = { + id: crypto.randomUUID(), + author: myName, + createdAt: nowMs(), + text, + } + onChange([...list, next]) + setDraft("") + } + + const handleDelete = (id: string) => { + onChange(list.filter((c) => c.id !== id)) + } + + return ( +
+ + {list.length === 0 ? ( +
+ + No comments yet. Leave context for teammates loading this request later. +
+ ) : ( +
+ {list + .slice() + .sort((a, b) => a.createdAt - b.createdAt) + .map((c) => ( +
+
+ {c.author} + · + {new Date(c.createdAt).toLocaleString()} + +
+
{c.text}
+
+ ))} +
+ )} +
+
+