Add fake-storyteller-web: an in-memory API stand-in for the webapp

Runs the webapp with no backend, no database, no cache, no object storage,
and no third-party clients. It is a separate Nx project in a different
language that production never builds, so there is no development flag
living inside storyteller-web that could be flipped by accident.

Covers the full surface the webapp's @storyteller/api clients call -- 148
routes across session/auth, credits, billing, omni_gen, jobs, media files,
uploads, projects, folders, tags, prompts, characters, API keys and
referral codes. State is in memory and stateful: uploads show up in the
library, credits go down, generations resolve on a timer into a real media
file. Everything resets to the same seeded fixture on restart.

Uploaded and generated bytes are stored in memory and served from /media,
so every cdn_url points back at the process itself. Audio, mesh and splat
results are synthesized as valid WAV, GLB and 3DGS PLY files, since the
repo only ships image and video fixtures.

Prompt triggers carry over unchanged: simulate_artcraft_failure
[category], test_artcraft_failure, artcraft_test_job_failure,
trigger_payment_failure, plus simulate_artcraft_slow [seconds].

Unimplemented routes return a 501 that names the fake, so a gap reads as a
gap rather than as a backend error.

No dependencies and no build step: node:http plus Node's native TypeScript
stripping, so `node src/main.ts` starts it in a bare checkout.

Response shapes follow the Rust structs rather than @storyteller/api. The
package turns out not to describe the backend accurately -- MediaFileClass
and MediaFileType are stale, media_links.thumbnail_template is really
maybe_thumbnail_template, maybe_creator_user is maybe_creator on the list
endpoints, and JobsApi.ListJobs() calls /v1/jobs/batch without the tokens
parameter the backend requires. Binding responses to the package would
have made the fake serve values the API never sends. contract_checks.ts
locks the parts that do agree (Visibility, JobStatus) at compile time; the
README lists the rest.

Three frontend changes make local media actually load, all keyed on the
API host being localhost: GetCdnOrigin returns the API host, the vite
/__cdn proxy stops assuming https, and pagescene skips both the proxy and
Cloudflare's /cdn-cgi resizer for local URLs.

