Skip to content

RUN-384: close the SDK gaps blocking external Studio forks#4

Merged
miguelrisero merged 12 commits into
mainfrom
mr/bb13-sdk-gaps-https-l
Jun 10, 2026
Merged

RUN-384: close the SDK gaps blocking external Studio forks#4
miguelrisero merged 12 commits into
mainfrom
mr/bb13-sdk-gaps-https-l

Conversation

@miguelrisero

Copy link
Copy Markdown
Contributor

Summary

Closes the five in-repo gaps from RUN-384 that made the external-fork story incomplete, each proven live against api.runflow.io. The two backend gaps (2 + 6) ship here as a precise contract spec for runflow-monorepo — see docs/plans/run-384-sdk-gaps/backend-contract.md.

Gap Change
1 rf.assets.upload(file) — the platform's presigned flow (session → storage PUT → confirm), returning a model-ready signed https url + stable runflow://assets/{id} ref. Kills the browser-upload 422 (data:runflow:// → validator rejection) with zero backend changes.
4 @runflow-io/proxy allowedPaths — strict full-path/segment allow-list, additive over new defaults that include the asset-upload pair (so gap 1 works through the proxy zero-config). Org-data reads stay opt-in, documented with a security warning.
5 composePinPrompt/pinRegion exported from the SDK — the pin→region prompt convention, previously four private copies discoverable only by grepping the published bundle. All four call sites now share it.
3 <StudioShell> props: tools / source / sentinel / copy (was zero props). Resolved into React context, multi-instance safe; zero props renders exactly as before. mount() forwards via props.
7 createMaskController in @runflow-io/studio/headless — framework-free dual-canvas brush engine (stroke interpolation, coverage, full-res thresholded mask blob). The shell now consumes it itself.

Also: first CI workflow for this repo (build/typecheck/test/lint), three minor changesets, README updates linking the reference repo as the worked fork example, and a chore commit bringing the long-red bun run lint to green ahead of the CI gate.

Spec: docs/plans/run-384-sdk-gaps/design.md.

Test plan

  • Unit: 72 tests green (bun run test) — 24 SDK (upload flow incl. proxy base, no-auth-on-PUT, size cap, error surfaces; all 9 pin regions + verbatim template), 26 proxy (allow-list defaults/negatives/traversal incl. percent-encoded, CSRF/auth/rate-limit stacking), 22 studio (mask controller against a rasterizing canvas fake; shell-config resolution; renderToString smoke of zero-prop + fully-customized shells).
  • Typecheck/lint/build: green across all packages.
  • Live e2e (bun run proof, real api.runflow.io): full modality sweep + new asset-upload sectionrf.assets.upload through the in-process proxy, asserts signed https + runflow:// ref, then feeds the url to google/nano-banana-pro/edit (the exact 422 repro, now green) + proxy allow-list assertions (403 negatives, default + opted-in rules, live run listing). Mask-reference flow now self-sufficient on RUNFLOW_API_KEY (R2 side-channel deleted). Proof output will be posted as a comment when the in-flight run completes.

Notes for reviewers

  • The ticket attributed data:runflow:// materialization and the flat 403 to this proxy; neither lives here (the proxy forwards bodies verbatim). The real defect is backend: refs resolve on reads but not at dispatch — hence the contract doc instead of code for gaps 2/6.
  • allowedPaths security posture: matches forward with the org key, so only the upload pair is default-on; reads are deliberate opt-ins.
  • RateLimitResult: voidundefined in the union (type-level only).

🤖 Generated with Claude Code

Grounds the plan in the actual code: the data:->runflow:// materialization
and flat-403 attributed to @runflow-io/proxy live in the backend, not here.
Covers in-repo gaps 1/3/4/5/7 + the live e2e-proof gate, and frames gaps 2/6
as a backend contract deliverable.
The lint script has been red since 0.0.3 (no CI enforced it). This brings
the tree to green ahead of adding a CI lint gate:

