diff --git a/.changeset/proxy-allowed-paths.md b/.changeset/proxy-allowed-paths.md new file mode 100644 index 0000000..9f209f9 --- /dev/null +++ b/.changeset/proxy-allowed-paths.md @@ -0,0 +1,5 @@ +--- +"@runflow-io/proxy": minor +--- + +Add `allowedPaths` — an extensible, strictly-matched route allow-list on top of the built-ins (dispatch, run polling, health). Defaults cover what `rf.assets.upload`/`rf.assets.get` need (`POST /v1/asset-uploads`, `POST /v1/asset-uploads/:id/confirmations`, `GET /v1/assets/:id`). Like `allowedModels`, a custom list **replaces** the defaults — spread the exported `DEFAULT_ALLOWED_PATHS` to extend, or pass `[]` to disable the asset routes. Rules support method arrays and `:param` segments, reject traversal (including percent-encoded) and empty segments. 403/415 bodies now carry actionable messages plus machine-readable `code`s (`path_not_allowed`, `model_not_allowed`, `origin_not_allowed`, `json_content_type_required`). The handler also exposes `PUT`/`PATCH`/`DELETE` for framework route exports. `RateLimitResult`'s `void` member is now `undefined` (type-level only). diff --git a/.changeset/sdk-assets-pin.md b/.changeset/sdk-assets-pin.md new file mode 100644 index 0000000..02a6560 --- /dev/null +++ b/.changeset/sdk-assets-pin.md @@ -0,0 +1,9 @@ +--- +"@runflow-io/sdk": minor +--- + +Add `rf.assets.upload(file)` — the browser-safe presigned upload flow (create session → PUT to storage → confirm) with transient-failure retry and a size-scaled PUT timeout, returning a model-ready signed HTTPS `url` plus the stable `runflow://assets/{id}` `ref`. Fixes the most common external-fork failure: browser file uploads ending up as `data:` URIs that models reject with a 422. Add `rf.assets.get(id)` to re-mint an expired signed url (store the `id`, not the `url`). + +Export `composePinPrompt`, `composeRegionPrompt`, `pinRegion`, and `PinPoint` — the pin→region prompt convention (3×3 grid baked into the edit prompt) that previously existed only as private copies inside the studio bundle. + +Hardening: proxy mode (`baseUrl`) now never sends `Authorization`, even when `apiKey` is also passed (the documented contract); presigned-URL query strings are redacted from error messages; non-https `upload_url`s are refused. New `RunflowErrorCode` union for autocompletable `catch` handling. diff --git a/.changeset/studio-props-mask.md b/.changeset/studio-props-mask.md new file mode 100644 index 0000000..54fa31e --- /dev/null +++ b/.changeset/studio-props-mask.md @@ -0,0 +1,9 @@ +--- +"@runflow-io/studio": minor +--- + +`` accepts four optional customization props — `tools` (workflow catalogue), `source` (initial asset URL or sample list, read at mount), `sentinel` (`{ enabled, taskDescription }`), and `copy` (brand/labels) — making vertical forks possible without rebuilding on `./headless`. Zero props renders exactly as before. `mount()` forwards them via the new `props` option. + +`./headless` now exports `createMaskController` — the framework-free dual-canvas brush engine (stroke interpolation, coverage, full-resolution thresholded mask blob, guarded against unattached use and bad brush sizes) the shell itself uses, so headless consumers get working mask creation for inpaint workflows without rebuilding it. + +The shell's file uploads now default to the SDK's presigned flow through `runflowProxy` (zero-config — no separate `upload` endpoint needed); hosts that explicitly set `urls.upload` keep the legacy multipart path. `unmount()` now also clears theme CSS variables, and blob preview URLs are revoked on unmount. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..195315a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: build · typecheck · test · lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Install + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Lint + run: bun run lint + + # Warning ratchet: biome warnings (mostly the studio components' + # downgraded a11y/hooks rules) must not grow past the checked-in + # budget. Lower the budget as warnings get fixed; raising it is a + # conscious review decision. + - name: Lint warning ratchet + run: | + BUDGET=45 + count=$(bunx biome check . 2>&1 | grep -oE 'Found [0-9]+ warnings' | grep -oE '[0-9]+' | head -1 || true) + count=${count:-0} + echo "biome warnings: $count (budget: $BUDGET)" + if [ "$count" -gt "$BUDGET" ]; then + echo "::error::Warning count $count exceeds the budget of $BUDGET — fix the new warnings or consciously raise the budget in ci.yml." + exit 1 + fi + + # The live e2e proof (examples/e2e-proof) needs RUNFLOW_API_KEY and + # spends real credits, so it stays a local/manual gate — see + # `bun run proof`. diff --git a/README.md b/README.md index b13b0f3..f42ccb2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ Add a changeset for every user-visible change: bun changeset ``` +## End-to-end proof + +`bun run proof` exercises the real customer chain — browser SDK → +`@runflow-io/proxy` → api.runflow.io — across every modality, including +file upload via `rf.assets.upload` and the proxy allow-list. It needs +`RUNFLOW_API_KEY` and spends real credits, so it's a local/manual gate +(not CI). Results land in `.proof/`. + +For a worked example of a vertical fork (customize `` via +its `tools` / `source` / `sentinel` / `copy` props, or build a custom UI +on `./headless`), see the +[real-estate-studio-sdk](https://github.com/runflow-io/real-estate-studio-sdk) +reference repo. + ## License MIT diff --git a/biome.json b/biome.json index 4efd033..fd6f82e 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,26 @@ } } }, + "overrides": [ + { + "include": ["packages/studio/src/components/**"], + "linter": { + "rules": { + "a11y": { + "noLabelWithoutControl": "warn", + "noAutofocus": "warn", + "useSemanticElements": "warn" + }, + "suspicious": { + "noArrayIndexKey": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + } + } + } + } + ], "javascript": { "formatter": { "quoteStyle": "double", diff --git a/bun.lock b/bun.lock index c8da09a..2e2c864 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/proxy": { "name": "@runflow-io/proxy", - "version": "0.0.2", + "version": "0.0.3", "devDependencies": { "@types/node": "^22.10.1", "tsup": "^8.3.5", @@ -32,7 +32,7 @@ }, "packages/sdk": { "name": "@runflow-io/sdk", - "version": "0.0.2", + "version": "0.0.3", "devDependencies": { "tsup": "^8.3.5", "typescript": "^5.7.2", @@ -41,12 +41,12 @@ }, "packages/studio": { "name": "@runflow-io/studio", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "jszip": "^3.10.1", }, "devDependencies": { - "@runflow-io/sdk": "^0.0.2", + "@runflow-io/sdk": "^0.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "react": "^18.3.1", @@ -56,7 +56,7 @@ "vitest": "^2.1.8", }, "peerDependencies": { - "@runflow-io/sdk": "^0.0.2", + "@runflow-io/sdk": "^0.0.3", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", }, diff --git a/docs/plans/run-384-sdk-gaps/backend-contract.md b/docs/plans/run-384-sdk-gaps/backend-contract.md new file mode 100644 index 0000000..87b8e72 --- /dev/null +++ b/docs/plans/run-384-sdk-gaps/backend-contract.md @@ -0,0 +1,75 @@ +# Backend contract: resolve `runflow://` asset refs at dispatch (RUN-384 gaps 2 + 6) + +**Target repo:** `runflow-monorepo` (api.runflow.io). **Not** implemented in `runflow-js` — +this document is the companion spec for the backend ticket. + +## Current behavior (verified in code) + +- **Writes** (`POST /v1/models/{owner}/{slug}/runs`, `POST /v1/comfyui-workflows/.../runs`, + batches, admin retries): inline `data:` URIs in `body.input`/`body.metadata` are + auto-materialized to R2 and stored as `runflow://assets/{uuid}` refs. This is correct + and should stay. +- **Reads** (`GET /v1/runs`, `GET /v1/runs/{id}`, batch/canonical listings): asset-backed + URLs are re-signed and `runflow://assets/{uuid}` refs are resolved to short-TTL signed + HTTPS URLs. Also correct. +- **The gap:** at **model dispatch**, the materialized `runflow://` ref is forwarded to the + model worker **as-is**. Worker media validators accept only HTTP(S)/`data:` URIs — + `google/nano-banana-pro/edit` (and most non-ComfyUI models) reject with + `422: media URL must use HTTP(S) or data URI, got 'runflow'`. ComfyUI workflow file + inputs already accept the refs; singleton model dispatch does not. + +So the `data:`-materialization *convenience* currently breaks the exact requests it +rewrites. (The original ticket framed this as "make materialization opt-in"; the better +fix below removes the need for any flag.) + +## Requested change + +At the dispatch layer — after materialization, before the input reaches the model +worker / provider transport — apply the **same resolution the read path already does**: + +1. Walk `body.input` (and `metadata` where it feeds workers) for strings of scheme + `runflow://assets/{uuid}`. +2. Resolve each ref org-scoped (existing read-side resolver semantics: load asset, check + `access_expires_at`, sign `r2_key`) into a signed HTTPS URL whose TTL comfortably + covers worker pull + retries (suggest ≥ the worker's max queue+run window). +3. Forward the signed HTTPS URL to the worker. Persist the **ref** (not the signed URL) + on the run record, as today. +4. Unknown/foreign-org/expired refs → 422 with a precise message + (`asset not found or expired: runflow://assets/{uuid}`) — fail at dispatch, not in + the worker. + +### Acceptance criteria + +- `POST /v1/models/google/nano-banana-pro/edit/runs` with `input.image_urls: + ["runflow://assets/{uuid}"]` succeeds end to end (no 422), for both an explicit ref + and one produced by `data:` auto-materialization. +- Read-side responses are unchanged (refs still resolve on read). +- ComfyUI dispatch behavior unchanged. +- A run whose ref points at a foreign org's asset 404s/422s without leaking existence + details beyond the standard non-leaky pattern. + +### Why dispatch-side resolution (not validator changes, not an opt-in flag) + +- One implementation point instead of N model-validator changes across providers. +- Asset refs become first-class on the write path, matching the read path — the SDK can + then hand `UploadedAsset.ref` (stable, no TTL) to any model instead of the signed `url`. +- The `data:` materialization default stays a pure convenience with no footgun, so no + config flag is needed. + +## Interim state (already shipped in runflow-js) + +`rf.assets.upload(file)` returns the **signed HTTPS** url from the confirmation response +(`routers/asset_uploads.py` signs it server-side), so external forks are unblocked today +without this change. Once dispatch-side resolution lands, the SDK will document `ref` as +the preferred long-lived input. + +## Suggested ticket + +> **Title:** Resolve `runflow://assets/{uuid}` refs to signed HTTPS at model dispatch +> **Parent:** RUN-384 +> **Why:** Auto-materialized `data:` inputs currently 422 on most singleton models +> (worker media validators only accept HTTP(S)/data:). Read path already resolves refs; +> dispatch must do the same so asset refs are first-class across the stack. +> **Scope:** dispatch layer for `POST /v1/models/.../runs` (+ batches, retries); +> resolver reuse from the read path; 422 on unknown/expired refs; tests per acceptance +> criteria above. ComfyUI unchanged. diff --git a/docs/plans/run-384-sdk-gaps/design.md b/docs/plans/run-384-sdk-gaps/design.md new file mode 100644 index 0000000..385098a --- /dev/null +++ b/docs/plans/run-384-sdk-gaps/design.md @@ -0,0 +1,140 @@ +# RUN-384 — Close SDK gaps blocking external Studio forks + +**Repo:** `runflow-js` (this monorepo). **Branch:** `mr/bb13-sdk-gaps-https-l`. +**Scope:** A — five in-repo gaps (1, 3, 4, 5, 7) implemented + proven end-to-end; +two backend gaps (2, 6) delivered as a contract spec + companion ticket, **not** coded here. + +## Why (grounded in the actual code, which diverges from the ticket) + +The ticket attributes `data:`→`runflow://` materialization and a flat-403 path filter to +`@runflow-io/proxy`. **Neither is in this package.** The JS proxy forwards bodies verbatim and +gates on a *model* allow-list (`handler.ts:140`, `classify()` at `:277`). The materialization is a +**backend** feature (`api.runflow.io`) and is, per the API contract, *correct*: `data:` URIs +materialize to R2 and **reads return signed HTTPS**. The real defect (gaps 2/6) is narrower: +`runflow://assets/{uuid}` refs are resolved on **reads** (`GET /v1/runs`) but **not at dispatch** +before a model worker's media validator runs — so `google/nano-banana-pro/edit` 422s on +`runflow://`. + +Consequently the *real-world* browser breakage is fixed entirely in-repo by **gap 1 + gap 4**, +with no backend change required: + +- `rf.assets.upload(file)` returns a **signed HTTPS** url (confirmed: `routers/asset_uploads.py:94-96` + signs the confirm payload via `storage.sign_asset_payloads`). HTTPS ⇒ no 422. +- The proxy's default allow-list is widened to reach the asset-upload endpoints those calls need. + +## In-repo changes + +### Gap 1 — `rf.assets.upload(file)` · `@runflow-io/sdk` +New `AssetsResource`, wired in `Runflow` constructor (`client.ts:39-42`) as `rf.assets`. +`upload(file: File | Blob, opts?: { filename?: string; folderId?: string }): Promise` +lifts `assetService.uploadFile` (`runflow-monorepo/frontend/platform/src/services/api/asset.ts:29`): + +1. `POST /v1/asset-uploads` `{ filename, mime_type, size_bytes }` → `{ asset_id, upload_url }` +2. **raw** `fetch(upload_url, { method:"PUT", headers:{ "Content-Type": mime }, body: file })` + — absolute storage URL, must bypass `client.request()` (no base prefix, no `Authorization`). +3. `POST /v1/asset-uploads/{asset_id}/confirmations` `{ folder_id: opts.folderId ?? null }` → `Asset`. + +Returns `{ id, url /* signed https */, ref: "runflow://assets/{id}", name, mimeType, sizeBytes, thumbnailUrl, createdAt }`. +- 50 MB guard (matches backend `MAX_FILE_SIZE`). Server callers pass a `Blob` + explicit `filename`. +- `request()` already passes `FormData` through (`client.ts:61`); here we use JSON for steps 1/3 and + a direct `fetcher` call for step 2 (add a minimal internal `rawFetch` on the client). +- Errors surface as `RunflowError` with the failing step + status. +- New exports: `AssetsResource`, type `UploadedAsset`, from `index.ts`. + +### Gap 4 — proxy `allowedPaths` · `@runflow-io/proxy` +Add `allowedPaths?: ReadonlyArray` to `ProxyConfig` (`types.ts:55`): +```ts +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +interface AllowedPath { method: HttpMethod | HttpMethod[]; path: string; } // path may use :param +``` +- New `matchAllowedPath()`: **strict, full-path** segment match; `:param` matches exactly one + non-empty segment that is not `.`/`..`; method must match. No prefix/wildcard matching. +- Integrate into the gate at `handler.ts:140`: a request that matches an allowed path is forwarded + (still through CSRF + `authenticate` + `rateLimit` + body-cap). It is **not** dispatch, so the + model allow-list and `onRun` are untouched. +- `DEFAULT_ALLOWED_PATHS` (so `rf.assets.upload`/`rf.assets.get` work **zero-config**): + `POST /v1/asset-uploads`, `POST /v1/asset-uploads/:id/confirmations`, `GET /v1/assets/:id`. + *(Revised in review round 1: a customer `allowedPaths` **replaces** the defaults — same + semantics as `allowedModels`; spread the exported `DEFAULT_ALLOWED_PATHS` to extend, pass `[]` + to disable. The first draft said "additive", which made the defaults impossible to turn off.)* + Built-in dispatch/runs/health stay always-on. +- **Security (council P0):** the proxy forwards with the org's secret key. Allowing *reads* like + `GET /v1/runs` or `GET /v1/billing/balance` exposes org-wide data to any same-origin browser — + therefore those are **opt-in only**, never defaulted. SSRF is bounded (upstream host is fixed to + `apiBase`); the risk is method/path scope, which strict matching controls. Documented in README + + JSDoc with an explicit warning. + +### Gap 5 — `composePinPrompt` · `@runflow-io/sdk` (+ de-dupe) +Export from SDK: +```ts +export function pinRegion(pin: { x: number; y: number }): string; // "upper-left" … "lower-right" +export function composePinPrompt(pin: { x:number;y:number }, instruction: string): string; +``` +`pinRegion` = `${y<.33?"upper":y<.66?"middle":"lower"}-${x<.33?"left":x<.66?"center":"right"}`. +`composePinPrompt` returns the **exact** existing template (behavior-preserving): +`Edit the ${region} area of this image: ${instruction}. Photoreal product photography, preserve the rest of the image, true colors and lighting.` +Replace all four copies: `studio/src/lib/runflow.ts:49,150`; `studio/src/tools/index.ts:78`; +`examples/e2e-proof/run.ts:104` (hardcoded) and `:513` (inline). The "no pin ⇒ center" fallback in +`runflow.ts:150` stays in the caller. + +### Gap 3 — `` props · `@runflow-io/studio` +`StudioShell` takes **zero** props today (`StudioShell.tsx:104`). Add four **optional** props, +preserving the zero-prop default exactly: +- `tools?` — override/extend the workflow catalogue (defaults to module `WORKFLOWS`). +- `source?` — initial source image(s) (defaults to `SAMPLES`). +- `sentinel?` — sentinel config: `{ enabled?, taskDescription?, judges? }` (defaults to current hardcoded call). +- `copy?` — UI copy overrides (headings/labels/CTA), shallow-merged over defaults. +`mount()` forwards an optional props arg. Internal `useState` seeds from props once; no behavior +change when omitted. (Full prop wiring decided against `StudioShell.tsx` during implementation.) + +### Gap 7 — brush + mask creation in `./headless` · `@runflow-io/studio` +Lift the dual-canvas mask logic out of `StudioShell.tsx` (`paintAt`/`updateCoverage`/`clearMask`/ +`generateMaskBlob`) — and the richer `runflow-prototypes/runflow-studio-v2` version — into a +**framework-agnostic** controller exported from `@runflow-io/studio/headless`: +```ts +createMaskController(opts): { + attach(visible: HTMLCanvasElement, hidden: HTMLCanvasElement, image: HTMLImageElement): void; + setBrushSize(px): void; strokeTo(x,y): void; beginStroke(x,y): void; endStroke(): void; + clear(): void; coverage(): number; toBlob(): Promise; // full-res B&W PNG +} +``` +React-free (so forks in any framework reuse it). `StudioShell` refactors to consume it — that +refactor is the proof it's reusable. `useMaskController` React hook ships from the main entry, not headless. + +### E2E proof — the gate · `examples/e2e-proof/run.ts` +`npm run proof` already runs browser SDK → in-process `@runflow-io/proxy` → **real api.runflow.io**. +Extend it so the five gaps are proven live (only `RUNFLOW_API_KEY` needed): +- **New `asset-upload` modality:** `rf.assets.upload(File)` **through the proxy** (proves gap 1 + + gap 4 defaults) → feed the returned **https** url to `google/nano-banana-pro/edit` → assert no 422 + + success. This is the direct repro of Fred's bug, now green. +- **Replace the R2 side-channel:** the `mask-reference` flow currently needs `R2_*` creds + (absent here). Re-upload source/mask/reference via `rf.assets.upload` instead → proof becomes + self-sufficient on the API key alone. (`uploads.ts` SigV4 helper retired; `buildSampleMask` kept.) +- **Gap-4 assertions (in-process):** a non-allowed path still 403s; asset-upload paths pass; a + custom `allowedPaths` entry (e.g. `GET /v1/runs`) passes only when configured. +- **Gap-5:** pin modality + chat-agent section call `composePinPrompt` (shared contract). +- All existing modalities stay green. Also add proxy/SDK **vitest** unit tests for each new surface. + +## Backend contract spec + companion ticket (gaps 2 & 6 — not coded here) +Deliver `docs/plans/run-384-sdk-gaps/backend-contract.md` + a ready-to-file ticket for +`runflow-monorepo`: +- **Gap 2:** resolve `runflow://assets/{uuid}` (and re-sign `{org}/assets|inline/...`) in + `body.input`/`metadata` at the **dispatch write path**, before the model worker's media + validator — mirroring the existing read-side resolver (`GET /v1/runs`). Makes asset refs + first-class so the SDK can eventually return `ref` instead of a TTL-bound signed url. +- **Gap 6:** reframed — `data:` materialization is **correct** (reads sign to https); the missing + piece is the same dispatch-side resolution as gap 2. Recommend that over a `data:` opt-out flag. +- Until shipped, gap 1 returns signed **https** so forks are unblocked today. + +## Cross-cutting +- **Docs:** README note — the worked-example story today is "consume headless primitives + build + UI"; link this repo's `examples/`. Mention `./headless` could later split into a minimal package. +- **Versioning:** changesets — `@runflow-io/sdk` **minor** (new `rf.assets`, `composePinPrompt`), + `@runflow-io/proxy` **minor** (`allowedPaths`), `@runflow-io/studio` **minor** (props + headless + mask). (Pre-1.0 minor = 0.0.3 → 0.1.0; flag if patch preferred.) +- **Quality gates:** `bun run typecheck`, `bun run test`, `bun run lint` (biome), then `npm run proof` + live. No `--no-verify`. + +## Out of scope +Backend code in `runflow-monorepo`; model-card doc edits; splitting `./headless` into its own +package; any change to the model allow-list semantics. diff --git a/examples/e2e-proof/fixtures.ts b/examples/e2e-proof/fixtures.ts new file mode 100644 index 0000000..eba0037 --- /dev/null +++ b/examples/e2e-proof/fixtures.ts @@ -0,0 +1,76 @@ +/** + * Proof fixtures — sample bytes the e2e run uploads through + * `rf.assets.upload`. (The previous R2 Sig V4 side-channel lived here; + * it's gone now that the SDK's presigned upload flow covers the same + * need with only the Runflow API key.) + */ + +import { deflateSync } from "node:zlib"; + +/** + * Download `url` and return its bytes. Used to fetch sample images so + * we can re-upload them as Runflow assets the model workers can reach. + */ +export async function fetchBytes(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`fetch ${r.status} for ${url}`); + return Buffer.from(await r.arrayBuffer()); +} + +/** + * Build a 512×512 PNG mask: a white centered rectangle on black. Marks + * the central ~45% of the image as the inpaint region, leaving the + * borders preserved. + */ +export function buildSampleMask(): Buffer { + // Minimal PNG encoder for an 8-bit greyscale 512×512 image. Built + // inline to avoid a runtime dep — the proof script needs to stay + // self-contained. + const W = 512; + const H = 512; + const data = Buffer.alloc(H * (1 + W)); // each row: filter byte + W bytes + for (let y = 0; y < H; y++) { + data[y * (1 + W)] = 0; // filter: None + const inner = y > H * 0.27 && y < H * 0.72; + for (let x = 0; x < W; x++) { + const px = inner && x > W * 0.27 && x < W * 0.72 ? 0xff : 0x00; + data[y * (1 + W) + 1 + x] = px; + } + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(W, 0); + ihdr.writeUInt32BE(H, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 0; // grayscale + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + const idat = deflateSync(data); + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", idat), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function chunk(type: string, data: Buffer): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, "ascii"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crc]); +} + +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (const b of buf) { + c ^= b; + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + return (c ^ 0xffffffff) >>> 0; +} diff --git a/examples/e2e-proof/run.ts b/examples/e2e-proof/run.ts index 89c23dd..5642dc8 100644 --- a/examples/e2e-proof/run.ts +++ b/examples/e2e-proof/run.ts @@ -8,10 +8,10 @@ * so no HTTP listener is needed. Upstream calls to api.runflow.io are * real. * - * Covers one run per modality that doesn't require additional customer - * infrastructure (uploads / sentinel / chat depend on customer-side - * services — those modalities are exercised in @runflow-io/sdk's unit - * tests via mergeToolValues + buildRequest assertions). + * Covers one run per modality, file uploads via rf.assets.upload + * (through the proxy's default allow-list), the proxy allow-list gate + * itself, and the packaged workflows. Only RUNFLOW_API_KEY is needed — + * the former R2 side-channel for mask/reference uploads is gone. * * Loads RUNFLOW_API_KEY (or RUNFLOW_API_TOKEN) from env. Run with: * @@ -24,9 +24,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { Runflow, RunFailedError } from "@runflow-io/sdk"; -import { runflowProxy } from "@runflow-io/proxy"; -import { buildSampleMask, fetchBytes, uploadAndPresign } from "./uploads.js"; +import { DEFAULT_ALLOWED_PATHS, runflowProxy } from "@runflow-io/proxy"; +import { RunFailedError, Runflow, composePinPrompt } from "@runflow-io/sdk"; +import { buildSampleMask, fetchBytes } from "./fixtures.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROOF_DIR = resolve(__dirname, "../../.proof"); @@ -38,13 +38,7 @@ const SOURCE_URL = interface Modality { name: string; - modality: - | "simple" - | "prompt" - | "color" - | "select" - | "pin" - | "text-to-image"; + modality: "simple" | "prompt" | "color" | "select" | "pin" | "text-to-image"; model: string; body: Record; /** Some workflows take longer; bump the timeout where needed. */ @@ -98,11 +92,10 @@ const MODALITIES: Modality[] = [ model: "google/nano-banana-pro/edit", body: { input: { - // The pin builder in @runflow-io/studio's ai-edit tool produces this - // exact preamble; we match it here to prove the dispatched body - // shape works upstream. - prompt: - "Edit the upper-center area of this image: remove the price tag. Photoreal product photography, preserve the rest of the image, true colors and lighting.", + // composePinPrompt is the same helper @runflow-io/studio's ai-edit + // tool uses, so this dispatch proves the shared pin contract works + // upstream. {x: 0.5, y: 0.2} → "upper-center". + prompt: composePinPrompt({ x: 0.5, y: 0.2 }, "remove the price tag"), image_urls: [SOURCE_URL], }, }, @@ -140,9 +133,21 @@ async function main() { }, }); + // Route same-origin proxy calls into the in-process handler — exactly + // what a browser does against /api/runflow — while absolute URLs to + // other hosts (the presigned storage PUT inside rf.assets.upload) go + // out over the real network. + const proofFetch: typeof fetch = (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.startsWith("http://proof.local/")) { + return handler(new Request(input as RequestInfo, init)); + } + return fetch(input as RequestInfo, init); + }; const rf = new Runflow({ baseUrl: "http://proof.local/api/runflow", - fetch: (input, init) => handler(new Request(input as RequestInfo, init)), + fetch: proofFetch, }); const summary: Array<{ @@ -160,8 +165,6 @@ async function main() { const start = Date.now(); try { const dispatched = await rf.models.run(mod.model, mod.body); - const enrichedBody = { ...mod.body, client_ref: `e2e-${mod.modality}-${start}` }; - void enrichedBody; const final = await rf.runs.wait(dispatched.id, { pollIntervalMs: 2_000, timeoutMs: mod.timeoutMs ?? 3 * 60_000, @@ -217,6 +220,179 @@ async function main() { await log(""); } + // ── Asset upload — rf.assets.upload through the proxy ─────────────── + // THE regression test for RUN-384 gaps 1+4: a browser-style file + // upload via the SDK's presigned flow (allowed through the proxy by + // default), whose signed https URL then feeds google/nano-banana-pro/ + // edit — the model that 422s on runflow:// refs. Green here means the + // external-fork upload path works end to end with no extra plumbing. + await log("▶ asset-upload — rf.assets.upload → nano-banana-pro/edit"); + const upStart = Date.now(); + try { + const srcBytes = await fetchBytes(SOURCE_URL); + const file = new File([new Uint8Array(srcBytes)], "proof-source.jpg", { type: "image/jpeg" }); + const uploaded = await rf.assets.upload(file); + if (!uploaded.url.startsWith("https://")) { + throw new Error(`expected a signed https url, got ${uploaded.url.slice(0, 60)}`); + } + if (uploaded.ref !== `runflow://assets/${uploaded.id}`) { + throw new Error(`unexpected asset ref ${uploaded.ref}`); + } + await log(` uploaded asset ${uploaded.id} (${uploaded.sizeBytes} bytes, signed https url)`); + const d = await rf.models.run("google/nano-banana-pro/edit", { + input: { + prompt: composePinPrompt({ x: 0.5, y: 0.5 }, "remove the price tag"), + image_urls: [uploaded.url], + }, + }); + const r = await rf.runs.wait(d.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const elapsed = +((Date.now() - upStart) / 1000).toFixed(1); + const url = extractImage(r.output); + await log(` ✓ SUCCEEDED in ${elapsed}s — ${url}`); + await writeFile( + resolve(PROOF_DIR, `asset-upload-${d.id}.json`), + JSON.stringify( + { + ok: true, + modality: "asset-upload", + name: "rf.assets.upload → nano-banana-pro/edit", + asset: { id: uploaded.id, ref: uploaded.ref, sizeBytes: uploaded.sizeBytes }, + runId: d.id, + output: { image: url }, + elapsedSeconds: elapsed, + completedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + summary.push({ + modality: "asset-upload", + name: "rf.assets.upload → nano-banana-pro/edit", + ok: true, + runId: d.id, + output: url ?? undefined, + elapsedSeconds: elapsed, + }); + } catch (err) { + const elapsed = +((Date.now() - upStart) / 1000).toFixed(1); + const msg = err instanceof Error ? err.message : String(err); + await log(` ✗ FAILED in ${elapsed}s — ${msg}`); + if (err instanceof RunFailedError) { + await log(` raw: ${JSON.stringify(err.run.error)}`); + } + summary.push({ + modality: "asset-upload", + name: "rf.assets.upload → nano-banana-pro/edit", + ok: false, + elapsedSeconds: elapsed, + error: msg, + }); + } + await log(""); + + // ── Proxy allow-list — allowedPaths gate assertions ───────────────── + // In-process checks (no model spend) plus one live read: the gate + // still hard-403s unknown routes, the default asset-upload rules and + // a customer-opted GET /v1/runs forward, and run listing works against + // the real API through an opted-in proxy. + await log("▶ proxy allow-list — allowedPaths gate assertions"); + const gateStart = Date.now(); + try { + const denied = await handler(new Request("http://proof.local/api/runflow/v1/secrets")); + if (denied.status !== 403) { + throw new Error(`expected 403 for /v1/secrets, got ${denied.status}`); + } + + const seen: string[] = []; + const mockProxy = runflowProxy({ + apiKey, + basePath: "/api/runflow", + // Replacement semantics (same as allowedModels): spread the + // defaults to extend them with a run-listing read. + allowedPaths: [...DEFAULT_ALLOWED_PATHS, { method: "GET", path: "/v1/runs" }], + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push(new Request(input as RequestInfo, init).url); + return new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch, + }); + const up = await mockProxy( + new Request("http://proof.local/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename: "x.png", mime_type: "image/png", size_bytes: 1 }), + }), + ); + if (up.status !== 200) throw new Error(`default asset-uploads rule failed: ${up.status}`); + const list = await mockProxy(new Request("http://proof.local/api/runflow/v1/runs?limit=1")); + if (list.status !== 200) throw new Error(`custom GET /v1/runs rule failed: ${list.status}`); + const refused = await mockProxy( + new Request("http://proof.local/api/runflow/v1/billing/balance"), + ); + if (refused.status !== 403) throw new Error(`expected 403 for billing, got ${refused.status}`); + if (seen.length !== 2) throw new Error(`expected exactly 2 upstream calls, saw ${seen.length}`); + + // A bare custom list REPLACES the defaults — forks can switch the + // asset routes off. + const optOutProxy = runflowProxy({ + apiKey, + basePath: "/api/runflow", + allowedPaths: [], + fetch: (async () => + new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + })) as typeof fetch, + }); + const optedOut = await optOutProxy( + new Request("http://proof.local/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + if (optedOut.status !== 403) { + throw new Error(`allowedPaths: [] should disable uploads, got ${optedOut.status}`); + } + + const liveListProxy = runflowProxy({ + apiKey, + basePath: "/api/runflow", + allowedPaths: [...DEFAULT_ALLOWED_PATHS, { method: "GET", path: "/v1/runs" }], + }); + const live = await liveListProxy(new Request("http://proof.local/api/runflow/v1/runs?limit=3")); + if (live.status !== 200) { + throw new Error(`live GET /v1/runs through the proxy: HTTP ${live.status}`); + } + const liveBody = (await live.json()) as { items?: unknown[] }; + if (!Array.isArray(liveBody.items)) { + throw new Error("live run listing: no items[] in response"); + } + await log(` live run listing through allowedPaths: ${liveBody.items.length} runs`); + + const elapsed = +((Date.now() - gateStart) / 1000).toFixed(1); + await log(` ✓ allow-list assertions passed in ${elapsed}s`); + summary.push({ + modality: "proxy-allowlist", + name: "allowedPaths gate", + ok: true, + elapsedSeconds: elapsed, + }); + } catch (err) { + const elapsed = +((Date.now() - gateStart) / 1000).toFixed(1); + const msg = err instanceof Error ? err.message : String(err); + await log(` ✗ FAILED in ${elapsed}s — ${msg}`); + summary.push({ + modality: "proxy-allowlist", + name: "allowedPaths gate", + ok: false, + elapsedSeconds: elapsed, + error: msg, + }); + } + await log(""); + // ── Package single-output (zalando) ───────────────────────────────── // Walks tag-removal → model-removal → background-color in sequence, // feeding each step's output into the next. Mirrors the @@ -273,25 +449,30 @@ async function main() { await log(""); // ── Mask + reference (runflow/reference-inpaint) ──────────────────── - // Mirrors the prototype's mask-ref flow: upload source + mask + ref to - // R2 (using the same Sig V4 path /demos/api/upload uses), then dispatch - // reference-inpaint with three URLs in the body. + // Uploads source + mask + reference as Runflow assets via + // rf.assets.upload (through the proxy's default allow-list), then + // dispatches reference-inpaint with the three signed URLs. await log("▶ mask + reference — reference-inpaint"); const maskStart = Date.now(); try { - const sessId = `e2e-${maskStart}`; const srcBytes = await fetchBytes(SOURCE_URL); const refBytes = await fetchBytes( // Second prototype sample, used as the reference image. "https://v3b.fal.media/files/b/0a991a67/wspXRxZt1H_09O0vv1_88_8c0fd4daa8444042b2df13df274e0f6a.jpg", ); const maskBytes = buildSampleMask(); - const [imageUrl, maskUrl, referenceUrl] = await Promise.all([ - uploadAndPresign(`demos/${sessId}/source.jpg`, srcBytes, "image/jpeg"), - uploadAndPresign(`demos/${sessId}/mask.png`, maskBytes, "image/png"), - uploadAndPresign(`demos/${sessId}/reference.jpg`, refBytes, "image/jpeg"), - ]); - await log(` uploaded source + mask + reference to R2`); + const [imageUrl, maskUrl, referenceUrl] = ( + await Promise.all([ + rf.assets.upload( + new File([new Uint8Array(srcBytes)], "source.jpg", { type: "image/jpeg" }), + ), + rf.assets.upload(new File([new Uint8Array(maskBytes)], "mask.png", { type: "image/png" })), + rf.assets.upload( + new File([new Uint8Array(refBytes)], "reference.jpg", { type: "image/jpeg" }), + ), + ]) + ).map((a) => a.url); + await log(" uploaded source + mask + reference via rf.assets.upload"); const dispatched = await rf.models.run("runflow/reference-inpaint", { input: { @@ -301,7 +482,10 @@ async function main() { prompt: "match the reference style in the masked area", }, }); - const final = await rf.runs.wait(dispatched.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const final = await rf.runs.wait(dispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const elapsed = +((Date.now() - maskStart) / 1000).toFixed(1); const outputUrl = extractImage(final.output); await log(` ✓ SUCCEEDED in ${elapsed}s — ${outputUrl}`); @@ -313,7 +497,12 @@ async function main() { modality: "mask-reference", name: "reference-inpaint", model: "runflow/reference-inpaint", - inputs: { image: imageUrl, mask: maskUrl, reference: referenceUrl, prompt: "match the reference style in the masked area" }, + inputs: { + image: imageUrl, + mask: maskUrl, + reference: referenceUrl, + prompt: "match the reference style in the masked area", + }, runId: dispatched.id, output: { image: outputUrl }, rawOutput: final.output, @@ -358,8 +547,14 @@ async function main() { try { const prep = await runChain(rf, SOURCE_URL, [ { model: "runflow/tag-removal", extra: {} }, - { model: "runflow/product-isolation", extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" } }, - { model: "runflow/background-color", extra: { color_red: 255, color_green: 255, color_blue: 255 } }, + { + model: "runflow/product-isolation", + extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" }, + }, + { + model: "runflow/background-color", + extra: { color_red: 255, color_green: 255, color_blue: 255 }, + }, ]); await log(` prep done → ${prep.outputUrl.slice(0, 80)}…`); @@ -375,7 +570,12 @@ async function main() { input: { image_url: prep.outputUrl, aspect_ratio: v.aspect_ratio, resolution: "2K" }, }); const r = await rf.runs.wait(d.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); - return { variant: v.id, ratio: v.aspect_ratio, runId: d.id, output: extractImage(r.output) }; + return { + variant: v.id, + ratio: v.aspect_ratio, + runId: d.id, + output: extractImage(r.output), + }; }), ); @@ -383,7 +583,7 @@ async function main() { await log(` ✓ SUCCEEDED in ${elapsed}s — 4 variants`); for (const v of variantRuns) await log(` · ${v.variant} (${v.ratio}): ${v.output}`); await writeFile( - resolve(PROOF_DIR, `package-fanout-omnichannel.json`), + resolve(PROOF_DIR, "package-fanout-omnichannel.json"), JSON.stringify( { ok: true, @@ -432,24 +632,36 @@ async function main() { "on a sun-warmed cobblestone street at golden hour, mid-stride pose, low three-quarter angle, soft long shadows, blurred city backdrop, editorial photoreal product photography, true colors and materials preserved"; const prep = await runChain(rf, SOURCE_URL, [ { model: "runflow/tag-removal", extra: {} }, - { model: "runflow/product-isolation", extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" } }, - { model: "runflow/background-color", extra: { color_red: 255, color_green: 255, color_blue: 255 } }, + { + model: "runflow/product-isolation", + extra: { aspect_ratio: "1:1", resolution: "2K", prompt: "the sneaker" }, + }, + { + model: "runflow/background-color", + extra: { color_red: 255, color_green: 255, color_blue: 255 }, + }, ]); - await log(` prep done`); + await log(" prep done"); const sceneDispatched = await rf.models.run("google/nano-banana-pro/edit", { input: { prompt: `Place the subject of this image ${direction}.`, image_urls: [prep.outputUrl], }, }); - const scene = await rf.runs.wait(sceneDispatched.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const scene = await rf.runs.wait(sceneDispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const sceneUrl = extractImage(scene.output); if (!sceneUrl) throw new Error("ai-scene returned no image"); - await log(` creative direction injected, scene generated`); + await log(" creative direction injected, scene generated"); const variantD = await rf.models.run("runflow/smart-resize", { input: { image_url: sceneUrl, aspect_ratio: "1:1", resolution: "2K" }, }); - const variant = await rf.runs.wait(variantD.id, { pollIntervalMs: 2_000, timeoutMs: 5 * 60_000 }); + const variant = await rf.runs.wait(variantD.id, { + pollIntervalMs: 2_000, + timeoutMs: 5 * 60_000, + }); const elapsed = +((Date.now() - cdStart) / 1000).toFixed(1); const finalUrl = extractImage(variant.output); await log(` ✓ SUCCEEDED in ${elapsed}s — ${finalUrl}`); @@ -505,16 +717,16 @@ async function main() { const chatStart = Date.now(); try { // Simulate the chat agent's plan: one ai-edit step with pin coords. - const planSteps = [{ workflow_id: "ai-edit", description: "Remove the price tag in the upper-left" }]; + const planSteps = [ + { workflow_id: "ai-edit", description: "Remove the price tag in the upper-left" }, + ]; const pin = { x: 0.25, y: 0.25 }; const instruction = "remove the price tag"; - // Build the ai-edit prompt exactly the way @runflow-io/studio's ai-edit - // tool does (positional words from normalized coords). - const yLabel = pin.y < 0.33 ? "upper" : pin.y < 0.66 ? "middle" : "lower"; - const xLabel = pin.x < 0.33 ? "left" : pin.x < 0.66 ? "center" : "right"; + // composePinPrompt is the exact helper @runflow-io/studio's ai-edit + // tool uses (positional words from normalized coords). const body = { input: { - prompt: `Edit the ${yLabel}-${xLabel} area of this image: ${instruction}. Photoreal product photography, preserve the rest of the image, true colors and lighting.`, + prompt: composePinPrompt(pin, instruction), image_urls: [SOURCE_URL], }, }; @@ -583,7 +795,11 @@ async function main() { ); await writeFile( resolve(PROOF_DIR, `sentinel-${verdict.evalId}.json`), - JSON.stringify({ ok: true, modality: "sentinel", ...verdict, elapsedSeconds: elapsed }, null, 2), + JSON.stringify( + { ok: true, modality: "sentinel", ...verdict, elapsedSeconds: elapsed }, + null, + 2, + ), ); summary.push({ modality: "sentinel", @@ -608,12 +824,16 @@ async function main() { await log(""); } } else { - await log("▶ sentinel — skipped (SENTINEL_API_KEY missing or no successful output to evaluate)"); + await log( + "▶ sentinel — skipped (SENTINEL_API_KEY missing or no successful output to evaluate)", + ); await log(""); } const ok = summary.every((s) => s.ok); - await log(`== summary: ${summary.filter((s) => s.ok).length}/${summary.length} modalities succeeded ==`); + await log( + `== summary: ${summary.filter((s) => s.ok).length}/${summary.length} modalities succeeded ==`, + ); await writeFile( resolve(PROOF_DIR, "summary.json"), JSON.stringify({ ok, summary, completedAt: new Date().toISOString() }, null, 2), @@ -634,7 +854,10 @@ async function runChain( const dispatched = await rf.models.run(step.model, { input: { image_url: current, ...step.extra }, }); - const final = await rf.runs.wait(dispatched.id, { pollIntervalMs: 2_000, timeoutMs: 3 * 60_000 }); + const final = await rf.runs.wait(dispatched.id, { + pollIntervalMs: 2_000, + timeoutMs: 3 * 60_000, + }); const url = extractImage(final.output); if (!url) throw new Error(`No output URL from ${step.model} run ${dispatched.id}`); current = url; @@ -648,7 +871,13 @@ async function runSentinel( apiKey: string, inputUrl: string, outputUrl: string, -): Promise<{ evalId: string; state: string; judgesPassed: number; judgesTotal: number; summary?: string }> { +): Promise<{ + evalId: string; + state: string; + judgesPassed: number; + judgesTotal: number; + summary?: string; +}> { const SENTINEL = "https://sentinel.runflow.io/api/v1"; const post = await fetch(`${SENTINEL}/evaluate?sync=false`, { method: "POST", @@ -661,11 +890,15 @@ async function runSentinel( generated_image_url: outputUrl, task_type: "product_photography", task_description: "Background removed and replaced with the requested backdrop.", - reference_images: [{ url: inputUrl, role: "reference_image", description: "Original source image" }], + reference_images: [ + { url: inputUrl, role: "reference_image", description: "Original source image" }, + ], }), }); if (!post.ok) { - throw new Error(`sentinel dispatch ${post.status}: ${await post.text().then((s) => s.slice(0, 200))}`); + throw new Error( + `sentinel dispatch ${post.status}: ${await post.text().then((s) => s.slice(0, 200))}`, + ); } const dispatched = (await post.json()) as { eval_id: string }; const evalId = dispatched.eval_id; diff --git a/examples/e2e-proof/uploads.ts b/examples/e2e-proof/uploads.ts deleted file mode 100644 index 879c9de..0000000 --- a/examples/e2e-proof/uploads.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * R2 upload helpers — used by the e2e proof to push the masks / - * references that file-modality runs need. - * - * This is a TypeScript port of the prototype's `lib/r2.mjs` (AWS Sig V4 - * S3-compatible upload + presign). The chain it exercises is the same - * one `/demos/api/upload` runs in production. - */ - -import { createHash, createHmac } from "node:crypto"; - -function endpoint(): string { - if (process.env.R2_ENDPOINT) return process.env.R2_ENDPOINT; - const accountId = process.env.R2_ACCOUNT_ID; - if (!accountId) return ""; - const raw = process.env.R2_JURISDICTION || "eu"; - const jur = raw && raw !== "default" ? `${raw}.` : ""; - return `https://${accountId}.${jur}r2.cloudflarestorage.com`; -} - -function bucket(): string { - return process.env.R2_BUCKET_NAME || process.env.R2_BUCKET || ""; -} - -const REGION = "auto"; - -function hmacSha256(key: Buffer | string, data: string): Buffer { - return createHmac("sha256", key).update(data).digest(); -} - -function sha256(data: Buffer | string): string { - return createHash("sha256").update(data).digest("hex"); -} - -function awsUriEncode(s: string): string { - return encodeURIComponent(s).replace( - /[!'()*]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, - ); -} - -function encodeKeyPath(key: string): string { - return key.split("/").map(awsUriEncode).join("/"); -} - -/** - * Upload a Buffer to R2 and return a 30-minute presigned GET URL. - * Mirrors the prototype's `/demos/api/upload` contract. - */ -export async function uploadAndPresign( - key: string, - body: Buffer, - contentType: string, - expiresInSeconds = 30 * 60, -): Promise { - const accessKey = process.env.R2_ACCESS_KEY_ID || ""; - const secretKey = process.env.R2_SECRET_ACCESS_KEY || ""; - const ENDPOINT = endpoint(); - const BUCKET = bucket(); - if (!accessKey || !secretKey || !ENDPOINT || !BUCKET) { - throw new Error("R2 not configured (missing R2_* env vars)."); - } - - const now = new Date(); - const dateStamp = `${now.toISOString().replace(/[-:]/g, "").split(".")[0]}Z`; - const shortDate = dateStamp.slice(0, 8); - const payloadHash = sha256(body); - const host = ENDPOINT.replace("https://", "").replace(`/${BUCKET}`, ""); - const path = `/${BUCKET}/${key}`; - - // PUT - const putHeaders: Record = { - host, - "x-amz-date": dateStamp, - "x-amz-content-sha256": payloadHash, - "content-type": contentType, - "content-length": String(body.length), - }; - const signedHeaderKeys = Object.keys(putHeaders).sort(); - const signedHeaders = signedHeaderKeys.join(";"); - const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${putHeaders[k]}\n`).join(""); - const canonicalRequest = ["PUT", path, "", canonicalHeaders, signedHeaders, payloadHash].join("\n"); - const scope = `${shortDate}/${REGION}/s3/aws4_request`; - const stringToSign = ["AWS4-HMAC-SHA256", dateStamp, scope, sha256(canonicalRequest)].join("\n"); - const kDate = hmacSha256(`AWS4${secretKey}`, shortDate); - const kRegion = hmacSha256(kDate, REGION); - const kService = hmacSha256(kRegion, "s3"); - const kSigning = hmacSha256(kService, "aws4_request"); - const signature = hmacSha256(kSigning, stringToSign).toString("hex"); - const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; - - const url = `${ENDPOINT.replace(`/${BUCKET}`, "")}${path}`; - const res = await fetch(url, { - method: "PUT", - headers: { ...putHeaders, authorization }, - body: new Uint8Array(body) as unknown as BodyInit, - }); - if (!res.ok) throw new Error(`R2 upload ${res.status}: ${await res.text().then((s) => s.slice(0, 200))}`); - - // Presign GET - const params: Array<[string, string]> = [ - ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"], - ["X-Amz-Credential", `${accessKey}/${scope}`], - ["X-Amz-Date", dateStamp], - ["X-Amz-Expires", String(expiresInSeconds)], - ["X-Amz-SignedHeaders", "host"], - ]; - params.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); - const canonicalQs = params.map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`).join("&"); - const ch = `host:${host}\n`; - const cr = ["GET", `/${BUCKET}/${encodeKeyPath(key)}`, canonicalQs, ch, "host", "UNSIGNED-PAYLOAD"].join("\n"); - const sts = ["AWS4-HMAC-SHA256", dateStamp, scope, sha256(cr)].join("\n"); - const sig = hmacSha256(kSigning, sts).toString("hex"); - return `${ENDPOINT.replace(`/${BUCKET}`, "")}/${BUCKET}/${encodeKeyPath(key)}?${canonicalQs}&X-Amz-Signature=${sig}`; -} - -/** - * Download `url` and return its bytes. Used to fetch sample images so we - * can re-upload them to R2 (`runflow/reference-inpaint` requires URLs - * served from a bucket the model worker can reach without auth headers). - */ -export async function fetchBytes(url: string): Promise { - const r = await fetch(url); - if (!r.ok) throw new Error(`fetch ${r.status} for ${url}`); - return Buffer.from(await r.arrayBuffer()); -} - -/** - * Build a 512×512 PNG mask: a white centered rectangle on black. Marks - * the central ~45% of the image as the inpaint region, leaving the - * borders preserved. - */ -export function buildSampleMask(): Buffer { - // Minimal PNG encoder for an 8-bit greyscale 512×512 image. Built - // inline to avoid a runtime dep — the proof script needs to stay - // self-contained. - const W = 512; - const H = 512; - const data = Buffer.alloc(H * (1 + W)); // each row: filter byte + W bytes - for (let y = 0; y < H; y++) { - data[y * (1 + W)] = 0; // filter: None - const inner = y > H * 0.27 && y < H * 0.72; - for (let x = 0; x < W; x++) { - const px = inner && x > W * 0.27 && x < W * 0.72 ? 0xff : 0x00; - data[y * (1 + W) + 1 + x] = px; - } - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(W, 0); - ihdr.writeUInt32BE(H, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 0; // grayscale - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - - const idatRaw = data; - const idat = zlibDeflate(idatRaw); - - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk("IHDR", ihdr), - chunk("IDAT", idat), - chunk("IEND", Buffer.alloc(0)), - ]); -} - -function chunk(type: string, data: Buffer): Buffer { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length, 0); - const typeBuf = Buffer.from(type, "ascii"); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); - return Buffer.concat([len, typeBuf, data, crc]); -} - -function crc32(buf: Buffer): number { - let c = 0xffffffff; - for (const b of buf) { - c ^= b; - for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); - } - return (c ^ 0xffffffff) >>> 0; -} - -import { deflateSync } from "node:zlib"; -function zlibDeflate(buf: Buffer): Buffer { - return deflateSync(buf); -} diff --git a/examples/e2e-proof/workflows.ts b/examples/e2e-proof/workflows.ts index ef92273..6369058 100644 --- a/examples/e2e-proof/workflows.ts +++ b/examples/e2e-proof/workflows.ts @@ -16,8 +16,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { Runflow, RunFailedError } from "@runflow-io/sdk"; import { runflowProxy } from "@runflow-io/proxy"; +import { RunFailedError, Runflow } from "@runflow-io/sdk"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROOF_DIR = resolve(__dirname, "../../.proof"); diff --git a/package.json b/package.json index f1e12e9..0053742 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,7 @@ "type": "git", "url": "https://github.com/runflow-io/runflow-js.git" }, - "workspaces": [ - "packages/*", - "examples/*" - ], + "workspaces": ["packages/*", "examples/*"], "scripts": { "build": "cd packages/sdk && bun run build && cd ../proxy && bun run build && cd ../studio && bun run build", "test": "bun run --filter='./packages/*' test", diff --git a/packages/proxy/README.md b/packages/proxy/README.md index 80619ab..23b831c 100644 --- a/packages/proxy/README.md +++ b/packages/proxy/README.md @@ -95,16 +95,52 @@ runflowProxy({ ## Path contract -The proxy accepts only these paths (configurable via `allowedModels`): +The proxy accepts these paths out of the box: -| Method | Path | Purpose | -|--------|-----------------------------------|------------------| -| POST | `/v1/models/{owner}/{slug…}/runs` | Dispatch a run. | -| GET | `/v1/runs/{uuid}` | Poll a run. | -| GET | `/v1/health` | Public health. | +| Method | Path | Purpose | +|--------|-----------------------------------------|----------------------------------| +| POST | `/v1/models/{owner}/{slug…}/runs` | Dispatch a run. | +| GET | `/v1/runs/{uuid}` | Poll a run. | +| GET | `/v1/health` | Public health. | +| POST | `/v1/asset-uploads` | Create a presigned upload. | +| POST | `/v1/asset-uploads/{id}/confirmations` | Confirm it (`rf.assets.upload`). | +| GET | `/v1/assets/{id}` | Re-sign an asset URL (`rf.assets.get`). | Everything else returns `403 Not allowed`. Run IDs are validated as -UUIDv4-shape to block path traversal. +UUIDv4-shape to block path traversal. Dispatch is additionally gated by +`allowedModels`. + +### Extending the allow-list: `allowedPaths` + +```ts +import { DEFAULT_ALLOWED_PATHS, runflowProxy } from "@runflow-io/proxy"; + +runflowProxy({ + apiKey: process.env.RUNFLOW_API_KEY!, + allowedPaths: [ + ...DEFAULT_ALLOWED_PATHS, // keep the rf.assets.upload/get routes + { method: "GET", path: "/v1/runs" }, // run listing + { method: "GET", path: "/v1/billing/balance" }, // billing read + ], +}); +``` + +Like `allowedModels`, a custom list **replaces** the defaults — spread +`DEFAULT_ALLOWED_PATHS` (exported) to extend them, as above, or pass +`[]` to turn the asset routes off entirely. Matching is strict — full +path, segment by segment, no prefixes or wildcards. A `:param` segment +matches exactly one non-empty segment and rejects traversal (`.`, `..`, +percent-encoded forms). `method` takes a string or an array +(`["GET", "DELETE"]`); the handler also exports `PUT`/`PATCH`/`DELETE` +for framework route files. Non-GET requests must send +`Content-Type: application/json` (CSRF gate) even when bodyless, and +upstream responses are fully buffered — avoid allowing large or binary +endpoints. + +> **Security:** every matched request is forwarded with **your** API +> key, so an allowed `GET /v1/runs` exposes org-wide run data to any +> same-origin browser session. Opt into reads deliberately and pair +> them with `authenticate` + `rateLimit` in production. ## License diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 42ea8be..5513a02 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -26,11 +26,7 @@ "require": "./dist/node.cjs" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/proxy/src/defaults.ts b/packages/proxy/src/defaults.ts index 0c9cf7c..b3799f9 100644 --- a/packages/proxy/src/defaults.ts +++ b/packages/proxy/src/defaults.ts @@ -23,6 +23,26 @@ export const DEFAULT_ALLOWED_MODELS: ReadonlyArray = [ "topaz/upscale/image", ]; +import type { AllowedPath } from "./types.js"; + +/** + * Extra upstream routes the proxy forwards out of the box: the + * presigned-upload pair `rf.assets.upload(file)` calls, plus the + * single-asset read `rf.assets.get(id)` uses to re-sign an expired + * asset URL. Like `allowedModels`, a customer-supplied `allowedPaths` + * REPLACES this list — spread `DEFAULT_ALLOWED_PATHS` to extend it, or + * pass `[]` to turn these routes off entirely. + * + * Deliberately NOT here: `GET /v1/runs` (org-wide run listing), + * billing reads, account info. Those expose org data through the + * proxy's API key and must be opted into explicitly. + */ +export const DEFAULT_ALLOWED_PATHS: ReadonlyArray = [ + { method: "POST", path: "/v1/asset-uploads" }, + { method: "POST", path: "/v1/asset-uploads/:id/confirmations" }, + { method: "GET", path: "/v1/assets/:id" }, +]; + export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const DEFAULT_RUNFLOW_BASE = "https://api.runflow.io"; diff --git a/packages/proxy/src/handler.ts b/packages/proxy/src/handler.ts index fd3f992..d5ccd5e 100644 --- a/packages/proxy/src/handler.ts +++ b/packages/proxy/src/handler.ts @@ -1,5 +1,6 @@ import { DEFAULT_ALLOWED_MODELS, + DEFAULT_ALLOWED_PATHS, DEFAULT_BASE_PATH, DEFAULT_MAX_BODY_BYTES, DEFAULT_RUNFLOW_BASE, @@ -7,6 +8,7 @@ import { UUID_RE, } from "./defaults.js"; import type { + AllowedPath, AuthResult, OnRunArgs, ProxyConfig, @@ -25,6 +27,7 @@ interface NormalizedConfig { upstreamTimeoutMs: number; fetcher: typeof fetch; allowedModelsFor: (auth: AuthResult | null) => ReadonlyArray; + allowedPaths: ReadonlyArray; allowedOrigins: ReadonlyArray | "same-origin" | false; requireJsonContentType: boolean; authenticate?: ProxyConfig["authenticate"]; @@ -39,7 +42,7 @@ function normalize(cfg: ProxyConfig): NormalizedConfig { } const allowed = cfg.allowedModels ?? DEFAULT_ALLOWED_MODELS; const allowedModelsFor = - typeof allowed === "function" ? allowed : (() => allowed as ReadonlyArray); + typeof allowed === "function" ? allowed : () => allowed as ReadonlyArray; return { apiKey: cfg.apiKey, basePath: stripTrailing(cfg.basePath ?? DEFAULT_BASE_PATH), @@ -48,6 +51,7 @@ function normalize(cfg: ProxyConfig): NormalizedConfig { upstreamTimeoutMs: cfg.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS, fetcher: cfg.fetch ?? globalThis.fetch, allowedModelsFor, + allowedPaths: cfg.allowedPaths ?? DEFAULT_ALLOWED_PATHS, allowedOrigins: cfg.allowedOrigins ?? "same-origin", requireJsonContentType: cfg.requireJsonContentType ?? true, authenticate: cfg.authenticate, @@ -67,10 +71,7 @@ function normalize(cfg: ProxyConfig): NormalizedConfig { * header. `false` opts out entirely (not recommended). An explicit * array of origins is matched case-insensitively on scheme + host + port. */ -function originAllowed( - req: Request, - policy: NormalizedConfig["allowedOrigins"], -): boolean { +function originAllowed(req: Request, policy: NormalizedConfig["allowedOrigins"]): boolean { if (policy === false) return true; const origin = req.headers.get("origin"); if (!origin) { @@ -123,10 +124,19 @@ function jsonContentTypeOK(req: Request): boolean { export function runflowProxy(cfg: ProxyConfig): ProxyHandler & { GET: ProxyHandler; POST: ProxyHandler; + PUT: ProxyHandler; + PATCH: ProxyHandler; + DELETE: ProxyHandler; } { const c = normalize(cfg); const handler: ProxyHandler = async (req) => handle(c, req); - return Object.assign(handler, { GET: handler, POST: handler }); + return Object.assign(handler, { + GET: handler, + POST: handler, + PUT: handler, + PATCH: handler, + DELETE: handler, + }); } async function handle(c: NormalizedConfig, req: Request): Promise { @@ -135,20 +145,46 @@ async function handle(c: NormalizedConfig, req: Request): Promise { const segments = upstreamPath.split("/").filter(Boolean); const { isDispatch, model, runId } = classify(req.method, segments); const isHealth = - req.method === "GET" && segments.length === 2 && segments[0] === "v1" && segments[1] === "health"; + req.method === "GET" && + segments.length === 2 && + segments[0] === "v1" && + segments[1] === "health"; - if (!isDispatch && !runId && !isHealth) { - return json({ error: "Not allowed" }, 403); + // Empty segments (`//`, trailing `/`) would make the matched path + // differ from the forwarded one — refuse to match them at all. + const hasEmptySegments = /\/\//.test(upstreamPath) || upstreamPath.endsWith("/"); + const isAllowedPath = + !isDispatch && + !runId && + !isHealth && + !hasEmptySegments && + matchAllowedPath(req.method, segments, c.allowedPaths); + + if (!isDispatch && !runId && !isHealth && !isAllowedPath) { + return json( + { + error: `Path not allowed: ${req.method} /${upstreamPath.slice(0, 120)}. The proxy forwards model dispatch, run polling, health, and the asset upload/read routes by default; add other upstream routes via the allowedPaths option.`, + code: "path_not_allowed", + }, + 403, + ); } // CSRF gate — must run before any authenticate hook so a malicious // page can't drain cookie credentials into the customer's API key. if (req.method !== "GET" && req.method !== "HEAD") { if (!originAllowed(req, c.allowedOrigins)) { - return json({ error: "Origin not allowed" }, 403); + return json({ error: "Origin not allowed", code: "origin_not_allowed" }, 403); } if (c.requireJsonContentType && !jsonContentTypeOK(req)) { - return json({ error: "Content-Type must be application/json" }, 415); + return json( + { + error: + "Content-Type must be application/json (CSRF defense — required on every non-GET request through the proxy, including bodyless DELETEs)", + code: "json_content_type_required", + }, + 415, + ); } } @@ -168,7 +204,10 @@ async function handle(c: NormalizedConfig, req: Request): Promise { if (isDispatch && model) { const allowed = c.allowedModelsFor(auth); if (!allowed.includes(model)) { - return json({ error: "Model not allowed" }, 403); + return json( + { error: `Model not allowed: ${model.slice(0, 120)}`, code: "model_not_allowed" }, + 403, + ); } } @@ -274,6 +313,53 @@ async function handle(c: NormalizedConfig, req: Request): Promise { }); } +/** + * Strict allow-list matcher for configured extra routes. Full-path, + * segment-by-segment comparison — no prefixes, no wildcards. A `:param` + * rule segment accepts exactly one non-empty path segment, rejecting + * `.`/`..` (and their percent-encoded forms) so a matched path can't + * traverse to a different upstream route. + */ +function matchAllowedPath( + method: string, + segments: string[], + rules: ReadonlyArray, +): boolean { + for (const rule of rules) { + const methods = Array.isArray(rule.method) ? rule.method : [rule.method]; + if (!methods.includes(method)) continue; + const ruleSegments = rule.path.split("/").filter(Boolean); + if (ruleSegments.length !== segments.length) continue; + let matched = true; + for (let i = 0; i < ruleSegments.length; i++) { + const ruleSegment = ruleSegments[i] ?? ""; + const pathSegment = segments[i] ?? ""; + if (ruleSegment.startsWith(":")) { + if (!isSafeParamSegment(pathSegment)) { + matched = false; + break; + } + } else if (ruleSegment !== pathSegment) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; +} + +function isSafeParamSegment(segment: string): boolean { + if (!segment) return false; + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + return false; + } + return decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\"); +} + function classify( method: string, segments: string[], @@ -339,7 +425,11 @@ async function readBoundedBody(req: Request, max: number): Promise { return new TextDecoder().decode(merged); } -function json(payload: unknown, status: number, extraHeaders: Record = {}): Response { +function json( + payload: unknown, + status: number, + extraHeaders: Record = {}, +): Response { return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json", ...extraHeaders }, diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index e4ceb3a..5746ba6 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -16,6 +16,8 @@ export { runflowProxy, type ProxyHandler } from "./handler.js"; export type { ProxyConfig, + AllowedPath, + AllowedPathMethod, AuthResult, AuthContext, ProxyRequestContext, @@ -26,6 +28,7 @@ export type { } from "./types.js"; export { DEFAULT_ALLOWED_MODELS, + DEFAULT_ALLOWED_PATHS, DEFAULT_BASE_PATH, DEFAULT_RUNFLOW_BASE, DEFAULT_MAX_BODY_BYTES, diff --git a/packages/proxy/src/node.ts b/packages/proxy/src/node.ts index 8238208..62e1e9f 100644 --- a/packages/proxy/src/node.ts +++ b/packages/proxy/src/node.ts @@ -65,7 +65,8 @@ function toHeaders(h: IncomingMessage["headers"]): Headers { async function collectBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + for await (const chunk of req) + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); return Buffer.concat(chunks); } diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index e57d487..8256ebe 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -23,7 +23,7 @@ export interface RateLimitDeniedResult { export interface RateLimitAllowedResult { status: 0; } -export type RateLimitResult = RateLimitDeniedResult | RateLimitAllowedResult | void; +export type RateLimitResult = RateLimitDeniedResult | RateLimitAllowedResult | undefined; export interface ProxyRequestContext { method: string; @@ -52,6 +52,25 @@ export interface OnRunArgs extends ProxyRequestContext { upstreamStatus: number; } +export type AllowedPathMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * One extra upstream route the proxy may forward beyond its built-ins + * (dispatch, run polling, health). + * + * `path` is matched against the upstream path (after `basePath` is + * stripped) segment by segment — full match only, no prefixes or + * wildcards. A `:param` segment matches exactly one non-empty segment; + * traversal segments (`.`, `..`, including percent-encoded forms) never + * match. + */ +export interface AllowedPath { + /** HTTP method(s) this rule applies to. */ + method: AllowedPathMethod | ReadonlyArray; + /** Upstream path pattern, e.g. `"/v1/asset-uploads/:id/confirmations"`. */ + path: string; +} + export interface ProxyConfig { /** Runflow API key — required. Sent as `Authorization: Bearer `. */ apiKey: string; @@ -63,6 +82,32 @@ export interface ProxyConfig { */ allowedModels?: ReadonlyArray | ((auth: AuthResult | null) => ReadonlyArray); + /** + * Extra upstream routes to forward beyond the always-on built-ins + * (dispatch, run polling, health). Like `allowedModels`, passing a + * list REPLACES the defaults (`DEFAULT_ALLOWED_PATHS`: the asset + * upload pair + `GET /v1/assets/:id`, what `rf.assets.upload`/`get` + * need). Spread the exported defaults to extend them: + * + * ```ts + * allowedPaths: [...DEFAULT_ALLOWED_PATHS, { method: "GET", path: "/v1/runs" }] + * ``` + * + * Pass `[]` to disable the asset routes entirely. + * + * SECURITY: every request that matches is forwarded with YOUR API key, + * so an allowed GET exposes that data to any same-origin browser + * session (the default upload routes included — pair the proxy with + * `authenticate` + `rateLimit` in production). Only allow reads like + * `GET /v1/runs` or `GET /v1/billing/balance` deliberately. + * + * Notes: upstream responses are fully buffered (no streaming) — avoid + * allowing large/binary endpoints; non-GET requests must send + * `Content-Type: application/json` (CSRF gate), including bodyless + * DELETE/PATCH/PUT. + */ + allowedPaths?: ReadonlyArray; + /** * The URL prefix the proxy is mounted at. Stripped from incoming * paths before forwarding. Default: `/api/runflow`. diff --git a/packages/proxy/tests/allowed-paths.test.ts b/packages/proxy/tests/allowed-paths.test.ts new file mode 100644 index 0000000..21d8eb7 --- /dev/null +++ b/packages/proxy/tests/allowed-paths.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_ALLOWED_PATHS, runflowProxy } from "../src/index.js"; +import type { ProxyConfig } from "../src/index.js"; + +function mockUpstream(handler: (req: Request) => Response | Promise): typeof fetch { + return ((input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input as string, init); + return Promise.resolve(handler(req)); + }) as typeof fetch; +} + +const KEY = "rf_live_test"; +const UUID = "11111111-2222-3333-4444-555555555555"; + +function spyProxy(extra?: Partial) { + const seen: Array<{ method: string; url: string; auth: string | null }> = []; + const proxy = runflowProxy({ + apiKey: KEY, + fetch: mockUpstream((req) => { + seen.push({ + method: req.method, + url: req.url, + auth: req.headers.get("authorization"), + }); + return new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }); + }), + ...extra, + }); + return { proxy, seen }; +} + +describe("allowedPaths — defaults (asset uploads)", () => { + it("forwards POST /v1/asset-uploads with bearer auth", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename: "a.png", mime_type: "image/png", size_bytes: 3 }), + }), + ); + expect(res.status).toBe(200); + expect(seen[0]?.auth).toBe(`Bearer ${KEY}`); + expect(seen[0]?.url).toBe("https://api.runflow.io/v1/asset-uploads"); + }); + + it("forwards GET /v1/assets/{id} (rf.assets.get re-signing)", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy(new Request(`http://app/api/runflow/v1/assets/${UUID}`)); + expect(res.status).toBe(200); + expect(seen[0]?.url).toBe(`https://api.runflow.io/v1/assets/${UUID}`); + }); + + it("rejects a trailing slash on allow-list matches", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy(new Request(`http://app/api/runflow/v1/assets/${UUID}/`)); + expect(res.status).toBe(403); + expect(seen.length).toBe(0); + }); + + it("rejects empty path segments so matching always equals forwarding", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy( + new Request("http://app/api/runflow/v1//asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + expect(seen.length).toBe(0); + }); + + it("403 body carries an actionable message + machine code", async () => { + const { proxy } = spyProxy(); + const res = await proxy(new Request("http://app/api/runflow/v1/secrets")); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string; code: string }; + expect(body.code).toBe("path_not_allowed"); + expect(body.error).toContain("GET /v1/secrets"); + expect(body.error).toContain("allowedPaths"); + }); + + it("forwards POST /v1/asset-uploads/{id}/confirmations", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy( + new Request(`http://app/api/runflow/v1/asset-uploads/${UUID}/confirmations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder_id: null }), + }), + ); + expect(res.status).toBe(200); + expect(seen[0]?.url).toBe(`https://api.runflow.io/v1/asset-uploads/${UUID}/confirmations`); + }); + + it("does NOT forward other methods or shapes on the same prefix", async () => { + const { proxy, seen } = spyProxy(); + for (const req of [ + new Request("http://app/api/runflow/v1/asset-uploads"), // GET + new Request("http://app/api/runflow/v1/asset-uploads/x/y/z", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + new Request(`http://app/api/runflow/v1/asset-uploads/${UUID}/confirmations/extra`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ]) { + const res = await proxy(req); + expect(res.status).toBe(403); + } + expect(seen.length).toBe(0); + }); + + it("rejects traversal in :param segments, including percent-encoded", async () => { + const { proxy, seen } = spyProxy(); + for (const id of ["..", ".", "%2e%2e", "%2E%2E", "a%2Fb", "a%5Cb"]) { + const res = await proxy( + new Request(`http://app/api/runflow/v1/asset-uploads/${id}/confirmations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(res.status, `id=${id}`).toBe(403); + } + expect(seen.length).toBe(0); + }); +}); + +describe("allowedPaths — custom rules", () => { + it("forwards an opted-in GET /v1/runs listing", async () => { + const { proxy, seen } = spyProxy({ allowedPaths: [{ method: "GET", path: "/v1/runs" }] }); + const res = await proxy(new Request("http://app/api/runflow/v1/runs?limit=5")); + expect(res.status).toBe(200); + expect(seen[0]?.url).toBe("https://api.runflow.io/v1/runs?limit=5"); + }); + + it("still 403s routes outside the configured set", async () => { + const { proxy } = spyProxy({ allowedPaths: [{ method: "GET", path: "/v1/runs" }] }); + expect((await proxy(new Request("http://app/api/runflow/v1/billing/balance"))).status).toBe( + 403, + ); + expect((await proxy(new Request("http://app/api/runflow/v1/api-keys"))).status).toBe(403); + expect( + (await proxy(new Request("http://app/api/runflow/v1/runs", { method: "DELETE" }))).status, + ).toBe(403); + }); + + it("accepts method arrays and :param patterns", async () => { + const { proxy, seen } = spyProxy({ + allowedPaths: [{ method: ["GET", "DELETE"], path: "/v1/assets/:id" }], + }); + expect((await proxy(new Request(`http://app/api/runflow/v1/assets/${UUID}`))).status).toBe(200); + const del = await proxy( + new Request(`http://app/api/runflow/v1/assets/${UUID}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }), + ); + expect(del.status).toBe(200); + expect(seen.map((s) => s.method)).toEqual(["GET", "DELETE"]); + }); + + it("custom rules REPLACE the defaults (same semantics as allowedModels)", async () => { + const { proxy } = spyProxy({ allowedPaths: [{ method: "GET", path: "/v1/runs" }] }); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + }); + + it("spreading DEFAULT_ALLOWED_PATHS extends instead of replacing", async () => { + const { proxy } = spyProxy({ + allowedPaths: [...DEFAULT_ALLOWED_PATHS, { method: "GET", path: "/v1/runs" }], + }); + const upload = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(upload.status).toBe(200); + const list = await proxy(new Request("http://app/api/runflow/v1/runs?limit=1")); + expect(list.status).toBe(200); + }); + + it("allowedPaths: [] disables the default asset routes entirely", async () => { + const { proxy, seen } = spyProxy({ allowedPaths: [] }); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + // Built-ins are unaffected by the path allow-list. + const health = await proxy(new Request("http://app/api/runflow/v1/health")); + expect(health.status).toBe(200); + expect(seen.length).toBe(1); + }); +}); + +describe("allowedPaths — existing gates still apply", () => { + it("CSRF origin check still rejects cross-origin POSTs to allowed paths", async () => { + const { proxy, seen } = spyProxy(); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://evil.example", + Host: "app", + }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + expect(seen.length).toBe(0); + }); + + it("authenticate hook still gates allowed paths", async () => { + const { proxy } = spyProxy({ authenticate: () => null }); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(res.status).toBe(401); + }); + + it("content-type gate still applies to allowed POSTs", async () => { + const { proxy } = spyProxy(); + const res = await proxy( + new Request("http://app/api/runflow/v1/asset-uploads", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "{}", + }), + ); + expect(res.status).toBe(415); + }); +}); diff --git a/packages/proxy/tests/handler.test.ts b/packages/proxy/tests/handler.test.ts index 5f47949..e8bb624 100644 --- a/packages/proxy/tests/handler.test.ts +++ b/packages/proxy/tests/handler.test.ts @@ -55,11 +55,17 @@ describe("runflowProxy — routing", () => { }), }); const res = await proxy( - new Request("http://app/api/runflow/v1/models/runfl0w/background-removal/runs".replace("runfl0w", "runflow"), { - method: "POST", - body: JSON.stringify({ input: { image_url: "https://cdn/x.png" } }), - headers: { "Content-Type": "application/json" }, - }), + new Request( + "http://app/api/runflow/v1/models/runfl0w/background-removal/runs".replace( + "runfl0w", + "runflow", + ), + { + method: "POST", + body: JSON.stringify({ input: { image_url: "https://cdn/x.png" } }), + headers: { "Content-Type": "application/json" }, + }, + ), ); expect(res.status).toBe(200); expect(seenAuth).toBe(`Bearer ${KEY}`); @@ -73,9 +79,15 @@ describe("runflowProxy — routing", () => { apiKey: KEY, fetch: mockUpstream( (req) => - new Response(JSON.stringify({ id: new URL(req.url).pathname.split("/").pop(), status_code: "succeeded" }), { - headers: { "Content-Type": "application/json" }, - }), + new Response( + JSON.stringify({ + id: new URL(req.url).pathname.split("/").pop(), + status_code: "succeeded", + }), + { + headers: { "Content-Type": "application/json" }, + }, + ), ), }); const goodUuid = "11111111-2222-3333-4444-555555555555"; diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c36912d..e30df6b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,6 +34,44 @@ const rf = new Runflow({ baseUrl: "/api/runflow" }); The browser SDK never sees your API key — it's injected by `@runflow-io/proxy` on your server. +### Uploading files + +```ts +const asset = await rf.assets.upload(fileInput.files[0]); +await rf.models.run("google/nano-banana-pro/edit", { + input: { prompt: "remove the price tag", image_urls: [asset.url] }, +}); +``` + +`upload()` runs the platform's presigned flow (create session → PUT the +bytes to storage → confirm), retrying transient failures, and returns +`{ id, url, ref, ... }`. `url` is a short-TTL **signed HTTPS URL** — +pass it straight to any model's media inputs, but don't persist it: +store `id` (or the stable `runflow://assets/{id}` `ref`) and re-mint a +fresh url with `rf.assets.get(id)` when needed. Works in the browser +through `@runflow-io/proxy` (the upload + asset-read endpoints are on +its default allow-list). Raw `Blob`s need `{ filename }`; the cap is +50 MB. + +### Pin-based editing + +Edit models like `google/nano-banana-pro/edit` have no `pin_x`/`pin_y` +inputs — the convention is a region phrase baked into the prompt. +`composePinPrompt` is that convention, shared with the Studio shell: + +```ts +import { composePinPrompt } from "@runflow-io/sdk"; + +await rf.models.run("google/nano-banana-pro/edit", { + input: { + // {x,y} are normalized 0..1; thirds map to upper|middle|lower × + // left|center|right ("upper-left" … "lower-right"). + prompt: composePinPrompt({ x: 0.25, y: 0.2 }, "remove the price tag"), + image_urls: [sourceUrl], + }, +}); +``` + ## Tools Declarative model bindings with typed inputs, presets, and outputs: @@ -109,8 +147,14 @@ information for `buildRequest` and the run helpers. - `runflow.models.run(model, body)` — dispatch a run. Model id segments are URL-encoded; `..`/empty segments are rejected. - `runflow.runs.get(id)` / `runflow.runs.poll(id)` / `runflow.runs.wait(id)` +- `runflow.assets.upload(file, opts?)` — presigned upload (with retry); + returns a signed https `url` + stable `runflow://` `ref`. +- `runflow.assets.get(id)` — re-fetch an asset with a freshly signed `url`. - `runflow.tools.run(tool, args)` / `runflow.tools.dispatch(tool, args)` - `runflow.health.check()` +- `pinRegion(pin)` / `composeRegionPrompt(region, instruction)` / + `composePinPrompt(pin, instruction)` — the shared pin→region prompt + contract. All return well-typed promises; errors are `RunflowError`, `RunFailedError`, or `RunTimeoutError`. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 943d8ef..fdf6d27 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -21,11 +21,7 @@ "require": "./dist/index.cjs" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/sdk/src/assets.ts b/packages/sdk/src/assets.ts new file mode 100644 index 0000000..91e723e --- /dev/null +++ b/packages/sdk/src/assets.ts @@ -0,0 +1,265 @@ +import type { Runflow } from "./client.js"; +import { RunflowError } from "./errors.js"; + +/** + * Pre-flight mirror of the backend's asset-upload cap (the backend stays + * authoritative — it re-validates `size_bytes` at session create). + */ +const MAX_UPLOAD_BYTES = 52_428_800; // 50 MB + +/** + * Floor for the storage PUT timeout. The effective default scales with + * file size (assumes a ~2 Mbit/s uplink floor) so a 50 MB file on a slow + * connection isn't doomed by a fixed cap. + */ +const MIN_UPLOAD_TIMEOUT_MS = 120_000; +const UPLOAD_BYTES_PER_SECOND_FLOOR = 256 * 1024; // ~2 Mbit/s + +/** + * Transient-failure retry schedule, mirroring the studio shell's upload + * path: a flaky network mid-flow (e.g. after the user painted a mask) + * shouldn't lose the work. Applies to network errors, timeouts, and 5xx. + */ +const RETRY_DELAYS_MS = [250, 750]; + +/** A confirmed, ready-to-use uploaded asset. */ +export interface UploadedAsset { + /** Asset id (uuid). The durable identifier — store THIS, not `url`. */ + id: string; + /** + * Short-TTL signed HTTPS URL for the file. Pass it directly to model + * inputs (`image_url`, `image_urls`, `mask_url`, …) right away. Do not + * persist it — it expires; re-mint a fresh one with `rf.assets.get(id)` + * (allowed through the proxy by default). + */ + url: string; + /** + * Canonical stable reference (`runflow://assets/{id}`). Accepted today by + * ComfyUI workflow file inputs; once the API resolves asset refs at model + * dispatch (RUN-418), prefer this over the expiring `url`. + */ + ref: string; + /** Original filename. */ + name: string; + mimeType: string; + sizeBytes: number; + thumbnailUrl: string | null; + createdAt: string | null; +} + +export interface UploadOptions { + /** Required when uploading a raw `Blob`; defaults to `file.name` for `File`s. */ + filename?: string; + /** Optional asset-library folder to file the upload under. */ + folderId?: string; + /** + * Timeout for the storage PUT step only (the JSON steps use the + * client's `requestTimeoutMs`). Default: scales with file size, + * minimum 120 s. + */ + timeoutMs?: number; + signal?: AbortSignal; +} + +interface UploadSession { + asset_id: string; + upload_url: string; +} + +/** + * Raw asset shape from the API (see + * docs/plans/run-384-sdk-gaps/backend-contract.md for the contract this + * mirrors — `Asset.FullValidator` with `url` signed on confirm/get). + */ +interface RawAsset { + id: string; + name: string; + url: string; + thumbnail_url?: string | null; + mime_type: string; + size_bytes: number; + created_at?: string | null; +} + +function isTransient(err: unknown): boolean { + if (!(err instanceof RunflowError)) return false; + if (err.code === "network_error" || err.code === "request_timeout") return true; + return (err.status ?? 0) >= 500; +} + +function abortError(): RunflowError { + return new RunflowError("assets: aborted by signal", { code: "aborted" }); +} + +/** Sleep that wakes up immediately when the signal aborts. */ +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + } + signal?.addEventListener("abort", done, { once: true }); + }); +} + +async function withRetry(fn: () => Promise, signal?: AbortSignal): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { + if (attempt > 0) { + if (signal?.aborted) throw abortError(); + await abortableSleep(RETRY_DELAYS_MS[attempt - 1] ?? 0, signal); + if (signal?.aborted) throw abortError(); + } + try { + return await fn(); + } catch (err) { + lastErr = err; + if (!isTransient(err) || signal?.aborted) throw err; + } + } + throw lastErr; +} + +function mapAsset(asset: RawAsset): UploadedAsset { + return { + id: asset.id, + url: asset.url, + ref: `runflow://assets/${asset.id}`, + name: asset.name, + mimeType: asset.mime_type, + sizeBytes: asset.size_bytes, + thumbnailUrl: asset.thumbnail_url ?? null, + createdAt: asset.created_at ?? null, + }; +} + +/** + * Asset uploads — the browser-safe path for getting a local file in front + * of a model. + * + * Mirrors the Runflow platform's own upload flow: + * 1. `POST /v1/asset-uploads` — create a presigned upload session + * 2. `PUT ` — send the bytes straight to storage (no auth) + * 3. `POST /v1/asset-uploads/{id}/confirmations` — verify + create the asset + * + * Steps 1 and 3 go through the configured base (so they work through + * `@runflow-io/proxy`, which allows them by default); step 2 goes directly + * to the storage host using the presigned URL. Every step retries + * transient failures (network/timeout/5xx) twice with backoff. + * + * @example Browser, through a proxy + * ```ts + * const rf = new Runflow({ baseUrl: "/api/runflow" }); + * const asset = await rf.assets.upload(fileInput.files[0]); + * await rf.models.run("google/nano-banana-pro/edit", { + * input: { prompt, image_urls: [asset.url] }, + * }); + * // Later, if you stored asset.id and the signed url expired: + * const fresh = await rf.assets.get(asset.id); + * ``` + */ +export class AssetsResource { + constructor(private readonly client: Runflow) {} + + async upload(file: File | Blob, opts: UploadOptions = {}): Promise { + const filename = + opts.filename ?? + (typeof File !== "undefined" && file instanceof File ? file.name : undefined); + if (!filename) { + throw new RunflowError("assets.upload: pass `filename` when uploading a raw Blob", { + code: "missing_filename", + }); + } + if (file.size > MAX_UPLOAD_BYTES) { + throw new RunflowError( + `assets.upload: file is ${file.size} bytes; the limit is ${MAX_UPLOAD_BYTES} (50 MB)`, + { code: "file_too_large" }, + ); + } + const mimeType = file.type || "application/octet-stream"; + + // 1. Create the presigned upload session. + const session = await withRetry( + () => + this.client.request("POST", "/v1/asset-uploads", { + body: { filename, mime_type: mimeType, size_bytes: file.size }, + signal: opts.signal, + }), + opts.signal, + ); + if (!session?.asset_id || !session.upload_url) { + throw new RunflowError("assets.upload: upload session response missing asset_id/upload_url", { + code: "bad_upload_session", + }); + } + if (!session.upload_url.startsWith("https://")) { + throw new RunflowError("assets.upload: refusing non-https upload_url from the API", { + code: "insecure_upload_url", + }); + } + + // 2. PUT the bytes to storage. Presigned URL — no auth header, and it + // must NOT go through the API base, so this bypasses request(). The + // presigned URL stays valid across retries. + const putTimeoutMs = + opts.timeoutMs ?? + Math.max(MIN_UPLOAD_TIMEOUT_MS, Math.ceil(file.size / UPLOAD_BYTES_PER_SECOND_FLOOR) * 1000); + await withRetry(async () => { + const putRes = await this.client.rawFetch( + session.upload_url, + { method: "PUT", headers: { "Content-Type": mimeType }, body: file, signal: opts.signal }, + putTimeoutMs, + ); + if (!putRes.ok) { + const text = await putRes.text().catch(() => ""); + throw new RunflowError( + `assets.upload: storage PUT failed (HTTP ${putRes.status})${text ? `: ${text.slice(0, 200)}` : ""}`, + { status: putRes.status, code: "storage_put_failed" }, + ); + } + }, opts.signal); + + // 3. Confirm — the backend HEADs the object, creates the asset record, + // and returns it with `url` already signed for GET. + const asset = await withRetry( + () => + this.client.request( + "POST", + `/v1/asset-uploads/${encodeURIComponent(session.asset_id)}/confirmations`, + { body: { folder_id: opts.folderId ?? null }, signal: opts.signal }, + ), + opts.signal, + ); + if (!asset?.id || !asset.url) { + throw new RunflowError("assets.upload: confirmation response missing id/url", { + code: "bad_upload_confirmation", + }); + } + + return mapAsset(asset); + } + + /** + * Fetch an asset by id with a freshly signed `url`. Use this instead of + * persisting `UploadedAsset.url`, which expires. Allowed through + * `@runflow-io/proxy` by default (`GET /v1/assets/:id`). + */ + async get(id: string, opts: { signal?: AbortSignal } = {}): Promise { + if (!id || id.includes("/") || id === "." || id === "..") { + throw new RunflowError(`assets.get: invalid asset id ${JSON.stringify(id)}`, { + code: "invalid_asset_id", + }); + } + const asset = await this.client.request( + "GET", + `/v1/assets/${encodeURIComponent(id)}`, + { signal: opts.signal }, + ); + if (!asset?.id || !asset.url) { + throw new RunflowError("assets.get: response missing id/url", { code: "bad_asset_response" }); + } + return mapAsset(asset); + } +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 106e405..a31f501 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,7 +1,8 @@ +import { AssetsResource } from "./assets.js"; import { RunflowError } from "./errors.js"; -import type { Run, RunDispatched, RunflowConfig, WaitOptions } from "./types.js"; import { RunFailedError, RunTimeoutError } from "./errors.js"; import { ToolsResource } from "./tools/run.js"; +import type { Run, RunDispatched, RunflowConfig, WaitOptions } from "./types.js"; const DEFAULT_API_BASE = "https://api.runflow.io"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -19,6 +20,7 @@ export class Runflow { readonly runs: RunsResource; readonly health: HealthResource; readonly tools: ToolsResource; + readonly assets: AssetsResource; constructor(config: RunflowConfig) { if (!config.apiKey && !config.baseUrl) { @@ -32,7 +34,10 @@ export class Runflow { } this.fetcher = config.fetch ?? globalThis.fetch; this.base = stripTrailing(config.baseUrl ?? config.apiBase ?? DEFAULT_API_BASE); - this.authHeader = config.apiKey ? `Bearer ${config.apiKey}` : undefined; + // In proxy mode (baseUrl) the proxy injects the key server-side — the + // browser-side client must never send one, even if a caller passes + // both. Matches the documented "baseUrl wins, bearer omitted" contract. + this.authHeader = config.apiKey && !config.baseUrl ? `Bearer ${config.apiKey}` : undefined; this.extraHeaders = { ...config.headers }; this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; @@ -40,6 +45,53 @@ export class Runflow { this.runs = new RunsResource(this); this.health = new HealthResource(this); this.tools = new ToolsResource(this); + this.assets = new AssetsResource(this); + } + + /** + * Fetch an absolute URL through the configured fetcher — no base-URL + * joining, no auth header. Used for presigned storage PUTs, which are + * authorized by the URL itself and must not leak the API key. + * @internal + */ + async rawFetch( + url: string, + init: { + method?: string; + headers?: Record; + body?: BodyInit; + signal?: AbortSignal; + }, + timeoutMs: number = this.requestTimeoutMs, + ): Promise { + const controller = mergeAbort(init.signal, timeoutMs); + try { + return await this.fetcher(url, { + method: init.method ?? "GET", + headers: init.headers, + body: init.body, + signal: controller.signal, + }); + } catch (err) { + // Presigned URLs carry bearer-like signatures in the query string — + // never echo them into error messages. + const redacted = redactUrl(url); + if (controller.timedOut()) { + throw new RunflowError( + `Request timed out (${timeoutMs}ms): ${init.method ?? "GET"} ${redacted}`, + { + code: "request_timeout", + cause: err, + }, + ); + } + throw new RunflowError(`Request failed: ${init.method ?? "GET"} ${redacted}`, { + code: "network_error", + cause: err, + }); + } finally { + controller.clear(); + } } /** @internal */ @@ -72,10 +124,13 @@ export class Runflow { res = await this.fetcher(url, { method, headers, body, signal: controller.signal }); } catch (err) { if (controller.timedOut()) { - throw new RunflowError(`Request timed out (${this.requestTimeoutMs}ms): ${method} ${path}`, { - code: "request_timeout", - cause: err, - }); + throw new RunflowError( + `Request timed out (${this.requestTimeoutMs}ms): ${method} ${path}`, + { + code: "request_timeout", + cause: err, + }, + ); } throw new RunflowError(`Request failed: ${method} ${path}`, { code: "network_error", @@ -87,16 +142,29 @@ export class Runflow { if (!res.ok) { const text = await res.text().catch(() => ""); - let parsed: { error?: { message?: string; code?: string }; message?: string } | null = null; + // Two error body shapes flow through here: the API's nested + // { error: { message, code } } and the proxy's flat + // { error: string, code: string }. + let parsed: { + error?: { message?: string; code?: string } | string; + message?: string; + code?: string; + } | null = null; try { parsed = text ? JSON.parse(text) : null; } catch { // body wasn't JSON } - const msg = parsed?.error?.message ?? parsed?.message ?? text.slice(0, 300) ?? res.statusText; + const errField = parsed?.error; + const msg = + (typeof errField === "string" ? errField : errField?.message) ?? + parsed?.message ?? + text.slice(0, 300) ?? + res.statusText; + const code = (typeof errField === "object" ? errField?.code : undefined) ?? parsed?.code; throw new RunflowError(`HTTP ${res.status}: ${msg || "request failed"}`, { status: res.status, - code: parsed?.error?.code, + code, }); } @@ -166,7 +234,11 @@ export class RunsResource { } opts.onPoll?.(run); yield run; - if (run.status_code === "succeeded" || run.status_code === "failed" || run.status_code === "canceled") { + if ( + run.status_code === "succeeded" || + run.status_code === "failed" || + run.status_code === "canceled" + ) { return; } await sleep(interval); @@ -189,10 +261,11 @@ export class RunsResource { throw new RunflowError(`Run ${id} produced no status updates`, { code: "no_status" }); } if (last.status_code === "failed" || last.status_code === "canceled") { - throw new RunFailedError( - last.error?.message ?? `Run ${id} ${last.status_code}`, - { id: last.id, status: last.status_code, error: last.error }, - ); + throw new RunFailedError(last.error?.message ?? `Run ${id} ${last.status_code}`, { + id: last.id, + status: last.status_code, + error: last.error, + }); } return last; } @@ -210,6 +283,12 @@ function stripTrailing(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } +/** Drop the query string (where presigned-URL signatures live). */ +function redactUrl(url: string): string { + const q = url.indexOf("?"); + return q === -1 ? url : `${url.slice(0, q)}?…`; +} + /** * Validate + percent-encode a multi-segment model id (`owner/slug`, * `owner/slug/subroute`). Each segment must be non-empty and not `.` @@ -223,9 +302,12 @@ function encodeModelId(model: string): string { const parts = model.split("/"); for (const p of parts) { if (p === "" || p === "." || p === "..") { - throw new RunflowError(`models.run: invalid model id segment ${JSON.stringify(p)} in ${JSON.stringify(model)}`, { - code: "invalid_model_id", - }); + throw new RunflowError( + `models.run: invalid model id segment ${JSON.stringify(p)} in ${JSON.stringify(model)}`, + { + code: "invalid_model_id", + }, + ); } } return parts.map(encodeURIComponent).join("/"); diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3d6b619..fb2aa65 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,7 +1,32 @@ +/** + * Known SDK error codes (open union — backend/proxy codes pass through + * verbatim, so any string remains assignable). + */ +export type RunflowErrorCode = + | "missing_config" + | "invalid_api_key" + | "request_timeout" + | "network_error" + | "missing_filename" + | "file_too_large" + | "bad_upload_session" + | "insecure_upload_url" + | "storage_put_failed" + | "bad_upload_confirmation" + | "invalid_asset_id" + | "bad_asset_response" + | "invalid_model_id" + | "invalid_run_id" + | "aborted" + | "no_status" + | "run_failed" + | "run_timeout" + | (string & {}); + export class RunflowError extends Error { constructor( message: string, - readonly opts: { status?: number; code?: string; cause?: unknown } = {}, + readonly opts: { status?: number; code?: RunflowErrorCode; cause?: unknown } = {}, ) { super(message); this.name = "RunflowError"; @@ -12,7 +37,7 @@ export class RunflowError extends Error { get status(): number | undefined { return this.opts.status; } - get code(): string | undefined { + get code(): RunflowErrorCode | undefined { return this.opts.code; } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c3d9000..81879d1 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -19,11 +19,14 @@ */ export { Runflow, ModelsResource, RunsResource, HealthResource } from "./client.js"; +export { AssetsResource } from "./assets.js"; +export type { UploadedAsset, UploadOptions } from "./assets.js"; export { RunflowError, RunFailedError, RunTimeoutError, } from "./errors.js"; +export type { RunflowErrorCode } from "./errors.js"; export type { Run, RunDispatched, diff --git a/packages/sdk/src/tools/define.ts b/packages/sdk/src/tools/define.ts index 51e5e3b..524844b 100644 --- a/packages/sdk/src/tools/define.ts +++ b/packages/sdk/src/tools/define.ts @@ -78,10 +78,9 @@ export interface ToolDef< * }), * }); */ -export function defineTool< - I extends Record, - O extends Record, ->(def: ToolDef): ToolDef { +export function defineTool, O extends Record>( + def: ToolDef, +): ToolDef { return def; } diff --git a/packages/sdk/src/tools/index.ts b/packages/sdk/src/tools/index.ts index 5d56433..edd8b15 100644 --- a/packages/sdk/src/tools/index.ts +++ b/packages/sdk/src/tools/index.ts @@ -1,4 +1,5 @@ export * from "./inputs.js"; export * from "./outputs.js"; export * from "./define.js"; +export * from "./pin.js"; export * from "./run.js"; diff --git a/packages/sdk/src/tools/inputs.ts b/packages/sdk/src/tools/inputs.ts index f8470f2..cab20fa 100644 --- a/packages/sdk/src/tools/inputs.ts +++ b/packages/sdk/src/tools/inputs.ts @@ -110,8 +110,8 @@ export type RuntimeInputValues> = : T[K]["optional"] extends true ? never : K]: InputValue; - } & // optional: source !== "preset" AND optional === true - { + } & { + // optional: source !== "preset" AND optional === true [K in keyof T as T[K]["source"] extends "preset" ? never : T[K]["optional"] extends true diff --git a/packages/sdk/src/tools/pin.ts b/packages/sdk/src/tools/pin.ts new file mode 100644 index 0000000..b895e21 --- /dev/null +++ b/packages/sdk/src/tools/pin.ts @@ -0,0 +1,63 @@ +/** + * Pin → prompt helpers. + * + * `google/nano-banana-pro/edit` (and the other prompt-driven edit models) + * have no `pin_x` / `pin_y` input fields. The convention — used by the + * Studio shell's ai-edit tool and expected by the model — is to name the + * region in the prompt text: a 3×3 grid of `upper|middle|lower` × + * `left|center|right` phrases derived from normalized pin coordinates. + * + * These helpers are the single public implementation of that convention, + * shared by `@runflow-io/studio` and external forks, so pin-based editing + * produces the exact same dispatch body everywhere. + */ + +/** A pin location in normalized image coordinates (0..1 on each axis). */ +export interface PinPoint { + /** Horizontal position: 0 = left edge, 1 = right edge. */ + x: number; + /** Vertical position: 0 = top edge, 1 = bottom edge. */ + y: number; +} + +/** + * Map a normalized pin to its region phrase on the 3×3 grid. + * + * Thirds are split at 0.33 and 0.66 — an exact 0.33 falls into the + * middle/center band, an exact 0.66 into the lower/right band: + * `{x: 0.1, y: 0.1}` → `"upper-left"`, + * `{x: 0.5, y: 0.5}` → `"middle-center"`, `{x: 0.9, y: 0.9}` → + * `"lower-right"`. + */ +export function pinRegion(pin: PinPoint): string { + const yLabel = pin.y < 0.33 ? "upper" : pin.y < 0.66 ? "middle" : "lower"; + const xLabel = pin.x < 0.33 ? "left" : pin.x < 0.66 ? "center" : "right"; + return `${yLabel}-${xLabel}`; +} + +/** + * Compose the full edit prompt for a named region. This is the exact + * template the Studio shell dispatches — kept verbatim so shells, forks, + * and the e2e proof share one contract. + */ +export function composeRegionPrompt(region: string, instruction: string): string { + return `Edit the ${region} area of this image: ${instruction}. Photoreal product photography, preserve the rest of the image, true colors and lighting.`; +} + +/** + * Compose the full edit prompt for a pin location. + * + * @example + * ```ts + * const body = { + * input: { + * prompt: composePinPrompt({ x: 0.25, y: 0.25 }, "remove the price tag"), + * image_urls: [sourceUrl], + * }, + * }; + * await rf.models.run("google/nano-banana-pro/edit", body); + * ``` + */ +export function composePinPrompt(pin: PinPoint, instruction: string): string { + return composeRegionPrompt(pinRegion(pin), instruction); +} diff --git a/packages/sdk/src/tools/run.ts b/packages/sdk/src/tools/run.ts index b0f55b9..d0629ea 100644 --- a/packages/sdk/src/tools/run.ts +++ b/packages/sdk/src/tools/run.ts @@ -56,12 +56,14 @@ export class ToolsResource { const dispatched = await this.client.models.run(tool.model, enriched, { signal: opts.signal }); const final = await this.client.runs.wait(dispatched.id, opts); if (final.status_code !== "succeeded") { - throw new RunFailedError( - final.error?.message ?? `Run ${final.id} ${final.status_code}`, - { id: final.id, status: final.status_code, error: final.error }, - ); + throw new RunFailedError(final.error?.message ?? `Run ${final.id} ${final.status_code}`, { + id: final.id, + status: final.status_code, + error: final.error, + }); } - const extractor = tool.extractOutput ?? (defaultExtract as unknown as (raw: unknown) => OutputValues); + const extractor = + tool.extractOutput ?? (defaultExtract as unknown as (raw: unknown) => OutputValues); const extracted = extractor(final.output); return { runId: final.id, status: "succeeded", output: extracted, raw: final }; } diff --git a/packages/sdk/tests/assets.test.ts b/packages/sdk/tests/assets.test.ts new file mode 100644 index 0000000..136f715 --- /dev/null +++ b/packages/sdk/tests/assets.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "vitest"; +import { Runflow, RunflowError } from "../src/index.js"; + +function mockFetch(handler: (req: Request) => Response | Promise): typeof fetch { + return ((input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input as string, init); + return Promise.resolve(handler(req)); + }) as typeof fetch; +} + +const ASSET_ID = "11111111-2222-3333-4444-555555555555"; +const SIGNED_URL = `https://storage.example/org/assets/${ASSET_ID}/photo.png?X-Amz-Signature=abc`; + +function uploadBackend( + opts: { onPut?: (req: Request) => void; onConfirm?: (body: unknown) => void } = {}, +) { + const calls: string[] = []; + const fetch = mockFetch(async (req) => { + const url = new URL(req.url); + calls.push(`${req.method} ${url.host}${url.pathname}`); + if (req.method === "POST" && url.pathname.endsWith("/v1/asset-uploads")) { + return Response.json( + { asset_id: ASSET_ID, upload_url: "https://storage.example/presigned-put?sig=xyz" }, + { status: 201 }, + ); + } + if ( + req.method === "PUT" && + url.host === "storage.example" && + url.pathname === "/presigned-put" + ) { + opts.onPut?.(req); + return new Response(null, { status: 200 }); + } + if ( + req.method === "POST" && + url.pathname.endsWith(`/v1/asset-uploads/${ASSET_ID}/confirmations`) + ) { + opts.onConfirm?.(await req.json()); + return Response.json( + { + id: ASSET_ID, + name: "photo.png", + url: SIGNED_URL, + thumbnail_url: null, + asset_type: "image", + mime_type: "image/png", + size_bytes: 3, + created_at: "2026-06-10T00:00:00Z", + }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }); + return { fetch, calls }; +} + +describe("rf.assets.upload", () => { + it("runs the 3-step presigned flow and returns a signed https url + stable ref", async () => { + let putAuth: string | null = "sentinel"; + let putContentType: string | null = null; + let confirmBody: unknown; + const backend = uploadBackend({ + onPut: (req) => { + putAuth = req.headers.get("authorization"); + putContentType = req.headers.get("content-type"); + }, + onConfirm: (body) => { + confirmBody = body; + }, + }); + const rf = new Runflow({ apiKey: "rf_live_x", fetch: backend.fetch }); + + const asset = await rf.assets.upload( + new File([new Uint8Array(3)], "photo.png", { type: "image/png" }), + ); + + expect(asset.id).toBe(ASSET_ID); + expect(asset.url).toBe(SIGNED_URL); + expect(asset.url.startsWith("https://")).toBe(true); + expect(asset.ref).toBe(`runflow://assets/${ASSET_ID}`); + expect(asset.mimeType).toBe("image/png"); + expect(asset.sizeBytes).toBe(3); + // The presigned PUT must not leak the API key — the URL is the auth. + expect(putAuth).toBeNull(); + expect(putContentType).toBe("image/png"); + expect(confirmBody).toEqual({ folder_id: null }); + expect(backend.calls).toEqual([ + "POST api.runflow.io/v1/asset-uploads", + "PUT storage.example/presigned-put", + `POST api.runflow.io/v1/asset-uploads/${ASSET_ID}/confirmations`, + ]); + }); + + it("works through a proxy base for the API steps while the PUT stays absolute", async () => { + const backend = uploadBackend(); + const rf = new Runflow({ + baseUrl: "http://app.local/api/runflow", + fetch: backend.fetch, + }); + const asset = await rf.assets.upload(new File(["x"], "a.png", { type: "image/png" })); + expect(asset.id).toBe(ASSET_ID); + expect(backend.calls[0]).toBe("POST app.local/api/runflow/v1/asset-uploads"); + expect(backend.calls[1]).toBe("PUT storage.example/presigned-put"); + expect(backend.calls[2]).toBe( + `POST app.local/api/runflow/v1/asset-uploads/${ASSET_ID}/confirmations`, + ); + }); + + it("uploads a raw Blob when filename is provided, and rejects one without", async () => { + const backend = uploadBackend(); + const rf = new Runflow({ apiKey: "rf_live_x", fetch: backend.fetch }); + const blob = new Blob([new Uint8Array(2)], { type: "image/png" }); + await expect(rf.assets.upload(blob)).rejects.toThrow(/filename/); + const asset = await rf.assets.upload(blob, { filename: "mask.png" }); + expect(asset.id).toBe(ASSET_ID); + }); + + it("passes folderId through to the confirmation", async () => { + let confirmBody: unknown; + const backend = uploadBackend({ + onConfirm: (b) => { + confirmBody = b; + }, + }); + const rf = new Runflow({ apiKey: "rf_live_x", fetch: backend.fetch }); + await rf.assets.upload(new File(["x"], "a.png", { type: "image/png" }), { folderId: "fold_1" }); + expect(confirmBody).toEqual({ folder_id: "fold_1" }); + }); + + it("rejects files over the 50 MB cap without any network call", async () => { + let called = false; + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch(() => { + called = true; + return new Response("nope"); + }), + }); + const big = { size: 52_428_801, type: "image/png", name: "big.png" } as File; + await expect(rf.assets.upload(big, { filename: "big.png" })).rejects.toThrow(/50 MB/); + expect(called).toBe(false); + }); + + it("surfaces a failed storage PUT with status and code", async () => { + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch((req) => { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/v1/asset-uploads") { + return Response.json({ asset_id: ASSET_ID, upload_url: "https://storage.example/p" }); + } + if (req.method === "PUT") return new Response("denied", { status: 403 }); + return new Response("not found", { status: 404 }); + }), + }); + const err = await rf.assets + .upload(new File(["x"], "a.png", { type: "image/png" })) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(RunflowError); + expect((err as RunflowError).status).toBe(403); + expect((err as RunflowError).message).toMatch(/storage PUT failed/); + }); + + it("retries transient failures (5xx / network) and then succeeds", async () => { + let sessionAttempts = 0; + let putAttempts = 0; + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch((req) => { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/v1/asset-uploads") { + sessionAttempts++; + if (sessionAttempts === 1) return new Response("oops", { status: 503 }); + return Response.json({ asset_id: ASSET_ID, upload_url: "https://storage.example/p" }); + } + if (req.method === "PUT") { + putAttempts++; + if (putAttempts === 1) throw new Error("socket reset"); + return new Response(null, { status: 200 }); + } + if (url.pathname.endsWith("/confirmations")) { + return Response.json({ + id: ASSET_ID, + name: "a.png", + url: SIGNED_URL, + mime_type: "image/png", + size_bytes: 1, + }); + } + return new Response("not found", { status: 404 }); + }), + }); + const asset = await rf.assets.upload(new File(["x"], "a.png", { type: "image/png" })); + expect(asset.id).toBe(ASSET_ID); + expect(sessionAttempts).toBe(2); + expect(putAttempts).toBe(2); + }); + + it("does not retry deterministic 4xx failures", async () => { + let attempts = 0; + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch(() => { + attempts++; + return new Response(JSON.stringify({ error: { message: "nope" } }), { status: 422 }); + }), + }); + await expect(rf.assets.upload(new File(["x"], "a.png", { type: "image/png" }))).rejects.toThrow( + /422/, + ); + expect(attempts).toBe(1); + }); + + it("refuses a non-https upload_url", async () => { + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch(() => + Response.json({ asset_id: ASSET_ID, upload_url: "http://storage.example/p" }), + ), + }); + await expect(rf.assets.upload(new File(["x"], "a.png", { type: "image/png" }))).rejects.toThrow( + /non-https/, + ); + }); + + it("never leaks presigned query strings into error messages", async () => { + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch((req) => { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/v1/asset-uploads") { + return Response.json({ + asset_id: ASSET_ID, + upload_url: "https://storage.example/p?X-Amz-Signature=SECRETSIG", + }); + } + throw new Error("network down"); + }), + }); + const err = await rf.assets + .upload(new File(["x"], "a.png", { type: "image/png" })) + .catch((e: unknown) => e as Error); + expect((err as Error).message).not.toContain("SECRETSIG"); + expect((err as Error).message).not.toContain("X-Amz-Signature"); + }); + + it("assets.get(id) returns a freshly signed asset and validates the id", async () => { + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch((req) => { + const url = new URL(req.url); + if (req.method === "GET" && url.pathname === `/v1/assets/${ASSET_ID}`) { + return Response.json({ + id: ASSET_ID, + name: "photo.png", + url: SIGNED_URL, + mime_type: "image/png", + size_bytes: 3, + }); + } + return new Response("not found", { status: 404 }); + }), + }); + const asset = await rf.assets.get(ASSET_ID); + expect(asset.url).toBe(SIGNED_URL); + expect(asset.ref).toBe(`runflow://assets/${ASSET_ID}`); + await expect(rf.assets.get("../sneaky")).rejects.toThrow(/invalid asset id/); + }); + + it("abort during retry backoff surfaces an aborted error immediately", async () => { + const ac = new AbortController(); + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch(() => { + // First (and only) attempt fails transiently; abort fires during + // the backoff sleep. + setTimeout(() => ac.abort(), 5); + return new Response("oops", { status: 503 }); + }), + }); + const started = Date.now(); + const err = await rf.assets + .upload(new File(["x"], "a.png", { type: "image/png" }), { signal: ac.signal }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(RunflowError); + expect((err as RunflowError).code).toBe("aborted"); + // Must not sit through the full 250ms backoff after the abort. + expect(Date.now() - started).toBeLessThan(200); + }); + + it("surfaces the proxy's flat { error, code } body shape", async () => { + const rf = new Runflow({ + baseUrl: "http://app.local/api/runflow", + fetch: mockFetch( + () => + new Response( + JSON.stringify({ error: "Path not allowed: GET /v1/foo", code: "path_not_allowed" }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ), + ), + }); + const err = await rf.assets + .get("11111111-2222-3333-4444-555555555555") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(RunflowError); + expect((err as RunflowError).code).toBe("path_not_allowed"); + expect((err as RunflowError).message).toContain("Path not allowed"); + }); + + it("rejects a malformed upload-session response", async () => { + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch(() => Response.json({ nope: true })), + }); + await expect(rf.assets.upload(new File(["x"], "a.png", { type: "image/png" }))).rejects.toThrow( + /upload session/, + ); + }); +}); diff --git a/packages/sdk/tests/auth-mode.test.ts b/packages/sdk/tests/auth-mode.test.ts new file mode 100644 index 0000000..5a7a820 --- /dev/null +++ b/packages/sdk/tests/auth-mode.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { Runflow } from "../src/index.js"; + +function mockFetch(handler: (req: Request) => Response | Promise): typeof fetch { + return ((input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input as string, init); + return Promise.resolve(handler(req)); + }) as typeof fetch; +} + +describe("auth mode — apiKey vs baseUrl", () => { + it("server mode (apiKey only) sends the bearer header", async () => { + let auth: string | null = null; + const rf = new Runflow({ + apiKey: "rf_live_x", + fetch: mockFetch((req) => { + auth = req.headers.get("authorization"); + return Response.json({ ok: true }); + }), + }); + await rf.health.check(); + expect(auth).toBe("Bearer rf_live_x"); + }); + + it("proxy mode (baseUrl) never sends Authorization — even when apiKey is also passed", async () => { + // The documented contract: "If both are set, baseUrl wins — the bearer + // header is omitted." The key must not reach the proxy origin. + let auth: string | null = "sentinel"; + const rf = new Runflow({ + apiKey: "rf_live_should_not_leak", + baseUrl: "http://app.local/api/runflow", + fetch: mockFetch((req) => { + auth = req.headers.get("authorization"); + return Response.json({ ok: true }); + }), + }); + await rf.health.check(); + expect(auth).toBeNull(); + }); +}); diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 9d463a9..7d4cf2d 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { Runflow, RunflowError, RunFailedError } from "../src/index.js"; +import { RunFailedError, Runflow, RunflowError } from "../src/index.js"; function mockFetch(handler: (req: Request) => Response | Promise): typeof fetch { return ((input: RequestInfo | URL, init?: RequestInit) => { @@ -95,17 +95,20 @@ describe("models.run + runs.wait", () => { ); }), }); - await expect(rf.runs.wait("run_2", { pollIntervalMs: 1 })).rejects.toBeInstanceOf(RunFailedError); + await expect(rf.runs.wait("run_2", { pollIntervalMs: 1 })).rejects.toBeInstanceOf( + RunFailedError, + ); }); it("surfaces HTTP errors as RunflowError with status", async () => { const rf = new Runflow({ apiKey: "rf_live_x", - fetch: mockFetch(() => - new Response(JSON.stringify({ error: { message: "rate limited" } }), { - status: 429, - headers: { "Content-Type": "application/json" }, - }), + fetch: mockFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }), ), }); try { @@ -131,14 +134,14 @@ describe("models.run + runs.get — path encoding", () => { }), }); await rf.models.run("space owner/has spaces/runs slug", {}); - expect(seenUrl).toMatch( - /\/v1\/models\/space%20owner\/has%20spaces\/runs%20slug\/runs/, - ); + expect(seenUrl).toMatch(/\/v1\/models\/space%20owner\/has%20spaces\/runs%20slug\/runs/); }); it("rejects model id with empty / dot / dot-dot segments", async () => { const rf = new Runflow({ apiKey: "rf_live_x", fetch: mockFetch(() => new Response("")) }); - await expect(rf.models.run("runflow//background-removal", {})).rejects.toThrow(/invalid model id/); + await expect(rf.models.run("runflow//background-removal", {})).rejects.toThrow( + /invalid model id/, + ); await expect(rf.models.run("runflow/../secret", {})).rejects.toThrow(/invalid model id/); await expect(rf.models.run("./foo", {})).rejects.toThrow(/invalid model id/); }); @@ -207,8 +210,9 @@ describe("tools.run — type-contract enforcement", () => { describe("tools.run", () => { it("merges presets and runtime args, then extracts output", async () => { - const { defineTool, imageInput, textInput, imageOutput, extractFirstImageUrl } = - await import("../src/tools/index.js"); + const { defineTool, imageInput, textInput, imageOutput, extractFirstImageUrl } = await import( + "../src/tools/index.js" + ); const tool = defineTool({ id: "scene-test", diff --git a/packages/sdk/tests/pin.test.ts b/packages/sdk/tests/pin.test.ts new file mode 100644 index 0000000..665f2e4 --- /dev/null +++ b/packages/sdk/tests/pin.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { composePinPrompt, composeRegionPrompt, pinRegion } from "../src/tools/pin.js"; + +describe("pinRegion", () => { + it("maps all nine grid regions", () => { + expect(pinRegion({ x: 0.1, y: 0.1 })).toBe("upper-left"); + expect(pinRegion({ x: 0.5, y: 0.1 })).toBe("upper-center"); + expect(pinRegion({ x: 0.9, y: 0.1 })).toBe("upper-right"); + expect(pinRegion({ x: 0.1, y: 0.5 })).toBe("middle-left"); + expect(pinRegion({ x: 0.5, y: 0.5 })).toBe("middle-center"); + expect(pinRegion({ x: 0.9, y: 0.5 })).toBe("middle-right"); + expect(pinRegion({ x: 0.1, y: 0.9 })).toBe("lower-left"); + expect(pinRegion({ x: 0.5, y: 0.9 })).toBe("lower-center"); + expect(pinRegion({ x: 0.9, y: 0.9 })).toBe("lower-right"); + }); + + it("puts exact band boundaries into the next band (matches the shell's historical behavior)", () => { + expect(pinRegion({ x: 0.33, y: 0.33 })).toBe("middle-center"); + expect(pinRegion({ x: 0.66, y: 0.66 })).toBe("lower-right"); + expect(pinRegion({ x: 0, y: 0 })).toBe("upper-left"); + expect(pinRegion({ x: 1, y: 1 })).toBe("lower-right"); + }); +}); + +describe("composeRegionPrompt / composePinPrompt", () => { + it("produces the verbatim template the studio shell has always dispatched", () => { + // This exact string is the contract with google/nano-banana-pro/edit. + // If it changes, pin edits behave differently for every consumer. + expect(composeRegionPrompt("upper-center", "remove the price tag")).toBe( + "Edit the upper-center area of this image: remove the price tag. Photoreal product photography, preserve the rest of the image, true colors and lighting.", + ); + }); + + it("composePinPrompt is composeRegionPrompt over pinRegion", () => { + const pin = { x: 0.25, y: 0.25 }; + expect(composePinPrompt(pin, "remove the price tag")).toBe( + composeRegionPrompt(pinRegion(pin), "remove the price tag"), + ); + expect(composePinPrompt(pin, "remove the price tag")).toContain("Edit the upper-left area"); + }); +}); diff --git a/packages/studio/README.md b/packages/studio/README.md index 935373c..71cd321 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -23,9 +23,11 @@ export const { GET, POST } = runflowProxy({ }); ``` -This handler covers the `/v1/models/*/runs` dispatch path and the -`/v1/runs/{id}` poll path the Studio uses for every workflow run. It -does NOT cover uploads, chat, image-proxy, or sentinel — see [Companion +This handler covers the `/v1/models/*/runs` dispatch path, the +`/v1/runs/{id}` poll path, and the asset-upload/read routes the Studio +uses for workflow runs and file uploads (`rf.assets.upload` through the +proxy's default allow-list — no extra upload endpoint needed). It does +NOT cover chat, image-proxy, or sentinel — see [Companion endpoints](#companion-endpoints) below. ## Browser: mount the Studio @@ -58,7 +60,9 @@ mount(target: string | HTMLElement, options?: { runflowProxy?: string; // /api/runflow — dispatch + poll runflowDevProxy?: string; // (off by default) — unreleased models imageProxy?: string; // /api/runflow/image — same-origin image fetch - upload?: string; // /api/runflow/upload — multipart → public URL + upload?: string; // optional legacy multipart endpoint — by default + // uploads use the SDK presigned flow through + // runflowProxy (no extra endpoint needed) chat?: string; // /api/runflow/chat — chat agent (SSE) sentinel?: string; // /api/runflow/sentinel — sentinel evaluation }; @@ -66,12 +70,35 @@ mount(target: string | HTMLElement, options?: { theme?: ThemeMode | ThemeOverrides; /** Inject the default