nx run fake-storyteller-web:smoke boots the server and drives ~50
assertions end to end; typecheck and the smoke test both pass.
This commit is contained in:
Tanish Manku
2026-07-30 18:02:30 -07:00
parent 0eddb659fa
commit 4d8dbe6aa5
48 changed files with 6434 additions and 3 deletions
@@ -52,6 +52,11 @@ function maybeProxyCdnUrl(url: string): string {
try {
const parsed = new URL(url, window.location.origin);
if (parsed.origin === window.location.origin) return url;
// A local backend sends permissive CORS headers already, so proxying it
// would only add a hop — and break range requests for video scrubbing.
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
return url;
}
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
} catch {
return url;
@@ -338,6 +343,11 @@ export const useWebAppPageSceneAdapter = (
if (width) params.push(`width=${width}`);
if (quality) params.push(`quality=${quality}`);
if (params.length === 0) return `${base}${path}`;
// `/cdn-cgi/image` is Cloudflare's resizer. A local backend has no such
// endpoint, so ask it for the original instead of a 404.
if (base.includes("//localhost") || base.includes("//127.0.0.1")) {
return `${base}${path}`;
}
return `${base}/cdn-cgi/image/${params.join(",")}${path}`;
},
+5 -1
View File
@@ -42,7 +42,11 @@ function cdnProxyPlugin() {
return;
}
const [, host, assetPath = '/', query = ''] = match;
const upstream = await fetch(`https://${host}${assetPath}${query}`, {
// A local backend serves media over http; every real CDN is https.
const scheme = /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host ?? '')
? 'http'
: 'https';
const upstream = await fetch(`${scheme}://${host}${assetPath}${query}`, {
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
headers: req.headers.range
? { range: String(req.headers.range) }
@@ -0,0 +1,158 @@
# fake-storyteller-web
An in-memory stand-in for the `storyteller-web` API, so the webapp can be run
and tested without a backend.
It has **no database, no cache, no object storage, and no third-party clients**.
There is no configuration that makes it talk to any of those, and no code path
that could. It is a separate Nx project that is never built into, imported by,
or deployed alongside anything that reaches production.
```
nx serve fake-storyteller-web # http://localhost:12345
nx serve artcraft-webapp # http://localhost:4201, already points at :12345
```
Log in as `localdev1` / `localdev1pass`.
## Why this exists
The previous approach put development flags inside `storyteller-web` itself
(`DEV_FAKE_GENERATION`, a filesystem bucket backend, a CDN host override). That
works, but every one of those flags is a switch that exists in the production
binary, and the objection was fair: a flag that exists can be flipped.
Here, none of it exists. The behaviour lives in a different program, in a
different language, that production never builds.
## What it does
- **Stateful, in memory.** Uploads appear in the library, generations produce
real media files, credits go down, folders and tags persist. Everything resets
on restart, always to the same seeded fixture.
- **Serves its own media.** Uploaded and generated bytes are stored in memory and
served from `/media/...`. Every `cdn_url` the API returns points back at this
process, so images, video, meshes and splats load in the browser with no
bucket and no proxy.
- **Resolves generations on a timer.** A submitted job sits `pending`, ticks its
progress up, and lands in `complete_success` or `complete_failure` after
`FAKE_API_RESOLVE_SECONDS` (default 6).
- **Fails loudly on gaps.** An unimplemented route returns a 501 whose body says
`fake-storyteller-web does not implement ...`, rather than something that
could be mistaken for a real backend error.
Endpoint coverage is everything the webapp's `@storyteller/api` clients call —
148 routes across session/auth, credits, billing, omni_gen, jobs, media files,
uploads, projects, folders, tags, prompts, characters, API keys and referral
codes.
## Prompt flags
Typing one of these into a prompt forces an outcome. They match the flags the
team already uses.
| Flag | Effect |
|----------------------------------------|---------------------------------------------------------|
| `simulate_artcraft_failure [category]` | Fail after the normal delay |
| `test_artcraft_failure [category]` | Alias of the above |
| `artcraft_test_job_failure` | Fail immediately, with no pending phase |
| `trigger_payment_failure` | Reject the submission with a 402 |
| `simulate_artcraft_slow [seconds]` | Stay pending for the given number of seconds |
`[category]` is any frontend failure category, for example
`simulate_artcraft_failure face_not_detected`. Omitted, it is
`generation_failed`. The full list is at `GET /__fake/state`.
## Control endpoints
These do not exist in the real API and are namespaced so that a dependency on
them is obvious.
| Endpoint | Purpose |
|-------------------------|------------------------------------------------------|
| `GET /__fake/state` | Record counts, resolve delay, failure category list |
| `POST /__fake/reset` | Reset to the seeded fixture (logs everyone out) |
| `POST /__fake/credits` | Set the logged-in user's balance |
| `GET /_status` | Health check, same path as the real backend |
## Configuration
| Variable | Default | Meaning |
|----------------------------|-------------|------------------------------------------|
| `FAKE_API_PORT` | `12345` | Listen port |
| `FAKE_API_HOST` | `127.0.0.1` | Listen address |
| `FAKE_API_PUBLIC_BASE_URL` | derived | Origin embedded in generated media URLs |
| `FAKE_API_RESOLVE_SECONDS` | `6` | How long a generation stays pending |
| `FAKE_API_DEMO_CREDITS` | `100000` | Credits granted to the seeded user |
## Testing
```
nx run fake-storyteller-web:smoke # boots the server and drives ~50 assertions
nx run fake-storyteller-web:typecheck
```
The smoke test covers login, the seeded library, media serving, the model
catalogue, cost, a full generation lifecycle, the failure and payment triggers,
credit deduction, upload round-tripping, and folders and tags. It exits non-zero
on the first failure, so it is usable as a CI gate.
## Implementation notes
**No dependencies and no build step.** It runs on `node:http` with Node's native
TypeScript stripping (`node src/main.ts`, Node 22.6+). Nothing is installed and
nothing is compiled, so it starts instantly and works in a bare checkout.
Because Node strips types rather than compiling them, runtime code here must
stay *erasable*: no TS `enum`, no `namespace`, no parameter properties. Use
`const` objects and union types instead. The server failing to boot is the
immediate signal if this is violated.
**Response shapes follow the Rust backend, not `@storyteller/api`.** This was a
deliberate call, and it is the most important thing to know before editing.
## Drift between `@storyteller/api` and the real backend
The suggestion that prompted this project was to depend on the frontend's API
package so the fake could not drift. That turns out to be only half possible:
the package is not currently an accurate description of the backend, so binding
responses to it would make the fake serve values the real API never sends, and
hide the very bugs it should surface.
So the fake mirrors the Rust structs, and `src/wire/contract_checks.ts` binds
the parts that *do* agree (`Visibility`, `JobStatus`) at compile time — if
either side changes those, `typecheck` fails.
The drift found while building this is worth fixing in the package itself:
| Where | Drift |
|----------------------------------------|--------------------------------------------------------------------------------------------------------|
| `enums/MediaFileClass` | Frontend has `animation`/`character`/`prop`/`scene`; backend sends `dimensional`/`mesh`/`splat`/`project` |
| `enums/MediaFileType` | Frontend has `mmd`/`scene`/`none` and is missing every concrete type sent (`jpg`, `png`, `mp4`, `spz`, `ply`, `scene_json`, ...) |
| `models/MediaFile.media_links` | Declares `thumbnail_template`; backend sends `maybe_thumbnail_template`, and also `maybe_video_previews` |
| `models/MediaFile.maybe_creator_user` | Named `maybe_creator` on `/list`, `/list_featured`, `/search_*`; absent entirely on `/list/user/:username` |
| `models/MediaFile.maybe_creator_user` | Typed `UserInfo`; backend sends the much smaller `UserDetailsLight` |
| `models/MediaFile.origin*` | Declared non-optional, but not sent at all by `/file/:token`, `/batch`, `/search_*`, `/mesh/list`, `/splat/list`, `/project/list` |
| `JobsApi.ListJobs()` | Calls `/v1/jobs/batch` with no `tokens` parameter, which the real backend rejects with a 400 — so this call always fails in production |
| `OmniGenApi` image requests | Sends `horizontal_angle`/`vertical_angle`/`zoom`; backend reads `adjust_*`, so the angle controls are silently dropped |
| `OmniGenVideoGenerateResponse` | Omits `all_job_tokens`, so batched video jobs beyond the first are never polled |
| `JobsApi.GetPreviewStatusByJobToken` | Reads `response.data`; the envelope is `{success, state}`, so it is always `undefined` |
Where the fake had to choose, it follows the backend — except for
`/v1/jobs/batch`, where it returns an empty list instead of a 400 so that the
client's own call pattern works.
## Layout
```
src/
main.ts server bootstrap, CORS, 501 handling
config.ts environment configuration
auth.ts sessions, cookies, the `session` header
http/ router, request context, multipart parser, responses
state/ records, the store, seeding, media and asset creation
wire/ response builders, one per real response shape
generation/ model catalogue, cost, submission, prompt flags, resolver
routes/ one module per API area
scripts/smoke_test.mjs end-to-end test
```
@@ -0,0 +1,14 @@
{
"name": "@frontend/fake-storyteller-web",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "In-memory fake of the storyteller-web API. Contains no database, cache, or third-party client. Never deployed.",
"engines": {
"node": ">=22.6.0"
},
"scripts": {
"serve": "node --disable-warning=ExperimentalWarning src/main.ts",
"dev": "node --watch --disable-warning=ExperimentalWarning src/main.ts"
}
}
@@ -0,0 +1,37 @@
{
"name": "fake-storyteller-web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/fake-storyteller-web/src",
"tags": ["scope:dev-tooling"],
"targets": {
"serve": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/fake-storyteller-web",
"command": "node --disable-warning=ExperimentalWarning src/main.ts"
}
},
"dev": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/fake-storyteller-web",
"command": "node --watch --disable-warning=ExperimentalWarning src/main.ts"
}
},
"typecheck": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/fake-storyteller-web",
"command": "tsc --noEmit -p tsconfig.json"
}
},
"smoke": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/fake-storyteller-web",
"command": "node scripts/smoke_test.mjs"
}
}
}
}
@@ -0,0 +1,315 @@
/**
* End-to-end smoke test.
*
* Boots the server on a scratch port, drives the flows the webapp depends on,
* and exits non-zero on the first failure. Run it with
* `nx run fake-storyteller-web:smoke` (or `node scripts/smoke_test.mjs`).
*/
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const PORT = Number(process.env["FAKE_API_PORT"] ?? 12456);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const APP_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
/** Generations resolve fast so the test does not spend its time waiting. */
const RESOLVE_SECONDS = 2;
const POLL_TIMEOUT_MILLIS = 20_000;
let failures = 0;
let cookie = "";
const server = spawn(
process.execPath,
["--disable-warning=ExperimentalWarning", join(APP_ROOT, "src", "main.ts")],
{
cwd: APP_ROOT,
env: {
...process.env,
FAKE_API_PORT: String(PORT),
FAKE_API_RESOLVE_SECONDS: String(RESOLVE_SECONDS),
},
stdio: ["ignore", "pipe", "inherit"],
},
);
try {
await waitForServer();
await runChecks();
} finally {
server.kill();
}
console.log(failures === 0 ? "\nAll smoke checks passed." : `\n${failures} smoke check(s) FAILED.`);
process.exit(failures === 0 ? 0 : 1);
async function runChecks() {
const status = await api("GET", "/_status");
check("health check responds", status.body.success === true);
const loggedOut = await api("GET", "/v1/session");
check("logged-out session says so", loggedOut.body.logged_in === false);
check("logged-out session sends user: null", loggedOut.body.user === null);
const badLogin = await api("POST", "/v1/login", {
username_or_email: "localdev1",
password: "wrong",
});
check("bad password is a 401", badLogin.status === 401);
check("bad password uses the error_type dialect", badLogin.body.error_type === "InvalidCredentials");
const login = await api("POST", "/v1/login", {
username_or_email: "localdev1",
password: "localdev1pass",
});
check("login succeeds", login.body.success === true);
check("login returns a signed session", typeof login.body.signed_session === "string");
const session = await api("GET", "/v1/session");
check("session cookie authenticates", session.body.logged_in === true);
check("session carries feature flags", Array.isArray(session.body.user?.maybe_feature_flags));
const credits = await api("GET", "/v1/credits/namespace/artcraft");
const startingCredits = credits.body.sum_total_credits;
check("credits are readable", startingCredits > 0);
const library = await api("GET", "/v1/media_files/list/user/localdev1");
check("seeded library is not empty", library.body.results.length >= 6);
check("library rows carry a media_class", library.body.results.every((row) => row.media_class !== "unknown"));
const firstImage = library.body.results.find((row) => row.media_class === "image");
const asset = await fetch(firstImage.media_links.cdn_url);
check("cdn_url serves real bytes", asset.ok && Number(asset.headers.get("content-length")) > 1000);
check("cdn_url serves the right content type", asset.headers.get("content-type") === "image/jpeg");
const thumbnail = await fetch(firstImage.media_links.maybe_thumbnail_template.replace("{WIDTH}", "256"));
check("thumbnail template resolves", thumbnail.ok);
const models = await api("GET", "/v1/omni_gen/models/image");
check("image catalogue is populated", models.body.models.length > 10);
check("catalogue lists providers", models.body.providers.length > 0);
const cost = await api("POST", "/v1/omni_gen/cost/image", { model: "flux_1_dev", prompt: "a cat" });
check("cost estimate returns credits", cost.body.cost_in_credits > 0);
const unknownModel = await api("POST", "/v1/omni_gen/cost/image", { model: "not_a_model" });
check("unknown model is rejected", unknownModel.status === 400);
await checkSuccessfulGeneration(startingCredits, cost.body.cost_in_credits);
await checkFailureTrigger();
await checkPaymentTrigger();
await checkUpload();
await checkFoldersAndTags();
const unimplemented = await api("GET", "/v1/definitely/not/a/real/route");
check("unknown routes 501 loudly", unimplemented.status === 501);
check("unknown routes name the fake", String(unimplemented.body.message).includes("fake-storyteller-web"));
}
async function checkSuccessfulGeneration(startingCredits, cost) {
const generate = await api("POST", "/v1/omni_gen/generate/image", {
idempotency_token: randomUuid(),
model: "flux_1_dev",
prompt: "a lighthouse at dusk",
});
check("generation returns a job token", typeof generate.body.inference_job_token === "string");
const jobToken = generate.body.inference_job_token;
const pending = await api("GET", `/v1/jobs/job/${jobToken}`);
check("job starts pending", ["pending", "started"].includes(pending.body.state.status.status));
const finished = await pollJob(jobToken);
check("job completes successfully", finished.status.status === "complete_success");
check("job reports 100% progress", finished.status.progress_percentage === 100);
check("job carries a result media file", typeof finished.maybe_result?.entity_token === "string");
const resultAsset = await fetch(finished.maybe_result.media_links.cdn_url);
check("generated media is downloadable", resultAsset.ok);
const afterCredits = await api("GET", "/v1/credits/namespace/artcraft");
check(
`credits were spent (${startingCredits} -> ${afterCredits.body.sum_total_credits})`,
afterCredits.body.sum_total_credits === startingCredits - cost,
);
const sessionJobs = await api("GET", "/v1/jobs/session");
check("session job list includes the job", sessionJobs.body.jobs.some((job) => job.job_token === jobToken));
check(
"session jobs expose the wide failure field",
sessionJobs.body.jobs.every((job) => "maybe_failure_category_updated" in job.status),
);
const batch = await api("GET", `/v1/jobs/batch?tokens=${jobToken}`);
check("batch job lookup works", batch.body.job_states.length === 1);
const emptyBatch = await api("GET", "/v1/jobs/batch");
check("batch with no tokens returns an empty list", emptyBatch.body.job_states.length === 0);
}
async function checkFailureTrigger() {
const generate = await api("POST", "/v1/omni_gen/generate/image", {
idempotency_token: randomUuid(),
model: "flux_1_dev",
prompt: "portrait simulate_artcraft_failure face_not_detected",
});
const finished = await pollJob(generate.body.inference_job_token);
check("failure trigger fails the job", finished.status.status === "complete_failure");
const sessionJobs = await api("GET", "/v1/jobs/session");
const failed = sessionJobs.body.jobs.find((job) => job.job_token === generate.body.inference_job_token);
check(
"failure category is reported",
failed.status.maybe_failure_category_updated === "face_not_detected",
);
}
async function checkPaymentTrigger() {
const generate = await api("POST", "/v1/omni_gen/generate/image", {
idempotency_token: randomUuid(),
model: "flux_1_dev",
prompt: "trigger_payment_failure",
});
check("payment trigger returns 402", generate.status === 402);
}
async function checkUpload() {
const pngBytes = Buffer.from(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bfabd40000000049454e44ae426082",
"hex",
);
const form = new FormData();
form.append("uuid_idempotency_token", randomUuid());
form.append("maybe_title", "smoke upload");
form.append("file", new Blob([pngBytes], { type: "image/png" }), "smoke.png");
const upload = await fetch(`${ORIGIN}/v1/media_files/upload/image`, {
method: "POST",
headers: { Cookie: cookie },
body: form,
});
const uploadBody = await upload.json();
check("upload succeeds", uploadBody.success === true);
check("upload returns a media token", typeof uploadBody.media_file_token === "string");
const record = await api("GET", `/v1/media_files/file/${uploadBody.media_file_token}`);
check("uploaded file is readable", record.body.media_file.maybe_title === "smoke upload");
check("uploaded file is flagged as an upload", record.body.media_file.is_user_upload === true);
const served = await fetch(record.body.media_file.media_links.cdn_url);
const servedBytes = Buffer.from(await served.arrayBuffer());
check("uploaded bytes round-trip exactly", servedBytes.equals(pngBytes));
const listed = await api("GET", "/v1/media_files/list/user/localdev1");
check(
"uploaded file appears in the library",
listed.body.results.some((row) => row.token === uploadBody.media_file_token),
);
}
async function checkFoldersAndTags() {
const folders = await api("GET", "/v1/folders/list_all");
check("seeded folder is listed", folders.body.folders.length >= 1);
check("folder carries thumbnails", folders.body.folders[0].last_media_thumbnails.length > 0);
const created = await api("POST", "/v1/folders/create", { name: "Smoke folder" });
check("folder creation works", created.body.folder.name === "Smoke folder");
const library = await api("GET", "/v1/media_files/list/user/localdev1");
const mediaToken = library.body.results[0].token;
const added = await api("POST", `/v1/folders/media_files/${created.body.folder.token}/bulk_add`, {
media_file_tokens: [mediaToken],
});
check("bulk add accepts the media file", added.body.accepted_media_file_tokens.length === 1);
const contents = await api("GET", `/v1/folders/media_files/${created.body.folder.token}`);
check("folder contents come back", contents.body.media_files.length === 1);
const tags = await api("GET", "/v1/tags/list");
check("seeded tags are listed", tags.body.tags.length >= 2);
check("tags report a use count", tags.body.tags.every((tag) => tag.use_count > 0));
const tagged = await api("POST", `/v1/tags/media_file/add/${mediaToken}`, {
maybe_tags_list: ["smoke"],
});
check("tagging works", tagged.body.tags.some((tag) => tag.tag_value === "smoke"));
}
async function pollJob(jobToken) {
const deadline = Date.now() + POLL_TIMEOUT_MILLIS;
while (Date.now() < deadline) {
const response = await api("GET", `/v1/jobs/job/${jobToken}`);
const state = response.body.state;
if (state.status.status === "complete_success" || state.status.status === "complete_failure") {
return state;
}
await sleep(250);
}
throw new Error(`job ${jobToken} did not finish within ${POLL_TIMEOUT_MILLIS}ms`);
}
async function api(method, path, body) {
const response = await fetch(`${ORIGIN}${path}`, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...(cookie === "" ? {} : { Cookie: cookie }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const setCookie = response.headers.get("set-cookie");
if (setCookie !== null && setCookie.startsWith("session=") && !setCookie.includes("Max-Age=0")) {
cookie = setCookie.split(";")[0];
}
const text = await response.text();
let parsed = {};
try {
parsed = JSON.parse(text);
} catch {
parsed = { raw: text };
}
return { status: response.status, body: parsed };
}
function check(description, condition) {
if (condition) {
console.log(` ok ${description}`);
return;
}
console.log(` FAIL ${description}`);
failures += 1;
}
async function waitForServer() {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
await fetch(`${ORIGIN}/_status`);
return;
} catch {
await sleep(100);
}
}
throw new Error("fake-storyteller-web did not start in time");
}
function randomUuid() {
return crypto.randomUUID();
}
function sleep(millis) {
return new Promise((resolve) => setTimeout(resolve, millis));
}
@@ -0,0 +1,94 @@
/**
* Session handling.
*
* The real backend issues an HS256 JWT, returns it as `signed_session` in the
* body, and sets the same string as a `session` cookie. The frontend uses both:
* the cookie normally, and a `session` header when cookies are unavailable. The
* fake mirrors all three so no client path has to be special-cased.
*
* The signature is real HMAC but the secret is a constant in this file. That is
* fine — this server has nothing to protect — and it keeps tokens inspectable.
*/
import { createHmac } from "node:crypto";
import type { RequestContext } from "./http/context.ts";
import type { UserRecord } from "./state/entities.ts";
import { store } from "./state/store.ts";
import { nowIso } from "./state/clock.ts";
import { makeToken, TOKEN_PREFIX } from "./state/tokens.ts";
const SESSION_COOKIE_NAME = "session";
const VISITOR_COOKIE_NAME = "visitor";
const SIGNING_SECRET = "fake-storyteller-web-development-only";
const COOKIE_MAX_AGE_SECONDS = 630_720_000;
/** Create a session for a user and register it. Returns the signed session string. */
export function startSession(user: UserRecord): string {
const signedSession = signJwt({
session_token: makeToken(TOKEN_PREFIX.appSession),
user_token: user.userToken,
version: "3",
});
store.sessionsBySignedSession.set(signedSession, {
signedSession,
userToken: user.userToken,
createdAt: nowIso(),
});
return signedSession;
}
export function endSession(signedSession: string | undefined): void {
if (signedSession !== undefined) {
store.sessionsBySignedSession.delete(signedSession);
}
}
/** The logged-in user for this request, or undefined. Cookie wins over header, as in the real backend. */
export function currentUser(context: RequestContext): UserRecord | undefined {
const signedSession = currentSignedSession(context);
if (signedSession === undefined) {
return undefined;
}
const session = store.sessionsBySignedSession.get(signedSession);
if (session === undefined) {
return undefined;
}
return store.usersByToken.get(session.userToken);
}
export function currentSignedSession(context: RequestContext): string | undefined {
return context.cookie(SESSION_COOKIE_NAME) ?? context.sessionHeader();
}
/** `Set-Cookie` value establishing the session. Not `Secure`, because dev runs over http. */
export function sessionCookieHeader(signedSession: string): string {
return `${SESSION_COOKIE_NAME}=${encodeURIComponent(signedSession)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
}
export function clearedSessionCookieHeader(): string {
return `${SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
}
/** The anonymous-visitor cookie the real `/v1/session` sets when one is absent. */
export function visitorCookieHeaderIfMissing(context: RequestContext): string | undefined {
if (context.cookie(VISITOR_COOKIE_NAME) !== undefined) {
return undefined;
}
const visitor = signJwt({ visitor_token: makeToken(TOKEN_PREFIX.appSession), version: "1" });
return `${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitor)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
}
function signJwt(payload: Record<string, string>): string {
const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64Url(JSON.stringify(payload));
const signature = createHmac("sha256", SIGNING_SECRET).update(`${header}.${body}`).digest("base64url");
return `${header}.${body}.${signature}`;
}
function base64Url(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
@@ -0,0 +1,65 @@
/** Server configuration. Everything has a working default; nothing is required. */
import { dirname, join } from "node:path";
import { existsSync } from "node:fs";
/** Matches the real backend's dev port, so the webapp needs no configuration change. */
const DEFAULT_PORT = 12345;
/** How long a generation job stays pending before the resolver completes it. */
const DEFAULT_RESOLVE_SECONDS = 6;
export const config = {
port: readNumber("FAKE_API_PORT", DEFAULT_PORT),
host: process.env["FAKE_API_HOST"] ?? "127.0.0.1",
/**
* Origin used to build `cdn_url` values. Media is served by this same
* process, so it points back at itself unless overridden.
*/
publicBaseUrl: (process.env["FAKE_API_PUBLIC_BASE_URL"] ?? "").replace(/\/$/, ""),
/** Seconds a submitted generation stays `pending`. Set to 0 to resolve immediately. */
resolveSeconds: readNumber("FAKE_API_RESOLVE_SECONDS", DEFAULT_RESOLVE_SECONDS),
/** Credits granted to the seeded demo user. */
demoCredits: readNumber("FAKE_API_DEMO_CREDITS", 100_000),
/** Repository root, used to load fixture media out of `test_data/`. */
repoRoot: findRepoRoot(),
} as const;
/** The origin to embed in generated URLs, resolved lazily so the port is known. */
export function publicOrigin(): string {
if (config.publicBaseUrl.length > 0) {
return config.publicBaseUrl;
}
return `http://localhost:${config.port}`;
}
function readNumber(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** Walk up from this file until the directory holding `Cargo.toml` and `test_data/`. */
function findRepoRoot(): string {
let current = import.meta.dirname;
for (let depth = 0; depth < 10; depth += 1) {
if (existsSync(join(current, "Cargo.toml")) && existsSync(join(current, "test_data"))) {
return current;
}
const parent = dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return import.meta.dirname;
}
@@ -0,0 +1,271 @@
/**
* The model catalogues served by `/v1/omni_gen/models/*`.
*
* The real catalogues are hand-tuned per model in Rust. Reproducing every
* capability flag verbatim would rot immediately, so the fake keeps the real
* model ids, creators and display names — the parts the UI shows and branches
* on — and applies one sensible capability profile per media type.
*
* Optional capability fields are left `undefined`, which drops them from the
* JSON. That matches the real backend, which omits absent capabilities rather
* than sending nulls.
*/
const IMAGE_ASPECT_RATIOS = [
"square_hd",
"square",
"tall_three_by_four",
"tall_nine_by_sixteen",
"wide_four_by_three",
"wide_sixteen_by_nine",
];
const VIDEO_ASPECT_RATIOS = ["square", "tall_nine_by_sixteen", "wide_sixteen_by_nine"];
const RESOLUTIONS = ["one_k", "two_k", "four_k"];
const QUALITIES = ["low", "medium", "high"];
type ModelSeed = [model: string, creator: string, fullName: string];
const IMAGE_MODELS: ModelSeed[] = [
["flux_1_dev", "black_forest_labs", "FLUX.1 [dev]"],
["flux_1_schnell", "black_forest_labs", "FLUX.1 [schnell]"],
["flux_pro_1p1", "black_forest_labs", "FLUX1.1 [pro]"],
["flux_pro_1p1_ultra", "black_forest_labs", "FLUX1.1 [pro] ultra"],
["flux_2_lora_angles", "black_forest_labs", "FLUX.2 Angles"],
["gpt_image_1", "open_ai", "GPT Image 1"],
["gpt_image_1p5", "open_ai", "GPT Image 1.5"],
["gpt_image_2", "open_ai", "GPT Image 2"],
["grok_imagine_image", "grok", "Grok Imagine"],
["grok_imagine_image_q", "grok", "Grok Imagine Quick"],
["midjourney_7", "midjourney", "Midjourney v7"],
["midjourney_7_niji", "midjourney", "Midjourney Niji v7"],
["midjourney_8", "midjourney", "Midjourney v8"],
["nano_banana", "google", "Nano Banana"],
["nano_banana_2", "google", "Nano Banana 2"],
["nano_banana_pro", "google", "Nano Banana Pro"],
["qwen_edit_2511_angles", "alibaba", "Qwen Edit Angles"],
["seedream_4", "bytedance", "Seedream 4"],
["seedream_4p5", "bytedance", "Seedream 4.5"],
["seedream_5_lite", "bytedance", "Seedream 5 Lite"],
["seedream_5p0_pro", "bytedance", "Seedream 5 Pro"],
["seedream_5p0_pro_u", "bytedance", "Seedream 5 Pro Ultra"],
];
const VIDEO_MODELS: ModelSeed[] = [
["grok_video", "grok", "Grok Video"],
["grok_imagine_video", "grok", "Grok Imagine Video"],
["grok_imagine_video_1p5", "grok", "Grok Imagine Video 1.5"],
["kling_1p6_pro", "kling", "Kling 1.6 Pro"],
["kling_2p1_pro", "kling", "Kling 2.1 Pro"],
["kling_2p1_master", "kling", "Kling 2.1 Master"],
["kling_2p5_turbo_pro", "kling", "Kling 2.5 Turbo Pro"],
["kling_2p6_pro", "kling", "Kling 2.6 Pro"],
["kling_3p0_standard", "kling", "Kling 3.0 Standard"],
["kling_3p0_pro", "kling", "Kling 3.0 Pro"],
["happy_horse_1p0", "artcraft", "Happy Horse 1.0"],
["seedance_1p0_lite", "bytedance", "Seedance 1.0 Lite"],
["seedance_1p5_pro", "bytedance", "Seedance 1.5 Pro"],
["seedance_2p0", "bytedance", "Seedance 2.0"],
["seedance_2p0_fast", "bytedance", "Seedance 2.0 Fast"],
["seedance_2p0_mini", "bytedance", "Seedance 2.0 Mini"],
["sora_2", "open_ai", "Sora 2"],
["sora_2_pro", "open_ai", "Sora 2 Pro"],
["veo_2", "google", "Veo 2"],
["veo_3", "google", "Veo 3"],
["veo_3_fast", "google", "Veo 3 Fast"],
["veo_3p1", "google", "Veo 3.1"],
["veo_3p1_fast", "google", "Veo 3.1 Fast"],
["veo_3p1_lite", "google", "Veo 3.1 Lite"],
["vidu_q3", "vidu", "Vidu Q3"],
["vidu_q3_turbo", "vidu", "Vidu Q3 Turbo"],
];
const AUDIO_MODELS: ModelSeed[] = [
["suno_music", "suno", "Suno Music"],
["suno_remix", "suno", "Suno Remix"],
["suno_sounds", "suno", "Suno Sounds"],
["suno_sample", "suno", "Suno Sample"],
["seed_audio_1p0", "bytedance", "Seed Audio 1.0"],
];
const MESH_MODELS: ModelSeed[] = [
["hunyuan_3d_2p0", "tencent", "Hunyuan3D 2.0"],
["hunyuan_3d_2p1", "tencent", "Hunyuan3D 2.1"],
["hunyuan_3d_3", "tencent", "Hunyuan3D 3"],
["hunyuan_3d_3_sketch", "tencent", "Hunyuan3D 3 Sketch"],
["hunyuan_3d_3p1_pro", "tencent", "Hunyuan3D 3.1 Pro"],
["hunyuan_3d_3p1_rapid", "tencent", "Hunyuan3D 3.1 Rapid"],
["tripo3d_h3p1", "tripo", "Tripo3D H3.1"],
["meshy_v6", "meshy", "Meshy v6"],
["rodin_2p5_fast", "deemos", "Rodin 2.5 Fast"],
];
const SPLAT_MODELS: ModelSeed[] = [
["marble_0p1_mini", "world_labs", "Marble 0.1 Mini"],
["marble_0p1_plus", "world_labs", "Marble 0.1 Plus"],
["marble_1p0", "world_labs", "Marble 1.0"],
["marble_1p0_draft", "world_labs", "Marble 1.0 Draft"],
["marble_1p1", "world_labs", "Marble 1.1"],
["marble_1p1_plus", "world_labs", "Marble 1.1 Plus"],
["triposplat", "tripo", "TripoSplat"],
];
/** Which provider each model is routed through, for the `providers` block. */
const PROVIDER_BY_CREATOR: Record<string, string> = {
artcraft: "artcraft",
grok: "grok",
midjourney: "midjourney",
open_ai: "sora",
world_labs: "world_labs",
};
export function imageModelsResponse(): object {
return catalogueResponse(IMAGE_MODELS, (seed) => ({
...baseModel(seed),
text_prompt_supported: true,
text_prompt_max_length: 2000,
image_refs_supported: true,
image_refs_max: 4,
has_fixed_editing_aspect_ratio: false,
aspect_ratio_options: IMAGE_ASPECT_RATIOS,
aspect_ratio_default: "square",
resolution_options: RESOLUTIONS,
resolution_default: "one_k",
quality_options: QUALITIES,
default_quality: "high",
batch_size_min: 1,
batch_size_max: 4,
batch_size_options: [1, 2, 3, 4],
batch_size_default: 1,
is_disabled: false,
}));
}
export function videoModelsResponse(): object {
return catalogueResponse(VIDEO_MODELS, (seed) => ({
...baseModel(seed),
extra_info: "Served by fake-storyteller-web. Capabilities are representative, not exact.",
extra_info_short: "Fake backend",
text_to_video_supported: true,
text_prompt_supported: true,
text_prompt_max_length: 2000,
starting_keyframe_supported: true,
starting_keyframe_required: false,
ending_keyframe_supported: true,
image_references_supported: true,
image_references_max: 4,
character_references_supported: true,
character_references_max: 2,
show_generate_with_sound_toggle: true,
aspect_ratio_options: VIDEO_ASPECT_RATIOS,
aspect_ratio_default: "wide_sixteen_by_nine",
resolution_options: ["seven_twenty_p", "ten_eighty_p"],
resolution_default: "seven_twenty_p",
bitrate_options: ["normal", "high"],
bitrate_default: "normal",
quality_options: QUALITIES,
default_quality: "high",
duration_seconds_min: 4,
duration_seconds_max: 10,
duration_seconds_options: [4, 6, 8, 10],
duration_seconds_default: 6,
batch_size_min: 1,
batch_size_max: 2,
batch_size_options: [1, 2],
batch_size_default: 1,
is_disabled: false,
}));
}
export function audioModelsResponse(): object {
return catalogueResponse(AUDIO_MODELS, (seed) => ({
...baseModel(seed),
extra_info: "Served by fake-storyteller-web.",
extra_info_short: "Fake backend",
text_prompt_supported: true,
style_prompt_supported: true,
audio_references_supported: true,
audio_references_max: 2,
image_references_supported: false,
keep_lyrics_supported: true,
instrumental_toggle_supported: true,
loopable_toggle_supported: true,
bpm_supported: true,
musical_key_supported: true,
sample_rate_hz_options: [44_100, 48_000],
sample_rate_hz_default: 44_100,
speed_supported: true,
volume_supported: true,
pitch_supported: true,
is_disabled: false,
}));
}
export function meshModelsResponse(): object {
return catalogueResponse(MESH_MODELS, (seed) => ({
...baseModel(seed),
extra_info: "Served by fake-storyteller-web.",
extra_info_short: "Fake backend",
text_prompt_supported: true,
image_input_supported: true,
sketch_input_supported: false,
multi_view_supported: true,
mesh_input_supported: false,
mesh_output_types: ["normal", "low_poly", "geometry"],
polygon_types: ["triangle", "quad"],
face_count_supported: true,
pbr_supported: true,
texture_toggle_supported: true,
texture_quality_supported: true,
geometry_quality_supported: true,
is_disabled: false,
}));
}
export function splatModelsResponse(): object {
return catalogueResponse(SPLAT_MODELS, (seed) => ({
...baseModel(seed),
extra_info: "Served by fake-storyteller-web.",
extra_info_short: "Fake backend",
text_prompt_supported: true,
image_references_supported: true,
image_references_max: 1,
video_reference_supported: true,
panorama_supported: true,
disable_recaption_supported: true,
is_disabled: false,
}));
}
/** Whether a model id exists in any catalogue — used to reject unknown models like the real API. */
export function isKnownModel(model: string): boolean {
return [...IMAGE_MODELS, ...VIDEO_MODELS, ...AUDIO_MODELS, ...MESH_MODELS, ...SPLAT_MODELS].some(
([modelId]) => modelId === model,
);
}
function catalogueResponse(seeds: ModelSeed[], build: (seed: ModelSeed) => object): object {
return {
success: true,
models: seeds.map(build),
providers: providerBlock(seeds),
};
}
function baseModel([model, creator, fullName]: ModelSeed): object {
return { model, model_creator: creator, full_name: fullName };
}
function providerBlock(seeds: ModelSeed[]): object[] {
const byProvider = new Map<string, { model: string }[]>();
for (const [model, creator] of seeds) {
const provider = PROVIDER_BY_CREATOR[creator] ?? "fal";
const models = byProvider.get(provider) ?? [];
models.push({ model });
byProvider.set(provider, models);
}
return [...byProvider].map(([provider, models]) => ({ provider, models }));
}
@@ -0,0 +1,112 @@
/**
* Prompt-driven test triggers.
*
* Typing one of these words into a prompt forces a specific outcome, which is
* how a developer exercises failure paths without a real provider. The flag
* names match the ones the team already uses.
*
* simulate_artcraft_failure [category] fail after the normal delay
* test_artcraft_failure [category] alias of the above
* artcraft_test_job_failure fail immediately, no pending phase
* trigger_payment_failure reject the submission with 402
* simulate_artcraft_slow [seconds] stay pending for the given seconds
*
* `category` is any `FrontendFailureCategory` value, e.g.
* `simulate_artcraft_failure face_not_detected`. Omitted, it is
* `generation_failed`.
*/
/** Every failure category the frontend knows how to render. */
export const FAILURE_CATEGORIES = [
"face_not_detected",
"no_foreground_subject_detected",
"format_not_supported",
"keep_alive_elapsed",
"not_yet_implemented",
"retryable_worker_error",
"model_rules_violation",
"rule_bans_user_image",
"rule_bans_user_image_with_faces",
"rule_bans_user_text_prompt",
"rule_bans_user_content",
"rule_bans_generated_video",
"rule_bans_generated_audio",
"rule_bans_generated_content",
"filesize_too_large",
"image_dimensions_too_small",
"image_dimensions_too_large",
"generation_failed",
] as const;
export type FailureCategory = (typeof FAILURE_CATEGORIES)[number];
const DEFAULT_FAILURE_CATEGORY: FailureCategory = "generation_failed";
const FAILURE_FLAGS = ["simulate_artcraft_failure", "test_artcraft_failure"];
const IMMEDIATE_FAILURE_FLAG = "artcraft_test_job_failure";
const PAYMENT_FAILURE_FLAG = "trigger_payment_failure";
const SLOW_FLAG = "simulate_artcraft_slow";
export interface PromptTriggers {
/** Reject the submission outright with a 402, before a job is created. */
rejectWithPaymentRequired: boolean;
/** Complete the job as a failure rather than a success. */
failWithCategory: FailureCategory | undefined;
/** Skip the pending phase entirely and land in a terminal state immediately. */
resolveImmediately: boolean;
/** Override how long the job stays pending, in seconds. */
overrideResolveSeconds: number | undefined;
}
export function readPromptTriggers(prompt: string | undefined): PromptTriggers {
const text = (prompt ?? "").toLowerCase();
const immediate = text.includes(IMMEDIATE_FAILURE_FLAG);
const delayedFailureFlag = FAILURE_FLAGS.find((flag) => text.includes(flag));
return {
rejectWithPaymentRequired: text.includes(PAYMENT_FAILURE_FLAG),
failWithCategory:
immediate || delayedFailureFlag !== undefined
? readCategoryAfter(text, delayedFailureFlag ?? IMMEDIATE_FAILURE_FLAG)
: undefined,
resolveImmediately: immediate,
overrideResolveSeconds: readSecondsAfter(text, SLOW_FLAG),
};
}
/** The word following a flag, when it names a known failure category. */
function readCategoryAfter(text: string, flag: string): FailureCategory {
const nextWord = wordAfter(text, flag);
if (nextWord === undefined) {
return DEFAULT_FAILURE_CATEGORY;
}
const match = FAILURE_CATEGORIES.find((category) => category === nextWord);
return match ?? DEFAULT_FAILURE_CATEGORY;
}
function readSecondsAfter(text: string, flag: string): number | undefined {
if (!text.includes(flag)) {
return undefined;
}
const nextWord = wordAfter(text, flag);
if (nextWord === undefined) {
return undefined;
}
const seconds = Number.parseInt(nextWord, 10);
return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
}
function wordAfter(text: string, flag: string): string | undefined {
const flagIndex = text.indexOf(flag);
if (flagIndex < 0) {
return undefined;
}
const remainder = text.slice(flagIndex + flag.length);
const words = remainder.split(/[^a-z0-9_]+/).filter((word) => word.length > 0);
return words[0];
}
@@ -0,0 +1,154 @@
/**
* The background resolver.
*
* Submitted jobs sit `pending`, tick their progress upward, then land in
* `complete_success` or `complete_failure`. This is what makes the fake usable
* for the flow the team actually needs to exercise: submit, watch a spinner for
* a few seconds, then get a result or a specific failure.
*/
import { nowIso } from "../state/clock.ts";
import type { JobRecord, MediaFileRecord } from "../state/entities.ts";
import { createMediaFile, createMediaFileFromFixture } from "../state/media_factory.ts";
import { store } from "../state/store.ts";
import { makeGlbBytes, makePlyBytes, makeWavBytes } from "../state/synthetic_assets.ts";
import { readPromptTriggers } from "./prompt_flags.ts";
const TICK_INTERVAL_MILLIS = 500;
/** Progress stops here until the job actually completes, so 100% always means done. */
const MAX_PENDING_PROGRESS = 95;
export function startGenerationResolver(): void {
const timer = setInterval(tick, TICK_INTERVAL_MILLIS);
timer.unref();
}
function tick(): void {
const now = Date.now();
for (const job of store.jobsByToken.values()) {
if (job.status !== "pending" && job.status !== "started") {
continue;
}
if (now >= job.resolveAtMillis) {
finishJob(job);
continue;
}
advanceProgress(job, now);
}
}
function advanceProgress(job: JobRecord, now: number): void {
const startedAtMillis = Date.parse(job.createdAt);
const totalMillis = Math.max(1, job.resolveAtMillis - startedAtMillis);
const fraction = Math.min(1, Math.max(0, (now - startedAtMillis) / totalMillis));
job.status = "started";
job.progressPercentage = Math.floor(fraction * MAX_PENDING_PROGRESS);
job.updatedAt = nowIso();
}
function finishJob(job: JobRecord): void {
const triggers = readPromptTriggers(promptTextFor(job));
if (triggers.failWithCategory !== undefined) {
job.status = "complete_failure";
job.progressPercentage = 0;
job.maybeFailureCategory = triggers.failWithCategory;
job.maybeFailureMessage = `fake-storyteller-web: forced failure (${triggers.failWithCategory}).`;
job.updatedAt = nowIso();
console.log(`[fake-api] job ${job.jobToken} failed on purpose (${triggers.failWithCategory})`);
return;
}
const result = createResultMediaFile(job);
job.status = "complete_success";
job.progressPercentage = 100;
job.maybeResultMediaFileToken = result.token;
job.maybeSuccessfullyCompletedAt = nowIso();
job.updatedAt = job.maybeSuccessfullyCompletedAt;
console.log(`[fake-api] job ${job.jobToken} completed -> ${result.token}`);
}
function createResultMediaFile(job: JobRecord): MediaFileRecord {
const shared = {
maybeCreatorUserToken: job.maybeCreatorUserToken,
maybePromptToken: job.maybePromptToken,
maybeBatchToken: job.maybeBatchToken,
maybeOriginModelType: job.maybeModelType,
maybeTitle: job.maybeRawInferenceText?.slice(0, 80),
originCategory: "inference" as const,
isUserUpload: false,
};
switch (job.inferenceCategory) {
case "video_generation":
return createMediaFileFromFixture("video", {
...shared,
mediaClass: "video",
mediaType: "mp4",
bucketPrefix: "video_",
extension: ".mp4",
originProductCategory: "video_gen",
maybeDurationMillis: 5_000,
});
case "audio_generation":
return createMediaFile({
...shared,
bytes: makeWavBytes(),
mediaClass: "audio",
mediaType: "wav",
bucketPrefix: "audio_",
extension: ".wav",
originProductCategory: "unknown",
maybeDurationMillis: 2_000,
});
case "object_generation":
return createMediaFile({
...shared,
bytes: makeGlbBytes(),
mediaClass: "mesh",
mediaType: "glb",
bucketPrefix: "engine_",
extension: ".glb",
originProductCategory: "world_gen",
});
case "splat_generation":
return createMediaFile({
...shared,
bytes: makePlyBytes(),
mediaClass: "splat",
mediaType: "ply",
bucketPrefix: "splat_",
extension: ".ply",
originProductCategory: "world_gen",
});
default:
return createMediaFileFromFixture("image", {
...shared,
mediaClass: "image",
mediaType: "jpg",
bucketPrefix: "image_",
extension: ".jpg",
originProductCategory: "image_gen",
});
}
}
function promptTextFor(job: JobRecord): string | undefined {
if (job.maybeRawInferenceText !== undefined) {
return job.maybeRawInferenceText;
}
if (job.maybePromptToken === undefined) {
return undefined;
}
return store.promptsByToken.get(job.maybePromptToken)?.maybePositivePrompt;
}
@@ -0,0 +1,198 @@
/**
* Cost estimation and job submission, shared by all five `omni_gen` media types.
*
* Costs are deterministic functions of the request rather than a price table,
* so a test can predict the credit balance after a generation without knowing
* anything about real pricing.
*/
import { config } from "../config.ts";
import { nowIso } from "../state/clock.ts";
import type { InferenceCategory, JobRecord, PromptRecord, UserRecord } from "../state/entities.ts";
import { store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { readPromptTriggers } from "./prompt_flags.ts";
export type GenerationKind = "image" | "video" | "audio" | "mesh" | "splat";
/** Base price per media type, before batch and duration multipliers. */
const BASE_COST_CREDITS: Record<GenerationKind, number> = {
image: 34,
video: 210,
audio: 60,
mesh: 104,
splat: 150,
};
const INFERENCE_CATEGORY: Record<GenerationKind, InferenceCategory> = {
image: "image_generation",
video: "video_generation",
audio: "audio_generation",
mesh: "object_generation",
splat: "splat_generation",
};
const UUID_PATTERN = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export interface GenerateRequestBody {
idempotency_token?: string;
model?: string;
prompt?: string;
negative_prompt?: string;
style_prompt?: string;
resolution?: string;
aspect_ratio?: string;
quality?: string;
bitrate?: string;
duration_seconds?: number;
image_batch_count?: number;
video_batch_count?: number;
generate_audio?: boolean;
image_media_tokens?: string[];
reference_image_media_tokens?: string[];
}
export interface CostBreakdown {
cost_in_credits: number;
cost_in_usd_cents: number;
is_free: boolean;
is_unlimited: boolean;
is_rate_limited: boolean;
has_watermark: boolean;
failures_are_refunded: boolean;
}
export function estimateCost(kind: GenerationKind, body: GenerateRequestBody): CostBreakdown {
const credits = BASE_COST_CREDITS[kind] * batchCount(kind, body) * durationMultiplier(kind, body);
return {
cost_in_credits: credits,
cost_in_usd_cents: Math.ceil(credits / 3),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: true,
};
}
export function batchCount(kind: GenerationKind, body: GenerateRequestBody): number {
const requested = kind === "video" ? body.video_batch_count : body.image_batch_count;
return Math.min(Math.max(requested ?? 1, 1), 4);
}
/** Whether this idempotency token has already been spent. */
export function isReplayedIdempotencyToken(token: string | undefined): boolean {
return token !== undefined && store.usedIdempotencyTokens.has(token);
}
export function isValidIdempotencyToken(token: string | undefined): boolean {
return token !== undefined && UUID_PATTERN.test(token);
}
export interface SubmittedGeneration {
promptToken: string;
jobTokens: string[];
}
/**
* Record the prompt, spend the credits, and queue one job per batch item. The
* resolver thread finishes them later; nothing here contacts a provider.
*/
export function submitGeneration(
kind: GenerationKind,
body: GenerateRequestBody,
user: UserRecord,
): SubmittedGeneration {
const cost = estimateCost(kind, body);
spendCredits(user, cost.cost_in_credits);
if (body.idempotency_token !== undefined) {
store.usedIdempotencyTokens.add(body.idempotency_token);
}
const prompt = recordPrompt(kind, body);
const triggers = readPromptTriggers(body.prompt);
const resolveSeconds = triggers.resolveImmediately
? 0
: triggers.overrideResolveSeconds ?? config.resolveSeconds;
const resolveAtMillis = Date.now() + resolveSeconds * 1000;
const count = batchCount(kind, body);
const maybeBatchToken = count > 1 ? makeToken(TOKEN_PREFIX.batchGeneration) : undefined;
const jobTokens: string[] = [];
for (let index = 0; index < count; index += 1) {
const timestamp = nowIso();
const job: JobRecord = {
jobToken: makeToken(TOKEN_PREFIX.inferenceJob),
inferenceCategory: INFERENCE_CATEGORY[kind],
status: "pending",
progressPercentage: 0,
maybePromptToken: prompt.token,
maybeModelType: body.model,
maybeModelTitle: body.model,
maybeRawInferenceText: body.prompt,
maybeCreatorUserToken: user.userToken,
maybeBatchToken,
maybeResultMediaFileToken: undefined,
maybeFailureCategory: undefined,
maybeFailureMessage: undefined,
maybeSuccessfullyCompletedAt: undefined,
resolveAtMillis,
isDismissed: false,
createdAt: timestamp,
updatedAt: timestamp,
};
store.jobsByToken.set(job.jobToken, job);
jobTokens.push(job.jobToken);
}
return { promptToken: prompt.token, jobTokens };
}
export function hasEnoughCredits(user: UserRecord, cost: number): boolean {
return user.monthlyCredits + user.bankedCredits >= cost;
}
/** Spend monthly credits first, then banked, mirroring the real wallet order. */
function spendCredits(user: UserRecord, cost: number): void {
const fromMonthly = Math.min(user.monthlyCredits, cost);
user.monthlyCredits -= fromMonthly;
user.bankedCredits = Math.max(0, user.bankedCredits - (cost - fromMonthly));
}
function recordPrompt(kind: GenerationKind, body: GenerateRequestBody): PromptRecord {
const prompt: PromptRecord = {
token: makeToken(TOKEN_PREFIX.prompt),
promptType: `${kind}_generation`,
maybePositivePrompt: body.prompt,
maybeNegativePrompt: body.negative_prompt,
maybeModelType: body.model,
maybeModelClass: kind,
maybeGenerationProvider: "artcraft",
maybeAspectRatio: body.aspect_ratio,
maybeResolution: body.resolution,
maybeBatchCount: batchCount(kind, body),
maybeDurationSeconds: body.duration_seconds,
maybeStyleName: undefined,
maybeGenerateAudio: body.generate_audio,
contextImageMediaTokens: [
...(body.image_media_tokens ?? []),
...(body.reference_image_media_tokens ?? []),
],
createdAt: nowIso(),
};
store.promptsByToken.set(prompt.token, prompt);
return prompt;
}
function durationMultiplier(kind: GenerationKind, body: GenerateRequestBody): number {
if (kind !== "video") {
return 1;
}
return Math.max(1, Math.round((body.duration_seconds ?? 6) / 6));
}
@@ -0,0 +1,103 @@
/** Everything a handler needs about the incoming request, already buffered. */
import type { IncomingHttpHeaders } from "node:http";
import { parseMultipart, type MultipartForm } from "./multipart.ts";
export class RequestContext {
readonly method: string;
readonly pathname: string;
readonly query: URLSearchParams;
readonly headers: IncomingHttpHeaders;
readonly body: Buffer;
/** Path parameters captured by the matched route pattern, e.g. `:token`. */
params: Readonly<Record<string, string>>;
private cachedCookies: Record<string, string> | undefined;
private cachedForm: MultipartForm | undefined;
constructor(args: {
method: string;
pathname: string;
query: URLSearchParams;
headers: IncomingHttpHeaders;
body: Buffer;
}) {
this.method = args.method;
this.pathname = args.pathname;
this.query = args.query;
this.headers = args.headers;
this.body = args.body;
this.params = {};
}
/**
* Parse the body as JSON. Returns an empty object for an empty body so
* handlers can destructure without guarding — a fake backend should not 400
* on a shape mismatch that the real one tolerates.
*/
json<T>(): Partial<T> {
if (this.body.length === 0) {
return {};
}
try {
return JSON.parse(this.body.toString("utf8")) as Partial<T>;
} catch {
return {};
}
}
/** Parse the body as `multipart/form-data`. Cached, since parsing copies buffers. */
form(): MultipartForm {
if (this.cachedForm === undefined) {
this.cachedForm = parseMultipart(this.body, this.headers["content-type"]);
}
return this.cachedForm;
}
/** A single query parameter, or undefined when absent or blank. */
queryValue(name: string): string | undefined {
const value = this.query.get(name);
return value === null || value === "" ? undefined : value;
}
/** A query parameter parsed as a positive integer, or undefined. */
queryNumber(name: string): number | undefined {
const raw = this.queryValue(name);
if (raw === undefined) {
return undefined;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
cookie(name: string): string | undefined {
return this.cookies()[name];
}
/** The `session` header the frontend sends when cookies are unavailable. */
sessionHeader(): string | undefined {
const value = this.headers["session"];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
private cookies(): Record<string, string> {
if (this.cachedCookies !== undefined) {
return this.cachedCookies;
}
const jar: Record<string, string> = {};
const header = this.headers.cookie;
if (typeof header === "string") {
for (const pair of header.split(";")) {
const separator = pair.indexOf("=");
if (separator > 0) {
jar[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim());
}
}
}
this.cachedCookies = jar;
return jar;
}
}
@@ -0,0 +1,159 @@
/**
* Minimal `multipart/form-data` parser.
*
* The upload endpoints only need field values and file bytes, so this walks the
* boundary markers directly rather than pulling in a streaming parser. Bodies
* are already buffered in memory — this is a fake backend, uploads are small,
* and nothing here is load bearing for production.
*/
const CRLF = "\r\n";
const HEADER_TERMINATOR = Buffer.from(CRLF + CRLF);
export interface UploadedFile {
fieldName: string;
fileName: string;
contentType: string;
bytes: Buffer;
}
export class MultipartForm {
private readonly fields: Map<string, string>;
private readonly files: Map<string, UploadedFile>;
constructor(fields: Map<string, string>, files: Map<string, UploadedFile>) {
this.fields = fields;
this.files = files;
}
/** Text value of a form field, or undefined when absent. */
field(name: string): string | undefined {
return this.fields.get(name);
}
/** All text fields, for endpoints that forward an open-ended blob of settings. */
allFields(): Record<string, string> {
return Object.fromEntries(this.fields);
}
/** The file uploaded under `name`, or undefined. */
file(name: string): UploadedFile | undefined {
return this.files.get(name);
}
/**
* The first uploaded file under any field name. Upload endpoints disagree on
* whether the part is called `file`, `video`, `image`, or the asset kind, so
* handlers that only care that *a* file arrived use this.
*/
anyFile(): UploadedFile | undefined {
for (const file of this.files.values()) {
return file;
}
return undefined;
}
}
/** Parse a buffered request body. Returns an empty form if `contentType` is not multipart. */
export function parseMultipart(body: Buffer, contentType: string | undefined): MultipartForm {
const fields = new Map<string, string>();
const files = new Map<string, UploadedFile>();
const boundary = readBoundary(contentType);
if (boundary === undefined || body.length === 0) {
return new MultipartForm(fields, files);
}
for (const part of splitParts(body, boundary)) {
const headerEnd = part.indexOf(HEADER_TERMINATOR);
if (headerEnd < 0) {
continue;
}
const headerText = part.subarray(0, headerEnd).toString("utf8");
const content = part.subarray(headerEnd + HEADER_TERMINATOR.length);
const fieldName = readHeaderParameter(headerText, "name");
if (fieldName === undefined) {
continue;
}
const fileName = readHeaderParameter(headerText, "filename");
if (fileName === undefined) {
fields.set(fieldName, content.toString("utf8"));
continue;
}
files.set(fieldName, {
fieldName,
fileName,
contentType: readContentType(headerText) ?? "application/octet-stream",
bytes: content,
});
}
return new MultipartForm(fields, files);
}
function splitParts(body: Buffer, boundary: string): Buffer[] {
const delimiter = Buffer.from(`--${boundary}`);
const parts: Buffer[] = [];
let cursor = body.indexOf(delimiter);
while (cursor >= 0) {
const partStart = cursor + delimiter.length;
// The delimiter after the final part is `--boundary--`.
if (body.subarray(partStart, partStart + 2).toString("latin1") === "--") {
break;
}
const next = body.indexOf(delimiter, partStart);
const partEnd = next < 0 ? body.length : next;
const part = body.subarray(partStart, partEnd);
// Each part is preceded by a CRLF and followed by the CRLF before the next
// delimiter; trim both so file bytes come out byte-exact.
parts.push(trimSurroundingCrlf(part));
if (next < 0) {
break;
}
cursor = next;
}
return parts;
}
function trimSurroundingCrlf(part: Buffer): Buffer {
let start = 0;
let end = part.length;
if (part.subarray(0, 2).toString("latin1") === CRLF) {
start = 2;
}
if (part.subarray(end - 2, end).toString("latin1") === CRLF) {
end -= 2;
}
return part.subarray(start, end);
}
function readBoundary(contentType: string | undefined): string | undefined {
if (contentType === undefined || !contentType.toLowerCase().includes("multipart/form-data")) {
return undefined;
}
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
const boundary = match?.[1] ?? match?.[2];
return boundary?.trim();
}
function readHeaderParameter(headerText: string, parameter: string): string | undefined {
const pattern = new RegExp(`${parameter}=(?:"([^"]*)"|([^;\\r\\n]+))`, "i");
const match = pattern.exec(headerText);
const value = match?.[1] ?? match?.[2];
return value?.trim();
}
function readContentType(headerText: string): string | undefined {
const match = /content-type:\s*([^;\r\n]+)/i.exec(headerText);
return match?.[1]?.trim();
}
@@ -0,0 +1,73 @@
/**
* Response helpers.
*
* Handlers normally return a plain object, which is serialized as a 200 JSON
* body. Return an `HttpResult` when the status, headers, or a binary body
* matter.
*/
export type HeaderMap = Record<string, string | string[]>;
/** An explicit status/header/body triple, for when a bare JSON 200 is not enough. */
export class HttpResult {
readonly status: number;
readonly body: unknown;
readonly headers: HeaderMap;
constructor(status: number, body: unknown, headers: HeaderMap = {}) {
this.status = status;
this.body = body;
this.headers = headers;
}
/** Return a copy with `header` added. Used to attach cookies to an existing result. */
withHeader(name: string, value: string | string[]): HttpResult {
return new HttpResult(this.status, this.body, { ...this.headers, [name]: value });
}
}
/** 200 with an arbitrary JSON payload. */
export function ok<T extends object>(payload: T): HttpResult {
return new HttpResult(200, payload);
}
/** 200 with the `{ success: true, ... }` envelope the real backend uses. */
export function success<T extends object>(payload: T = {} as T): HttpResult {
return new HttpResult(200, { success: true, ...payload });
}
/** Binary response, for serving stored media bytes back to the browser. */
export function bytes(payload: Buffer, contentType: string, headers: HeaderMap = {}): HttpResult {
return new HttpResult(200, payload, { "Content-Type": contentType, ...headers });
}
/**
* The real backend's error envelope. Callers read a mix of `error_type`,
* `error_code`, `error_code_str` and `message` depending on the endpoint and
* how old it is, so the fake always emits all four.
*/
export function failure(status: number, errorCode: string, message: string): HttpResult {
return new HttpResult(status, {
success: false,
error_type: errorCode,
error_code: errorCode,
error_code_str: errorCode,
message,
});
}
export function badRequest(message: string, errorCode = "BadRequest"): HttpResult {
return failure(400, errorCode, message);
}
export function unauthorized(message = "You must be logged in."): HttpResult {
return failure(401, "NotLoggedIn", message);
}
export function notFound(message = "Not found."): HttpResult {
return failure(404, "NotFound", message);
}
export function paymentRequired(message: string): HttpResult {
return failure(402, "InsufficientCredits", message);
}
@@ -0,0 +1,120 @@
/**
* Tiny path router.
*
* Patterns are literal segments plus `:name` captures and a trailing `*name`
* that swallows the rest of the path (used by the media file server).
*/
import type { RequestContext } from "./context.ts";
import type { HttpResult } from "./respond.ts";
export type HandlerResult = HttpResult | object | undefined;
export type RouteHandler = (ctx: RequestContext) => Promise<HandlerResult> | HandlerResult;
export interface RouteMatch {
handler: RouteHandler;
params: Record<string, string>;
}
interface Route {
method: string;
segments: string[];
handler: RouteHandler;
}
export class Router {
private readonly routes: Route[] = [];
get(pattern: string, handler: RouteHandler): void {
this.add("GET", pattern, handler);
}
post(pattern: string, handler: RouteHandler): void {
this.add("POST", pattern, handler);
}
put(pattern: string, handler: RouteHandler): void {
this.add("PUT", pattern, handler);
}
patch(pattern: string, handler: RouteHandler): void {
this.add("PATCH", pattern, handler);
}
delete(pattern: string, handler: RouteHandler): void {
this.add("DELETE", pattern, handler);
}
/** Resolve a request to a handler, or undefined when nothing matches. */
match(method: string, pathname: string): RouteMatch | undefined {
const requested = splitPath(pathname);
for (const route of this.routes) {
if (route.method !== method) {
continue;
}
const params = matchSegments(route.segments, requested);
if (params !== undefined) {
return { handler: route.handler, params };
}
}
return undefined;
}
/** Whether any method is registered at this path — lets the server answer 405 instead of 404. */
allowedMethods(pathname: string): string[] {
const requested = splitPath(pathname);
const allowed = new Set<string>();
for (const route of this.routes) {
if (matchSegments(route.segments, requested) !== undefined) {
allowed.add(route.method);
}
}
return [...allowed];
}
/** Number of registered routes, reported at boot so coverage regressions are visible. */
get size(): number {
return this.routes.length;
}
private add(method: string, pattern: string, handler: RouteHandler): void {
this.routes.push({ method, segments: splitPath(pattern), handler });
}
}
function matchSegments(pattern: string[], requested: string[]): Record<string, string> | undefined {
const params: Record<string, string> = {};
for (let index = 0; index < pattern.length; index += 1) {
const segment = pattern[index]!;
if (segment.startsWith("*")) {
params[segment.slice(1)] = requested.slice(index).join("/");
return params;
}
const value = requested[index];
if (value === undefined) {
return undefined;
}
if (segment.startsWith(":")) {
params[segment.slice(1)] = decodeURIComponent(value);
continue;
}
if (segment !== value) {
return undefined;
}
}
return pattern.length === requested.length ? params : undefined;
}
function splitPath(path: string): string[] {
return path.split("/").filter((segment) => segment.length > 0);
}
@@ -0,0 +1,169 @@
/**
* fake-storyteller-web
*
* An in-memory stand-in for the storyteller-web API, used to run the webapp
* without a backend. It has no database, no cache, and no third-party clients,
* and it is never built or deployed as part of the production image.
*
* Start it with `nx serve fake-storyteller-web`, then point the webapp at
* `http://localhost:12345` (its existing development default).
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { config } from "./config.ts";
import { RequestContext } from "./http/context.ts";
import { HttpResult, failure } from "./http/respond.ts";
import type { Router } from "./http/router.ts";
import { buildRouter } from "./routes/index.ts";
import { startGenerationResolver } from "./generation/resolver.ts";
import { seedState } from "./state/seed.ts";
/** Refuse bodies larger than this rather than buffering without limit. */
const MAX_BODY_BYTES = 256 * 1024 * 1024;
const router = buildRouter();
seedState();
startGenerationResolver();
const server = createServer((request, response) => {
handleRequest(router, request, response).catch((error: unknown) => {
writeResult(response, internalError(error), request);
});
});
server.listen(config.port, config.host, () => {
console.log(`fake-storyteller-web listening on http://${config.host}:${config.port}`);
console.log(` routes registered: ${router.size}`);
console.log(` media served from: ${config.repoRoot}`);
console.log(` generations resolve in: ${config.resolveSeconds}s`);
console.log(" NOTE: all data is in memory and is lost on restart.");
});
async function handleRequest(
activeRouter: Router,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const method = request.method ?? "GET";
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
if (method === "OPTIONS") {
writeResult(response, new HttpResult(204, undefined), request);
return;
}
const match = activeRouter.match(method, url.pathname);
if (match === undefined) {
writeResult(response, unroutedResult(activeRouter, method, url.pathname), request);
return;
}
const context = new RequestContext({
method,
pathname: url.pathname,
query: url.searchParams,
headers: request.headers,
body: await readBody(request),
});
context.params = match.params;
const handlerResult = await match.handler(context);
const result =
handlerResult instanceof HttpResult
? handlerResult
: new HttpResult(200, handlerResult ?? { success: true });
writeResult(response, result, request);
}
function readBody(request: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
request.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_BODY_BYTES) {
reject(new Error(`Request body exceeded ${MAX_BODY_BYTES} bytes`));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => resolve(Buffer.concat(chunks)));
request.on("error", reject);
});
}
function writeResult(response: ServerResponse, result: HttpResult, request: IncomingMessage): void {
const headers: Record<string, string | string[]> = {
...corsHeaders(request),
...result.headers,
};
let payload: Buffer | undefined;
if (Buffer.isBuffer(result.body)) {
payload = result.body;
} else if (result.body !== undefined) {
payload = Buffer.from(JSON.stringify(result.body));
headers["Content-Type"] ??= "application/json";
}
if (payload !== undefined) {
headers["Content-Length"] = String(payload.length);
}
response.writeHead(result.status, headers);
response.end(payload);
}
/**
* Permissive CORS that reflects the caller's origin, because the webapp runs on
* a different port and sends credentialed requests (a wildcard origin is not
* allowed alongside `Allow-Credentials`).
*/
function corsHeaders(request: IncomingMessage): Record<string, string> {
const origin = request.headers.origin;
return {
"Access-Control-Allow-Origin": typeof origin === "string" ? origin : "*",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, session, Session",
"Access-Control-Expose-Headers": "Content-Length, Content-Type",
"Access-Control-Max-Age": "86400",
};
}
/**
* A deliberately loud 404/405.
*
* The whole point of this server is to make gaps obvious, so an unimplemented
* endpoint names itself rather than looking like an ordinary backend error.
*/
function unroutedResult(activeRouter: Router, method: string, pathname: string): HttpResult {
const allowed = activeRouter.allowedMethods(pathname);
if (allowed.length > 0) {
console.warn(`[fake-api] 405 ${method} ${pathname} (allowed: ${allowed.join(", ")})`);
return failure(
405,
"MethodNotAllowed",
`fake-storyteller-web: ${pathname} exists but not for ${method} (allowed: ${allowed.join(", ")}).`,
).withHeader("Allow", allowed.join(", "));
}
console.warn(`[fake-api] 501 ${method} ${pathname} -- not implemented`);
return failure(
501,
"NotImplementedInFake",
`fake-storyteller-web does not implement ${method} ${pathname}. ` +
"This endpoint exists in the real backend but has no fake yet -- add one in apps/fake-storyteller-web/src/routes.",
);
}
function internalError(error: unknown): HttpResult {
const message = error instanceof Error ? error.message : String(error);
console.error("[fake-api] handler threw:", error);
return failure(500, "InternalError", `fake-storyteller-web handler failed: ${message}`);
}
@@ -0,0 +1,188 @@
/**
* `/v1/api_keys` and `/v1/user_referral_codes` — settings-page resources.
*
* Both use a response envelope that returns the created record's fields at the
* top level rather than nested, and both parse error bodies on 4xx, so the
* shapes here matter more than usual.
*/
import { randomBytes } from "node:crypto";
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import type { ApiKeyRecord, ReferralCodeRecord } from "../state/entities.ts";
import { sortNewestFirst, store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
const LOCAL_IP = "127.0.0.1";
export function registerAccountRoutes(router: Router): void {
// Literal paths must be registered before `:token`, which would otherwise capture them.
router.get("/v1/api_keys/list", listApiKeys);
router.post("/v1/api_keys/create", createApiKey);
router.get("/v1/api_keys/:token", getApiKey);
router.put("/v1/api_keys/:token", updateApiKey);
router.delete("/v1/api_keys/:token", deleteApiKey);
router.get("/v1/user_referral_codes/list", listReferralCodes);
router.post("/v1/user_referral_codes/create", createReferralCode);
router.delete("/v1/user_referral_codes/code/:token", deleteReferralCode);
}
function listApiKeys(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = sortNewestFirst(
[...store.apiKeysByToken.values()].filter((key) => key.ownerUserToken === user.userToken),
);
const offset = context.queryNumber("offset") ?? 0;
const limit = context.queryNumber("limit") ?? owned.length;
return success({ api_keys: owned.slice(offset, offset + limit).map(apiKeyPayload) });
}
function getApiKey(context: RequestContext): HttpResult {
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
if (key === undefined) {
return notFound();
}
return success({ api_key: apiKeyPayload(key) });
}
function createApiKey(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ name: string; maybe_description: string | null }>();
if ((body.name ?? "").trim().length === 0) {
return failure(400, "BadInput", "name is required");
}
const timestamp = nowIso();
const key: ApiKeyRecord = {
token: makeToken(TOKEN_PREFIX.apiKey),
apiKey: `sk_fake_${randomBytes(24).toString("hex")}`,
name: body.name ?? "",
maybeDescription: body.maybe_description ?? undefined,
ownerUserToken: user.userToken,
maybeDeletedAt: undefined,
createdAt: timestamp,
updatedAt: timestamp,
};
store.apiKeysByToken.set(key.token, key);
// The full secret is returned once, at creation, exactly like the real API.
return success({ api_key_token: key.token, api_key: key.apiKey });
}
function updateApiKey(context: RequestContext): HttpResult {
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
if (key === undefined) {
return notFound();
}
const body = context.json<{ maybe_description: string | null }>();
key.maybeDescription = body.maybe_description ?? undefined;
key.updatedAt = nowIso();
return success();
}
/** Soft delete, because the list endpoint returns deleted rows and the UI filters them. */
function deleteApiKey(context: RequestContext): HttpResult {
const key = store.apiKeysByToken.get(context.params["token"] ?? "");
if (key === undefined) {
return notFound();
}
key.maybeDeletedAt = nowIso();
key.updatedAt = key.maybeDeletedAt;
return success();
}
function listReferralCodes(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = sortNewestFirst(
[...store.referralCodesByToken.values()].filter((code) => code.ownerUserToken === user.userToken),
);
return success({ referral_codes: owned.map(referralCodePayload) });
}
function createReferralCode(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ code: string }>();
const code = (body.code ?? "").trim();
if (code.length === 0) {
return failure(400, "BadInput", "code is required");
}
for (const existing of store.referralCodesByToken.values()) {
if (existing.code.toLowerCase() === code.toLowerCase()) {
return failure(400, "BadInput", "that referral code is already taken");
}
}
const timestamp = nowIso();
const record: ReferralCodeRecord = {
token: makeToken(TOKEN_PREFIX.appSession),
code,
ownerUserToken: user.userToken,
createdAt: timestamp,
updatedAt: timestamp,
};
store.referralCodesByToken.set(record.token, record);
return success({ token: record.token, code: record.code, code_lowercase: record.code.toLowerCase() });
}
function deleteReferralCode(context: RequestContext): HttpResult {
const token = context.params["token"] ?? "";
if (!store.referralCodesByToken.has(token)) {
return notFound();
}
store.referralCodesByToken.delete(token);
return success();
}
function apiKeyPayload(key: ApiKeyRecord): object {
return {
token: key.token,
truncated_api_key: `${key.apiKey.slice(0, 12)}...`,
name: key.name,
maybe_description: key.maybeDescription ?? null,
owner_user_token: key.ownerUserToken,
ip_address_creation: LOCAL_IP,
ip_address_update: LOCAL_IP,
created_at: key.createdAt,
updated_at: key.updatedAt,
maybe_deleted_at: key.maybeDeletedAt ?? null,
};
}
function referralCodePayload(record: ReferralCodeRecord): object {
return {
token: record.token,
code: record.code,
code_lowercase: record.code.toLowerCase(),
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
@@ -0,0 +1,138 @@
/**
* Credits, subscriptions, and Stripe checkout.
*
* There is no Stripe to redirect to, so the checkout endpoints return a URL
* pointing back at this server, which explains itself when opened. Grant
* credits with `POST /__fake/credits` instead of going through a checkout.
*/
import { currentUser } from "../auth.ts";
import { publicOrigin } from "../config.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
const VALID_PLANS = new Set(["artcraft_basic", "artcraft_pro", "artcraft_max"]);
const VALID_CADENCES = new Set(["monthly", "yearly"]);
export function registerBillingRoutes(router: Router): void {
router.get("/v1/credits/namespace/:namespace", getCredits);
router.get("/v1/billing/active_subscriptions", listActiveSubscriptions);
router.post("/v1/stripe_artcraft/checkout/subscription", subscriptionCheckout);
router.post("/v1/stripe_artcraft/user_signup_subscription_checkout", signupSubscriptionCheckout);
router.post("/v1/stripe_artcraft/checkout/credits_pack", creditsPackCheckout);
router.post("/v1/stripe_artcraft/portal/switch_plan", switchPlan);
router.post("/v1/stripe_artcraft/portal/manage_plan", managePlan);
router.post("/v1/stripe_artcraft/portal/cancel_plan", managePlan);
router.post("/v1/stripe_artcraft/portal/update_payment_method", managePlan);
}
function getCredits(context: RequestContext): HttpResult {
const namespace = context.params["namespace"] ?? "";
if (namespace !== "artcraft" && namespace !== "fakeyou") {
return failure(400, "BadInput", `unknown payments namespace: ${namespace}`);
}
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
return success({
free_credits: 0,
monthly_credits: user.monthlyCredits,
banked_credits: user.bankedCredits,
sum_total_credits: user.monthlyCredits + user.bankedCredits,
});
}
function listActiveSubscriptions(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const subscriptions = user.subscriptionSlug === undefined
? []
: [{ namespace: "artcraft", product_slug: user.subscriptionSlug }];
return success({ maybe_loyalty_program: null, active_subscriptions: subscriptions });
}
function subscriptionCheckout(context: RequestContext): HttpResult {
const body = context.json<{ plan: string; cadence: string }>();
const checked = checkPlan(body.plan, body.cadence);
if (!checked.ok) {
return checked.error;
}
return success({ stripe_checkout_redirect_url: explainerUrl("subscription", checked.plan) });
}
function signupSubscriptionCheckout(context: RequestContext): HttpResult {
const body = context.json<{ plan: string; cadence: string }>();
const checked = checkPlan(body.plan, body.cadence);
if (!checked.ok) {
return checked.error;
}
return success({
stripe_checkout_redirect_url: explainerUrl("subscription", checked.plan),
generated_user: null,
session: null,
});
}
function creditsPackCheckout(context: RequestContext): HttpResult {
const body = context.json<{ credits_pack: string }>();
if (body.credits_pack === undefined) {
return failure(400, "BadInput", "no credits pack supplied");
}
return success({ stripe_checkout_redirect_url: explainerUrl("credits_pack", body.credits_pack) });
}
function switchPlan(context: RequestContext): HttpResult {
const body = context.json<{ plan: string; cadence: string }>();
const checked = checkPlan(body.plan, body.cadence);
if (!checked.ok) {
return checked.error;
}
const user = currentUser(context);
if (user !== undefined) {
user.subscriptionSlug = checked.plan;
}
return success({ stripe_portal_url: explainerUrl("switch_plan", checked.plan) });
}
function managePlan(): HttpResult {
return success({ stripe_portal_url: explainerUrl("manage_plan", "") });
}
type CheckedPlan = { ok: true; plan: string } | { ok: false; error: HttpResult };
function checkPlan(plan: string | undefined, cadence: string | undefined): CheckedPlan {
if (plan === undefined) {
return { ok: false, error: failure(400, "BadInput", "no plan supplied") };
}
if (!VALID_PLANS.has(plan)) {
return { ok: false, error: failure(400, "BadInput", `unknown plan: ${plan}`) };
}
if (cadence === undefined) {
return { ok: false, error: failure(400, "BadInput", "no cadence supplied") };
}
if (!VALID_CADENCES.has(cadence)) {
return { ok: false, error: failure(400, "BadInput", `unknown cadence: ${cadence}`) };
}
return { ok: true, plan };
}
function explainerUrl(flow: string, detail: string): string {
const query = new URLSearchParams({ flow, detail });
return `${publicOrigin()}/__fake/checkout?${query}`;
}
@@ -0,0 +1,181 @@
/**
* `/v1/character` — reusable character references for video generation.
*
* Creating a character enqueues a job in the real backend, so the fake does the
* same: the job resolves like any other and the character becomes usable once
* it finishes.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { config } from "../config.ts";
import { nowIso } from "../state/clock.ts";
import type { CharacterRecord } from "../state/entities.ts";
import { sortNewestFirst, store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { mediaLinks } from "../wire/media.ts";
const PAGE_SIZE = 50;
export function registerCharacterRoutes(router: Router): void {
router.post("/v1/character/create", createCharacter);
router.post("/v1/character/edit", editCharacter);
router.get("/v1/characters/session", listSessionCharacters);
router.get("/v1/character/:characterToken", getCharacter);
router.delete("/v1/character/:characterToken", deleteCharacter);
}
function createCharacter(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{
image_media_token: string;
model: string;
uuid_idempotency_token: string;
character_name: string;
character_description: string | null;
}>();
if (body.image_media_token === undefined || body.character_name === undefined) {
return failure(400, "BadInput", "image_media_token and character_name are required");
}
const character: CharacterRecord = {
token: makeToken(TOKEN_PREFIX.character),
name: body.character_name,
maybeDescription: body.character_description ?? undefined,
maybeAvatarMediaFileToken: body.image_media_token,
maybeFullImageMediaFileToken: body.image_media_token,
models: body.model === undefined ? [] : [body.model],
ownerUserToken: user.userToken,
isUserCreated: true,
createdAt: nowIso(),
};
store.charactersByToken.set(character.token, character);
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
const timestamp = nowIso();
store.jobsByToken.set(jobToken, {
jobToken,
inferenceCategory: "character_generation",
status: "pending",
progressPercentage: 0,
maybePromptToken: undefined,
maybeModelType: body.model,
maybeModelTitle: body.model,
maybeRawInferenceText: body.character_name,
maybeCreatorUserToken: user.userToken,
maybeBatchToken: undefined,
maybeResultMediaFileToken: undefined,
maybeFailureCategory: undefined,
maybeFailureMessage: undefined,
maybeSuccessfullyCompletedAt: undefined,
resolveAtMillis: Date.now() + config.resolveSeconds * 1000,
isDismissed: false,
createdAt: timestamp,
updatedAt: timestamp,
});
return success({ inference_job_token: jobToken });
}
function editCharacter(context: RequestContext): HttpResult {
const body = context.json<{
token: string;
updated_name: string | null;
updated_description: string | null;
}>();
const character = store.charactersByToken.get(body.token ?? "");
if (character === undefined) {
return notFound();
}
if (body.updated_name != null) {
character.name = body.updated_name;
}
if (body.updated_description !== undefined) {
character.maybeDescription = body.updated_description ?? undefined;
}
return success();
}
function getCharacter(context: RequestContext): HttpResult {
const character = store.charactersByToken.get(context.params["characterToken"] ?? "");
if (character === undefined) {
return notFound();
}
return success({ character: characterPayload(character) });
}
function deleteCharacter(context: RequestContext): HttpResult {
const token = context.params["characterToken"] ?? "";
if (!store.charactersByToken.has(token)) {
return notFound();
}
store.charactersByToken.delete(token);
return success();
}
/**
* `next_cursor` must be null on the last page — the client loops until it is
* falsy, so returning a stale cursor here spins forever.
*/
function listSessionCharacters(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = sortNewestFirst(
[...store.charactersByToken.values()].filter(
(character) => character.ownerUserToken === user.userToken,
),
);
const offset = context.queryNumber("cursor") ?? 0;
const page = owned.slice(offset, offset + PAGE_SIZE);
const nextOffset = offset + PAGE_SIZE;
return success({
characters: page.map(characterPayload),
next_cursor: nextOffset < owned.length ? nextOffset : null,
});
}
function characterPayload(character: CharacterRecord): object {
return {
token: character.token,
name: character.name,
maybe_description: character.maybeDescription ?? null,
maybe_avatar: characterMediaLinks(character.maybeAvatarMediaFileToken),
maybe_full_image: characterMediaLinks(character.maybeFullImageMediaFileToken),
models: character.models,
is_user_created: character.isUserCreated,
};
}
function characterMediaLinks(mediaFileToken: string | undefined): object | null {
if (mediaFileToken === undefined) {
return null;
}
const record = store.mediaFilesByToken.get(mediaFileToken);
if (record === undefined) {
return null;
}
const links = mediaLinks(record);
return {
cdn_url: links.cdn_url,
maybe_thumbnail_template: links.maybe_thumbnail_template,
maybe_video_previews: null,
};
}
@@ -0,0 +1,86 @@
/**
* Control endpoints that the real backend does not have.
*
* Everything here is namespaced under `/__fake` (plus the real `/_status`
* health check) so it is obvious at a glance that a caller has become dependent
* on the fake rather than on the API.
*/
import { currentUser } from "../auth.ts";
import { config, publicOrigin } from "../config.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, success } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { objectCount } from "../state/assets.ts";
import { seedState } from "../state/seed.ts";
import { store } from "../state/store.ts";
import { FAILURE_CATEGORIES } from "../generation/prompt_flags.ts";
export function registerControlRoutes(router: Router): void {
router.get("/_status", () => success({ service: "fake-storyteller-web" }));
router.get("/__fake/state", describeState);
router.post("/__fake/reset", resetState);
router.post("/__fake/credits", grantCredits);
router.get("/__fake/checkout", explainCheckout);
}
function describeState(): HttpResult {
return success({
users: store.usersByToken.size,
sessions: store.sessionsBySignedSession.size,
media_files: store.mediaFilesByToken.size,
stored_objects: objectCount(),
jobs: store.jobsByToken.size,
prompts: store.promptsByToken.size,
folders: store.foldersByToken.size,
tags: store.tagsByToken.size,
characters: store.charactersByToken.size,
resolve_seconds: config.resolveSeconds,
failure_categories: FAILURE_CATEGORIES,
});
}
/** Return to the seeded fixture. Logs everyone out, since sessions are cleared too. */
function resetState(): HttpResult {
seedState();
return success({ message: "fake-storyteller-web state reset to the seeded fixture" });
}
function grantCredits(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return failure(401, "NotLoggedIn", "log in before granting credits");
}
const body = context.json<{ banked_credits: number; monthly_credits: number }>();
user.bankedCredits = body.banked_credits ?? user.bankedCredits;
user.monthlyCredits = body.monthly_credits ?? user.monthlyCredits;
return success({
banked_credits: user.bankedCredits,
monthly_credits: user.monthlyCredits,
sum_total_credits: user.bankedCredits + user.monthlyCredits,
});
}
/** Where the fake's Stripe checkout URLs land, so a click explains itself. */
function explainCheckout(context: RequestContext): HttpResult {
const flow = context.queryValue("flow") ?? "unknown";
const detail = context.queryValue("detail") ?? "";
const page = `<!doctype html>
<meta charset="utf-8">
<title>fake-storyteller-web checkout</title>
<style>body{font:16px/1.6 system-ui;margin:4rem auto;max-width:34rem;padding:0 1rem}code{background:#8883;padding:.1em .3em;border-radius:.2em}</style>
<h1>There is no Stripe here</h1>
<p>The webapp is pointed at <code>fake-storyteller-web</code>, which has no payment provider.</p>
<p>Requested flow: <code>${escapeHtml(flow)}</code>${detail ? ` (<code>${escapeHtml(detail)}</code>)` : ""}</p>
<p>To change your credit balance, post to <code>${publicOrigin()}/__fake/credits</code> with
<code>{"banked_credits": 100000}</code> while logged in.</p>`;
return new HttpResult(200, Buffer.from(page, "utf8"), { "Content-Type": "text/html; charset=utf-8" });
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
@@ -0,0 +1,377 @@
/**
* `/v1/folders` — library organisation.
*
* Folder membership is an ordered token list on the folder record, so bulk
* add/move/remove can report the counts the UI displays.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import type { FolderRecord } from "../state/entities.ts";
import { sortNewestFirst, store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { folderMediaFilePayload, folderThumbnailPayload } from "../wire/media.ts";
import { clampPageSize, decodeCursor, nextCursorOnly } from "../wire/pagination.ts";
/** Folder cards show up to this many recent thumbnails. */
const FOLDER_THUMBNAIL_COUNT = 4;
export function registerFolderRoutes(router: Router): void {
router.post("/v1/folders/create", createFolder);
router.get("/v1/folders/list_all", listAllFolders);
router.get("/v1/folders/folder/:folderToken", getFolder);
router.delete("/v1/folders/folder/:folderToken", deleteFolder);
router.put("/v1/folders/folder/:folderToken/color_code", setColorCode);
router.put("/v1/folders/folder/:folderToken/cover_image", setCoverImage);
router.put("/v1/folders/folder/:folderToken/rename", renameFolder);
router.put("/v1/folders/folder/:folderToken/star", starFolder);
router.get("/v1/folders/subfolders/:folderToken", listSubfolders);
router.post("/v1/folders/subfolders/:folderToken/bulk_add", bulkAddSubfolders);
router.post("/v1/folders/subfolders/:folderToken/bulk_remove", bulkRemoveSubfolders);
router.get("/v1/folders/media_files/:folderToken", listFolderMedia);
router.get("/v1/folders/media_files_without_folder", listMediaWithoutFolder);
router.post("/v1/folders/media_files/:folderToken/bulk_add", bulkAddMedia);
router.post("/v1/folders/media_files/:folderToken/bulk_move", bulkMoveMedia);
router.post("/v1/folders/media_files/:folderToken/bulk_remove", bulkRemoveMedia);
}
function createFolder(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{
name: string;
maybe_parent_folder_token: string | null;
maybe_color_code: string | null;
}>();
const timestamp = nowIso();
const folder: FolderRecord = {
token: makeToken(TOKEN_PREFIX.folder),
name: body.name ?? "Untitled folder",
ownerUserToken: user.userToken,
maybeParentFolderToken: body.maybe_parent_folder_token ?? undefined,
maybeColorCode: body.maybe_color_code ?? undefined,
maybeCustomCoverMediaFileToken: undefined,
hasStar: false,
mediaFileTokens: [],
createdAt: timestamp,
updatedAt: timestamp,
};
store.foldersByToken.set(folder.token, folder);
return success({ folder: folderPayload(folder) });
}
function listAllFolders(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = sortNewestFirst(
[...store.foldersByToken.values()].filter((folder) => folder.ownerUserToken === user.userToken),
);
const { page, maybeCursor } = sliceWithCursor(owned, context);
return success({ folders: page.map(folderPayload), maybe_cursor: maybeCursor });
}
function getFolder(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
return success({ folder: folderPayload(folder) });
}
function deleteFolder(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
store.foldersByToken.delete(folder.token);
for (const other of store.foldersByToken.values()) {
if (other.maybeParentFolderToken === folder.token) {
other.maybeParentFolderToken = undefined;
}
}
return success();
}
function setColorCode(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ maybe_color_code: string | null }>();
folder.maybeColorCode = body.maybe_color_code ?? undefined;
folder.updatedAt = nowIso();
return success();
}
function setCoverImage(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ maybe_media_file_token: string | null }>();
folder.maybeCustomCoverMediaFileToken = body.maybe_media_file_token ?? undefined;
folder.updatedAt = nowIso();
return success({ maybe_resolved_cover_media_file_token: folder.maybeCustomCoverMediaFileToken ?? null });
}
function renameFolder(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ new_name: string }>();
if (body.new_name !== undefined) {
folder.name = body.new_name;
folder.updatedAt = nowIso();
}
return success();
}
function starFolder(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ has_star: boolean }>();
folder.hasStar = body.has_star ?? false;
folder.updatedAt = nowIso();
return success();
}
function listSubfolders(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const children = sortNewestFirst(
[...store.foldersByToken.values()].filter((child) => child.maybeParentFolderToken === folder.token),
);
const { page, maybeCursor } = sliceWithCursor(children, context);
return success({ subfolders: page.map(folderPayload), maybe_cursor: maybeCursor });
}
function bulkAddSubfolders(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ subfolder_tokens: string[] }>();
const accepted: string[] = [];
for (const token of body.subfolder_tokens ?? []) {
const child = store.foldersByToken.get(token);
if (child === undefined || child.token === folder.token) {
continue;
}
child.maybeParentFolderToken = folder.token;
child.updatedAt = nowIso();
accepted.push(token);
}
return success({ accepted_subfolder_tokens: accepted });
}
function bulkRemoveSubfolders(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ subfolder_tokens: string[] }>();
let removed = 0;
for (const token of body.subfolder_tokens ?? []) {
const child = store.foldersByToken.get(token);
if (child?.maybeParentFolderToken !== folder.token) {
continue;
}
child.maybeParentFolderToken = undefined;
child.updatedAt = nowIso();
removed += 1;
}
return success({ removed_count: removed });
}
function listFolderMedia(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const records = folder.mediaFileTokens
.map((token) => store.mediaFilesByToken.get(token))
.filter((record) => record !== undefined);
const { page, maybeCursor } = sliceWithCursor(records, context);
return success({
media_files: page.map((record) => folderMediaFilePayload(record, folder.updatedAt)),
maybe_cursor: maybeCursor,
});
}
function listMediaWithoutFolder(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const filed = new Set<string>();
for (const folder of store.foldersByToken.values()) {
for (const token of folder.mediaFileTokens) {
filed.add(token);
}
}
const filterMediaClass = context.queryValue("filter_media_class");
const unfiled = sortNewestFirst(
[...store.mediaFilesByToken.values()].filter(
(record) =>
record.maybeCreatorUserToken === user.userToken &&
!filed.has(record.token) &&
(filterMediaClass === undefined || record.mediaClass === filterMediaClass),
),
);
const { page, maybeCursor } = sliceWithCursor(unfiled, context);
return success({
media_files: page.map((record) => folderMediaFilePayload(record)),
maybe_cursor: maybeCursor,
});
}
function bulkAddMedia(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ media_file_tokens: string[] }>();
const accepted = addMediaToFolder(folder, body.media_file_tokens ?? []);
return success({ accepted_media_file_tokens: accepted });
}
function bulkMoveMedia(context: RequestContext): HttpResult {
const destination = findFolder(context);
if (destination === undefined) {
return notFound();
}
const body = context.json<{ media_file_tokens: string[]; source_folder: string }>();
const tokens = body.media_file_tokens ?? [];
const accepted = addMediaToFolder(destination, tokens);
let removedFromSource = 0;
const source = store.foldersByToken.get(body.source_folder ?? "");
if (source !== undefined) {
const before = source.mediaFileTokens.length;
source.mediaFileTokens = source.mediaFileTokens.filter((token) => !tokens.includes(token));
removedFromSource = before - source.mediaFileTokens.length;
source.updatedAt = nowIso();
}
return success({
accepted_media_file_tokens: accepted,
added_to_destination_count: accepted.length,
removed_from_source_count: removedFromSource,
});
}
function bulkRemoveMedia(context: RequestContext): HttpResult {
const folder = findFolder(context);
if (folder === undefined) {
return notFound();
}
const body = context.json<{ media_file_tokens: string[] }>();
const tokens = new Set(body.media_file_tokens ?? []);
const before = folder.mediaFileTokens.length;
folder.mediaFileTokens = folder.mediaFileTokens.filter((token) => !tokens.has(token));
folder.updatedAt = nowIso();
return success({ removed_count: before - folder.mediaFileTokens.length });
}
function addMediaToFolder(folder: FolderRecord, tokens: string[]): string[] {
const accepted: string[] = [];
for (const token of tokens) {
if (!store.mediaFilesByToken.has(token) || folder.mediaFileTokens.includes(token)) {
continue;
}
folder.mediaFileTokens.push(token);
accepted.push(token);
}
folder.updatedAt = nowIso();
return accepted;
}
function folderPayload(folder: FolderRecord): object {
const thumbnails = folder.mediaFileTokens
.slice(-FOLDER_THUMBNAIL_COUNT)
.map((token) => store.mediaFilesByToken.get(token))
.filter((record) => record !== undefined)
.map(folderThumbnailPayload);
const customCover = folder.maybeCustomCoverMediaFileToken === undefined
? undefined
: store.mediaFilesByToken.get(folder.maybeCustomCoverMediaFileToken);
return {
token: folder.token,
name: folder.name,
owner_user_token: folder.ownerUserToken,
maybe_parent_folder_token: folder.maybeParentFolderToken ?? null,
last_media_thumbnails: thumbnails,
maybe_custom_cover_thumbnail: customCover === undefined ? null : folderThumbnailPayload(customCover),
maybe_color_code: folder.maybeColorCode ?? null,
has_star: folder.hasStar,
is_orphaned:
folder.maybeParentFolderToken !== undefined && !store.foldersByToken.has(folder.maybeParentFolderToken),
created_at: folder.createdAt,
updated_at: folder.updatedAt,
};
}
function findFolder(context: RequestContext): FolderRecord | undefined {
return store.foldersByToken.get(context.params["folderToken"] ?? "");
}
/** Shared cursor slicing for the `maybe_cursor` style used across folders and tags. */
function sliceWithCursor<T>(records: T[], context: RequestContext): { page: T[]; maybeCursor: string | null } {
const pageSize = clampPageSize(context.queryNumber("limit") ?? context.queryNumber("page_size"));
const offset = decodeCursor(context.queryValue("cursor"));
return {
page: records.slice(offset, offset + pageSize),
maybeCursor: nextCursorOnly(records, offset, pageSize),
};
}
@@ -0,0 +1,43 @@
/** Route registration. Order matters where a literal path shares a shape with a `:param` one. */
import { Router } from "../http/router.ts";
import { registerAccountRoutes } from "./account.ts";
import { registerBillingRoutes } from "./billing.ts";
import { registerCharacterRoutes } from "./characters.ts";
import { registerControlRoutes } from "./control.ts";
import { registerFolderRoutes } from "./folders.ts";
import { registerJobRoutes } from "./jobs.ts";
import { registerLegacyRoutes } from "./legacy.ts";
import { registerMediaFileRoutes } from "./media_files.ts";
import { registerMediaServingRoutes } from "./media_serving.ts";
import { registerMediaUploadRoutes } from "./media_upload.ts";
import { registerOmniGenRoutes } from "./omni_gen.ts";
import { registerPromptRoutes } from "./prompts.ts";
import { registerSessionRoutes } from "./session.ts";
import { registerTagRoutes } from "./tags.ts";
export function buildRouter(): Router {
const router = new Router();
registerControlRoutes(router);
registerMediaServingRoutes(router);
registerSessionRoutes(router);
registerBillingRoutes(router);
registerOmniGenRoutes(router);
registerJobRoutes(router);
// Uploads register `/v1/media_files/upload/...`, which must be matched before
// the reading routes' `/v1/media_files/:something` patterns.
registerMediaUploadRoutes(router);
registerMediaFileRoutes(router);
registerFolderRoutes(router);
registerTagRoutes(router);
registerPromptRoutes(router);
registerCharacterRoutes(router);
registerAccountRoutes(router);
registerLegacyRoutes(router);
return router;
}
@@ -0,0 +1,108 @@
/**
* `/v1/jobs` — polling generation progress.
*
* One deliberate divergence from the real backend: `GET /v1/jobs/batch`
* requires a `tokens` query parameter server-side, but `JobsApi.ListJobs()`
* calls it with none and always gets a 400. The fake returns an empty list
* instead, so the client's own call pattern works.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import { sortNewestFirst, store } from "../state/store.ts";
import { jobPreviewPayload, jobStatePayload, sessionJobPayload } from "../wire/jobs.ts";
export function registerJobRoutes(router: Router): void {
router.get("/v1/jobs/job/:token", getJob);
router.delete("/v1/jobs/job/:token", deleteJob);
router.get("/v1/jobs/batch", batchGetJobs);
router.get("/v1/jobs/session", listSessionJobs);
router.post("/v1/jobs/session/dismiss_finished", dismissFinishedJobs);
router.get("/v1/workflows/preview_status/:token", getPreviewStatus);
router.get("/v1/model_inference/job_status/:token", getJob);
router.delete("/v1/model_inference/job/:token", deleteJob);
router.get("/v1/model_inference/queue_length", getQueueLength);
}
function getJob(context: RequestContext): HttpResult {
const job = store.jobsByToken.get(context.params["token"] ?? "");
if (job === undefined) {
return notFound();
}
return success({ state: jobStatePayload(job) });
}
function deleteJob(context: RequestContext): HttpResult {
const token = context.params["token"] ?? "";
if (!store.jobsByToken.has(token)) {
return notFound();
}
store.jobsByToken.delete(token);
return success();
}
function batchGetJobs(context: RequestContext): HttpResult {
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
const wanted = new Set(requested.filter((token) => token.length > 0 && token !== "None"));
const states = [...wanted]
.map((token) => store.jobsByToken.get(token))
.filter((job) => job !== undefined)
.map((job) => jobStatePayload(job));
return success({ job_states: states });
}
function listSessionJobs(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = [...store.jobsByToken.values()].filter(
(job) => job.maybeCreatorUserToken === user.userToken && !job.isDismissed,
);
return success({ jobs: sortNewestFirst(owned).map(sessionJobPayload) });
}
function dismissFinishedJobs(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
for (const job of store.jobsByToken.values()) {
if (job.maybeCreatorUserToken !== user.userToken) {
continue;
}
if (job.status === "complete_success" || job.status === "complete_failure") {
job.isDismissed = true;
job.updatedAt = nowIso();
}
}
return success();
}
function getPreviewStatus(context: RequestContext): HttpResult {
const job = store.jobsByToken.get(context.params["token"] ?? "");
if (job === undefined) {
return new HttpResult(404, { success: false, error_message: "job not found" });
}
return success({ state: jobPreviewPayload(job) });
}
function getQueueLength(): HttpResult {
let pending = 0;
for (const job of store.jobsByToken.values()) {
if (job.status === "pending" || job.status === "started") {
pending += 1;
}
}
return success({ pending_job_count: pending });
}
@@ -0,0 +1,96 @@
/**
* Endpoints with a client in `@storyteller/api` but no live webapp consumer:
* weights, TTS, the video studio workflow, bookmarks, and ratings.
*
* They are implemented as empty-but-correctly-shaped responses. That is cheap,
* and it means a page that starts calling one of them gets an empty list rather
* than the 501 an unimplemented route would return — which would look like a
* bug in the page rather than a gap in the fake.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { config } from "../config.ts";
import { nowIso } from "../state/clock.ts";
import { store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
export function registerLegacyRoutes(router: Router): void {
router.get("/v1/weights/list", listWeightsWithCursor);
router.get("/v1/weights/list_featured", listWeightsWithCursor);
router.get("/v1/weights/list_pinned", () => success({ results: [] }));
router.get("/v1/weights/by_user/:username", listWeightsWithPages);
router.post("/v1/weights/search", () => success({ weights: [] }));
router.get("/v1/weights/weight/:weightToken", () => success());
router.post("/v1/weights/weight/:weightToken", () => success());
router.delete("/v1/weights/weight/:weightToken", () => success());
router.post("/v1/weights/weight/:weightToken/cover_image", () => success());
router.post("/v1/tts/inference", (context) => enqueueStubJob(context, "text_to_speech"));
router.post("/v1/workflows/enqueue_studio", (context) => enqueueStubJob(context, "workflow"));
router.get("/v1/user_bookmarks/batch", () => success({ bookmarks: [] }));
router.post("/v1/user_bookmarks/create", createBookmark);
router.delete("/v1/user_bookmarks/delete/:entityToken", () => success());
router.get("/v1/user_rating/batch", () => success({ ratings: [] }));
router.post("/v1/user_rating/rate", () => success({ new_positive_rating_count_for_entity: 1 }));
router.get("/v1/user_rating/view/:entityType/:entityToken", () => success({ maybe_rating_value: null }));
}
function listWeightsWithCursor(): HttpResult {
return success({
results: [],
pagination: { maybe_next: null, maybe_previous: null, cursor_is_reversed: false },
});
}
function listWeightsWithPages(): HttpResult {
return success({ results: [], pagination: { current: 0, total_page_count: 1 } });
}
function createBookmark(): HttpResult {
return success({
new_bookmark_count_for_entity: 1,
user_bookmark_token: makeToken(TOKEN_PREFIX.userBookmark),
});
}
/**
* Queue a job that the resolver will complete like any other, so callers that
* poll for a result get one instead of hanging.
*/
function enqueueStubJob(context: RequestContext, category: "text_to_speech" | "workflow"): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
const timestamp = nowIso();
store.jobsByToken.set(jobToken, {
jobToken,
inferenceCategory: category,
status: "pending",
progressPercentage: 0,
maybePromptToken: undefined,
maybeModelType: undefined,
maybeModelTitle: undefined,
maybeRawInferenceText: undefined,
maybeCreatorUserToken: user.userToken,
maybeBatchToken: undefined,
maybeResultMediaFileToken: undefined,
maybeFailureCategory: undefined,
maybeFailureMessage: undefined,
maybeSuccessfullyCompletedAt: undefined,
resolveAtMillis: Date.now() + config.resolveSeconds * 1000,
isDismissed: false,
createdAt: timestamp,
updatedAt: timestamp,
});
return success({ inference_job_token: jobToken, inference_job_token_type: "inference_job" });
}
@@ -0,0 +1,290 @@
/**
* `/v1/media_files` — reading and editing the library.
*
* Each listing endpoint has its own response shape in the real backend (see
* `src/wire/media.ts`), so the handlers here mostly filter and paginate, then
* hand off to the matching builder.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import type { MediaFileRecord, Visibility } from "../state/entities.ts";
import { mediaFilesForUser, sortNewestFirst, store } from "../state/store.ts";
import {
batchListMediaFilePayload,
batchMediaFilePayload,
getMediaFilePayload,
mediaFileListItemPayload,
projectMediaFilePayload,
searchMediaFilePayload,
sessionMediaFilePayload,
userMediaFileListItemPayload,
} from "../wire/media.ts";
import { paginateByCursor, paginateByIndex } from "../wire/pagination.ts";
const VALID_VISIBILITIES = new Set<Visibility>(["public", "hidden", "private"]);
export function registerMediaFileRoutes(router: Router): void {
router.get("/v1/media_files/file/:token", getMediaFile);
router.delete("/v1/media_files/file/:token", deleteMediaFile);
router.get("/v1/media_files/batch", batchGetMediaFiles);
router.get("/v1/media_files/batch/:batchToken", listByBatchToken);
router.get("/v1/media_files/list", listMediaFiles);
router.get("/v1/media_files/list_featured", listFeatured);
router.get("/v1/media_files/list/user/:username", listUserMediaFiles);
router.get("/v1/media_files/mesh/list", (context) => listSessionByClass(context, "mesh"));
router.get("/v1/media_files/splat/list", (context) => listSessionByClass(context, "splat"));
router.get("/v1/media_files/project/list", listProjects);
router.get("/v1/media_files/search_featured", (context) => search(context, false));
router.get("/v1/media_files/search_session", (context) => search(context, true));
router.post("/v1/media_files/rename/:token", renameMediaFile);
router.post("/v1/media_files/cover_image/:token", setCoverImage);
router.post("/v1/media_files/visibility/:token", setVisibility);
}
function getMediaFile(context: RequestContext): HttpResult {
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
if (record === undefined) {
return notFound();
}
return success({ media_file: getMediaFilePayload(record) });
}
function deleteMediaFile(context: RequestContext): HttpResult {
const token = context.params["token"] ?? "";
if (!store.mediaFilesByToken.has(token)) {
return notFound();
}
store.mediaFilesByToken.delete(token);
detachFromFoldersAndTags(token);
return success();
}
function batchGetMediaFiles(context: RequestContext): HttpResult {
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
const wanted = new Set(requested.filter((token) => token.length > 0));
const media = [...wanted]
.map((token) => store.mediaFilesByToken.get(token))
.filter((record) => record !== undefined)
.map((record) => batchMediaFilePayload(record));
return success({ media_files: media });
}
function listByBatchToken(context: RequestContext): HttpResult {
const batchToken = context.params["batchToken"] ?? "";
const records = sortNewestFirst(
[...store.mediaFilesByToken.values()].filter((record) => record.maybeBatchToken === batchToken),
);
const page = paginateByIndex(records, {
pageIndex: context.queryNumber("page_index"),
pageSize: context.queryNumber("page_size"),
});
return success({
results: page.results.map(batchListMediaFilePayload),
pagination: page.pagination,
});
}
function listMediaFiles(context: RequestContext): HttpResult {
const visible = applyFilters(
context,
[...store.mediaFilesByToken.values()].filter((record) => record.creatorSetVisibility === "public"),
);
const page = paginateByCursor(sortNewestFirst(visible), {
cursor: context.queryValue("cursor"),
pageSize: context.queryNumber("page_size"),
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
});
return success({
results: page.results.map(mediaFileListItemPayload),
pagination: page.pagination,
});
}
function listFeatured(context: RequestContext): HttpResult {
const visible = applyFilters(
context,
[...store.mediaFilesByToken.values()].filter((record) => record.creatorSetVisibility === "public"),
);
const page = paginateByCursor(sortNewestFirst(visible), {
cursor: context.queryValue("cursor"),
pageSize: context.queryNumber("page_size"),
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
});
return success({
results: page.results.map(mediaFileListItemPayload),
pagination: page.pagination,
});
}
function listUserMediaFiles(context: RequestContext): HttpResult {
const user = store.usersByUsername.get((context.params["username"] ?? "").toLowerCase());
if (user === undefined) {
return notFound();
}
const owned = applyFilters(context, mediaFilesForUser(user.userToken));
const page = paginateByIndex(owned, {
pageIndex: context.queryNumber("page_index"),
pageSize: context.queryNumber("page_size"),
});
return success({
results: page.results.map(userMediaFileListItemPayload),
pagination: page.pagination,
});
}
function listSessionByClass(context: RequestContext, mediaClass: "mesh" | "splat"): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = mediaFilesForUser(user.userToken).filter((record) => record.mediaClass === mediaClass);
const page = paginateByCursor(owned, {
cursor: context.queryValue("cursor"),
pageSize: context.queryNumber("page_size") ?? 100,
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
});
return success({
results: page.results.map(sessionMediaFilePayload),
pagination: page.pagination,
});
}
function listProjects(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const filterProjectType = context.queryValue("filter_project_type");
const owned = mediaFilesForUser(user.userToken).filter(
(record) =>
record.mediaClass === "project" &&
(filterProjectType === undefined || record.maybeProjectType === filterProjectType),
);
const page = paginateByCursor(owned, {
cursor: context.queryValue("cursor"),
pageSize: context.queryNumber("page_size") ?? 100,
cursorIsReversed: context.queryValue("cursor_is_reversed") === "true",
});
return success({
results: page.results.map(projectMediaFilePayload),
pagination: page.pagination,
});
}
function search(context: RequestContext, sessionOnly: boolean): HttpResult {
const searchTerm = context.queryValue("search_term");
if (searchTerm === undefined) {
return failure(400, "BadInput", "search_term is required");
}
let candidates = [...store.mediaFilesByToken.values()];
if (sessionOnly) {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
candidates = mediaFilesForUser(user.userToken);
}
const needle = searchTerm.toLowerCase();
const matched = applyFilters(context, candidates).filter((record) =>
`${record.maybeTitle ?? ""} ${record.maybeOriginalFilename ?? ""}`.toLowerCase().includes(needle),
);
return success({ results: sortNewestFirst(matched).map(searchMediaFilePayload) });
}
function renameMediaFile(context: RequestContext): HttpResult {
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
if (record === undefined) {
return notFound();
}
const body = context.json<{ name: string | null }>();
const name = body.name ?? undefined;
record.maybeTitle = name === undefined || name.length === 0 ? undefined : name;
record.updatedAt = nowIso();
return success();
}
function setCoverImage(context: RequestContext): HttpResult {
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
if (record === undefined) {
return notFound();
}
const body = context.json<{ cover_image_media_file_token: string | null }>();
record.maybeCoverImageMediaFileToken = body.cover_image_media_file_token ?? undefined;
record.updatedAt = nowIso();
return success();
}
function setVisibility(context: RequestContext): HttpResult {
const record = store.mediaFilesByToken.get(context.params["token"] ?? "");
if (record === undefined) {
return notFound();
}
const body = context.json<{ creator_set_visibility: Visibility | null }>();
const visibility = body.creator_set_visibility ?? undefined;
if (visibility !== undefined && !VALID_VISIBILITIES.has(visibility)) {
return failure(400, "BadInput", `unknown visibility: ${visibility}`);
}
record.creatorSetVisibility = visibility ?? "public";
record.updatedAt = nowIso();
return success();
}
/** Apply the `filter_media_classes` / `filter_media_type` query filters shared by the list endpoints. */
function applyFilters(context: RequestContext, records: MediaFileRecord[]): MediaFileRecord[] {
const classes = commaSeparated(context.queryValue("filter_media_classes"));
const types = commaSeparated(context.queryValue("filter_media_type"));
return records.filter((record) => {
if (classes !== undefined && !classes.has(record.mediaClass)) {
return false;
}
if (types !== undefined && !types.has(record.mediaType)) {
return false;
}
return true;
});
}
function commaSeparated(value: string | undefined): Set<string> | undefined {
if (value === undefined) {
return undefined;
}
const entries = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
return entries.length === 0 ? undefined : new Set(entries);
}
function detachFromFoldersAndTags(mediaFileToken: string): void {
for (const folder of store.foldersByToken.values()) {
folder.mediaFileTokens = folder.mediaFileTokens.filter((token) => token !== mediaFileToken);
}
for (const tag of store.tagsByToken.values()) {
tag.mediaFileTokens.delete(mediaFileToken);
}
}
@@ -0,0 +1,43 @@
/**
* `/media/*` — serving stored bytes back.
*
* This is the fake's CDN. Every `cdn_url` the API hands out points here, so
* uploads and generation results load in the browser with no bucket, no proxy,
* and no external host involved.
*/
import type { RequestContext } from "../http/context.ts";
import { HttpResult, bytes, notFound } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { getObject } from "../state/assets.ts";
export function registerMediaServingRoutes(router: Router): void {
// The thumbnail route must come first: the catch-all below would swallow it.
router.get("/media/thumb/:width/*path", serveThumbnail);
router.get("/media/*path", serveObject);
}
function serveObject(context: RequestContext): HttpResult {
return serve(context.params["path"] ?? "");
}
/**
* Thumbnails come from `maybe_thumbnail_template`, which the frontend expands
* by substituting a width. The fake ignores the width and serves the original —
* correct enough for layout, and it keeps one copy of the bytes.
*/
function serveThumbnail(context: RequestContext): HttpResult {
return serve(context.params["path"] ?? "");
}
function serve(bucketPath: string): HttpResult {
const object = getObject(bucketPath);
if (object === undefined) {
return notFound(`fake-storyteller-web has no stored object at /media/${bucketPath}`);
}
return bytes(object.bytes, object.contentType, {
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
});
}
@@ -0,0 +1,322 @@
/**
* `/v1/media_files/upload/*` — multipart uploads.
*
* Uploaded bytes are kept in memory and served straight back from `/media`, so
* an uploaded file is immediately visible in the library and loadable in the
* viewer, exactly as it would be through the real bucket.
*
* These handlers use the upload-specific error envelope (`{"BadInput": "..."}`)
* rather than the shared one, because that is what the frontend reads here.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, success } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import type { MultipartForm } from "../http/multipart.ts";
import { contentTypeForExtension, extensionOf, putObject } from "../state/assets.ts";
import { nowIso } from "../state/clock.ts";
import type { MediaClass, MediaType, ProjectType, Visibility } from "../state/entities.ts";
import { classifyUpload, createMediaFile } from "../state/media_factory.ts";
import { store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { isValidIdempotencyToken } from "../generation/submit.ts";
/** Which media type each project path segment produces. */
const PROJECT_MEDIA_TYPES: Record<string, { mediaType: MediaType; extension: string }> = {
scene_3d: { mediaType: "scene_json", extension: ".scn.json" },
mood_board: { mediaType: "mood_json", extension: ".mood.json" },
editor_2d: { mediaType: "editor_json", extension: ".editor.json" },
video_timeline: { mediaType: "timeline_json", extension: ".timeline.json" },
};
export function registerMediaUploadRoutes(router: Router): void {
router.post("/v1/media_files/upload/image", (context) =>
upload(context, { mediaClass: "image", mediaType: "png", bucketPrefix: "image_", fallbackExtension: ".png" }),
);
router.post("/v1/media_files/upload/audio", (context) =>
upload(context, { mediaClass: "audio", mediaType: "wav", bucketPrefix: "audio_", fallbackExtension: ".wav" }),
);
router.post("/v1/media_files/upload/new_video", (context) =>
upload(context, { mediaClass: "video", mediaType: "mp4", bucketPrefix: "video_", fallbackExtension: ".mp4" }),
);
router.post("/v1/media_files/upload/spz", (context) =>
upload(context, { mediaClass: "splat", mediaType: "spz", bucketPrefix: "splat_", fallbackExtension: ".spz" }),
);
router.post("/v1/media_files/upload/new_engine_asset", (context) => upload(context, { inferFromFilename: true }));
router.post("/v1/media_files/upload/pmx", (context) =>
upload(context, { mediaClass: "dimensional", mediaType: "pmx", bucketPrefix: "engine_", fallbackExtension: ".pmx" }),
);
router.post("/v1/media_files/upload", (context) => upload(context, { inferFromFilename: true, useShortFieldNames: true }));
router.post("/v1/media_files/upload/new_scene", (context) =>
upload(context, { mediaClass: "project", mediaType: "scene_json", bucketPrefix: "project_", fallbackExtension: ".scn.json", projectType: "scene_3d" }),
);
router.post("/v1/media_files/upload/saved_scene/:token", updateSavedScene);
router.post("/v1/media_files/upload/studio_shot", (context) =>
upload(context, { mediaClass: "image", mediaType: "png", bucketPrefix: "image_", fallbackExtension: ".png" }),
);
router.post("/v1/media_files/upload/scene_snapshot", uploadSceneSnapshot);
router.post("/v1/image_studio/scene_snapshot", uploadSceneSnapshot);
router.post("/v1/media_files/upload/project/:projectType/new", createProject);
router.post("/v1/media_files/upload/project/:projectType/update/:token", updateProject);
router.post("/v1/conversion/enqueue_fbx_to_gltf", enqueueFbxToGltf);
}
interface UploadOptions {
mediaClass?: MediaClass;
mediaType?: MediaType;
bucketPrefix?: string;
fallbackExtension?: string;
projectType?: ProjectType;
/** Derive class and type from the uploaded filename instead of fixed values. */
inferFromFilename?: boolean;
/** The generic `/upload` endpoint uses `title`/`visibility` rather than `maybe_*`. */
useShortFieldNames?: boolean;
}
function upload(context: RequestContext, options: UploadOptions): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
const form = context.form();
const idempotencyError = checkIdempotency(form);
if (idempotencyError !== undefined) {
return idempotencyError;
}
const file = form.file("file") ?? form.anyFile();
if (file === undefined) {
return uploadError("BadInput", "no file part in the multipart body");
}
const inferred = classifyUpload(file.fileName);
const mediaClass = options.inferFromFilename ? inferred.mediaClass : options.mediaClass ?? inferred.mediaClass;
const mediaType = options.inferFromFilename ? inferred.mediaType : options.mediaType ?? inferred.mediaType;
const bucketPrefix = options.inferFromFilename ? inferred.bucketPrefix : options.bucketPrefix ?? inferred.bucketPrefix;
const extension = extensionOf(file.fileName) || options.fallbackExtension || "";
const titleField = options.useShortFieldNames ? "title" : "maybe_title";
const visibilityField = options.useShortFieldNames ? "visibility" : "maybe_visibility";
const record = createMediaFile({
bytes: file.bytes,
mediaClass,
mediaType,
bucketPrefix,
extension,
maybeCreatorUserToken: user.userToken,
maybeTitle: form.field(titleField),
maybeOriginalFilename: file.fileName,
maybePromptToken: form.field("maybe_prompt_token"),
maybeBatchToken: form.field("maybe_batch_token"),
maybeProjectType: options.projectType,
maybeEngineCategory: form.field("engine_category"),
maybeAnimationType: form.field("maybe_animation_type"),
maybeDurationMillis: numberField(form, "maybe_duration_millis"),
maybeSceneSourceMediaFileToken: form.field("maybe_scene_source_media_file_token"),
originCategory: "upload",
originProductCategory: "unknown",
isUserUpload: true,
isIntermediateSystemFile: form.field("is_intermediate_system_file") === "true",
creatorSetVisibility: visibilityOf(form.field(visibilityField)),
});
return success({ media_file_token: record.token });
}
function uploadSceneSnapshot(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
const form = context.form();
const file = form.file("snapshot") ?? form.file("file") ?? form.anyFile();
if (file === undefined) {
return uploadError("BadInput", "no snapshot part in the multipart body");
}
const record = createMediaFile({
bytes: file.bytes,
mediaClass: "image",
mediaType: "png",
bucketPrefix: "image_",
extension: extensionOf(file.fileName) || ".png",
maybeCreatorUserToken: user.userToken,
maybeTitle: form.field("maybe_title"),
maybeOriginalFilename: file.fileName,
maybeSceneSourceMediaFileToken: form.field("scene_media_token") ?? form.field("maybe_scene_source_media_file_token"),
originCategory: "studio",
originProductCategory: "image_studio",
isUserUpload: false,
isIntermediateSystemFile: true,
});
return success({ snapshot_media_token: record.token, media_file_token: record.token });
}
function createProject(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
const projectType = context.params["projectType"] ?? "";
const shape = PROJECT_MEDIA_TYPES[projectType];
if (shape === undefined) {
return uploadError("BadInput", `unknown project type: ${projectType}`);
}
const form = context.form();
const idempotencyError = checkIdempotency(form);
if (idempotencyError !== undefined) {
return idempotencyError;
}
const file = form.file("file") ?? form.anyFile();
if (file === undefined) {
return uploadError("BadInput", "no file part in the multipart body");
}
const record = createMediaFile({
bytes: file.bytes,
mediaClass: "project",
mediaType: shape.mediaType,
bucketPrefix: "project_",
extension: shape.extension,
maybeCreatorUserToken: user.userToken,
maybeTitle: form.field("maybe_title"),
maybeProjectType: projectType as ProjectType,
originCategory: "studio",
originProductCategory: "studio",
isUserUpload: false,
creatorSetVisibility: visibilityOf(form.field("maybe_visibility")),
});
return success({ media_file_token: record.token });
}
function updateProject(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
const token = context.params["token"] ?? "";
const record = store.mediaFilesByToken.get(token);
if (record === undefined) {
return uploadError("BadInput", `unknown media file: ${token}`);
}
const projectType = context.params["projectType"] ?? "";
if (record.maybeProjectType !== undefined && record.maybeProjectType !== projectType) {
return uploadError("BadInput", `media file is not a ${projectType} project`);
}
return replaceBytes(context, token);
}
function updateSavedScene(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
return replaceBytes(context, context.params["token"] ?? "");
}
/** Overwrite an existing media file's bytes in place, keeping its token and URL. */
function replaceBytes(context: RequestContext, token: string): HttpResult {
const record = store.mediaFilesByToken.get(token);
if (record === undefined) {
return uploadError("BadInput", `unknown media file: ${token}`);
}
const file = context.form().file("file") ?? context.form().anyFile();
if (file === undefined) {
return uploadError("BadInput", "no file part in the multipart body");
}
// Re-storing under the same bucket path keeps every already-issued URL valid.
putObject(record.bucketPath, file.bytes, contentTypeForExtension(extensionOf(record.bucketPath)));
record.updatedAt = nowIso();
return success({ media_file_token: record.token });
}
function enqueueFbxToGltf(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return uploadError("NotAuthorized");
}
const body = context.json<{ media_file_token: string; uuid_idempotency_token: string }>();
if (body.media_file_token === undefined) {
return uploadError("BadInput", "media_file_token is required");
}
const jobToken = makeToken(TOKEN_PREFIX.inferenceJob);
const timestamp = nowIso();
store.jobsByToken.set(jobToken, {
jobToken,
inferenceCategory: "format_conversion",
status: "pending",
progressPercentage: 0,
maybePromptToken: undefined,
maybeModelType: undefined,
maybeModelTitle: undefined,
maybeRawInferenceText: undefined,
maybeCreatorUserToken: user.userToken,
maybeBatchToken: undefined,
maybeResultMediaFileToken: undefined,
maybeFailureCategory: undefined,
maybeFailureMessage: undefined,
maybeSuccessfullyCompletedAt: undefined,
resolveAtMillis: Date.now() + 2_000,
isDismissed: false,
createdAt: timestamp,
updatedAt: timestamp,
});
return success({ inference_job_token: jobToken });
}
function checkIdempotency(form: MultipartForm): HttpResult | undefined {
const token = form.field("uuid_idempotency_token");
if (!isValidIdempotencyToken(token)) {
return uploadError("BadInput", "invalid idempotency token");
}
if (store.usedIdempotencyTokens.has(token!)) {
return uploadError("BadInput", "repeated idempotency token");
}
store.usedIdempotencyTokens.add(token!);
return undefined;
}
/** The upload endpoints' own error envelope, which the frontend reads as `response.BadInput`. */
function uploadError(kind: "BadInput" | "NotAuthorized" | "ServerError" | "RateLimited", detail?: string): HttpResult {
const status = kind === "NotAuthorized" ? 401 : kind === "RateLimited" ? 429 : kind === "ServerError" ? 500 : 400;
const body = detail === undefined ? kind : { [kind]: detail };
return new HttpResult(status, body);
}
function visibilityOf(value: string | undefined): Visibility {
return value === "hidden" || value === "private" ? value : "public";
}
function numberField(form: MultipartForm, name: string): number | undefined {
const raw = form.field(name);
if (raw === undefined) {
return undefined;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
@@ -0,0 +1,118 @@
/**
* `/v1/omni_gen` — model catalogues, cost estimates, and generation submission.
*
* `cost/*` is anonymous; `generate/*` requires a session, spends credits, and
* queues jobs for the resolver. No provider is contacted, and there is no code
* path here that could.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, paymentRequired, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import {
audioModelsResponse,
imageModelsResponse,
isKnownModel,
meshModelsResponse,
splatModelsResponse,
videoModelsResponse,
} from "../generation/catalogue.ts";
import { readPromptTriggers } from "../generation/prompt_flags.ts";
import {
estimateCost,
hasEnoughCredits,
isReplayedIdempotencyToken,
isValidIdempotencyToken,
submitGeneration,
type GenerateRequestBody,
type GenerationKind,
} from "../generation/submit.ts";
const CATALOGUES: Record<GenerationKind, () => object> = {
image: imageModelsResponse,
video: videoModelsResponse,
audio: audioModelsResponse,
mesh: meshModelsResponse,
splat: splatModelsResponse,
};
/** Only the image response omits `all_job_tokens`, matching the real handlers. */
const KINDS_WITH_ALL_JOB_TOKENS = new Set<GenerationKind>(["video", "audio", "mesh", "splat"]);
export function registerOmniGenRoutes(router: Router): void {
for (const kind of Object.keys(CATALOGUES) as GenerationKind[]) {
router.get(`/v1/omni_gen/models/${kind}`, () => new HttpResult(200, CATALOGUES[kind]()));
router.post(`/v1/omni_gen/cost/${kind}`, (context) => getCost(kind, context));
router.post(`/v1/omni_gen/generate/${kind}`, (context) => generate(kind, context));
}
}
function getCost(kind: GenerationKind, context: RequestContext): HttpResult {
const body = readBody(context);
const modelError = validateModel(body);
if (modelError !== undefined) {
return modelError;
}
return new HttpResult(200, { success: true, ...estimateCost(kind, body) });
}
function generate(kind: GenerationKind, context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = readBody(context);
const modelError = validateModel(body);
if (modelError !== undefined) {
return modelError;
}
if (!isValidIdempotencyToken(body.idempotency_token)) {
return failure(400, "BadInput", "invalid idempotency token");
}
if (isReplayedIdempotencyToken(body.idempotency_token)) {
return failure(400, "BadInput", "repeated idempotency token");
}
if (readPromptTriggers(body.prompt).rejectWithPaymentRequired) {
return paymentRequired(
"fake-storyteller-web: payment failure forced by the trigger_payment_failure prompt flag.",
);
}
const cost = estimateCost(kind, body);
if (!hasEnoughCredits(user, cost.cost_in_credits)) {
return paymentRequired(
`Not enough credits: ${cost.cost_in_credits} required, ${user.monthlyCredits + user.bankedCredits} available.`,
);
}
const submitted = submitGeneration(kind, body, user);
const primaryToken = submitted.jobTokens[0];
const payload: Record<string, unknown> = { success: true, inference_job_token: primaryToken };
if (KINDS_WITH_ALL_JOB_TOKENS.has(kind)) {
payload["all_job_tokens"] = submitted.jobTokens;
}
return new HttpResult(200, payload);
}
function validateModel(body: GenerateRequestBody): HttpResult | undefined {
if (body.model === undefined) {
return failure(400, "BadInput", "no model supplied");
}
if (!isKnownModel(body.model)) {
return failure(400, "BadInput", `unknown model: ${body.model}`);
}
return undefined;
}
function readBody(context: RequestContext): GenerateRequestBody {
return context.json<GenerateRequestBody>();
}
@@ -0,0 +1,83 @@
/**
* `/v1/prompts` — the settings a generation was submitted with.
*
* The frontend's "recreate" flow reads these back to repopulate the prompt box,
* so the record has to round-trip whatever was submitted.
*/
import type { RequestContext } from "../http/context.ts";
import { HttpResult, notFound, success } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import type { PromptRecord } from "../state/entities.ts";
import { store } from "../state/store.ts";
import { mediaLinks } from "../wire/media.ts";
export function registerPromptRoutes(router: Router): void {
router.get("/v1/prompts/:token", getPrompt);
router.get("/v1/prompt/batch", batchGetPrompts);
}
function getPrompt(context: RequestContext): HttpResult {
const prompt = store.promptsByToken.get(context.params["token"] ?? "");
if (prompt === undefined) {
return notFound();
}
return success({ prompt: promptPayload(prompt) });
}
function batchGetPrompts(context: RequestContext): HttpResult {
const requested = context.query.getAll("tokens").flatMap((value) => value.split(","));
const wanted = new Set(requested.filter((token) => token.length > 0));
const prompts = [...wanted]
.map((token) => store.promptsByToken.get(token))
.filter((prompt) => prompt !== undefined)
.map(promptPayload);
return success({ prompts });
}
function promptPayload(prompt: PromptRecord): object {
return {
token: prompt.token,
prompt_type: prompt.promptType,
created_at: prompt.createdAt,
lcm_disabled: false,
lipsync_enabled: false,
use_cinematic: false,
used_face_detailer: false,
used_upscaler: false,
maybe_aspect_ratio: prompt.maybeAspectRatio ?? null,
maybe_batch_count: prompt.maybeBatchCount ?? null,
maybe_context_images: contextImagesPayload(prompt),
maybe_duration_seconds: prompt.maybeDurationSeconds ?? null,
maybe_frame_skip: null,
maybe_generate_audio: prompt.maybeGenerateAudio ?? null,
maybe_generation_mode: null,
maybe_generation_provider: prompt.maybeGenerationProvider ?? null,
maybe_global_ipa_image_token: null,
maybe_inference_duration_millis: null,
maybe_model_class: prompt.maybeModelClass ?? null,
maybe_model_type: prompt.maybeModelType ?? null,
maybe_moderator_fields: null,
maybe_negative_prompt: prompt.maybeNegativePrompt ?? null,
maybe_positive_prompt: prompt.maybePositivePrompt ?? null,
maybe_resolution: prompt.maybeResolution ?? null,
maybe_strength: null,
maybe_style_name: prompt.maybeStyleName ?? null,
maybe_travel_prompt: null,
};
}
function contextImagesPayload(prompt: PromptRecord): object[] | null {
const images = prompt.contextImageMediaTokens
.map((token) => store.mediaFilesByToken.get(token))
.filter((record) => record !== undefined)
.map((record) => ({
media_token: record.token,
semantic: "reference",
media_links: mediaLinks(record),
}));
return images.length === 0 ? null : images;
}
@@ -0,0 +1,301 @@
/**
* Authentication, session, and account management.
*
* These endpoints use three different error dialects in the real backend and
* the frontend reads a different field from each, so they are reproduced
* exactly: login sends `error_type` + `error_message`, create_account sends
* `error_type` + `error_fields`, password reset sends `kind` + `message`, and
* everything else uses the shared `error_code` envelope.
*/
import {
clearedSessionCookieHeader,
currentSignedSession,
currentUser,
endSession,
sessionCookieHeader,
startSession,
visitorCookieHeaderIfMissing,
} from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, failure, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import type { UserRecord } from "../state/entities.ts";
import { md5, registerUser } from "../state/seed.ts";
import { store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { sessionUserPayload, userProfilePayload } from "../wire/users.ts";
import { config } from "../config.ts";
const RESERVED_USERNAMES = new Set(["demo", "admin", "test", "dev", "root", "artcraft"]);
export function registerSessionRoutes(router: Router): void {
for (const prefix of ["/v1", ""]) {
router.get(`${prefix}/session`, getSession);
router.post(`${prefix}/login`, login);
router.post(`${prefix}/logout`, logout);
router.post(`${prefix}/create_account`, createAccount);
}
router.get("/v1/session_token", getSessionToken);
router.post("/v1/accounts/google_sso", googleSso);
router.get("/v1/user/:username/profile", getProfile);
router.post("/v1/user/change_password", changePassword);
router.post("/v1/user/edit_email", editEmail);
router.post("/v1/user/edit_username", editUsername);
router.post("/v1/web_referrals/record", recordWebReferral);
router.post("/v1/password_reset/request", requestPasswordReset);
router.post("/v1/password_reset/redeem", redeemPasswordReset);
}
function getSession(context: RequestContext): HttpResult {
const user = currentUser(context);
const payload = user === undefined
? { success: true, logged_in: false, user: null }
: { success: true, logged_in: true, user: sessionUserPayload(user) };
const result = new HttpResult(200, payload);
const visitorCookie = visitorCookieHeaderIfMissing(context);
return visitorCookie === undefined ? result : result.withHeader("Set-Cookie", visitorCookie);
}
function login(context: RequestContext): HttpResult {
const body = context.json<{ username_or_email: string; password: string }>();
const user = findUserByUsernameOrEmail(body.username_or_email);
if (user === undefined || user.password !== body.password) {
return new HttpResult(401, {
success: false,
error_type: "InvalidCredentials",
error_message: "invalid credentials",
});
}
const signedSession = startSession(user);
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
"Set-Cookie",
sessionCookieHeader(signedSession),
);
}
function logout(context: RequestContext): HttpResult {
endSession(currentSignedSession(context));
return success().withHeader("Set-Cookie", clearedSessionCookieHeader());
}
function createAccount(context: RequestContext): HttpResult {
const body = context.json<{
username: string;
password: string;
password_confirmation: string;
email_address: string;
}>();
const username = (body.username ?? "").trim();
const email = (body.email_address ?? "").trim();
if (username.length < 3) {
return badInput("BadInput", { username: "username is too short" });
}
if (RESERVED_USERNAMES.has(username.toLowerCase())) {
return badInput("UsernameReserved", { username: "username is reserved" });
}
if (store.usersByUsername.has(username.toLowerCase())) {
return badInput("UsernameTaken", { username: "username is taken" });
}
if (!email.includes("@")) {
return badInput("BadInput", { email_address: "invalid email address" });
}
if (body.password !== body.password_confirmation) {
return badInput("BadInput", { password: "passwords do not match" });
}
if ((body.password ?? "").length < 8) {
return badInput("BadInput", { password: "password is too short" });
}
const user: UserRecord = {
userToken: makeToken(TOKEN_PREFIX.user),
username: username.toLowerCase(),
displayName: username,
emailAddress: email,
password: body.password ?? "",
gravatarHash: md5(email),
featureFlags: ["explore_media", "studio", "upload_3d", "referrals", "api_key"],
defaultAvatar: { image_index: 4, color_index: 2 },
bankedCredits: config.demoCredits,
monthlyCredits: 0,
subscriptionSlug: undefined,
createdAt: nowIso(),
};
registerUser(user);
const signedSession = startSession(user);
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
"Set-Cookie",
sessionCookieHeader(signedSession),
);
}
/**
* There is no Google to talk to, so any credential logs in the demo user. This
* keeps the SSO button working rather than dead-ending a developer who clicks it.
*/
function googleSso(context: RequestContext): HttpResult {
const body = context.json<{ google_credential: string }>();
if ((body.google_credential ?? "").length === 0) {
return failure(400, "BadInput", "google_credential is required");
}
const user = [...store.usersByToken.values()][0];
if (user === undefined) {
return failure(500, "InternalError", "fake-storyteller-web has no seeded user to sign in as");
}
const signedSession = startSession(user);
return new HttpResult(200, {
success: true,
signed_session: signedSession,
username_not_yet_customized: false,
maybe_user_display_name: user.displayName,
}).withHeader("Set-Cookie", sessionCookieHeader(signedSession));
}
function getSessionToken(context: RequestContext): HttpResult {
return new HttpResult(200, { maybe_signed_session: currentSignedSession(context) ?? null });
}
function getProfile(context: RequestContext): HttpResult {
const user = store.usersByUsername.get((context.params["username"] ?? "").toLowerCase());
if (user === undefined) {
return notFound();
}
return success({ user: userProfilePayload(user) });
}
function changePassword(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ password: string; password_confirmation: string }>();
if (body.password !== body.password_confirmation) {
return failure(400, "BadInput", "the passwords do not match");
}
if ((body.password ?? "").length < 8) {
return failure(400, "BadInput", "password is too short");
}
user.password = body.password ?? "";
return success();
}
function editEmail(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ email_address: string }>();
if (!(body.email_address ?? "").includes("@")) {
return failure(400, "BadInput", "bad email: invalid email address");
}
user.emailAddress = body.email_address ?? "";
user.gravatarHash = md5(user.emailAddress);
return success();
}
function editUsername(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ display_name: string }>();
const displayName = (body.display_name ?? "").trim();
if (displayName.length < 3) {
return failure(400, "BadInput", "bad username: username is too short");
}
const existing = store.usersByUsername.get(displayName.toLowerCase());
if (existing !== undefined && existing.userToken !== user.userToken) {
return failure(400, "BadInput", "username is taken");
}
store.usersByUsername.delete(user.username);
user.username = displayName.toLowerCase();
user.displayName = displayName;
store.usersByUsername.set(user.username, user);
return success();
}
function recordWebReferral(): HttpResult {
return success();
}
/** Always succeeds, matching the real handler's anti-enumeration behaviour. */
function requestPasswordReset(): HttpResult {
return success();
}
/**
* There is no email to read a token out of, so the fake accepts any non-empty
* reset token and logs the user in as the demo user.
*/
function redeemPasswordReset(context: RequestContext): HttpResult {
const body = context.json<{
reset_token: string;
new_password: string;
new_password_validation: string;
}>();
if (body.new_password !== body.new_password_validation) {
return new HttpResult(400, {
success: false,
kind: "PasswordsDoNotMatch",
message: "The passwords do not match.",
});
}
const user = [...store.usersByToken.values()][0];
if ((body.reset_token ?? "").length === 0 || user === undefined) {
return new HttpResult(400, {
success: false,
kind: "InvalidRedemption",
message:
"The redemption code is invalid, has already been redeemed, or has been replaced by a newer code.",
});
}
user.password = body.new_password ?? "";
const signedSession = startSession(user);
return new HttpResult(200, { success: true, signed_session: signedSession }).withHeader(
"Set-Cookie",
sessionCookieHeader(signedSession),
);
}
function findUserByUsernameOrEmail(usernameOrEmail: string | undefined): UserRecord | undefined {
const needle = (usernameOrEmail ?? "").trim().toLowerCase();
if (needle.length === 0) {
return undefined;
}
if (needle.includes("@")) {
for (const user of store.usersByToken.values()) {
if (user.emailAddress.toLowerCase() === needle) {
return user;
}
}
return undefined;
}
return store.usersByUsername.get(needle);
}
function badInput(errorType: string, errorFields: Record<string, string>): HttpResult {
return new HttpResult(400, { success: false, error_type: errorType, error_fields: errorFields });
}
@@ -0,0 +1,286 @@
/**
* `/v1/tags` — free-text tagging of media files.
*
* Tags are stored per owner and deduplicated by lowercased value, so adding an
* existing tag reuses its token and bumps `use_count` rather than creating a
* duplicate — the behaviour the library UI assumes.
*/
import { currentUser } from "../auth.ts";
import type { RequestContext } from "../http/context.ts";
import { HttpResult, notFound, success, unauthorized } from "../http/respond.ts";
import type { Router } from "../http/router.ts";
import { nowIso } from "../state/clock.ts";
import type { TagRecord, UserRecord } from "../state/entities.ts";
import { sortNewestFirst, store } from "../state/store.ts";
import { makeToken, TOKEN_PREFIX } from "../state/tokens.ts";
import { folderMediaFilePayload } from "../wire/media.ts";
import { clampPageSize, decodeCursor, nextCursorOnly } from "../wire/pagination.ts";
export function registerTagRoutes(router: Router): void {
router.get("/v1/tags/list", listTags);
router.put("/v1/tags/rename/:tagToken", renameTag);
router.delete("/v1/tags/:tagToken", deleteTag);
router.post("/v1/tags/bulk_add", bulkAddTags);
router.post("/v1/tags/bulk_set", bulkSetTags);
router.get("/v1/tags/media_file/list/:mediaFileToken", listTagsForMediaFile);
router.post("/v1/tags/media_file/add/:mediaFileToken", addTagsToMediaFile);
router.post("/v1/tags/media_file/set/:mediaFileToken", setTagsOnMediaFile);
router.post("/v1/tags/media_file/clear/:mediaFileToken", clearTagsOnMediaFile);
router.get("/v1/tags/media_files/list_tagged", (context) => listByTagged(context, true));
router.get("/v1/tags/media_files/list_untagged", (context) => listByTagged(context, false));
router.get("/v1/tags/media_files/with_tag/:tagToken", listMediaWithTag);
router.post("/v1/tags/media_files/bulk_list_tags", bulkListTags);
}
function listTags(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const owned = sortNewestFirst(tagsOwnedBy(user));
const pageSize = clampPageSize(context.queryNumber("limit"));
const offset = decodeCursor(context.queryValue("cursor"));
return success({
tags: owned.slice(offset, offset + pageSize).map(tagPayload),
maybe_cursor: nextCursorOnly(owned, offset, pageSize),
});
}
function renameTag(context: RequestContext): HttpResult {
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
if (tag === undefined) {
return notFound();
}
const body = context.json<{ new_tag_value: string }>();
if (body.new_tag_value !== undefined) {
tag.tagValue = body.new_tag_value;
}
return success({ tag: tagPayload(tag) });
}
function deleteTag(context: RequestContext): HttpResult {
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
if (tag === undefined) {
return notFound();
}
const removedLinkCount = tag.mediaFileTokens.size;
store.tagsByToken.delete(tag.tagToken);
return success({ removed_link_count: removedLinkCount });
}
function bulkAddTags(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ media_file_tokens: string[]; maybe_tags_list: string[] }>();
const accepted = knownMediaTokens(body.media_file_tokens ?? []);
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
for (const tag of tags) {
for (const token of accepted) {
tag.mediaFileTokens.add(token);
}
}
return success({ accepted_media_file_tokens: accepted, tags: tags.map(tagPayload) });
}
function bulkSetTags(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const body = context.json<{ media_file_tokens: string[]; maybe_tags_list: string[] }>();
const accepted = knownMediaTokens(body.media_file_tokens ?? []);
const removedCount = detachTags(user, accepted);
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
for (const tag of tags) {
for (const token of accepted) {
tag.mediaFileTokens.add(token);
}
}
return success({
accepted_media_file_tokens: accepted,
tags: tags.map(tagPayload),
removed_count: removedCount,
});
}
function listTagsForMediaFile(context: RequestContext): HttpResult {
const mediaFileToken = context.params["mediaFileToken"] ?? "";
const tags = [...store.tagsByToken.values()].filter((tag) => tag.mediaFileTokens.has(mediaFileToken));
return success({ tags: tags.map(tagPayload) });
}
function addTagsToMediaFile(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const mediaFileToken = context.params["mediaFileToken"] ?? "";
const body = context.json<{ maybe_tags_list: string[] }>();
for (const value of body.maybe_tags_list ?? []) {
findOrCreateTag(user, value).mediaFileTokens.add(mediaFileToken);
}
return listTagsForMediaFile(context);
}
function setTagsOnMediaFile(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const mediaFileToken = context.params["mediaFileToken"] ?? "";
const removedCount = detachTags(user, [mediaFileToken]);
const body = context.json<{ maybe_tags_list: string[] }>();
const tags = (body.maybe_tags_list ?? []).map((value) => findOrCreateTag(user, value));
for (const tag of tags) {
tag.mediaFileTokens.add(mediaFileToken);
}
return success({ tags: tags.map(tagPayload), removed_count: removedCount });
}
function clearTagsOnMediaFile(context: RequestContext): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const removedCount = detachTags(user, [context.params["mediaFileToken"] ?? ""]);
return success({ removed_count: removedCount });
}
function listByTagged(context: RequestContext, tagged: boolean): HttpResult {
const user = currentUser(context);
if (user === undefined) {
return unauthorized();
}
const taggedTokens = new Set<string>();
for (const tag of tagsOwnedBy(user)) {
for (const token of tag.mediaFileTokens) {
taggedTokens.add(token);
}
}
const records = sortNewestFirst(
[...store.mediaFilesByToken.values()].filter(
(record) => record.maybeCreatorUserToken === user.userToken && taggedTokens.has(record.token) === tagged,
),
);
const pageSize = clampPageSize(context.queryNumber("limit"));
const offset = decodeCursor(context.queryValue("cursor"));
return success({
media_files: records.slice(offset, offset + pageSize).map((record) => folderMediaFilePayload(record)),
maybe_cursor: nextCursorOnly(records, offset, pageSize),
});
}
function listMediaWithTag(context: RequestContext): HttpResult {
const tag = store.tagsByToken.get(context.params["tagToken"] ?? "");
if (tag === undefined) {
return notFound();
}
const records = sortNewestFirst(
[...tag.mediaFileTokens]
.map((token) => store.mediaFilesByToken.get(token))
.filter((record) => record !== undefined),
);
const pageSize = clampPageSize(context.queryNumber("limit"));
const offset = decodeCursor(context.queryValue("cursor"));
return success({
media_files: records.slice(offset, offset + pageSize).map((record) => folderMediaFilePayload(record)),
maybe_cursor: nextCursorOnly(records, offset, pageSize),
});
}
function bulkListTags(context: RequestContext): HttpResult {
const body = context.json<{ media_file_tokens: string[] }>();
const entries = (body.media_file_tokens ?? []).map((mediaFileToken) => ({
media_file_token: mediaFileToken,
tags: [...store.tagsByToken.values()]
.filter((tag) => tag.mediaFileTokens.has(mediaFileToken))
.map(tagPayload),
}));
return success({ media_files: entries });
}
function findOrCreateTag(user: UserRecord, value: string): TagRecord {
const normalized = value.trim().toLowerCase();
for (const tag of tagsOwnedBy(user)) {
if (tag.tagValue.toLowerCase() === normalized) {
return tag;
}
}
const tag: TagRecord = {
tagToken: makeToken(TOKEN_PREFIX.tag),
tagValue: value.trim(),
ownerUserToken: user.userToken,
mediaFileTokens: new Set(),
createdAt: nowIso(),
};
store.tagsByToken.set(tag.tagToken, tag);
return tag;
}
/** Remove these media files from every tag the user owns; returns how many links went away. */
function detachTags(user: UserRecord, mediaFileTokens: string[]): number {
let removed = 0;
for (const tag of tagsOwnedBy(user)) {
for (const token of mediaFileTokens) {
if (tag.mediaFileTokens.delete(token)) {
removed += 1;
}
}
}
return removed;
}
function tagsOwnedBy(user: UserRecord): TagRecord[] {
return [...store.tagsByToken.values()].filter((tag) => tag.ownerUserToken === user.userToken);
}
function knownMediaTokens(tokens: string[]): string[] {
return tokens.filter((token) => store.mediaFilesByToken.has(token));
}
function tagPayload(tag: TagRecord): object {
return {
tag_token: tag.tagToken,
tag_value: tag.tagValue,
tag_value_lowercase: tag.tagValue.toLowerCase(),
use_count: tag.mediaFileTokens.size,
};
}
@@ -0,0 +1,117 @@
/**
* In-memory object store standing in for the media bucket + CDN.
*
* Uploaded bytes and generation results both land here, keyed by a bucket path
* shaped like the real one, and are served straight back out of memory at
* `/media/<path>`. Nothing touches disk except the one-time fixture load.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { config, publicOrigin } from "../config.ts";
/** Fixtures shipped in the repo, reused as the visible content of fake media. */
const FIXTURES = {
image: join("test_data", "image", "juno.jpg"),
imageAlternate: join("test_data", "image", "mochi.jpg"),
video: join("test_data", "video", "mp4", "golden_sun_garoh.mp4"),
} as const;
export type FixtureName = keyof typeof FIXTURES;
export interface StoredObject {
bytes: Buffer;
contentType: string;
}
const objects = new Map<string, StoredObject>();
const fixtureCache = new Map<FixtureName, Buffer>();
/** Store bytes at a bucket path and return that path. */
export function putObject(bucketPath: string, bytes: Buffer, contentType: string): string {
objects.set(normalize(bucketPath), { bytes, contentType });
return bucketPath;
}
export function getObject(bucketPath: string): StoredObject | undefined {
return objects.get(normalize(bucketPath));
}
export function objectCount(): number {
return objects.size;
}
export function clearObjects(): void {
objects.clear();
}
/**
* Build a bucket path with the same hash fan-out the real backend uses:
* `<h0>/<h1>/<h2>/<h3>/<h4>/<hash>/<prefix><hash><extension>`.
*/
export function makeBucketPath(hash: string, prefix: string, extension: string): string {
const fanOut = hash.slice(0, 5).split("").join("/");
return `${fanOut}/${hash}/${prefix}${hash}${extension}`;
}
/** Absolute URL the frontend should fetch this object from. */
export function bucketPathToCdnUrl(bucketPath: string): string {
return `${publicOrigin()}/media/${normalize(bucketPath)}`;
}
/**
* The thumbnail template the frontend expands by replacing `{WIDTH}`. The fake
* ignores the requested width and serves the full-size object.
*/
export function bucketPathToThumbnailTemplate(bucketPath: string): string {
return `${publicOrigin()}/media/thumb/{WIDTH}/${normalize(bucketPath)}`;
}
/** Read a repo fixture, cached after the first load. */
export function fixtureBytes(name: FixtureName): Buffer {
const cached = fixtureCache.get(name);
if (cached !== undefined) {
return cached;
}
const bytes = readFileSync(join(config.repoRoot, FIXTURES[name]));
fixtureCache.set(name, bytes);
return bytes;
}
/** Best-effort content type from a file extension, for serving stored bytes back. */
export function contentTypeForExtension(extension: string): string {
const known: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".fbx": "application/octet-stream",
".spz": "application/octet-stream",
".ply": "application/octet-stream",
".pmx": "application/octet-stream",
".json": "application/json",
".zip": "application/zip",
};
return known[extension.toLowerCase()] ?? "application/octet-stream";
}
/** File extension including the dot, or an empty string. */
export function extensionOf(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return dot > 0 ? fileName.slice(dot) : "";
}
function normalize(bucketPath: string): string {
return bucketPath.replace(/^\/+/, "");
}
@@ -0,0 +1,9 @@
/** Timestamps in the RFC3339 form the Rust backend emits. */
export function nowIso(): string {
return new Date().toISOString();
}
export function isoFromMillis(millis: number): string {
return new Date(millis).toISOString();
}
@@ -0,0 +1,183 @@
/**
* The in-memory record types.
*
* These are storage records, not wire types — each one feeds several different
* response shapes, because the real backend emits a different subset of media
* file fields on almost every endpoint. Response construction lives in
* `src/wire/`.
*/
export type MediaClass = "unknown" | "audio" | "image" | "video" | "dimensional" | "mesh" | "splat" | "project";
export type MediaType =
| "audio" | "image" | "video"
| "bvh" | "fbx" | "obj" | "ply" | "glb" | "gltf" | "spz"
| "scene_ron" | "scene_json" | "mood_json" | "timeline_json" | "editor_json"
| "pmd" | "vmd" | "pmx" | "csv"
| "jpg" | "png" | "gif" | "mp4" | "wav" | "mp3" | "webp" | "webm" | "mov"
| "opus" | "ogg" | "aac" | "m4a" | "flac" | "json";
export type ProjectType = "scene_3d" | "mood_board" | "workflow" | "video_timeline" | "editor_2d";
export type Visibility = "public" | "hidden" | "private";
export type OriginCategory =
| "inference" | "inference3rd" | "processed" | "upload" | "device_api" | "studio" | "story_engine";
export type ProductCategory =
| "unknown" | "face_animator" | "face_fusion" | "face_mirror" | "vst" | "image_studio"
| "studio" | "tts" | "voice_conversion" | "zs_voice" | "mocap" | "image_gen" | "video_gen"
| "video_edit" | "world_gen" | "video_filter" | "workflow";
export type JobStatus =
| "pending" | "started" | "complete_success" | "complete_failure"
| "attempt_failed" | "dead" | "cancelled_by_user" | "cancelled_by_system";
export type InferenceCategory =
| "image_generation" | "video_generation" | "audio_generation" | "object_generation"
| "splat_generation" | "character_generation" | "text_to_speech" | "voice_conversion"
| "lipsync_animation" | "format_conversion" | "workflow";
export interface UserRecord {
userToken: string;
username: string;
displayName: string;
emailAddress: string;
password: string;
gravatarHash: string;
featureFlags: string[];
defaultAvatar: { image_index: number; color_index: number };
bankedCredits: number;
monthlyCredits: number;
subscriptionSlug: string | undefined;
createdAt: string;
}
export interface SessionRecord {
signedSession: string;
userToken: string;
createdAt: string;
}
export interface MediaFileRecord {
token: string;
mediaClass: MediaClass;
mediaType: MediaType;
/** Path within the object store, also the URL path under `/media/`. */
bucketPath: string;
maybeProjectType: ProjectType | undefined;
maybeCreatorUserToken: string | undefined;
maybeTitle: string | undefined;
maybeOriginalFilename: string | undefined;
maybePromptToken: string | undefined;
maybeBatchToken: string | undefined;
maybeStyleName: string | undefined;
maybeDurationMillis: number | undefined;
maybeEngineCategory: string | undefined;
maybeAnimationType: string | undefined;
maybeEngineExtension: string | undefined;
maybeSceneSourceMediaFileToken: string | undefined;
maybeCoverImageMediaFileToken: string | undefined;
maybeTextTranscript: string | undefined;
creatorSetVisibility: Visibility;
isUserUpload: boolean;
isIntermediateSystemFile: boolean;
isFeatured: boolean;
originCategory: OriginCategory;
originProductCategory: ProductCategory;
maybeOriginModelType: string | undefined;
createdAt: string;
updatedAt: string;
}
export interface JobRecord {
jobToken: string;
inferenceCategory: InferenceCategory;
status: JobStatus;
progressPercentage: number;
maybePromptToken: string | undefined;
maybeModelType: string | undefined;
maybeModelTitle: string | undefined;
maybeRawInferenceText: string | undefined;
maybeCreatorUserToken: string | undefined;
maybeBatchToken: string | undefined;
maybeResultMediaFileToken: string | undefined;
maybeFailureCategory: string | undefined;
maybeFailureMessage: string | undefined;
maybeSuccessfullyCompletedAt: string | undefined;
/** Epoch millis at which the resolver should finish this job. */
resolveAtMillis: number;
isDismissed: boolean;
createdAt: string;
updatedAt: string;
}
export interface PromptRecord {
token: string;
promptType: string;
maybePositivePrompt: string | undefined;
maybeNegativePrompt: string | undefined;
maybeModelType: string | undefined;
maybeModelClass: string | undefined;
maybeGenerationProvider: string | undefined;
maybeAspectRatio: string | undefined;
maybeResolution: string | undefined;
maybeBatchCount: number | undefined;
maybeDurationSeconds: number | undefined;
maybeStyleName: string | undefined;
maybeGenerateAudio: boolean | undefined;
contextImageMediaTokens: string[];
createdAt: string;
}
export interface FolderRecord {
token: string;
name: string;
ownerUserToken: string;
maybeParentFolderToken: string | undefined;
maybeColorCode: string | undefined;
maybeCustomCoverMediaFileToken: string | undefined;
hasStar: boolean;
mediaFileTokens: string[];
createdAt: string;
updatedAt: string;
}
export interface TagRecord {
tagToken: string;
tagValue: string;
ownerUserToken: string;
mediaFileTokens: Set<string>;
createdAt: string;
}
export interface CharacterRecord {
token: string;
name: string;
maybeDescription: string | undefined;
maybeAvatarMediaFileToken: string | undefined;
maybeFullImageMediaFileToken: string | undefined;
models: string[];
ownerUserToken: string;
isUserCreated: boolean;
createdAt: string;
}
export interface ApiKeyRecord {
token: string;
apiKey: string;
name: string;
maybeDescription: string | undefined;
ownerUserToken: string;
maybeDeletedAt: string | undefined;
createdAt: string;
updatedAt: string;
}
export interface ReferralCodeRecord {
token: string;
code: string;
ownerUserToken: string;
createdAt: string;
updatedAt: string;
}
@@ -0,0 +1,147 @@
/**
* Creating media files.
*
* Uploads and finished generations both come through here so that a generated
* image and an uploaded one are indistinguishable to the rest of the server —
* same bucket layout, same URL shape, same record fields.
*/
import {
contentTypeForExtension,
fixtureBytes,
makeBucketPath,
putObject,
type FixtureName,
} from "./assets.ts";
import { nowIso } from "./clock.ts";
import type {
MediaClass,
MediaFileRecord,
MediaType,
OriginCategory,
ProductCategory,
ProjectType,
Visibility,
} from "./entities.ts";
import { store } from "./store.ts";
import { makeToken, TOKEN_PREFIX } from "./tokens.ts";
export interface CreateMediaFileArgs {
bytes: Buffer;
mediaClass: MediaClass;
mediaType: MediaType;
/** Filename prefix inside the bucket, e.g. `image_`, matching the real layout. */
bucketPrefix: string;
extension: string;
maybeCreatorUserToken: string | undefined;
originCategory: OriginCategory;
originProductCategory: ProductCategory;
isUserUpload: boolean;
maybeTitle?: string | undefined;
maybeOriginalFilename?: string | undefined;
maybePromptToken?: string | undefined;
maybeBatchToken?: string | undefined;
maybeProjectType?: ProjectType | undefined;
maybeEngineCategory?: string | undefined;
maybeAnimationType?: string | undefined;
maybeDurationMillis?: number | undefined;
maybeSceneSourceMediaFileToken?: string | undefined;
maybeOriginModelType?: string | undefined;
creatorSetVisibility?: Visibility | undefined;
isIntermediateSystemFile?: boolean | undefined;
}
export function createMediaFile(args: CreateMediaFileArgs): MediaFileRecord {
const token = makeToken(TOKEN_PREFIX.mediaFile);
const hash = token.slice(TOKEN_PREFIX.mediaFile.length);
const bucketPath = makeBucketPath(hash, args.bucketPrefix, args.extension);
putObject(bucketPath, args.bytes, contentTypeForExtension(args.extension));
writeVideoThumbnails(bucketPath, args.mediaClass, args.mediaType);
const timestamp = nowIso();
const record: MediaFileRecord = {
token,
mediaClass: args.mediaClass,
mediaType: args.mediaType,
bucketPath,
maybeProjectType: args.maybeProjectType,
maybeCreatorUserToken: args.maybeCreatorUserToken,
maybeTitle: args.maybeTitle,
maybeOriginalFilename: args.maybeOriginalFilename,
maybePromptToken: args.maybePromptToken,
maybeBatchToken: args.maybeBatchToken,
maybeStyleName: undefined,
maybeDurationMillis: args.maybeDurationMillis,
maybeEngineCategory: args.maybeEngineCategory,
maybeAnimationType: args.maybeAnimationType,
maybeEngineExtension: undefined,
maybeSceneSourceMediaFileToken: args.maybeSceneSourceMediaFileToken,
maybeCoverImageMediaFileToken: undefined,
maybeTextTranscript: undefined,
creatorSetVisibility: args.creatorSetVisibility ?? "public",
isUserUpload: args.isUserUpload,
isIntermediateSystemFile: args.isIntermediateSystemFile ?? false,
isFeatured: false,
originCategory: args.originCategory,
originProductCategory: args.originProductCategory,
maybeOriginModelType: args.maybeOriginModelType,
createdAt: timestamp,
updatedAt: timestamp,
};
store.mediaFilesByToken.set(token, record);
return record;
}
/** Media type and class inferred from an uploaded file's extension. */
export function classifyUpload(fileName: string): { mediaClass: MediaClass; mediaType: MediaType; bucketPrefix: string } {
const extension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
const table: Record<string, { mediaClass: MediaClass; mediaType: MediaType; bucketPrefix: string }> = {
".jpg": { mediaClass: "image", mediaType: "jpg", bucketPrefix: "image_" },
".jpeg": { mediaClass: "image", mediaType: "jpg", bucketPrefix: "image_" },
".png": { mediaClass: "image", mediaType: "png", bucketPrefix: "image_" },
".gif": { mediaClass: "image", mediaType: "gif", bucketPrefix: "image_" },
".webp": { mediaClass: "image", mediaType: "webp", bucketPrefix: "image_" },
".mp4": { mediaClass: "video", mediaType: "mp4", bucketPrefix: "video_" },
".webm": { mediaClass: "video", mediaType: "webm", bucketPrefix: "video_" },
".mov": { mediaClass: "video", mediaType: "mov", bucketPrefix: "video_" },
".mp3": { mediaClass: "audio", mediaType: "mp3", bucketPrefix: "audio_" },
".wav": { mediaClass: "audio", mediaType: "wav", bucketPrefix: "audio_" },
".glb": { mediaClass: "mesh", mediaType: "glb", bucketPrefix: "engine_" },
".gltf": { mediaClass: "mesh", mediaType: "gltf", bucketPrefix: "engine_" },
".fbx": { mediaClass: "dimensional", mediaType: "fbx", bucketPrefix: "engine_" },
".obj": { mediaClass: "dimensional", mediaType: "obj", bucketPrefix: "engine_" },
".ply": { mediaClass: "splat", mediaType: "ply", bucketPrefix: "engine_" },
".spz": { mediaClass: "splat", mediaType: "spz", bucketPrefix: "splat_" },
".pmx": { mediaClass: "dimensional", mediaType: "pmx", bucketPrefix: "engine_" },
".bvh": { mediaClass: "dimensional", mediaType: "bvh", bucketPrefix: "engine_" },
".json": { mediaClass: "project", mediaType: "json", bucketPrefix: "project_" },
};
return table[extension] ?? { mediaClass: "unknown", mediaType: "json", bucketPrefix: "file_" };
}
/** Create a media file whose visible content is one of the repo fixtures. */
export function createMediaFileFromFixture(
fixture: FixtureName,
args: Omit<CreateMediaFileArgs, "bytes">,
): MediaFileRecord {
return createMediaFile({ ...args, bytes: fixtureBytes(fixture) });
}
/**
* Videos are served with `-thumb.jpg` / `-thumb.gif` siblings, because
* `media_links.maybe_video_previews` points at them. Both get image bytes so
* the browser renders something.
*/
function writeVideoThumbnails(bucketPath: string, mediaClass: MediaClass, mediaType: MediaType): void {
if (mediaClass !== "video" && mediaType !== "mp4") {
return;
}
const still = fixtureBytes("image");
putObject(`${bucketPath}-thumb.jpg`, still, "image/jpeg");
putObject(`${bucketPath}-thumb.gif`, still, "image/gif");
}
@@ -0,0 +1,142 @@
/**
* The fixture the server starts from.
*
* A fresh process always contains the same demo user, credits, gallery and
* folder, so "log in and look at the library" works immediately and tests can
* rely on what they find.
*/
import { createHash } from "node:crypto";
import { config } from "../config.ts";
import { nowIso } from "./clock.ts";
import type { FolderRecord, MediaFileRecord, TagRecord, UserRecord } from "./entities.ts";
import { createMediaFileFromFixture } from "./media_factory.ts";
import { clearStore, store } from "./store.ts";
import { makeToken, TOKEN_PREFIX } from "./tokens.ts";
export const DEMO_USERNAME = "localdev1";
export const DEMO_PASSWORD = "localdev1pass";
const DEMO_EMAIL = "localdev1@example.com";
/** Titles for the seeded gallery, so the library is not a wall of identical rows. */
const SEED_IMAGE_TITLES = [
"Juno at the lake",
"Mochi in the studio",
"Golden hour portrait",
"Concept sheet, front view",
"Reference plate 04",
"Lighting test, warm key",
];
export function seedState(): void {
clearStore();
const user = seedDemoUser();
const images = seedGallery(user);
seedVideo(user);
seedFolder(user, images);
seedTags(user, images);
console.log(`[fake-api] seeded ${DEMO_USERNAME} / ${DEMO_PASSWORD} with ${config.demoCredits} credits`);
console.log(`[fake-api] seeded ${store.mediaFilesByToken.size} media files`);
}
function seedDemoUser(): UserRecord {
const user: UserRecord = {
userToken: makeToken(TOKEN_PREFIX.user),
username: DEMO_USERNAME,
displayName: "LocalDev1",
emailAddress: DEMO_EMAIL,
password: DEMO_PASSWORD,
gravatarHash: md5(DEMO_EMAIL),
featureFlags: ["explore_media", "studio", "upload_3d", "video_style_transfer", "referrals", "api_key"],
defaultAvatar: { image_index: 16, color_index: 5 },
bankedCredits: config.demoCredits,
monthlyCredits: 0,
subscriptionSlug: "artcraft_pro",
createdAt: nowIso(),
};
registerUser(user);
return user;
}
/** Register a user in both lookup maps. Also used by the create-account handler. */
export function registerUser(user: UserRecord): void {
store.usersByToken.set(user.userToken, user);
store.usersByUsername.set(user.username.toLowerCase(), user);
}
export function md5(value: string): string {
return createHash("md5").update(value.trim().toLowerCase()).digest("hex");
}
function seedGallery(user: UserRecord): MediaFileRecord[] {
return SEED_IMAGE_TITLES.map((title, index) =>
createMediaFileFromFixture(index % 2 === 0 ? "image" : "imageAlternate", {
mediaClass: "image",
mediaType: "jpg",
bucketPrefix: "image_",
extension: ".jpg",
maybeCreatorUserToken: user.userToken,
maybeTitle: title,
maybeOriginalFilename: `${title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}.jpg`,
originCategory: "upload",
originProductCategory: "image_gen",
isUserUpload: true,
}),
);
}
function seedVideo(user: UserRecord): MediaFileRecord {
return createMediaFileFromFixture("video", {
mediaClass: "video",
mediaType: "mp4",
bucketPrefix: "video_",
extension: ".mp4",
maybeCreatorUserToken: user.userToken,
maybeTitle: "Garoh, golden sun",
maybeOriginalFilename: "golden_sun_garoh.mp4",
maybeDurationMillis: 8_000,
originCategory: "upload",
originProductCategory: "video_gen",
isUserUpload: true,
});
}
function seedFolder(user: UserRecord, images: MediaFileRecord[]): FolderRecord {
const timestamp = nowIso();
const folder: FolderRecord = {
token: makeToken(TOKEN_PREFIX.folder),
name: "References",
ownerUserToken: user.userToken,
maybeParentFolderToken: undefined,
maybeColorCode: "#7c5cff",
maybeCustomCoverMediaFileToken: undefined,
hasStar: true,
mediaFileTokens: images.slice(0, 3).map((image) => image.token),
createdAt: timestamp,
updatedAt: timestamp,
};
store.foldersByToken.set(folder.token, folder);
return folder;
}
function seedTags(user: UserRecord, images: MediaFileRecord[]): void {
const assignments: [string, MediaFileRecord[]][] = [
["character", images.slice(0, 2)],
["lighting", images.slice(4, 6)],
];
for (const [value, taggedImages] of assignments) {
const tag: TagRecord = {
tagToken: makeToken(TOKEN_PREFIX.tag),
tagValue: value,
ownerUserToken: user.userToken,
mediaFileTokens: new Set(taggedImages.map((image) => image.token)),
createdAt: nowIso(),
};
store.tagsByToken.set(tag.tagToken, tag);
}
}
@@ -0,0 +1,92 @@
/**
* The whole server's state.
*
* One module-level object holding plain Maps. It is process-local and lost on
* restart, which is the point: every run starts from the same seeded fixture,
* so a developer or a test can rely on what is there.
*/
import type {
ApiKeyRecord,
CharacterRecord,
FolderRecord,
JobRecord,
MediaFileRecord,
PromptRecord,
ReferralCodeRecord,
SessionRecord,
TagRecord,
UserRecord,
} from "./entities.ts";
import { clearObjects } from "./assets.ts";
import { resetTokenSequence } from "./tokens.ts";
export interface FakeStore {
usersByToken: Map<string, UserRecord>;
usersByUsername: Map<string, UserRecord>;
sessionsBySignedSession: Map<string, SessionRecord>;
mediaFilesByToken: Map<string, MediaFileRecord>;
jobsByToken: Map<string, JobRecord>;
promptsByToken: Map<string, PromptRecord>;
foldersByToken: Map<string, FolderRecord>;
tagsByToken: Map<string, TagRecord>;
charactersByToken: Map<string, CharacterRecord>;
apiKeysByToken: Map<string, ApiKeyRecord>;
referralCodesByToken: Map<string, ReferralCodeRecord>;
/** Idempotency tokens already spent, so replays are rejected like the real API. */
usedIdempotencyTokens: Set<string>;
}
export const store: FakeStore = {
usersByToken: new Map(),
usersByUsername: new Map(),
sessionsBySignedSession: new Map(),
mediaFilesByToken: new Map(),
jobsByToken: new Map(),
promptsByToken: new Map(),
foldersByToken: new Map(),
tagsByToken: new Map(),
charactersByToken: new Map(),
apiKeysByToken: new Map(),
referralCodesByToken: new Map(),
usedIdempotencyTokens: new Set(),
};
/** Drop everything, including stored bytes, and restart the token sequence. */
export function clearStore(): void {
for (const collection of [
store.usersByToken,
store.usersByUsername,
store.sessionsBySignedSession,
store.mediaFilesByToken,
store.jobsByToken,
store.promptsByToken,
store.foldersByToken,
store.tagsByToken,
store.charactersByToken,
store.apiKeysByToken,
store.referralCodesByToken,
]) {
collection.clear();
}
store.usedIdempotencyTokens.clear();
clearObjects();
resetTokenSequence();
}
/** Media files owned by a user, newest first. */
export function mediaFilesForUser(userToken: string | undefined): MediaFileRecord[] {
const owned: MediaFileRecord[] = [];
for (const record of store.mediaFilesByToken.values()) {
if (record.maybeCreatorUserToken === userToken) {
owned.push(record);
}
}
return sortNewestFirst(owned);
}
/** Sort any record set by `createdAt`, newest first — the default order everywhere in the UI. */
export function sortNewestFirst<T extends { createdAt: string }>(records: T[]): T[] {
return [...records].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
@@ -0,0 +1,148 @@
/**
* Synthesized result assets.
*
* The repo ships image and video fixtures, but nothing for audio, mesh or
* splat results. Rather than hand back a broken file and let the viewer fail in
* a confusing way, these build small well-formed assets in the formats the
* frontend actually loads.
*/
const SAMPLE_RATE_HZ = 44_100;
const TONE_SECONDS = 2;
const TONE_HZ = 440;
const TONE_AMPLITUDE = 0.12;
const SPLAT_POINT_COUNT = 512;
/** 16-bit mono PCM WAV holding a quiet tone, so waveform UIs render something. */
export function makeWavBytes(): Buffer {
const sampleCount = SAMPLE_RATE_HZ * TONE_SECONDS;
const dataBytes = sampleCount * 2;
const buffer = Buffer.alloc(44 + dataBytes);
buffer.write("RIFF", 0, "ascii");
buffer.writeUInt32LE(36 + dataBytes, 4);
buffer.write("WAVE", 8, "ascii");
buffer.write("fmt ", 12, "ascii");
buffer.writeUInt32LE(16, 16); // PCM header size
buffer.writeUInt16LE(1, 20); // PCM
buffer.writeUInt16LE(1, 22); // mono
buffer.writeUInt32LE(SAMPLE_RATE_HZ, 24);
buffer.writeUInt32LE(SAMPLE_RATE_HZ * 2, 28); // byte rate
buffer.writeUInt16LE(2, 32); // block align
buffer.writeUInt16LE(16, 34); // bits per sample
buffer.write("data", 36, "ascii");
buffer.writeUInt32LE(dataBytes, 40);
for (let index = 0; index < sampleCount; index += 1) {
const value = Math.sin((2 * Math.PI * TONE_HZ * index) / SAMPLE_RATE_HZ) * TONE_AMPLITUDE;
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * 2);
}
return buffer;
}
/** A valid single-triangle GLB, enough for a 3D viewer to load and frame something. */
export function makeGlbBytes(): Buffer {
const vertices = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const binary = Buffer.from(vertices.buffer);
const gltf = {
asset: { version: "2.0", generator: "fake-storyteller-web" },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [
{
bufferView: 0,
componentType: 5126, // FLOAT
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [1, 1, 0],
},
],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }],
buffers: [{ byteLength: binary.length }],
};
const jsonChunk = padChunk(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const binaryChunk = padChunk(binary, 0x00);
const header = Buffer.alloc(12);
header.write("glTF", 0, "ascii");
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + jsonChunk.length + 8 + binaryChunk.length, 8);
return Buffer.concat([
header,
chunkHeader(jsonChunk.length, 0x4e4f_534a),
jsonChunk,
chunkHeader(binaryChunk.length, 0x004e_4942),
binaryChunk,
]);
}
/**
* A binary little-endian PLY in the 3D Gaussian Splatting layout, holding a
* cloud of points in a small cube.
*/
export function makePlyBytes(): Buffer {
const properties = [
"x", "y", "z",
"nx", "ny", "nz",
"f_dc_0", "f_dc_1", "f_dc_2",
"opacity",
"scale_0", "scale_1", "scale_2",
"rot_0", "rot_1", "rot_2", "rot_3",
];
const header =
"ply\n" +
"format binary_little_endian 1.0\n" +
`element vertex ${SPLAT_POINT_COUNT}\n` +
properties.map((property) => `property float ${property}\n`).join("") +
"end_header\n";
const body = Buffer.alloc(SPLAT_POINT_COUNT * properties.length * 4);
let offset = 0;
const write = (value: number): void => {
body.writeFloatLE(value, offset);
offset += 4;
};
for (let index = 0; index < SPLAT_POINT_COUNT; index += 1) {
const angle = (index / SPLAT_POINT_COUNT) * Math.PI * 2;
const radius = 0.5 + 0.25 * Math.sin(index * 0.37);
write(Math.cos(angle) * radius);
write(Math.sin(index * 0.11) * 0.4);
write(Math.sin(angle) * radius);
write(0); write(1); write(0); // normals, unused by splat renderers
write(0.6); write(0.4); write(0.9); // spherical-harmonic DC term (colour)
write(4); // opacity, pre-sigmoid
write(-4); write(-4); write(-4); // log-scale
write(1); write(0); write(0); write(0); // rotation quaternion
}
return Buffer.concat([Buffer.from(header, "ascii"), body]);
}
function chunkHeader(length: number, type: number): Buffer {
const header = Buffer.alloc(8);
header.writeUInt32LE(length, 0);
header.writeUInt32LE(type, 4);
return header;
}
/** GLB chunks must be four-byte aligned; JSON pads with spaces, binary with zeroes. */
function padChunk(chunk: Buffer, padByte: number): Buffer {
const remainder = chunk.length % 4;
if (remainder === 0) {
return chunk;
}
return Buffer.concat([chunk, Buffer.alloc(4 - remainder, padByte)]);
}
@@ -0,0 +1,82 @@
/**
* Token generation, mirroring `crates/schema/public/tokens`.
*
* Tokens are Crockford base32 with a Stripe-like prefix, and the prefix counts
* toward the total length. Some frontend code branches on the prefix, so the
* fake uses the real ones.
*
* The generator is a seeded PRNG rather than `Math.random`, so a fresh server
* always produces the same tokens in the same order. That keeps snapshot-style
* tests stable across runs.
*/
const CROCKFORD_LOWER = "0123456789abcdefghjkmnpqrstvwxyz";
const DEFAULT_TOKEN_LENGTH = 32;
const DEFAULT_SEED = 0x5f37_59df;
export const TOKEN_PREFIX = {
apiKey: "api_key_",
appSession: "app_session_",
batchGeneration: "batch_g_",
character: "character_",
folder: "folder_",
inferenceJob: "jinf_",
mediaFile: "m_",
mediaUpload: "mu_",
modelWeight: "weight_",
prompt: "prompt_",
tag: "tag_",
user: "user_",
userBookmark: "ub_",
wallet: "wallet_",
} as const;
export type TokenPrefix = (typeof TOKEN_PREFIX)[keyof typeof TOKEN_PREFIX];
/** User tokens are 18 characters total; everything else is 32. */
const TOKEN_LENGTH_OVERRIDES: Partial<Record<TokenPrefix, number>> = {
[TOKEN_PREFIX.user]: 18,
};
let nextRandom = mulberry32(DEFAULT_SEED);
/** Generate a token with the given prefix, e.g. `m_00f2h...`. */
export function makeToken(prefix: TokenPrefix): string {
const totalLength = TOKEN_LENGTH_OVERRIDES[prefix] ?? DEFAULT_TOKEN_LENGTH;
const entropyLength = Math.max(1, totalLength - prefix.length);
let entropy = "";
for (let index = 0; index < entropyLength; index += 1) {
entropy += CROCKFORD_LOWER[Math.floor(nextRandom() * CROCKFORD_LOWER.length)];
}
return prefix + entropy;
}
/** A bare uuid-shaped identifier, for the idempotency and request ids the API echoes back. */
export function makeUuid(): string {
const hex = "0123456789abcdef";
let out = "";
for (let index = 0; index < 32; index += 1) {
if (index === 8 || index === 12 || index === 16 || index === 20) {
out += "-";
}
out += hex[Math.floor(nextRandom() * hex.length)];
}
return out;
}
/** Restart the token stream. Called by the state reset endpoint so tests replay identically. */
export function resetTokenSequence(seed = DEFAULT_SEED): void {
nextRandom = mulberry32(seed);
}
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b_79f5) >>> 0;
let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
return ((drawn ^ (drawn >>> 14)) >>> 0) / 4_294_967_296;
};
}
@@ -0,0 +1,43 @@
/**
* Compile-time checks against `@storyteller/api`.
*
* The fake exists to behave like the real Rust backend, so its response shapes
* are modelled on the Rust structs rather than on this package. Where the two
* agree, though, binding them together is free insurance: if the frontend
* changes one of these enums, `nx typecheck fake-storyteller-web` fails instead
* of the fake quietly serving a value the app no longer understands.
*
* These are type-only imports, so nothing from `@storyteller/api` is loaded at
* runtime — which matters, because its barrel touches `window` on import and
* would crash under Node.
*
* DELIBERATELY NOT CHECKED: `MediaFileClass` and `MediaFileType`. Both are
* stale in `@storyteller/api` relative to the backend, and locking to them
* would force the fake to serve wrong values:
*
* MediaFileClass frontend has animation/character/prop/scene;
* the backend emits dimensional/mesh/splat/project.
* MediaFileType frontend has mmd/scene/none and is missing every concrete
* type the backend actually sends (jpg, png, mp4, spz, ply,
* scene_json, ...).
*
* Fixing those enums in `@storyteller/api` is tracked separately; see this
* app's README for the full drift list.
*/
import type { JobStatus as FrontendJobStatus } from "@storyteller/api/enums/Job.js";
import type { Visibility as FrontendVisibility } from "@storyteller/api/enums/Visibility.js";
import type { JobStatus, Visibility } from "../state/entities.ts";
/** True only when both types accept exactly the same values. */
type Exact<Left, Right> = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false;
type Assert<Condition extends true> = Condition;
/**
* `${Enum}` widens a TypeScript string enum to the union of its literal values,
* which is what a plain JSON payload actually contains.
*/
export type VisibilityMatchesFrontend = Assert<Exact<`${FrontendVisibility}`, Visibility>>;
export type JobStatusMatchesFrontend = Assert<Exact<`${FrontendJobStatus}`, JobStatus>>;
@@ -0,0 +1,137 @@
/**
* Job response builders.
*
* `/v1/jobs/job/:token` and `/v1/jobs/session` return deliberately different
* status objects in the real backend: only the session listing carries
* `maybe_failure_category_updated`, `maybe_failure_message` and
* `maybe_batch_token`. The generation UI reads the session variant, so the
* difference is load bearing.
*/
import type { JobRecord } from "../state/entities.ts";
import { store } from "../state/store.ts";
import { mediaLinks } from "./media.ts";
/** The narrow failure enum still sent on `maybe_failure_category`. */
const OLD_CLIENT_FAILURE_CATEGORIES = new Set([
"face_not_detected",
"keep_alive_elapsed",
"not_yet_implemented",
"retryable_worker_error",
]);
/** `GET /v1/jobs/job/:token` and `GET /v1/jobs/batch`. */
export function jobStatePayload(job: JobRecord): object {
return {
job_token: job.jobToken,
request: jobRequestPayload(job),
status: {
status: job.status,
maybe_extra_status_description: null,
maybe_assigned_worker: null,
maybe_assigned_cluster: null,
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
attempt_count: job.status === "pending" ? 0 : 1,
requires_keepalive: false,
maybe_failure_category: oldClientFailureCategory(job),
progress_percentage: job.progressPercentage,
},
maybe_result: jobResultPayload(job, false),
created_at: job.createdAt,
updated_at: job.updatedAt,
};
}
/** `GET /v1/jobs/session` — the richer status the generation list consumes. */
export function sessionJobPayload(job: JobRecord): object {
return {
job_token: job.jobToken,
request: jobRequestPayload(job),
status: {
status: job.status,
maybe_extra_status_description: null,
maybe_assigned_worker: null,
maybe_assigned_cluster: null,
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
maybe_current_execution_duration_seconds: job.status === "started" ? 1 : null,
attempt_count: job.status === "pending" ? 0 : 1,
requires_keepalive: false,
maybe_failure_category: oldClientFailureCategory(job),
maybe_failure_category_updated: job.maybeFailureCategory ?? null,
maybe_failure_message: job.maybeFailureMessage ?? null,
progress_percentage: job.progressPercentage,
},
maybe_result: jobResultPayload(job, true),
created_at: job.createdAt,
updated_at: job.updatedAt,
};
}
/** `GET /v1/workflows/preview_status/:token` — a deprecated endpoint with an empty request object. */
export function jobPreviewPayload(job: JobRecord): object {
return {
job_token: job.jobToken,
request: {},
status: {
status: job.status,
maybe_extra_status_description: null,
maybe_assigned_worker: null,
maybe_assigned_cluster: null,
maybe_first_started_at: job.status === "pending" ? null : job.createdAt,
attempt_count: job.status === "pending" ? 0 : 1,
requires_keepalive: false,
maybe_failure_category: oldClientFailureCategory(job),
progress_percentage: job.progressPercentage,
},
maybe_result: null,
created_at: job.createdAt,
updated_at: job.updatedAt,
};
}
function jobRequestPayload(job: JobRecord): object {
return {
inference_category: job.inferenceCategory,
maybe_prompt_token: job.maybePromptToken ?? null,
maybe_model_type: job.maybeModelType ?? null,
maybe_model_token: null,
maybe_model_title: job.maybeModelTitle ?? null,
maybe_raw_inference_text: job.maybeRawInferenceText ?? null,
maybe_style_name: null,
maybe_live_portrait_details: null,
maybe_lipsync_details: null,
};
}
function jobResultPayload(job: JobRecord, includeBatchToken: boolean): object | null {
if (job.maybeResultMediaFileToken === undefined) {
return null;
}
const record = store.mediaFilesByToken.get(job.maybeResultMediaFileToken);
if (record === undefined) {
return null;
}
const result: Record<string, unknown> = {
entity_type: "media_file",
entity_token: record.token,
};
if (includeBatchToken) {
result["maybe_batch_token"] = job.maybeBatchToken ?? null;
}
result["maybe_public_bucket_media_path"] = `/media/${record.bucketPath}`;
result["media_links"] = mediaLinks(record);
result["maybe_successfully_completed_at"] = job.maybeSuccessfullyCompletedAt ?? null;
return result;
}
function oldClientFailureCategory(job: JobRecord): string | null {
if (job.maybeFailureCategory === undefined) {
return null;
}
return OLD_CLIENT_FAILURE_CATEGORIES.has(job.maybeFailureCategory) ? job.maybeFailureCategory : null;
}
@@ -0,0 +1,395 @@
/**
* Media file response builders.
*
* The real backend emits a different subset of fields on nearly every endpoint
* — `/file/:token` has no `origin`, `/list` renames `maybe_creator_user` to
* `maybe_creator`, `/list/user/:username` has no creator at all — so each
* variant gets its own builder rather than one object trimmed after the fact.
* Getting this wrong is exactly the kind of bug the fake exists to surface.
*/
import { bucketPathToCdnUrl, bucketPathToThumbnailTemplate } from "../state/assets.ts";
import type { MediaFileRecord, UserRecord } from "../state/entities.ts";
import { store } from "../state/store.ts";
/** Extensions the real backend generates a thumbnail template for. */
const THUMBNAILED_TYPES = new Set(["jpg", "png", "gif", "image", "webp"]);
export interface MediaLinks {
cdn_url: string;
maybe_thumbnail_template: string | null;
maybe_video_previews: {
still: string;
animated: string;
still_thumbnail_template: string;
animated_thumbnail_template: string;
} | null;
}
export function mediaLinks(record: MediaFileRecord): MediaLinks {
const cdnUrl = bucketPathToCdnUrl(record.bucketPath);
const isVideo = record.mediaType === "mp4" || record.mediaClass === "video";
return {
cdn_url: cdnUrl,
maybe_thumbnail_template: THUMBNAILED_TYPES.has(record.mediaType)
? bucketPathToThumbnailTemplate(record.bucketPath)
: null,
maybe_video_previews: isVideo
? {
still: `${cdnUrl}-thumb.jpg`,
animated: `${cdnUrl}-thumb.gif`,
still_thumbnail_template: bucketPathToThumbnailTemplate(`${record.bucketPath}-thumb.jpg`),
animated_thumbnail_template: bucketPathToThumbnailTemplate(`${record.bucketPath}-thumb.gif`),
}
: null,
};
}
/** The four-field cover shape used by storyteller_web's own responses. */
export function coverImage(record: MediaFileRecord): object {
const cover = coverLinks(record);
const coverRecord = record.maybeCoverImageMediaFileToken === undefined
? undefined
: store.mediaFilesByToken.get(record.maybeCoverImageMediaFileToken);
return {
maybe_cover_image_public_bucket_path: coverRecord === undefined ? null : `/media/${coverRecord.bucketPath}`,
maybe_cover_image_public_bucket_url: coverRecord === undefined ? null : bucketPathToCdnUrl(coverRecord.bucketPath),
maybe_links: cover,
default_cover: defaultCover(record.token),
};
}
/** The trimmed two-field cover shape used by mesh/splat/project lists. */
export function coverImageLight(record: MediaFileRecord): object {
return {
maybe_links: coverLinks(record),
default_cover: defaultCover(record.token),
};
}
export function userDetailsLight(user: UserRecord | undefined): object | null {
if (user === undefined) {
return null;
}
return {
user_token: user.userToken,
username: user.username,
display_name: user.displayName,
gravatar_hash: user.gravatarHash,
default_avatar: user.defaultAvatar,
};
}
export function originDetails(record: MediaFileRecord): object {
return {
origin_category: record.originCategory,
product_category: record.originProductCategory,
maybe_model: record.maybeOriginModelType === undefined ? null : { model_type: record.maybeOriginModelType },
};
}
/** `GET /v1/media_files/file/:token`. Note: no `origin*` fields on this endpoint. */
export function getMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
maybe_engine_extension: record.maybeEngineExtension ?? null,
maybe_batch_token: record.maybeBatchToken ?? null,
maybe_scene_source_media_file_token: record.maybeSceneSourceMediaFileToken ?? null,
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
cover_image: coverImage(record),
maybe_model_weight_info: null,
maybe_creator_user: creatorOf(record),
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
maybe_text_transcript: record.maybeTextTranscript ?? null,
maybe_live_portrait_details: null,
maybe_style_name: record.maybeStyleName ?? null,
used_face_detailer: false,
used_upscaler: false,
maybe_prompt_token: record.maybePromptToken ?? null,
maybe_original_filename: record.maybeOriginalFilename ?? null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
is_emulated_media_file: false,
is_featured: record.isFeatured,
stats: entityStats(),
created_at: record.createdAt,
updated_at: record.updatedAt,
maybe_moderator_fields: null,
};
}
/** `GET /v1/media_files/batch?tokens=…`. */
export function batchMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
maybe_engine_extension: record.maybeEngineExtension ?? null,
maybe_batch_token: record.maybeBatchToken ?? null,
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
cover_image: coverImage(record),
maybe_model_weight_info: null,
maybe_creator_user: creatorOf(record),
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
maybe_text_transcript: record.maybeTextTranscript ?? null,
maybe_live_portrait_details: null,
maybe_style_name: record.maybeStyleName ?? null,
maybe_prompt_token: record.maybePromptToken ?? null,
maybe_original_filename: record.maybeOriginalFilename ?? null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
is_emulated_media_file: false,
stats: entityStats(),
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/list` and `/list_featured` — creator key is `maybe_creator`. */
export function mediaFileListItemPayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
origin_category: record.originCategory,
origin_product_category: record.originProductCategory,
maybe_origin_model_type: record.maybeOriginModelType ?? null,
maybe_origin_model_token: null,
origin: originDetails(record),
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
cover_image: coverImage(record),
maybe_creator: creatorOf(record),
stats: entityStats(),
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
maybe_text_transcript: record.maybeTextTranscript ?? null,
maybe_style_name: record.maybeStyleName ?? null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/list/user/:username` — no creator field at all. */
export function userMediaFileListItemPayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
origin: originDetails(record),
origin_category: record.originCategory,
origin_product_category: record.originProductCategory,
maybe_origin_model_type: record.maybeOriginModelType ?? null,
maybe_origin_model_token: null,
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
maybe_prompt_token: record.maybePromptToken ?? null,
cover_image: coverImage(record),
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
maybe_text_transcript: record.maybeTextTranscript ?? null,
maybe_style_name: record.maybeStyleName ?? null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
stats: entityStats(),
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/{mesh,splat}/list` — the narrow session shape. */
export function sessionMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
media_links: mediaLinks(record),
cover_image: coverImageLight(record),
maybe_creator_user: creatorOf(record),
creator_set_visibility: record.creatorSetVisibility,
maybe_prompt_token: record.maybePromptToken ?? null,
maybe_title: record.maybeTitle ?? null,
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/project/list`. */
export function projectMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
project_type: record.maybeProjectType ?? null,
media_type: record.mediaType,
media_links: mediaLinks(record),
cover_image: coverImageLight(record),
maybe_creator_user: creatorOf(record),
creator_set_visibility: record.creatorSetVisibility,
maybe_title: record.maybeTitle ?? null,
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/search_{featured,session}` — no stats, no origin. */
export function searchMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
cover_image: coverImage(record),
maybe_creator: creatorOf(record),
creator_set_visibility: record.creatorSetVisibility,
is_featured: record.isFeatured,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/** `GET /v1/media_files/batch/:batchToken`. */
export function batchListMediaFilePayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_engine_category: record.maybeEngineCategory ?? null,
maybe_animation_type: record.maybeAnimationType ?? null,
origin: originDetails(record),
origin_category: record.originCategory,
origin_product_category: record.originProductCategory,
maybe_origin_model_type: record.maybeOriginModelType ?? null,
maybe_origin_model_token: null,
public_bucket_path: `/media/${record.bucketPath}`,
public_bucket_url: bucketPathToCdnUrl(record.bucketPath),
media_links: mediaLinks(record),
cover_image: coverImage(record),
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
is_intermediate_system_file: record.isIntermediateSystemFile,
maybe_title: record.maybeTitle ?? null,
maybe_text_transcript: record.maybeTextTranscript ?? null,
maybe_style_name: record.maybeStyleName ?? null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
stats: entityStats(),
created_at: record.createdAt,
updated_at: record.updatedAt,
};
}
/**
* The row shape shared by folder and tag media listings. `addedToFolderAt` is
* only present on folder responses.
*/
export function folderMediaFilePayload(record: MediaFileRecord, addedToFolderAt?: string): object {
const payload: Record<string, unknown> = {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
maybe_prompt_token: record.maybePromptToken ?? null,
maybe_batch_token: record.maybeBatchToken ?? null,
media_links: mediaLinks(record),
cover_image: coverImage(record),
maybe_title: record.maybeTitle ?? null,
maybe_original_filename: record.maybeOriginalFilename ?? null,
maybe_frame_width: null,
maybe_frame_height: null,
maybe_duration_millis: record.maybeDurationMillis ?? null,
creator_set_visibility: record.creatorSetVisibility,
is_user_upload: record.isUserUpload,
created_at: record.createdAt,
updated_at: record.updatedAt,
};
if (addedToFolderAt !== undefined) {
payload["is_intermediate_system_file"] = record.isIntermediateSystemFile;
payload["added_to_folder_at"] = addedToFolderAt;
}
return payload;
}
/** A thumbnail entry, used for folder covers. */
export function folderThumbnailPayload(record: MediaFileRecord): object {
return {
token: record.token,
media_class: record.mediaClass,
media_type: record.mediaType,
cdn_url: bucketPathToCdnUrl(record.bucketPath),
maybe_thumbnail_template: mediaLinks(record).maybe_thumbnail_template,
};
}
function creatorOf(record: MediaFileRecord): object | null {
if (record.maybeCreatorUserToken === undefined) {
return null;
}
return userDetailsLight(store.usersByToken.get(record.maybeCreatorUserToken));
}
function coverLinks(record: MediaFileRecord): object | null {
if (record.maybeCoverImageMediaFileToken === undefined) {
return null;
}
const cover = store.mediaFilesByToken.get(record.maybeCoverImageMediaFileToken);
if (cover === undefined) {
return null;
}
return {
cdn_url: bucketPathToCdnUrl(cover.bucketPath),
thumbnail_template: bucketPathToThumbnailTemplate(cover.bucketPath),
};
}
function entityStats(): object {
return { positive_rating_count: 0, bookmark_count: 0 };
}
/** Deterministic placeholder cover derived from the token, as the real backend does. */
function defaultCover(token: string): { image_index: number; color_index: number } {
return {
image_index: hashToken(token, 25, 5),
color_index: hashToken(token, 8, 1),
};
}
function hashToken(token: string, modulo: number, salt: number): number {
let hash = salt >>> 0;
for (let index = 0; index < token.length; index += 1) {
hash = (Math.imul(hash, 31) + token.charCodeAt(index)) >>> 0;
}
return hash % modulo;
}
@@ -0,0 +1,91 @@
/**
* Pagination helpers.
*
* The real backend uses opaque encrypted row-id cursors. The fake uses a plain
* offset — opaque enough for a client that only ever echoes it back, and much
* easier to reason about when a list looks wrong.
*/
const DEFAULT_PAGE_SIZE = 25;
export interface PaginationCursors {
maybe_next: string | null;
maybe_previous: string | null;
cursor_is_reversed: boolean;
}
export interface PaginationPage {
current: number;
total_page_count: number;
}
export interface CursorPage<T> {
results: T[];
pagination: PaginationCursors;
}
export interface IndexedPage<T> {
results: T[];
pagination: PaginationPage;
}
export function paginateByCursor<T>(
records: T[],
options: { cursor?: string; pageSize?: number; cursorIsReversed?: boolean },
): CursorPage<T> {
const pageSize = clampPageSize(options.pageSize);
const offset = decodeCursor(options.cursor);
const results = records.slice(offset, offset + pageSize);
const hasNext = offset + pageSize < records.length;
const hasPrevious = offset > 0;
return {
results,
pagination: {
maybe_next: hasNext ? encodeCursor(offset + pageSize) : null,
maybe_previous: hasPrevious ? encodeCursor(Math.max(0, offset - pageSize)) : null,
cursor_is_reversed: options.cursorIsReversed ?? false,
},
};
}
export function paginateByIndex<T>(
records: T[],
options: { pageIndex?: number; pageSize?: number },
): IndexedPage<T> {
const pageSize = clampPageSize(options.pageSize);
const pageIndex = Math.max(0, options.pageIndex ?? 0);
return {
results: records.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
pagination: {
current: pageIndex,
total_page_count: Math.max(1, Math.ceil(records.length / pageSize)),
},
};
}
/** Cursor for the `maybe_cursor` style used by folders and tags (a bare next cursor). */
export function nextCursorOnly(records: unknown[], offset: number, pageSize: number): string | null {
return offset + pageSize < records.length ? encodeCursor(offset + pageSize) : null;
}
export function decodeCursor(cursor: string | undefined): number {
if (cursor === undefined) {
return 0;
}
const parsed = Number.parseInt(Buffer.from(cursor, "base64url").toString("utf8"), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
}
export function encodeCursor(offset: number): string {
return Buffer.from(String(offset), "utf8").toString("base64url");
}
export function clampPageSize(pageSize: number | undefined): number {
if (pageSize === undefined || pageSize <= 0) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, 1000);
}
@@ -0,0 +1,87 @@
/**
* User response builders.
*
* `GET /v1/session` is the single most load-bearing response in the app — most
* of the UI keys off it — so the full permission block is reproduced rather
* than trimmed to the fields currently read.
*/
import type { UserRecord } from "../state/entities.ts";
export function sessionUserPayload(user: UserRecord): object {
return {
core_info: coreInfoPayload(user),
user_token: user.userToken,
username: user.username,
display_name: user.displayName,
email_gravatar_hash: user.gravatarHash,
onboarding: {
email_not_set: false,
email_not_confirmed: false,
password_not_set: false,
username_not_customized: false,
},
can_access_studio: user.featureFlags.includes("studio"),
maybe_feature_flags: user.featureFlags,
fakeyou_plan: "free",
storyteller_stream_plan: "free",
can_use_tts: true,
can_use_w2l: true,
can_delete_own_tts_results: true,
can_delete_own_w2l_results: true,
can_delete_own_account: true,
can_upload_tts_models: true,
can_upload_w2l_templates: true,
can_delete_own_tts_models: true,
can_delete_own_w2l_templates: true,
can_approve_w2l_templates: false,
can_edit_other_users_profiles: false,
can_edit_other_users_tts_models: false,
can_edit_other_users_w2l_templates: false,
can_delete_other_users_tts_models: false,
can_delete_other_users_tts_results: false,
can_delete_other_users_w2l_templates: false,
can_delete_other_users_w2l_results: false,
can_ban_users: false,
can_delete_users: false,
};
}
export function coreInfoPayload(user: UserRecord): object {
return {
user_token: user.userToken,
username: user.username,
display_name: user.displayName,
gravatar_hash: user.gravatarHash,
default_avatar: user.defaultAvatar,
};
}
/** `GET /v1/user/:username/profile`. */
export function userProfilePayload(user: UserRecord): object {
return {
user_token: user.userToken,
core_info: coreInfoPayload(user),
username: user.username,
display_name: user.displayName,
email_gravatar_hash: user.gravatarHash,
default_avatar_index: user.defaultAvatar.image_index,
default_avatar_color_index: user.defaultAvatar.color_index,
profile_markdown: "",
profile_rendered_html: "",
user_role_slug: "user",
disable_gravatar: false,
preferred_tts_result_visibility: "public",
preferred_w2l_result_visibility: "public",
discord_username: null,
twitch_username: null,
twitter_username: null,
patreon_username: null,
github_username: null,
cashapp_username: null,
website_url: null,
badges: [],
created_at: user.createdAt,
maybe_moderator_fields: null,
};
}
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"types": ["node"],
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2023",
"composite": false,
"declaration": false,
"declarationMap": false,
"emitDeclarationOnly": false,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"strict": true,
"baseUrl": "../..",
"paths": {
"@storyteller/api/*": ["libs/api/src/lib/*"]
}
},
"include": ["src/**/*.ts"]
}
+18 -1
View File
@@ -1,4 +1,21 @@
import { StorytellerApiHostStore } from "./config/StorytellerApiHostStore.js";
// TODO(bt,2025-05-15): Remove this code
const PRODUCTION_CDN_ORIGIN = "https://cdn-2.fakeyou.com";
/**
* Origin that media bucket paths are resolved against.
*
* When the API host is a local one, media is served by that same process
* (fake-storyteller-web, or a local backend writing to a filesystem bucket)
* rather than by the CDN, so the API host is the correct origin. Any other
* host — including every production and staging build — keeps the CDN.
*/
export function GetCdnOrigin(): string {
return "https://cdn-2.fakeyou.com";
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
return isLocalHost(apiSchemeAndHost) ? apiSchemeAndHost : PRODUCTION_CDN_ORIGIN;
}
function isLocalHost(url: string): boolean {
return url.includes("//localhost") || url.includes("//127.0.0.1");
}
+2 -1
View File
@@ -121,6 +121,7 @@
"apps/**/*",
"libs/**/*",
"!apps/artcraft-dashboard",
"!apps/artcraft-dashboard/**"
"!apps/artcraft-dashboard/**",
"!apps/fake-storyteller-web"
]
}