- biome format/organize-imports across the repo (mechanical)
- type="button" on all 57 typeless buttons (no <form> exists, so none
  relied on submit semantics)
- aria-hidden="true" on 46 decorative inline svgs
- optional chains, template-literal cleanups, guarded shift() instead of
  non-null assertions, for..of instead of forEach, label htmlFor wiring
- RateLimitResult: void -> undefined in the proxy's public types
- biome.json override: 5 design-judgment rules (useExhaustiveDependencies,
  noArrayIndexKey, noLabelWithoutControl, noAutofocus, useSemanticElements)
  downgraded to warn for studio components only — their auto-fixes change
  runtime/UX behavior, so they stay visible as warnings instead of blocking

No functional changes intended; build, tests, typecheck all green.
…RUN-384 gap 5)

The pin→region encoding (3×3 grid of upper|middle|lower × left|center|right
baked into the edit prompt) existed as four private copies: studio
lib/runflow.ts, studio tools/index.ts (ai-edit), and twice in the e2e
proof. Customers doing pin-based editing had to grep the published bundle
to discover it.

- new @runflow-io/sdk exports: PinPoint, pinRegion, composeRegionPrompt,
  composePinPrompt — template kept verbatim (behavior-preserving)
- all four former copies now call the helper; the 'no pin ⇒ center'
  fallback stays in the studio dispatcher
- unit tests: all 9 regions, band boundaries, verbatim template contract
…p 1)

Browser file upload was the most common path broken for external forks:
there was no SDK upload helper, so files ended up as data: URIs that the
backend materializes into runflow:// refs, which model media validators
reject with a 422 ('media URL must use HTTP(S) or data URI').

New AssetsResource mirrors the platform's own assetService.uploadFile:
  1. POST /v1/asset-uploads (filename, mime_type, size_bytes)
  2. PUT the bytes to the presigned storage URL — bypasses the API base
     and sends no Authorization header (the URL is the auth)
  3. POST /v1/asset-uploads/{id}/confirmations

Returns { id, url, ref, ... } where url is the backend-signed HTTPS URL
(model-ready, avoids the 422 entirely) and ref is the stable
runflow://assets/{id} for APIs that accept asset refs.

- 50 MB guard matching the backend cap; Blob + filename supported
- internal rawFetch on the client for absolute presigned URLs
- works in the browser through @runflow-io/proxy for steps 1/3
- 7 unit tests: full flow, proxy base, blob/filename, folderId, size cap,
  PUT failure surface, malformed session
The proxy forwarded only dispatch, run polling, and health; everything
else got a flat 403. Asset uploads, run listing, and billing reads were
unreachable, so customers built parallel bridges (duplicate plumbing)
for exactly the endpoints the SDK needs.

- new ProxyConfig.allowedPaths: ReadonlyArray<{ method, path }> — strict
  full-path segment matching, no prefixes/wildcards; :param matches one
  non-empty segment and rejects traversal (., .., percent-encoded, slashes)
- defaults now include the rf.assets.upload pair (POST /v1/asset-uploads,
  POST /v1/asset-uploads/:id/confirmations) so browser uploads work
  zero-config; customer rules are additive
- org-data reads (GET /v1/runs, billing) stay opt-in only — documented
  with an explicit security warning since matches forward the org key
- handler now also exposes PUT/PATCH/DELETE for Next.js route exports
- CSRF, authenticate, rateLimit, and body-cap gates unchanged and still
  apply to allowed paths (covered by tests)
- 11 new tests: defaults, negatives, traversal, custom rules, gate stacking
…gine (RUN-384 gap 7)

Brush/mask creation was embedded in StudioShell's internals, so headless
consumers building a custom UI had to rebuild the dual-canvas painting
logic from scratch to operate the mask workflows (mask-only, mask-ref).

- new framework-free createMaskController in lib/mask.ts: visible
  overlay + hidden B&W canvas, stroke interpolation, DPR-aware display
  sync, sampled coverage, full-resolution thresholded PNG mask blob
