RUN-384: close the SDK gaps blocking external Studio forks#4
Conversation
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)
Live e2e proof — 13/13 green ✅
Row 7 is the exact path that previously 422'd ( Backend companion ticket for the dispatch-side 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
Review trail — complete ✅Round 1 — full council (4 personas × Claude + Codex = 8 reports, 0 BLOCK): every P1 applied in Between rounds — independent Codex review: 4 findings, all applied in 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 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: 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): |
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 forrunflow-monorepo— seedocs/plans/run-384-sdk-gaps/backend-contract.md.rf.assets.upload(file)— the platform's presigned flow (session → storage PUT → confirm), returning a model-ready signed httpsurl+ stablerunflow://assets/{id}ref. Kills the browser-upload 422 (data:→runflow://→ validator rejection) with zero backend changes.@runflow-io/proxyallowedPaths— 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.composePinPrompt/pinRegionexported 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.<StudioShell>props:tools/source/sentinel/copy(was zero props). Resolved into React context, multi-instance safe; zero props renders exactly as before.mount()forwards viaprops.createMaskControllerin@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
chorecommit bringing the long-redbun run lintto green ahead of the CI gate.Spec:
docs/plans/run-384-sdk-gaps/design.md.Test plan
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;renderToStringsmoke of zero-prop + fully-customized shells).bun run proof, realapi.runflow.io): full modality sweep + new asset-upload section —rf.assets.uploadthrough the in-process proxy, asserts signed https +runflow://ref, then feeds the url togoogle/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 onRUNFLOW_API_KEY(R2 side-channel deleted). Proof output will be posted as a comment when the in-flight run completes.Notes for reviewers
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.allowedPathssecurity posture: matches forward with the org key, so only the upload pair is default-on; reads are deliberate opt-ins.RateLimitResult:void→undefinedin the union (type-level only).🤖 Generated with Claude Code