Closes the two remaining fully-local-stack seams found after the last round.
Uploads (previously every upload endpoint returned 500 with dummy S3 creds -
the bucket put happens before the DB insert and rust-s3 is fail-on-err):
- LegacyBucketClient gains an internal backend enum (S3 unchanged | LocalDir)
and a create_local(root, bucket_name, root_prefix) constructor. All upload/
download methods route LocalDir to the filesystem; bucket_name() reports the
configured name. Objects map disk = root + rooted object path via the new
pub local_object_disk_path (rejects ".." and backslash segments - PMX zips
feed user-controlled names), unit-tested. The fake-generation resolver now
uses the same function instead of its private copy so the layouts can't
drift.
- build_dependencies: when SERVER_ENVIRONMENT=Development and
LOCAL_MEDIA_ROOT is set, all three bucket clients are constructed in local
mode (private/gc have zero callers in storyteller-web, so this is purely
uniform); the server_environment read moved above client construction.
Uploads land under .devstack/media and are immediately viewable through
the existing /media mount + CDN_BASE_URL - no S3/R2/MinIO.
Frontend (pagescene was the only surface actually broken against a local
backend; moodboard/library/create-* already consume media_links correctly):
- The vite /__cdn dev proxy assumed https upstreams, so localhost media
(http) died on a TLS handshake with a 502 - breaking every pagescene
GLB/splat/animation fetch. fetchAsset no longer proxies localhost at all
(the dev backend sends CORS for any localhost origin), and the proxy
itself now picks http for localhost hosts as a backstop.
- GetCdnOrigin() returns the API host when it is localhost (the local
backend serves media itself; CDN origin == API origin), production CDN
otherwise. The pagescene adapter's getCdnUrl skips the Cloudflare
/cdn-cgi/image resize segment on localhost - the local backend has no
resizer, and dev serves originals anyway.
- vite /v1 fallback proxy now targets http://localhost:12345 instead of
production, so nothing can silently fall back to prod traffic in dev;
fixed the stale main.tsx comment claiming setDevelopment() is disabled.
Docs: seams table updated (uploads + vite proxy + GetCdnOrigin closed;
API_TARGETS/SegmentationApi noted as dead code; the earlier moodboard claim
was wrong - it never had a hardcoded host), LOCAL_MEDIA_ROOT now documents
the storage mode, verification checklist gains the upload check.
Verified E2E on this machine: POST /v1/media_files/upload/image (multipart,
real endpoint) returns success, the object lands under .devstack/media at
the canonical fan-out path, lists in the library with media_class=image,
and serves 200 image/jpeg from http://localhost:12345/media/...;
cloud_storage unit tests pass; storyteller-web + webapp dev server boot
clean with "Local filesystem storage mode is ON".
Closes the two gaps found smoke-testing the local stack: an empty gallery
(zero media_files rows, media URLs hardcoded to the remote r2.dev bucket)
and generation flows that could not complete (omni_gen calls providers
synchronously in the handler, so dummy keys meant a 500 after billing;
the webapp blocked submits earlier anyway because signup grants no wallet).
Backend (all development-only):
- CDN_BASE_URL env override in cdn_link.rs (host-only, validated loudly;
precedence over the compiled-in hosts for every domain/env) and a
dev-gated actix-files mount serving LOCAL_MEDIA_ROOT/media at /media,
mirroring the public bucket layout - media URLs in API responses resolve
against the backend itself
- DEV_FAKE_GENERATION flag on ServerState (easyenv bool, forced off outside
Development): the omni_gen image/video pipelines skip the external
provider call and return a synthetic Fal enqueue (external id fake_<uuid>),
so billing, prompt insert, and the pending generic_inference_jobs row all
stay real
- dev_fake_generation_resolver_thread: polls pending fake_ jobs (new
runtime-query list_pending_fake_generation_jobs in mysql_queries) and
after DEV_FAKE_GENERATION_RESOLVE_SECS (default 6s) marks them
complete_success with a media_files row backed by a test_data asset
copied under LOCAL_MEDIA_ROOT (mp4 results get -thumb.jpg/-thumb.gif
siblings since the thumbnail job never runs locally), or complete_failure
via the existing simulate_artcraft_failure prompt triggers (category word
supported, e.g. "simulate_artcraft_failure face_not_detected")
Bootstrap/seeds (PS + bash kept in lockstep):
- seed_demo_user seeds an artcraft wallet (DEMO_CREDITS, default 100k
banked + ledger rows; idempotent top-up) - signup creates no wallet and
the webapp gates generate on balance, which is why submits went nowhere
- seed_demo_user also seeds 6 gallery images (test_data jpgs) with objects
under .devstack/media in the exact bucket fan-out layout
- fixed the already-exists detection in seed_demo_user: create_account
returns error_type, not error_code, so re-runs died on UsernameTaken
- generated secrets env gains DEV_FAKE_GENERATION/CDN_BASE_URL/
LOCAL_MEDIA_ROOT (appended to existing files on bootstrap re-runs);
stale "providers are only contacted by workers" comments corrected
- doctor checks wallet balance, media rows, and the new secrets keys
Docs: dev_setup_local_stack.md documents the new flags, corrects the
sync-provider-call and upload-500 claims, updates the seams table and
verification checklist; storyteller_web CLAUDE.md video model examples
fixed (seedance_2p0_fast_g -> seedance_2p0_fast).
Verified E2E on this machine: gallery lists 8 items on localhost URLs;
image and video submits go pending ~6s then complete with locally-served
results (mp4 + gif preview 200); failure trigger yields complete_failure
with category face_not_detected; credits genuinely deducted (100000 ->
99932); doctor 0 failures / 0 warnings.
## Summary
easyenv had no test coverage for boolean, num, string, or duration
getters, despite being used across every service and job in the
workspace. adds 34 tests covering the optional/required/or_default triad
for each type.tests use unique env var names per test instead of adding
`serial_test` dev-dep — `env::set_var` is process-global so cargo's
default parallel execution can race. cleanup via a `Drop` guard
(`EnvVarGuard`, in `tests_common.rs`) so a panicking test doesn't leak
state into its neighbors.
## follow-up
left `pathbuf.rs` for a separate PR — it depends on
`storyteller_root::get_substituted_path` which needs more setup than
belongs in this diff.
## behaviors these tests pin down but don't change
- boolean parsing only accepts `true` / `TRUE` / `false` / `FALSE` —
`"1"`, `"0"`, `"yes"`, `"no"` return `ParseError`
- duration parses whole seconds only, no `"5s"` style suffixes
worth flagging in case either is intentional and someone wants to loosen
these separately.
## verification
- `cargo test --package easyenv --test-threads=8` — 34/34 pass, no
flakes across 20 consecutive runs
- `cargo clippy --package easyenv -- -D warnings` — clean