- exported from @runflow-io/studio/headless with types
- StudioShell now consumes the controller itself (deleted its inline
  paintAt/updateCoverage/clearMask/generateMaskBlob plumbing) — the
  shell is the proof the primitive is sufficient
- injectable createCanvas factory: works in any DOM host, testable
  without one
- 8 unit tests against a real rasterizing canvas fake: coverage math,
  stroke interpolation, threshold binarity, DPR cap, brush sizing
…(RUN-384 gap 3)

The published shell took zero props, so 'fork the shell for your
vertical' meant rebuilding the whole UI on ./headless. Four optional
props now cover the fork story, with the zero-prop default rendering
exactly as before:

- tools: replace/extend the workflow catalogue (cards, chat agent,
  package steps, step pickers all follow)
- source: a URL string (single starting asset) or SampleAsset[]
  (replaces the bundled samples)
- sentinel: { enabled, taskDescription } — disable quality evals or
  re-template their task description; badges/retries follow
- copy: brandName/brandTag/avatarInitials/assetsTitle/emptyTitle/
  emptySubtitle, shallow-merged over defaults

Mechanics: props resolve once into a ResolvedShellConfig provided via
React context (multi-instance safe — no module-global registry);
WorkflowsPanel/ChatPanel/StepEditor read it through useShellConfig(),
catalogue helpers take the list as a parameter. mount() grows a
'props' option and forwards it. New exports: StudioShellProps,
StudioCopy, StudioSentinelOptions, resolveShellConfig, Workflow,
SampleAsset.

Tests: config resolution per axis + renderToString smoke proving the
zero-prop default and a fully-overridden vertical shell both render.
The proof now needs only RUNFLOW_API_KEY:

- new asset-upload section: rf.assets.upload(File) runs the presigned
  flow through the in-process proxy (default allow-list), asserts the
  returned url is signed https + the ref is runflow://assets/{id}, then
  feeds the url to google/nano-banana-pro/edit — the exact path that
  used to 422 on runflow:// refs, now green end to end
- new proxy allow-list section: unknown route still 403s, default
  asset-upload rules + an opted-in GET /v1/runs forward (mock upstream,
  no spend), and a LIVE run listing through allowedPaths against
  api.runflow.io
- mask-reference section uploads source/mask/reference via
  rf.assets.upload instead of hand-rolled R2 Sig V4; uploads.ts is
  replaced by fixtures.ts (sample mask + fetch helpers only)
- the SDK's fetch routes proof.local to the in-process handler and
  everything else (the presigned storage PUT) over the real network —
  matching what a browser actually does
…UN-384)

- changesets: minor bumps for @runflow-io/sdk (assets.upload, pin
  helpers), @runflow-io/proxy (allowedPaths), @runflow-io/studio
  (shell props, headless mask controller)
- READMEs: SDK upload + pin-editing sections and API surface; proxy
  path-contract table + allowedPaths guide with security note; studio
  "Customizing the shell" + headless mask example; root README links
  the real-estate-studio-sdk reference repo as the worked fork example
  and documents the live proof gate
- docs/plans/run-384-sdk-gaps/backend-contract.md: companion spec for
  backend gaps 2+6 — resolve runflow:// refs at model dispatch (read
  path already does), acceptance criteria, suggested ticket text
- .github/workflows/ci.yml: bun install/build/typecheck/test/lint on
  PRs and main (first CI for this repo; live proof stays manual)
@miguelrisero

Copy link
Copy Markdown
Contributor Author

Live e2e proof — 13/13 green ✅

bun run proof against real api.runflow.io (browser SDK → in-process @runflow-io/proxy → upstream), only RUNFLOW_API_KEY set (no R2 creds, no Sentinel key — Sentinel section auto-skips by design):

# Section Result
1 simple — background-removal ✓ 17.9s
2 color — background-color ✓ 6.9s
3 select — smart-resize 1:1 @ 2K ✓ 41.4s
4 prompt — object-removal/prompt ✓ 37.0s
5 text-to-image — nano-banana-pro ✓ 45.6s
6 pin — ai-edit via composePinPrompt ✓ 47.8s
7 asset-upload — rf.assets.upload → nano-banana-pro/edit (the RUN-384 422 repro) ✓ 32.7s
8 proxy allow-list — gate assertions + live GET /v1/runs through allowedPaths ✓ 0.2s
9 package single — zalando 3-step chain ✓ 122.2s
10 mask + reference — reference-inpaint, all three files via rf.assets.upload ✓ 44.5s
11 package fan-out — omnichannel, 4 variants ✓ 227.8s
12 package creative-direction — campaign ✓ 193.7s
13 chat-agent — structural plan dispatch ✓ 30.4s

Row 7 is the exact path that previously 422'd (media URL must use HTTP(S) or data URI, got 'runflow'): browser-style File → presigned flow through the proxy's default allow-list → signed https URL → nano-banana edit → image out. Row 10 confirms the retired R2 SigV4 side-channel is fully replaced by the SDK flow.

Backend companion ticket for the dispatch-side runflow:// resolution (gaps 2+6): RUN-418.

Council round 1 (4 Claude + 4 Codex personas) + CodeRabbit CLI review in progress; fixes will land as follow-up commits before this flips to ready-for-review.

…bit findings

Security/correctness:
- sdk: proxy mode (baseUrl) never sends Authorization, even when apiKey
  is also passed — the documented contract now holds (cross-engine High)
- sdk: presigned-URL query strings (bearer-like signatures) redacted
  from rawFetch error messages; non-https upload_url refused
- proxy: reject empty path segments so matching always equals forwarding

API semantics (the one breaking-ish change, pre-publish):
- proxy: allowedPaths now REPLACES the defaults, exactly like
  allowedModels — spread DEFAULT_ALLOWED_PATHS to extend, [] to disable
  the asset routes (consensus P1: two opposite override semantics on one
  config object; forks could never turn the default uploads off)
- proxy: defaults gain GET /v1/assets/:id for rf.assets.get

Resilience (perf P1):
- sdk: rf.assets.upload retries transient failures (network/timeout/5xx,
  250/750ms) on all three legs — parity with the studio path it replaces
- sdk: PUT timeout scales with file size (2 Mbit/s floor, min 120s)

DX:
- sdk: rf.assets.get(id) re-mints expired signed urls; "store the id"
  documented; RunflowErrorCode union for autocompletable catch blocks
- proxy: 403/415 bodies carry actionable messages + machine codes
  (path_not_allowed, model_not_allowed, origin_not_allowed,
  json_content_type_required)
- studio: uploadFile defaults to the SDK presigned flow through
  runflowProxy (zero-config); hosts with an explicit urls.upload keep
  the legacy multipart path — ends the two-contradictory-upload-stories
  problem; READMEs aligned

Maintainability:
- studio: useShellConfig fallback warns once instead of silently serving
  built-ins; = WORKFLOWS default params removed; StudioShellProps
  documents mount-only source + stable-reference expectations
- studio: config memo keys on fields, not object identity — inline
  copy={{...}}/sentinel={{...}} no longer re-mint the context value
- studio: mask controller clamps brush size, warns once on
  unattached/unsynced use, willReadFrequently on CPU-read canvases;
  shell lazily inits the controller ref
- ci: biome warning-count ratchet (budget 45)

CodeRabbit CLI (3 of 8 taken; rest skipped with reasons in the PR):
- pin.ts boundary doc corrected (0.66 falls lower/right)
- unmount(): theme CSS variables cleared
- blob preview URLs revoked on unmount (GeneratePanel + StudioShell) via
  ref mirror — unmount-only, never revokes live previews

Tests: 72 → 84 (auth-mode pair; retry/4xx/https/redaction/assets.get;
replacement/spread/opt-out/empty-segment/coded-403 proxy coverage).
- studio: passing a urls object with DEFAULT values back (e.g. copied
  from the docs) no longer counts as customization — only a real
  non-default upload endpoint switches uploads off the presigned path
- sdk: retry backoff is abortable — aborting mid-delay surfaces a
  RunflowError("aborted") immediately instead of finishing the sleep
  and throwing the stale transient error
- sdk: error parser now understands the proxy's flat { error, code }
  body shape, so err.code carries path_not_allowed/etc. through to
  SDK callers (was undefined with raw JSON in the message)
- proxy: trailing slash rejected on allow-list matches (same
  match-equals-forward invariant as the // guard)
- ci: warning-ratchet grep guarded for the zero-warnings case

Tests: 84 → 89 (trailing slash, abort-during-backoff timing, flat
error shape, urls default-value no-op + restore).
… round 2

- studio README: the proxy DOES cover uploads now (SDK presigned flow,
  zero-config); the multipart 'upload' companion endpoint is optional
  legacy, not required infrastructure
- design doc: gap-4 section updated to the shipped replacement
  semantics (with a revision note so the 'additive' first draft can't
  resurrect the round-1 bug) + GET /v1/assets/:id in the defaults
@miguelrisero

Copy link
Copy Markdown
Contributor Author

Review trail — complete ✅

Round 1 — full council (4 personas × Claude + Codex = 8 reports, 0 BLOCK): every P1 applied in b0c7093baseUrl mode never sends Authorization (cross-engine High), allowedPaths switched to replacement semantics matching allowedModels (spread DEFAULT_ALLOWED_PATHS to extend, [] to disable — forks can now turn the default upload routes off), upload retry + size-scaled PUT timeout, rf.assets.get(id) + GET /v1/assets/:id default route, presigned-URL redaction in errors, coded/actionable proxy 403s, studio uploads unified onto the SDK presigned flow, mask-controller guards, config-memo identity fixes.

Between rounds — independent Codex review: 4 findings, all applied in 9ad3b2c (default-value urls passes don't disable presigned uploads; abortable retry backoff; SDK parses the proxy's flat {error, code} shape; trailing-slash rejection).

Round 2 — verification pass (Codex): "The code-level fixes look coherent… I did not find a runtime regression in the audited paths" — blocked only on two doc-drift spots, fixed in 2a707a1.

CodeRabbit CLI (the GitHub app isn't installed on this repo): 8 findings — 3 applied (pin-boundary doc, unmount CSS-var cleanup, blob-URL revocation on unmount with a corrected ref-mirror approach; CodeRabbit's own suggested fix would have revoked live previews). 5 skipped with reasons: as never test casts are the pre-existing convention; the && build chain fails fast and encodes the required sdk→proxy→studio order; the bun pin matches the local + CI toolchain; the expired sample URL is pre-existing data (follow-up: regenerate samples.generated.json); @runflow-io/sdk as a studio peerDep while noExternal-bundled was a deliberate 0.0.3 packaging decision — open question for the maintainer, one of the two should eventually change.

Final state: 89 unit tests green · CI green (incl. new lint-warning ratchet, budget 45) · live proof re-run on the final tree: 13/13 sections against api.runflow.io · backend companion ticket RUN-418 filed.

Deferred with reasons (follow-ups, not blockers): React.memo on panel components (needs a callback-memoization sweep; no measured regression), splitting run.ts into per-gap functions (manual proof script), migrating runflow-prototypes' mask copy onto createMaskController, regenerating sample URLs.

@miguelrisero miguelrisero marked this pull request as ready for review June 10, 2026 20:30
@miguelrisero miguelrisero merged commit 979198d into main Jun 10, 2026
1 check passed
@miguelrisero miguelrisero deleted the mr/bb13-sdk-gaps-https-l branch June 10, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant