diff --git a/workers/api/.dev.vars.example b/workers/api/.dev.vars.example index 5d39220..dc59996 100644 --- a/workers/api/.dev.vars.example +++ b/workers/api/.dev.vars.example @@ -60,6 +60,29 @@ GA_CHAIN_ID="84532" GA_RPC_URL="https://sepolia.base.org" GA_FACTORY_ADDRESS="" +# --- Render-token service --------------------------------------------- +# Address that receives ETH for token-pack purchases. Reuses the +# GA_CHAIN_ID / GA_RPC_URL above. Until set, +# POST /v1/tokens/purchase/confirm returns 503 purchase_unconfigured. +TOKEN_TREASURY_ADDRESS="" + +# Claude API key for the `anthropic` render-model provider. Production: +# wrangler secret put ANTHROPIC_API_KEY --env production +ANTHROPIC_API_KEY="" + +# fal.ai API key for the `fal_custom` render-model provider — creator- +# trained custom art models (fine-tune / LoRA on a diffusion base). +# Workers AI can't host these: its LoRA fine-tuning only covers text +# model_types. Production: +# wrangler secret put FAL_KEY --env production +FAL_KEY="" + +# OPT-IN ONLY. When exactly "1", runInference() returns deterministic +# canned output with no network call, so the render pipeline is +# exercisable without an Anthropic key, a Workers AI binding, or a +# fal.ai key. Production must NEVER set RENDER_MOCK. +RENDER_MOCK="1" + # --- Task #20: observability (all optional; unset = the surface no-ops) ---- # ADMIN_HANDLES gates /v1/internal/*; empty means those routes 403 with # `admin_unconfigured`. Set it to your own handle to use /v1/internal/stats diff --git a/workers/api/migrations/0018_token_service.sql b/workers/api/migrations/0018_token_service.sql new file mode 100644 index 0000000..99333b9 --- /dev/null +++ b/workers/api/migrations/0018_token_service.sql @@ -0,0 +1,218 @@ +-- Render-token service: prepaid compute credits, a registry of custom +-- render models, and the job rows that spend tokens against them. +-- +-- NAMING — read this before touching anything below. +-- A "render token" is a prepaid compute credit. It is NOT an ERC-721 +-- token. This codebase already uses `token_id` / `mints.token_id` for +-- on-chain NFTs (see 0001_init.sql and 0014_traits.sql); nothing in +-- this migration touches that. Every table here is about paying for +-- inference, never about minting. +-- +-- LEDGER INVARIANT +-- `token_accounts.balance` is a cache. `token_ledger` is the source of +-- truth: an append-only list of signed deltas. Every balance mutation +-- writes exactly one ledger row and updates the cached balance in the +-- SAME D1 batch (batches are transactional), guarded by the identical +-- `balance + delta >= 0` predicate on both statements so they can only +-- apply together. `balance_after` is stamped on each row, so +-- `SELECT balance_after FROM token_ledger ORDER BY id DESC LIMIT 1` +-- must always equal `token_accounts.balance` — the audit assertion in +-- test/units.test.mjs and scripts/smoke_api.mjs both rely on that. + +-- --------------------------------------------------------------------------- +-- Balances +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS token_accounts ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance INTEGER NOT NULL DEFAULT 0, + lifetime_purchased INTEGER NOT NULL DEFAULT 0, + lifetime_spent INTEGER NOT NULL DEFAULT 0, + lifetime_earned INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (balance >= 0) +); + +-- Append-only. No UPDATE or DELETE statement in src/ targets this table. +-- +-- `idempotency_key` is the replay guard for every mutation path: a +-- retried purchase confirm, a double-clicked render, or a queue redelivery +-- collides on the UNIQUE index, the whole batch rolls back, and the +-- handler returns the original entry instead of moving the balance twice. +CREATE TABLE IF NOT EXISTS token_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Signed: credits are positive, debits negative. Never zero. + delta INTEGER NOT NULL, + -- grant | purchase | debit | refund | earn | adjust + kind TEXT NOT NULL, + balance_after INTEGER NOT NULL, + -- What the entry is about: 'purchase' | 'render_job' | 'signup' | 'admin' + ref_kind TEXT, + ref_id INTEGER, + memo TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + CHECK (delta <> 0) +); + +CREATE INDEX IF NOT EXISTS idx_token_ledger_user + ON token_ledger(user_id, id DESC); + +-- --------------------------------------------------------------------------- +-- Purchases (on-chain payment, no payment processor) +-- --------------------------------------------------------------------------- +-- +-- Packs are priced directly in wei rather than pegged to a fiat rate: +-- there is no oracle in this stack and an admin edit is cheaper than a +-- price feed. `price_wei` is TEXT because wei exceeds 2^53 and SQLite +-- INTEGER math would silently lose precision through the JS bridge. + +CREATE TABLE IF NOT EXISTS token_packs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + tokens INTEGER NOT NULL, + price_wei TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + CHECK (tokens > 0) +); + +-- One row per settled payment. UNIQUE(chain_id, tx_hash) is what stops +-- the same transfer being redeemed for a second pack: the confirm +-- handler verifies the receipt on-chain and then races to insert here, +-- so replaying a valid tx hash hits the constraint, not the balance. +CREATE TABLE IF NOT EXISTS token_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pack_id INTEGER NOT NULL REFERENCES token_packs(id), + chain_id INTEGER NOT NULL, + tx_hash TEXT NOT NULL, + from_address TEXT NOT NULL, + value_wei TEXT NOT NULL, + tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (chain_id, tx_hash) +); + +CREATE INDEX IF NOT EXISTS idx_token_purchases_user + ON token_purchases(user_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- Custom model registry +-- --------------------------------------------------------------------------- +-- +-- A "custom model" is a creator-published render recipe: a provider +-- model plus a system prompt, a parameter schema, and a price. Coders +-- publish them, other coders render with them, and the publisher earns +-- a share of every run (see render_jobs.owner_earn_tokens). +-- +-- `provider` is the execution backend, not a brand: +-- anthropic — code generation through the Claude API +-- workers_ai — image/texture generation through the Workers AI binding +-- mock — deterministic canned output; dev + tests only + +CREATE TABLE IF NOT EXISTS render_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + -- code | image + kind TEXT NOT NULL, + provider TEXT NOT NULL, + -- public | unlisted | private + visibility TEXT NOT NULL DEFAULT 'private', + -- active | disabled + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_render_models_owner + ON render_models(owner_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_render_models_public + ON render_models(visibility, status, updated_at DESC); + +-- Versions are immutable once written: a render job references the exact +-- version it ran against, so editing one in place would rewrite the +-- provenance of every past job. Publishing a change means inserting a +-- new version row. +CREATE TABLE IF NOT EXISTS render_model_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + model_id INTEGER NOT NULL REFERENCES render_models(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + -- Provider-side identifier, e.g. 'claude-opus-5' or a Workers AI model id. + provider_model_id TEXT NOT NULL, + system_prompt TEXT, + params_schema_json TEXT, + price_tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (model_id, version), + CHECK (price_tokens >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_render_model_versions_model + ON render_model_versions(model_id, version DESC); + +-- --------------------------------------------------------------------------- +-- Render jobs +-- --------------------------------------------------------------------------- +-- +-- Lifecycle: queued → running → succeeded | failed. +-- Tokens are debited at queue time (so a caller can't outrun their own +-- balance with concurrent requests) and refunded in full when the job +-- ends in `failed` — the refund is a second ledger row, never a reversal +-- of the first. +-- +-- `seed` keeps the platform's determinism contract: same model version + +-- same params + same seed must reproduce the same output. + +CREATE TABLE IF NOT EXISTS render_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + model_version_id INTEGER NOT NULL REFERENCES render_model_versions(id), + -- Optional: the studio project this render was made for. + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + seed TEXT NOT NULL, + params_json TEXT, + prompt_hash TEXT, + status TEXT NOT NULL DEFAULT 'queued', + price_tokens INTEGER NOT NULL, + -- Share of price_tokens credited to the model owner on success. + owner_earn_tokens INTEGER NOT NULL DEFAULT 0, + -- code | image + output_kind TEXT, + -- Inline output for `code` jobs; R2 key for `image` jobs. + output_text TEXT, + output_key TEXT, + output_hash TEXT, + error_code TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + finished_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_render_jobs_user + ON render_jobs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_render_jobs_model_version + ON render_jobs(model_version_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- Seeds +-- --------------------------------------------------------------------------- +-- +-- Pack prices are Base ETH amounts chosen so the cheapest pack is a +-- low-commitment trial. Edit with an UPDATE in a later migration rather +-- than in place, so price history stays auditable in git. +-- starter 500 tokens 0.001 ETH +-- studio 3000 tokens 0.005 ETH +-- pro 10000 tokens 0.015 ETH +INSERT OR IGNORE INTO token_packs (slug, title, tokens, price_wei, sort, created_at) +VALUES + ('starter', 'Starter', 500, '1000000000000000', 10, strftime('%s','now')), + ('studio', 'Studio', 3000, '5000000000000000', 20, strftime('%s','now')), + ('pro', 'Pro', 10000, '15000000000000000', 30, strftime('%s','now')); diff --git a/workers/api/migrations/0019_custom_model_provider.sql b/workers/api/migrations/0019_custom_model_provider.sql new file mode 100644 index 0000000..6f303c9 --- /dev/null +++ b/workers/api/migrations/0019_custom_model_provider.sql @@ -0,0 +1,29 @@ +-- Adds the `fal_custom` provider lane: creator-trained custom art models +-- (a fine-tuned checkpoint or LoRA on a diffusion base, in the vein of +-- Refik Anadol's bespoke, dataset-trained visual signatures) as distinct +-- from prompting a fixed catalogue model. +-- +-- Why not Workers AI for this: its LoRA fine-tuning (open beta) only +-- accepts text model_types (mistral/gemma/llama) — no image/diffusion +-- LoRA support as of this writing. A creative coder's own trained +-- checkpoint has to run somewhere that hosts custom weights on demand; +-- fal.ai's private-model inference fits the platform's no-idle-GPU +-- budget stance (pay-per-run, no fixed hosting cost) better than +-- self-hosting a GPU or Replicate's cold-start-billed custom deploys. +-- +-- These columns describe training lineage, not runtime params, so they +-- live on the immutable per-version row alongside provider_model_id — +-- a creator retraining on a new dataset publishes a new version rather +-- than rewriting one that jobs already reference. + +ALTER TABLE render_model_versions ADD COLUMN training_method TEXT; +ALTER TABLE render_model_versions ADD COLUMN base_model TEXT; +-- Creator's own description of what the model was trained on. This is +-- the platform's provenance/disclosure surface for a custom model — +-- public alongside the version, unlike weights_ref and system_prompt. +ALTER TABLE render_model_versions ADD COLUMN dataset_note TEXT; +-- Pointer to the trained weights at the provider (e.g. a fal.ai model +-- id or LoRA URL). Required when provider = 'fal_custom'. Treated with +-- the same owner-only visibility as system_prompt — it's how another +-- creator could clone the model's exact output. +ALTER TABLE render_model_versions ADD COLUMN weights_ref TEXT; diff --git a/workers/api/package-lock.json b/workers/api/package-lock.json index 28a4232..6554af6 100644 --- a/workers/api/package-lock.json +++ b/workers/api/package-lock.json @@ -8,6 +8,7 @@ "name": "@generatedart/api", "version": "0.0.1", "dependencies": { + "@anthropic-ai/sdk": "^0.117.1", "hono": "^4.12.16", "siwe": "^2.3.2", "viem": "^2.48.8" @@ -32,6 +33,36 @@ "license": "MIT", "peer": true }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.117.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.117.1.tgz", + "integrity": "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", @@ -1348,6 +1379,12 @@ "valid-url": "^1.0.9" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@stablelib/binary": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", @@ -1716,6 +1753,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1781,6 +1824,19 @@ "ws": "*" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -1830,6 +1886,16 @@ "node": ">=16.13" } }, + "node_modules/miniflare/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -2083,6 +2149,16 @@ "get-source": "^2.0.12" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stoppable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", @@ -2101,6 +2177,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2768,11 +2850,12 @@ } }, "node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "devOptional": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/workers/api/package.json b/workers/api/package.json index 192ed6a..1cef2be 100644 --- a/workers/api/package.json +++ b/workers/api/package.json @@ -29,6 +29,7 @@ "migrate:prod": "wrangler d1 migrations apply DB --env production --remote" }, "dependencies": { + "@anthropic-ai/sdk": "^0.117.1", "hono": "^4.12.16", "siwe": "^2.3.2", "viem": "^2.48.8" diff --git a/workers/api/scripts/smoke_api.mjs b/workers/api/scripts/smoke_api.mjs index 42284f3..08feedb 100644 --- a/workers/api/scripts/smoke_api.mjs +++ b/workers/api/scripts/smoke_api.mjs @@ -32,7 +32,11 @@ function record(name, ok, detail) { async function api(method, path, body, opts = {}) { const headers = { Origin: ORIGIN }; - if (cookie) headers.Cookie = cookie; + // opts.anon: skip the session cookie even though a global `cookie` is + // set — lets a still-logged-in suite probe how a public endpoint + // renders for an unauthenticated viewer without a full logout/login + // round-trip. + if (cookie && !opts.anon) headers.Cookie = cookie; if (body !== undefined && !(body instanceof FormData) && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body)) { headers["Content-Type"] = "application/json"; } @@ -231,6 +235,217 @@ let projectId = null; record("GET /v1/projects/:id/mints/:tokenId (404 expected)", r.status === 404, `status=${r.status}`); } +// ---- render-token service (user 1, still signed in from above) +// Render jobs report as succeeding when RENDER_MOCK=1 is set in +// .dev.vars, or 503 provider_unconfigured otherwise (no live +// Anthropic key / Workers AI binding in a bare local setup) — both are +// accepted, same "unconfigured is fine" posture as freeze/mint above. +{ + const r = await api("GET", "/v1/tokens/account"); + record( + "GET /v1/tokens/account (signup grant applied)", + r.status === 200 && r.data?.balance === 200, + JSON.stringify(r.data), + ); +} +{ + const r = await api("GET", "/v1/tokens/ledger"); + record( + "GET /v1/tokens/ledger (signup grant entry present)", + r.status === 200 && Array.isArray(r.data?.entries) && r.data.entries.some((e) => e.kind === "grant"), + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("GET", "/v1/tokens/packs"); + record( + "GET /v1/tokens/packs (seeded packs visible)", + r.status === 200 && Array.isArray(r.data?.packs) && r.data.packs.length >= 3, + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("POST", "/v1/tokens/purchase/confirm", { pack_id: 1, tx_hash: "0x" + "1".repeat(64) }); + record( + "POST /v1/tokens/purchase/confirm without treasury configured → 503", + r.status === 503, + JSON.stringify(r.data), + ); +} + +let modelSlug = null; +{ + const r = await api("POST", "/v1/models", { + title: `Smoke Model ${RUN}`, + kind: "code", + provider: "anthropic", + visibility: "public", + }); + modelSlug = r.data?.model?.slug ?? null; + record("POST /v1/models", r.status === 201 && !!modelSlug, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("POST", "/v1/models", { + title: "Bad combo", + kind: "image", + provider: "anthropic", + }); + record( + "POST /v1/models rejects provider/kind mismatch", + r.status === 400 && r.data?.error === "provider_not_allowed_for_kind", + JSON.stringify(r.data), + ); +} +{ + const r = await api("GET", "/v1/models/mine"); + record( + "GET /v1/models/mine", + r.status === 200 && Array.isArray(r.data?.models) && r.data.models.some((m) => m.slug === modelSlug), + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("POST", `/v1/models/${modelSlug}/versions`, { + provider_model_id: "claude-opus-5", + system_prompt: "You write playful p5.js sketches.", + price_tokens: 10, + }); + record( + "POST /v1/models/:slug/versions", + r.status === 201 && r.data?.version?.version === 1, + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("GET", `/v1/models/${modelSlug}`); + record( + "GET /v1/models/:slug (owner sees system_prompt, latest version listed)", + r.status === 200 && r.data?.versions?.[0]?.system_prompt === "You write playful p5.js sketches.", + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + const r = await api("GET", "/v1/models?kind=code"); + record( + "GET /v1/models?kind=code (public catalogue)", + r.status === 200 && r.data?.models?.some((m) => m.slug === modelSlug), + JSON.stringify(r.data).slice(0, 200), + ); +} + +// fal_custom — the creator-trained-model lane (see +// migrations/0019_custom_model_provider.sql). Registration and publish +// validation are exercised for real here even though the actual render +// still runs mocked (no live FAL_KEY in CI). +let falModelSlug = null; +{ + const r = await api("POST", "/v1/models", { + title: `Smoke Custom Model ${RUN}`, + kind: "image", + provider: "fal_custom", + visibility: "public", + }); + falModelSlug = r.data?.model?.slug ?? null; + record("POST /v1/models (fal_custom)", r.status === 201 && !!falModelSlug, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("POST", `/v1/models/${falModelSlug}/versions`, { + provider_model_id: "fal-ai/flux-lora", + price_tokens: 25, + }); + record( + "POST /v1/models/:slug/versions (fal_custom) rejects missing weights_ref", + r.status === 400 && r.data?.error === "weights_ref_required_for_fal_custom", + JSON.stringify(r.data), + ); +} +{ + const r = await api("POST", `/v1/models/${falModelSlug}/versions`, { + provider_model_id: "fal-ai/flux-lora", + price_tokens: 25, + training_method: "lora", + base_model: "FLUX.1 [dev]", + dataset_note: "2,400 hand-collected botanical scans, own photography", + weights_ref: "smoke-test/botanical-lora-v1", + }); + record( + "POST /v1/models/:slug/versions (fal_custom) accepts training lineage + weights_ref", + r.status === 201 && r.data?.version?.training_method === "lora" && r.data?.version?.weights_ref === "smoke-test/botanical-lora-v1", + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + // Public listing surfaces the provenance disclosure (training_method, + // base_model, dataset_note) but never the owner-only weights_ref. + const r = await api("GET", `/v1/models/${falModelSlug}`, undefined, { anon: true }); + const v0 = r.data?.versions?.[0]; + record( + "GET /v1/models/:slug (anon) shows provenance, hides weights_ref", + r.status === 200 && v0?.dataset_note?.includes("botanical") && v0?.weights_ref === undefined, + JSON.stringify(r.data).slice(0, 250), + ); +} + +let renderJobId = null; +{ + const r = await api("POST", `/v1/models/${modelSlug}/render`, { + prompt: "a field of drifting particles", + seed: "smoke-seed-1", + }); + renderJobId = r.data?.job?.id ?? null; + record( + "POST /v1/models/:slug/render (mock success or provider_unconfigured)", + (r.status === 201 && r.data?.job?.status === "succeeded") || (r.status === 503 && r.data?.error === "provider_unconfigured"), + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + // Same idempotency_key twice must not double-charge. The first call + // creates the job (201); the replay returns the SAME job unchanged + // (200, replayed:true — it isn't a new creation, so a different + // status code here is correct REST, not a bug). + const key = "smoke-idem-" + RUN; + const before = await api("GET", "/v1/tokens/account"); + const first = await api("POST", `/v1/models/${modelSlug}/render`, { prompt: "p", idempotency_key: key }); + const second = await api("POST", `/v1/models/${modelSlug}/render`, { prompt: "p", idempotency_key: key }); + const after = await api("GET", "/v1/tokens/account"); + const sameJob = first.data?.job?.id && first.data.job.id === second.data?.job?.id; + const balanceMovedOnce = + typeof before.data?.balance === "number" && + typeof after.data?.balance === "number" && + before.data.balance - after.data.balance === (first.data?.job?.price_tokens ?? 0); + record( + "POST render with repeated idempotency_key replays instead of double-charging", + first.status === 201 && second.status === 200 && second.data?.replayed === true && sameJob && balanceMovedOnce, + `first=${first.status} second=${second.status} replayed=${second.data?.replayed} before=${before.data?.balance} after=${after.data?.balance} price=${first.data?.job?.price_tokens}`, + ); +} +if (renderJobId) { + const r = await api("GET", `/v1/jobs/${renderJobId}`); + record("GET /v1/jobs/:id (owner)", r.status === 200 && r.data?.job?.id === renderJobId, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("GET", "/v1/jobs"); + record("GET /v1/jobs (mine)", r.status === 200 && Array.isArray(r.data?.jobs), JSON.stringify(r.data).slice(0, 200)); +} +{ + // Draining the balance below the model's price must fail closed with + // 402 and refund nothing — never silently go negative. + const cheapPrompt = { prompt: "x" }; + let last = null; + for (let i = 0; i < 25; i++) { + last = await api("POST", `/v1/models/${modelSlug}/render`, cheapPrompt); + if (last.status === 402) break; + } + record( + "render debits stop at 402 insufficient_balance rather than going negative", + last && (last.status === 402 || last.status === 503), + `status=${last?.status} body=${JSON.stringify(last?.data).slice(0, 150)}`, + ); + const acct = await api("GET", "/v1/tokens/account"); + record("balance never goes negative", acct.status === 200 && acct.data?.balance >= 0, JSON.stringify(acct.data)); +} + // ---- galleries { const r = await api("GET", "/v1/galleries"); diff --git a/workers/api/src/ai/inference.ts b/workers/api/src/ai/inference.ts new file mode 100644 index 0000000..1fd29e4 --- /dev/null +++ b/workers/api/src/ai/inference.ts @@ -0,0 +1,299 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Env } from "../types"; +import type { ModelKind, ModelProvider } from "../db/render"; + +/** + * Model-execution layer for the render-token service. + * + * Every provider returns the SAME shape so the render handler never + * branches on provider after this call. `outputHash` is sha256 of the + * bytes actually returned — it's what render_jobs.output_hash records, + * so two jobs with the same model version + params + seed can be + * compared for reproducibility without re-running anything. + */ + +export interface InferenceInput { + kind: ModelKind; + provider: ModelProvider; + providerModelId: string; + systemPrompt: string | null; + /** User-supplied instruction/prompt for this run. */ + prompt: string; + params: Record | null; + /** Deterministic seed threaded into the provider call where supported. */ + seed: string; + /** + * fal_custom only: the trained model/LoRA identifier at the provider + * (render_model_versions.weights_ref). Absent for every other + * provider. + */ + weightsRef?: string | null; +} + +export interface InferenceOutput { + outputKind: "code" | "image"; + /** Present for `code` jobs — the generated source, inline. */ + text: string | null; + /** Present for `image` jobs — raw bytes for the caller to persist to R2. */ + bytes: Uint8Array | null; + contentType: string | null; + outputHash: string; +} + +export class InferenceError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + } +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * Whether mock mode is active. OPT-IN ONLY, mirrors PINNING_MOCK / + * GITHUB_MOCK: production must never set RENDER_MOCK. A missing + * ANTHROPIC_API_KEY does NOT enable mock mode — the anthropic provider + * fails closed with `provider_unconfigured` instead. + */ +export function isRenderMock(env: Env): boolean { + return env.RENDER_MOCK === "1"; +} + +/** + * Deterministic canned output for dev/tests. Reproducible from + * (providerModelId, prompt, seed) alone — no network, no randomness — + * so scripts/smoke_api.mjs and local `wrangler dev` runs are hermetic. + */ +async function runMock(input: InferenceInput): Promise { + if (input.kind === "code") { + const text = + `// mock render — provider=${input.provider} model=${input.providerModelId}\n` + + `// seed=${input.seed}\n` + + `function setup() { createCanvas(400, 400); }\n` + + `function draw() { background(0); }\n` + + `// prompt: ${input.prompt.slice(0, 120)}\n`; + const outputHash = await sha256Hex(new TextEncoder().encode(text)); + return { outputKind: "code", text, bytes: null, contentType: null, outputHash }; + } + // A tiny valid 1x1 PNG, identical for every mock image job. Real + // pixels aren't the point of the mock path — reachability is. + const onePxPng = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), + ); + const outputHash = await sha256Hex(onePxPng); + return { + outputKind: "image", + text: null, + bytes: onePxPng, + contentType: "image/png", + outputHash, + }; +} + +/** + * Claude API — code generation. + * + * Uses the official Anthropic SDK per this workspace's convention. + * The determinism contract lives in the system prompt: the model is + * instructed to write self-contained p5.js/three.js code with no + * network calls and no non-seeded randomness — the same discipline the + * frozen-artifact pipeline already requires of every minted sketch. + */ +async function runAnthropic( + env: Env, + input: InferenceInput, +): Promise { + if (!env.ANTHROPIC_API_KEY) { + throw new InferenceError( + "ANTHROPIC_API_KEY is not configured", + "provider_unconfigured", + ); + } + const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); + const determinism = + "Output only self-contained JavaScript for p5.js or three.js. " + + "No network calls, no external imports, no Date.now() or Math.random() " + + "— use the numeric seed given below for any randomness. Return code only, " + + "no prose, no markdown fences."; + const system = [input.systemPrompt, determinism].filter(Boolean).join("\n\n"); + const userMessage = + `Seed: ${input.seed}\n` + + (input.params ? `Params: ${JSON.stringify(input.params)}\n` : "") + + `Instruction: ${input.prompt}`; + + let message; + try { + message = await client.messages.create({ + model: input.providerModelId, + max_tokens: 4096, + system, + messages: [{ role: "user", content: userMessage }], + thinking: { type: "adaptive" }, + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "anthropic request failed", + "provider_error", + ); + } + const text = message.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("\n") + .trim(); + if (!text) { + throw new InferenceError("empty response from model", "provider_error"); + } + const outputHash = await sha256Hex(new TextEncoder().encode(text)); + return { outputKind: "code", text, bytes: null, contentType: null, outputHash }; +} + +/** + * Cloudflare Workers AI — image generation (reference art / textures). + * Output feeds the studio's working material only; it is never eligible + * for the frozen-artifact bundle (enforced in the freeze pipeline, not + * here — this function just runs the model). + */ +async function runWorkersAi( + env: Env, + input: InferenceInput, +): Promise { + if (!env.AI) { + throw new InferenceError( + "Workers AI binding is not configured", + "provider_unconfigured", + ); + } + let result: unknown; + try { + result = await env.AI.run(input.providerModelId, { + prompt: input.prompt, + seed: /^\d+$/.test(input.seed) ? parseInt(input.seed, 10) : undefined, + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "workers ai request failed", + "provider_error", + ); + } + // Workers AI image models return a ReadableStream (binary) response. + let bytes: Uint8Array; + if (result instanceof ReadableStream) { + const buf = await new Response(result).arrayBuffer(); + bytes = new Uint8Array(buf); + } else if (result instanceof Uint8Array) { + bytes = result; + } else { + throw new InferenceError( + "unexpected Workers AI response shape", + "provider_error", + ); + } + const outputHash = await sha256Hex(bytes); + return { + outputKind: "image", + text: null, + bytes, + contentType: "image/png", + outputHash, + }; +} + +/** + * fal.ai — creator-trained custom art models (fine-tune / LoRA on a + * diffusion base). This is the one lane Workers AI can't serve: its + * LoRA fine-tuning only accepts text model_types (mistral/gemma/llama), + * so a coder's own trained checkpoint runs through fal.ai's private- + * model inference instead — pay-per-run, no idle GPU cost, matching the + * budget stance behind the rest of this stack. + * + * Raw HTTP, not an SDK: fal.ai has no first-party client bundled here + * and the request shape is stable and small enough not to warrant one. + * `weightsRef` is `render_model_versions.weights_ref` — the model or + * LoRA identifier the creator registered when they published the + * version — and is REQUIRED; there is no catalogue fallback for this + * provider the way `provider_model_id` alone is enough for the others. + */ +async function runFalCustom( + env: Env, + input: InferenceInput, +): Promise { + if (!env.FAL_KEY) { + throw new InferenceError("FAL_KEY is not configured", "provider_unconfigured"); + } + if (!input.weightsRef) { + throw new InferenceError( + "fal_custom model version has no weights_ref", + "model_misconfigured", + ); + } + let res: Response; + try { + res = await fetch(`https://fal.run/${input.providerModelId}`, { + method: "POST", + headers: { + Authorization: `Key ${env.FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: input.prompt, + loras: [{ path: input.weightsRef }], + seed: /^\d+$/.test(input.seed) ? parseInt(input.seed, 10) : undefined, + ...(input.params ?? {}), + }), + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "fal request failed", + "provider_error", + ); + } + if (!res.ok) { + throw new InferenceError(`fal request failed: ${res.status}`, "provider_error"); + } + const data = (await res.json()) as { images?: { url?: string }[] }; + const imageUrl = data.images?.[0]?.url; + if (!imageUrl) { + throw new InferenceError("fal response had no image url", "provider_error"); + } + let bytes: Uint8Array; + try { + const imgRes = await fetch(imageUrl); + if (!imgRes.ok) throw new Error(`fetch image failed: ${imgRes.status}`); + bytes = new Uint8Array(await imgRes.arrayBuffer()); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "fal image fetch failed", + "provider_error", + ); + } + const outputHash = await sha256Hex(bytes); + return { + outputKind: "image", + text: null, + bytes, + contentType: "image/png", + outputHash, + }; +} + +export async function runInference( + env: Env, + input: InferenceInput, +): Promise { + if (isRenderMock(env)) return runMock(input); + if (input.provider === "anthropic") return runAnthropic(env, input); + if (input.provider === "workers_ai") return runWorkersAi(env, input); + if (input.provider === "fal_custom") return runFalCustom(env, input); + throw new InferenceError(`unsupported provider: ${input.provider}`, "provider_unconfigured"); +} diff --git a/workers/api/src/auth/siwe.ts b/workers/api/src/auth/siwe.ts index 5215fad..3686d2b 100644 --- a/workers/api/src/auth/siwe.ts +++ b/workers/api/src/auth/siwe.ts @@ -6,6 +6,14 @@ import { checkRateLimit } from "../lib/rateLimit"; import { upsertUserByAddress } from "../db/users"; import { issueSession, SESSION_TTL_SECONDS } from "./jwt"; import { setSessionCookie } from "../lib/cookies"; +import { applyLedgerEntry } from "../db/tokens"; + +// Free render-token allowance granted on signup, so a new creative coder +// can try the render service before paying for a pack. Idempotency key +// is keyed on the user id, not the sign-in event, so this call is safe +// to make on every sign-in — it applies exactly once and no-ops (via +// `replayed`) on every sign-in after the first. +const SIGNUP_BONUS_TOKENS = 200; const NONCE_TTL_SECONDS = 5 * 60; @@ -160,6 +168,23 @@ export async function verifyHandler(c: Context<{ Bindings: Env }>) { } const user = await upsertUserByAddress(c.env.DB, siwe.address); + + try { + await applyLedgerEntry(c.env.DB, { + userId: user.id, + delta: SIGNUP_BONUS_TOKENS, + kind: "grant", + idempotencyKey: `signup:${user.id}`, + refKind: "signup", + memo: "Welcome grant", + }); + } catch (e) { + // Best-effort, same as recordEvent elsewhere: a missed bonus grant + // is recoverable (an admin can re-grant); sign-in must not fail + // because of it. + console.error("signup_bonus_failed", e); + } + const { token, jti } = await issueSession(c.env.JWT_SECRET, user.id, user.address); setSessionCookie(c, token, c.env.COOKIE_DOMAIN, SESSION_TTL_SECONDS); diff --git a/workers/api/src/db/render.ts b/workers/api/src/db/render.ts new file mode 100644 index 0000000..6062d87 --- /dev/null +++ b/workers/api/src/db/render.ts @@ -0,0 +1,625 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { slugify } from "../lib/slug"; + +/** + * Custom render models and the jobs run against them. + * + * A model is a creator-published recipe (provider + system prompt + + * parameter schema + price). Versions are immutable: a job stores the + * version id it ran against, so editing a published version in place + * would rewrite the provenance of every earlier job. + */ + +export const MODEL_KINDS = ["code", "image"] as const; +export type ModelKind = (typeof MODEL_KINDS)[number]; + +// `fal_custom` — a creator-trained custom art model (fine-tuned +// checkpoint or LoRA on a diffusion base), run through fal.ai's private- +// model inference. This is the platform's Refik-Anadol-style lane: a +// bespoke model trained on a curated dataset, distinct from prompting a +// fixed catalogue model. See migrations/0019_custom_model_provider.sql +// for why Workers AI can't host it (its LoRA fine-tuning is text-only). +export const MODEL_PROVIDERS = [ + "anthropic", + "workers_ai", + "fal_custom", + "mock", +] as const; +export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; + +export const TRAINING_METHODS = [ + "prompt_recipe", + "lora", + "dreambooth", + "full_finetune", +] as const; +export type TrainingMethod = (typeof TRAINING_METHODS)[number]; + +export function isTrainingMethod(v: unknown): v is TrainingMethod { + return ( + typeof v === "string" && (TRAINING_METHODS as readonly string[]).includes(v) + ); +} + +export const MODEL_VISIBILITIES = ["public", "unlisted", "private"] as const; +export type ModelVisibility = (typeof MODEL_VISIBILITIES)[number]; + +export const JOB_STATUSES = [ + "queued", + "running", + "succeeded", + "failed", +] as const; +export type JobStatus = (typeof JOB_STATUSES)[number]; + +export function isModelKind(v: unknown): v is ModelKind { + return typeof v === "string" && (MODEL_KINDS as readonly string[]).includes(v); +} +export function isModelProvider(v: unknown): v is ModelProvider { + return ( + typeof v === "string" && + (MODEL_PROVIDERS as readonly string[]).includes(v) + ); +} +export function isModelVisibility(v: unknown): v is ModelVisibility { + return ( + typeof v === "string" && + (MODEL_VISIBILITIES as readonly string[]).includes(v) + ); +} + +/** + * Which provider each model kind is allowed to use. + * + * Enforced at registration time rather than at render time: an image + * model pointed at the code provider would only fail once someone had + * already been charged for the job. + */ +export const PROVIDERS_BY_KIND: Record = { + code: ["anthropic", "mock"], + image: ["workers_ai", "fal_custom", "mock"], +}; + +export function providerAllowedForKind( + kind: ModelKind, + provider: ModelProvider, +): boolean { + return PROVIDERS_BY_KIND[kind].includes(provider); +} + +export interface ModelRow { + id: number; + owner_id: number; + slug: string; + title: string; + description: string | null; + kind: string; + provider: string; + visibility: string; + status: string; + created_at: number; + updated_at: number; +} + +export interface ModelVersionRow { + id: number; + model_id: number; + version: number; + provider_model_id: string; + system_prompt: string | null; + params_schema_json: string | null; + price_tokens: number; + created_at: number; + // Training lineage — see migrations/0019_custom_model_provider.sql. + // Null for anthropic/workers_ai versions; populated for fal_custom. + training_method: string | null; + base_model: string | null; + dataset_note: string | null; + weights_ref: string | null; +} + +export interface ModelListItem extends ModelRow { + owner_handle: string; + owner_display_name: string | null; + owner_avatar_url: string | null; + latest_version: number | null; + latest_price_tokens: number | null; + run_count: number; +} + +export async function uniqueModelSlug( + db: D1Database, + base: string, +): Promise { + const baseSlug = slugify(base); + for (let i = 0; i < 50; i++) { + const candidate = i === 0 ? baseSlug : `${baseSlug}-${i + 1}`; + const taken = await db + .prepare("SELECT 1 FROM render_models WHERE slug = ?") + .bind(candidate) + .first(); + if (!taken) return candidate; + } + return `${baseSlug}-${crypto.randomUUID().slice(0, 6)}`; +} + +export async function insertModel( + db: D1Database, + input: { + ownerId: number; + slug: string; + title: string; + description: string | null; + kind: ModelKind; + provider: ModelProvider; + visibility: ModelVisibility; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_models + (owner_id, slug, title, description, kind, provider, visibility, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + RETURNING *`, + ) + .bind( + input.ownerId, + input.slug, + input.title, + input.description, + input.kind, + input.provider, + input.visibility, + now, + now, + ) + .first(); + if (!row) throw new Error("insertModel returned no row"); + return row; +} + +export async function getModelBySlug( + db: D1Database, + slug: string, +): Promise { + return await db + .prepare(`SELECT * FROM render_models WHERE slug = ?`) + .bind(slug) + .first(); +} + +export async function updateModel( + db: D1Database, + id: number, + patch: { + title?: string; + description?: string | null; + visibility?: ModelVisibility; + status?: "active" | "disabled"; + }, +): Promise { + const sets: string[] = []; + const binds: unknown[] = []; + if (patch.title !== undefined) { + sets.push("title = ?"); + binds.push(patch.title); + } + if (patch.description !== undefined) { + sets.push("description = ?"); + binds.push(patch.description); + } + if (patch.visibility !== undefined) { + sets.push("visibility = ?"); + binds.push(patch.visibility); + } + if (patch.status !== undefined) { + sets.push("status = ?"); + binds.push(patch.status); + } + if (sets.length === 0) { + return await db + .prepare(`SELECT * FROM render_models WHERE id = ?`) + .bind(id) + .first(); + } + sets.push("updated_at = ?"); + binds.push(Math.floor(Date.now() / 1000), id); + return await db + .prepare( + `UPDATE render_models SET ${sets.join(", ")} WHERE id = ? RETURNING *`, + ) + .bind(...binds) + .first(); +} + +/** + * Public listing: `public` + `active` models only, newest first, with + * the owner joined and the latest version's price denormalised so the + * catalogue renders without an N+1. + */ +export async function listPublicModels( + db: D1Database, + opts: { kind?: ModelKind; limit: number; before?: number }, +): Promise { + const where: string[] = [ + "m.visibility = 'public'", + "m.status = 'active'", + ]; + const binds: unknown[] = []; + if (opts.kind) { + where.push("m.kind = ?"); + binds.push(opts.kind); + } + if (opts.before) { + where.push("m.updated_at < ?"); + binds.push(opts.before); + } + binds.push(opts.limit); + const { results } = await db + .prepare( + `SELECT m.*, + u.handle AS owner_handle, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + (SELECT MAX(version) FROM render_model_versions v + WHERE v.model_id = m.id) AS latest_version, + (SELECT v.price_tokens FROM render_model_versions v + WHERE v.model_id = m.id + ORDER BY v.version DESC LIMIT 1) AS latest_price_tokens, + (SELECT COUNT(*) FROM render_jobs j + JOIN render_model_versions v2 ON v2.id = j.model_version_id + WHERE v2.model_id = m.id AND j.status = 'succeeded') + AS run_count + FROM render_models m + JOIN users u ON u.id = m.owner_id + WHERE ${where.join(" AND ")} + ORDER BY m.updated_at DESC + LIMIT ?`, + ) + .bind(...binds) + .all(); + return results ?? []; +} + +export async function listModelsByOwner( + db: D1Database, + ownerId: number, +): Promise { + const { results } = await db + .prepare( + `SELECT m.*, + u.handle AS owner_handle, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + (SELECT MAX(version) FROM render_model_versions v + WHERE v.model_id = m.id) AS latest_version, + (SELECT v.price_tokens FROM render_model_versions v + WHERE v.model_id = m.id + ORDER BY v.version DESC LIMIT 1) AS latest_price_tokens, + (SELECT COUNT(*) FROM render_jobs j + JOIN render_model_versions v2 ON v2.id = j.model_version_id + WHERE v2.model_id = m.id AND j.status = 'succeeded') + AS run_count + FROM render_models m + JOIN users u ON u.id = m.owner_id + WHERE m.owner_id = ? + ORDER BY m.updated_at DESC + LIMIT 200`, + ) + .bind(ownerId) + .all(); + return results ?? []; +} + +/** + * Appends an immutable version. `version` is allocated server-side as + * MAX+1 rather than taken from the caller, so two concurrent publishes + * can't both claim v3 — the UNIQUE(model_id, version) index turns that + * race into a constraint error the handler retries. + */ +export async function insertModelVersion( + db: D1Database, + input: { + modelId: number; + providerModelId: string; + systemPrompt: string | null; + paramsSchemaJson: string | null; + priceTokens: number; + trainingMethod?: string | null; + baseModel?: string | null; + datasetNote?: string | null; + weightsRef?: string | null; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_model_versions + (model_id, version, provider_model_id, system_prompt, + params_schema_json, price_tokens, training_method, base_model, + dataset_note, weights_ref, created_at) + SELECT ?, + COALESCE((SELECT MAX(version) FROM render_model_versions + WHERE model_id = ?), 0) + 1, + ?, ?, ?, ?, ?, ?, ?, ?, ? + RETURNING *`, + ) + .bind( + input.modelId, + input.modelId, + input.providerModelId, + input.systemPrompt, + input.paramsSchemaJson, + input.priceTokens, + input.trainingMethod ?? null, + input.baseModel ?? null, + input.datasetNote ?? null, + input.weightsRef ?? null, + now, + ) + .first(); + if (!row) throw new Error("insertModelVersion returned no row"); + // Surface the new version on the catalogue's sort key. + await db + .prepare(`UPDATE render_models SET updated_at = ? WHERE id = ?`) + .bind(now, input.modelId) + .run(); + return row; +} + +export async function listModelVersions( + db: D1Database, + modelId: number, +): Promise { + const { results } = await db + .prepare( + `SELECT * FROM render_model_versions + WHERE model_id = ? ORDER BY version DESC LIMIT 100`, + ) + .bind(modelId) + .all(); + return results ?? []; +} + +export async function getModelVersion( + db: D1Database, + versionId: number, +): Promise { + return await db + .prepare(`SELECT * FROM render_model_versions WHERE id = ?`) + .bind(versionId) + .first(); +} + +export async function getLatestModelVersion( + db: D1Database, + modelId: number, +): Promise { + return await db + .prepare( + `SELECT * FROM render_model_versions + WHERE model_id = ? ORDER BY version DESC LIMIT 1`, + ) + .bind(modelId) + .first(); +} + +export function publicModel(m: ModelRow, extra?: Partial) { + return { + id: m.id, + slug: m.slug, + title: m.title, + description: m.description, + kind: m.kind, + provider: m.provider, + visibility: m.visibility, + status: m.status, + created_at: m.created_at, + updated_at: m.updated_at, + latest_version: extra?.latest_version ?? null, + latest_price_tokens: extra?.latest_price_tokens ?? null, + run_count: extra?.run_count ?? 0, + owner: extra?.owner_handle + ? { + handle: extra.owner_handle, + display_name: extra.owner_display_name ?? null, + avatar_url: extra.owner_avatar_url ?? null, + } + : undefined, + }; +} + +export function publicModelVersion(v: ModelVersionRow, includePrompt = false) { + return { + id: v.id, + version: v.version, + provider_model_id: v.provider_model_id, + // `system_prompt` is the creator's authored work and the thing a + // competing publisher would copy — omitted for every viewer except + // the model's own owner (includePrompt), who gets it back so they + // can review/iterate on what they published. + system_prompt: includePrompt ? v.system_prompt : undefined, + params_schema: v.params_schema_json + ? safeParse(v.params_schema_json) + : null, + price_tokens: v.price_tokens, + // Training lineage is the provenance disclosure for a custom model + // (the Anadol-style "what this was trained on") — shown to every + // viewer, same as a project's frozen CID. `weights_ref` is the + // pointer to the actual trained weights at the provider; gated + // owner-only like system_prompt, since it's what another creator + // would need to clone the model outright. + training_method: v.training_method, + base_model: v.base_model, + dataset_note: v.dataset_note, + weights_ref: includePrompt ? v.weights_ref : undefined, + created_at: v.created_at, + }; +} + +function safeParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Jobs +// --------------------------------------------------------------------------- + +export interface JobRow { + id: number; + user_id: number; + model_version_id: number; + project_id: number | null; + seed: string; + params_json: string | null; + prompt_hash: string | null; + status: string; + price_tokens: number; + owner_earn_tokens: number; + output_kind: string | null; + output_text: string | null; + output_key: string | null; + output_hash: string | null; + error_code: string | null; + idempotency_key: string; + created_at: number; + finished_at: number | null; +} + +export async function insertJob( + db: D1Database, + input: { + userId: number; + modelVersionId: number; + projectId: number | null; + seed: string; + paramsJson: string | null; + promptHash: string | null; + priceTokens: number; + ownerEarnTokens: number; + idempotencyKey: string; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_jobs + (user_id, model_version_id, project_id, seed, params_json, + prompt_hash, status, price_tokens, owner_earn_tokens, + idempotency_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?) + RETURNING *`, + ) + .bind( + input.userId, + input.modelVersionId, + input.projectId, + input.seed, + input.paramsJson, + input.promptHash, + input.priceTokens, + input.ownerEarnTokens, + input.idempotencyKey, + now, + ) + .first(); + if (!row) throw new Error("insertJob returned no row"); + return row; +} + +export async function getJobByIdempotencyKey( + db: D1Database, + key: string, +): Promise { + return await db + .prepare(`SELECT * FROM render_jobs WHERE idempotency_key = ?`) + .bind(key) + .first(); +} + +export async function getJobById( + db: D1Database, + id: number, +): Promise { + return await db + .prepare(`SELECT * FROM render_jobs WHERE id = ?`) + .bind(id) + .first(); +} + +export async function finishJob( + db: D1Database, + id: number, + patch: { + status: "succeeded" | "failed"; + outputKind?: string | null; + outputText?: string | null; + outputKey?: string | null; + outputHash?: string | null; + errorCode?: string | null; + }, +): Promise { + return await db + .prepare( + `UPDATE render_jobs + SET status = ?, output_kind = ?, output_text = ?, output_key = ?, + output_hash = ?, error_code = ?, finished_at = ? + WHERE id = ? AND status = 'running' + RETURNING *`, + ) + .bind( + patch.status, + patch.outputKind ?? null, + patch.outputText ?? null, + patch.outputKey ?? null, + patch.outputHash ?? null, + patch.errorCode ?? null, + Math.floor(Date.now() / 1000), + id, + ) + .first(); +} + +export async function listJobsByUser( + db: D1Database, + userId: number, + limit: number, + before?: number, +): Promise { + const sql = before + ? `SELECT * FROM render_jobs WHERE user_id = ? AND id < ? + ORDER BY id DESC LIMIT ?` + : `SELECT * FROM render_jobs WHERE user_id = ? + ORDER BY id DESC LIMIT ?`; + const stmt = db.prepare(sql); + const bound = before + ? stmt.bind(userId, before, limit) + : stmt.bind(userId, limit); + const { results } = await bound.all(); + return results ?? []; +} + +export function publicJob(j: JobRow) { + return { + id: j.id, + model_version_id: j.model_version_id, + project_id: j.project_id, + seed: j.seed, + params: j.params_json ? safeParse(j.params_json) : null, + status: j.status, + price_tokens: j.price_tokens, + output_kind: j.output_kind, + output_text: j.output_text, + output_key: j.output_key, + output_hash: j.output_hash, + error_code: j.error_code, + created_at: j.created_at, + finished_at: j.finished_at, + }; +} diff --git a/workers/api/src/db/tokens.ts b/workers/api/src/db/tokens.ts new file mode 100644 index 0000000..933f891 --- /dev/null +++ b/workers/api/src/db/tokens.ts @@ -0,0 +1,391 @@ +import type { D1Database } from "@cloudflare/workers-types"; + +/** + * Render-token accounting. + * + * A render token is a prepaid compute credit, never an ERC-721 token — + * see the header of migrations/0018_token_service.sql. + * + * `token_ledger` is the source of truth and `token_accounts.balance` is + * a cache of it. Both move in a single D1 batch (batches are + * transactional) under the identical `balance + delta >= 0` guard, so a + * debit that would overdraw applies to neither table. + */ + +export const LEDGER_KINDS = [ + "grant", + "purchase", + "debit", + "refund", + "earn", + "adjust", +] as const; + +export type LedgerKind = (typeof LEDGER_KINDS)[number]; + +export function isLedgerKind(v: unknown): v is LedgerKind { + return typeof v === "string" && (LEDGER_KINDS as readonly string[]).includes(v); +} + +export interface TokenAccount { + user_id: number; + balance: number; + lifetime_purchased: number; + lifetime_spent: number; + lifetime_earned: number; + created_at: number; + updated_at: number; +} + +export interface LedgerRow { + id: number; + user_id: number; + delta: number; + kind: string; + balance_after: number; + ref_kind: string | null; + ref_id: number | null; + memo: string | null; + idempotency_key: string; + created_at: number; +} + +export interface LedgerEntryInput { + userId: number; + /** Signed. Positive credits, negative debits. Zero is rejected. */ + delta: number; + kind: LedgerKind; + /** Replay guard. Must be stable for the operation it represents. */ + idempotencyKey: string; + refKind?: string | null; + refId?: number | null; + memo?: string | null; +} + +export type LedgerResult = + | { status: "applied"; entry: LedgerRow } + | { status: "replayed"; entry: LedgerRow } + | { status: "insufficient"; balance: number; shortfall: number }; + +/** + * Which lifetime counter a ledger entry advances. + * + * Split out as a pure function because the mapping is the part that is + * easy to get subtly wrong (a refund must not reduce `lifetime_spent`, + * or a user who renders and gets refunded reads as having spent a + * negative amount). Unit-tested directly. + */ +export function lifetimeDeltas(kind: LedgerKind, delta: number): { + purchased: number; + spent: number; + earned: number; +} { + return { + purchased: kind === "purchase" || kind === "grant" ? Math.max(0, delta) : 0, + spent: kind === "debit" ? Math.max(0, -delta) : 0, + earned: kind === "earn" ? Math.max(0, delta) : 0, + }; +} + +/** Creates the account row on first touch. Safe to call repeatedly. */ +export async function ensureAccount( + db: D1Database, + userId: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + await db + .prepare( + `INSERT OR IGNORE INTO token_accounts + (user_id, balance, created_at, updated_at) + VALUES (?, 0, ?, ?)`, + ) + .bind(userId, now, now) + .run(); +} + +export async function getAccount( + db: D1Database, + userId: number, +): Promise { + await ensureAccount(db, userId); + const row = await db + .prepare(`SELECT * FROM token_accounts WHERE user_id = ?`) + .bind(userId) + .first(); + // ensureAccount just guaranteed the row; the fallback keeps the return + // type honest rather than asserting non-null. + return ( + row ?? { + user_id: userId, + balance: 0, + lifetime_purchased: 0, + lifetime_spent: 0, + lifetime_earned: 0, + created_at: 0, + updated_at: 0, + } + ); +} + +function isUniqueViolation(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e); + return /UNIQUE constraint failed/i.test(msg); +} + +async function entryByKey( + db: D1Database, + key: string, +): Promise { + return await db + .prepare(`SELECT * FROM token_ledger WHERE idempotency_key = ?`) + .bind(key) + .first(); +} + +/** + * The only balance-mutating path in the codebase. + * + * Returns `insufficient` rather than throwing when a debit would + * overdraw, so callers can surface the shortfall to the user. Returns + * `replayed` when `idempotencyKey` has already been used, carrying the + * original entry — a retried request is a no-op, not a second charge. + */ +export async function applyLedgerEntry( + db: D1Database, + input: LedgerEntryInput, +): Promise { + if (!Number.isInteger(input.delta) || input.delta === 0) { + throw new Error("ledger delta must be a non-zero integer"); + } + await ensureAccount(db, input.userId); + + // Fast path for an already-applied key. The UNIQUE index below is what + // actually enforces this; the lookup just avoids a guaranteed-failing + // batch on the common retry. + const existing = await entryByKey(db, input.idempotencyKey); + if (existing) return { status: "replayed", entry: existing }; + + const now = Math.floor(Date.now() / 1000); + const life = lifetimeDeltas(input.kind, input.delta); + const { userId, delta } = input; + + // Statement order matters. The INSERT runs first so `a.balance` is + // still the pre-mutation value and `balance_after` is computed from + // it. Both statements carry the same `balance + delta >= 0` guard, so + // an overdraw silently affects zero rows in both — which is exactly + // the signal read back from results[0].meta.changes. + const insertLedger = db + .prepare( + `INSERT INTO token_ledger + (user_id, delta, kind, balance_after, ref_kind, ref_id, memo, + idempotency_key, created_at) + SELECT ?, ?, ?, a.balance + ?, ?, ?, ?, ?, ? + FROM token_accounts a + WHERE a.user_id = ? AND a.balance + ? >= 0`, + ) + .bind( + userId, + delta, + input.kind, + delta, + input.refKind ?? null, + input.refId ?? null, + input.memo ?? null, + input.idempotencyKey, + now, + userId, + delta, + ); + + const updateAccount = db + .prepare( + `UPDATE token_accounts + SET balance = balance + ?, + lifetime_purchased = lifetime_purchased + ?, + lifetime_spent = lifetime_spent + ?, + lifetime_earned = lifetime_earned + ?, + updated_at = ? + WHERE user_id = ? AND balance + ? >= 0`, + ) + .bind(delta, life.purchased, life.spent, life.earned, now, userId, delta); + + const readBack = db + .prepare(`SELECT * FROM token_ledger WHERE idempotency_key = ?`) + .bind(input.idempotencyKey); + + let applied = false; + let entry: LedgerRow | null = null; + try { + const results = await db.batch([ + insertLedger, + updateAccount, + readBack, + ]); + applied = (results[0]?.meta?.changes ?? 0) > 0; + entry = results[2]?.results?.[0] ?? null; + } catch (e) { + // A concurrent request won the race on the same key. The batch rolled + // back, so re-reading gives that request's entry. + if (isUniqueViolation(e)) { + const raced = await entryByKey(db, input.idempotencyKey); + if (raced) return { status: "replayed", entry: raced }; + } + throw e; + } + + if (!applied || !entry) { + const account = await getAccount(db, userId); + return { + status: "insufficient", + balance: account.balance, + shortfall: Math.max(0, -delta - account.balance), + }; + } + return { status: "applied", entry }; +} + +export async function listLedger( + db: D1Database, + userId: number, + limit: number, + before?: number, +): Promise { + const sql = before + ? `SELECT * FROM token_ledger WHERE user_id = ? AND id < ? + ORDER BY id DESC LIMIT ?` + : `SELECT * FROM token_ledger WHERE user_id = ? + ORDER BY id DESC LIMIT ?`; + const stmt = db.prepare(sql); + const bound = before + ? stmt.bind(userId, before, limit) + : stmt.bind(userId, limit); + const { results } = await bound.all(); + return results ?? []; +} + +export function publicLedgerEntry(row: LedgerRow) { + return { + id: row.id, + delta: row.delta, + kind: row.kind, + balance_after: row.balance_after, + ref_kind: row.ref_kind, + ref_id: row.ref_id, + memo: row.memo, + created_at: row.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Packs + purchases +// --------------------------------------------------------------------------- + +export interface PackRow { + id: number; + slug: string; + title: string; + tokens: number; + price_wei: string; + active: number; + sort: number; + created_at: number; +} + +export async function listActivePacks(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT * FROM token_packs WHERE active = 1 ORDER BY sort ASC, id ASC`, + ) + .all(); + return results ?? []; +} + +export async function getPackById( + db: D1Database, + id: number, +): Promise { + return await db + .prepare(`SELECT * FROM token_packs WHERE id = ? AND active = 1`) + .bind(id) + .first(); +} + +export function publicPack(p: PackRow) { + return { + id: p.id, + slug: p.slug, + title: p.title, + tokens: p.tokens, + price_wei: p.price_wei, + }; +} + +export interface PurchaseRow { + id: number; + user_id: number; + pack_id: number; + chain_id: number; + tx_hash: string; + from_address: string; + value_wei: string; + tokens: number; + created_at: number; +} + +/** + * Claims a settled on-chain payment. The UNIQUE(chain_id, tx_hash) + * constraint is the anti-replay mechanism — a second attempt to redeem + * the same transfer returns null instead of inserting. + */ +export async function claimPurchase( + db: D1Database, + input: { + userId: number; + packId: number; + chainId: number; + txHash: string; + fromAddress: string; + valueWei: string; + tokens: number; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + try { + const row = await db + .prepare( + `INSERT INTO token_purchases + (user_id, pack_id, chain_id, tx_hash, from_address, value_wei, + tokens, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING *`, + ) + .bind( + input.userId, + input.packId, + input.chainId, + input.txHash.toLowerCase(), + input.fromAddress.toLowerCase(), + input.valueWei, + input.tokens, + now, + ) + .first(); + return row ?? null; + } catch (e) { + if (isUniqueViolation(e)) return null; + throw e; + } +} + +export async function getPurchaseByTx( + db: D1Database, + chainId: number, + txHash: string, +): Promise { + return await db + .prepare( + `SELECT * FROM token_purchases WHERE chain_id = ? AND tx_hash = ?`, + ) + .bind(chainId, txHash.toLowerCase()) + .first(); +} diff --git a/workers/api/src/index.ts b/workers/api/src/index.ts index 584f07f..03c3be6 100644 --- a/workers/api/src/index.ts +++ b/workers/api/src/index.ts @@ -67,6 +67,23 @@ import { uploadGalleryCoverHandler, projectGalleriesHandler, } from "./galleries/handlers"; +import { + tokenAccountHandler, + tokenLedgerHandler, + listPacksHandler, + confirmPurchaseHandler, +} from "./tokens/handlers"; +import { + createModelHandler, + patchModelHandler, + listModelsHandler, + myModelsHandler, + getModelHandler, + publishVersionHandler, + renderHandler, + myJobsHandler, + getJobHandler, +} from "./render/handlers"; import { accessMiddleware } from "./middleware/access"; import { logError } from "./lib/log"; import { captureException } from "./lib/sentry"; @@ -199,6 +216,27 @@ v1.get("/feed", requireAuth, feedHandler); v1.get("/notifications", requireAuth, notificationsHandler); v1.post("/notifications/read", requireAuth, markNotificationsReadHandler); +// Render-token service. Balances/ledger/purchases are all personal or +// public catalogue reads — no admin surface needed yet. Route order +// matters: the literal "mine" and "packs"/"purchase" segments must +// precede the `:slug`/`:id` patterns below so Hono doesn't swallow them +// as a slug/id (same reasoning as the galleries "cover" route above). +v1.get("/tokens/account", requireAuth, tokenAccountHandler); +v1.get("/tokens/ledger", requireAuth, tokenLedgerHandler); +v1.get("/tokens/packs", listPacksHandler); +v1.post("/tokens/purchase/confirm", requireAuth, confirmPurchaseHandler); + +v1.get("/models", listModelsHandler); +v1.get("/models/mine", requireAuth, myModelsHandler); +v1.post("/models", requireAuth, createModelHandler); +v1.get("/models/:slug{[a-z0-9-]+}", getModelHandler); +v1.patch("/models/:slug{[a-z0-9-]+}", requireAuth, patchModelHandler); +v1.post("/models/:slug{[a-z0-9-]+}/versions", requireAuth, publishVersionHandler); +v1.post("/models/:slug{[a-z0-9-]+}/render", requireAuth, renderHandler); + +v1.get("/jobs", requireAuth, myJobsHandler); +v1.get("/jobs/:id{[0-9]+}", requireAuth, getJobHandler); + // Admin-only observability surface. requireAuth runs first so // requireAdmin can read the session. _throw is a forced 500 used // to verify the request_id round-trips end to end. diff --git a/workers/api/src/render/handlers.ts b/workers/api/src/render/handlers.ts new file mode 100644 index 0000000..9db4ab8 --- /dev/null +++ b/workers/api/src/render/handlers.ts @@ -0,0 +1,645 @@ +import type { Context } from "hono"; +import type { Env } from "../types"; +import type { AuthVariables } from "../auth/middleware"; +import { getAuthUser } from "../auth/middleware"; +import { maybeAuthUser } from "../users/handlers"; +import { checkRateLimit } from "../lib/rateLimit"; +import { getProjectById } from "../db/projects"; +import { applyLedgerEntry } from "../db/tokens"; +import { runInference, InferenceError } from "../ai/inference"; +import { + isModelKind, + isModelProvider, + isModelVisibility, + isTrainingMethod, + providerAllowedForKind, + uniqueModelSlug, + insertModel, + getModelBySlug, + updateModel, + listPublicModels, + listModelsByOwner, + insertModelVersion, + listModelVersions, + getLatestModelVersion, + getModelVersion, + publicModel, + publicModelVersion, + insertJob, + getJobByIdempotencyKey, + getJobById, + finishJob, + listJobsByUser, + publicJob, + type ModelKind, +} from "../db/render"; + +function badRequest(c: Context, error: string, detail?: unknown) { + return c.json({ error, detail }, 400); +} + +const TITLE_MAX = 100; +const DESC_MAX = 2000; +const PROMPT_MAX = 4000; +const SYSTEM_PROMPT_MAX = 8000; +const PARAMS_JSON_MAX = 8000; +const PRICE_MAX = 100_000; // ceiling against a fat-fingered pack-draining price +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,47}$/; + +// --------------------------------------------------------------------------- +// Model registry +// --------------------------------------------------------------------------- + +interface CreateModelBody { + slug?: unknown; + title?: unknown; + description?: unknown; + kind?: unknown; + provider?: unknown; + visibility?: unknown; +} + +/** POST /v1/models — register a new (versionless) model shell. */ +export async function createModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `models:create:${session.uid}`, + limit: 20, + windowSeconds: 86400, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + let body: CreateModelBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!title || title.length > TITLE_MAX) { + return badRequest(c, "invalid_title", { max: TITLE_MAX }); + } + if (!isModelKind(body.kind)) { + return badRequest(c, "invalid_kind"); + } + if (!isModelProvider(body.provider)) { + return badRequest(c, "invalid_provider"); + } + if (!providerAllowedForKind(body.kind, body.provider)) { + return badRequest(c, "provider_not_allowed_for_kind", { + kind: body.kind, + provider: body.provider, + }); + } + const visibility = isModelVisibility(body.visibility) ? body.visibility : "private"; + + let description: string | null = null; + if (body.description !== undefined && body.description !== null) { + if (typeof body.description !== "string" || body.description.length > DESC_MAX) { + return badRequest(c, "invalid_description", { max: DESC_MAX }); + } + description = body.description.trim() || null; + } + + let slug: string; + if (typeof body.slug === "string" && body.slug.length > 0) { + if (!SLUG_RE.test(body.slug)) return badRequest(c, "invalid_slug"); + const taken = await getModelBySlug(c.env.DB, body.slug); + if (taken) return badRequest(c, "slug_taken", 409 as never); + slug = body.slug; + } else { + slug = await uniqueModelSlug(c.env.DB, title); + } + + const model = await insertModel(c.env.DB, { + ownerId: session.uid, + slug, + title, + description, + kind: body.kind, + provider: body.provider, + visibility, + }); + return c.json({ model: publicModel(model) }, 201); +} + +interface PatchModelBody { + title?: unknown; + description?: unknown; + visibility?: unknown; + status?: unknown; +} + +/** PATCH /v1/models/:slug — owner-only metadata edits. */ +export async function patchModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.owner_id !== session.uid) return c.json({ error: "forbidden" }, 403); + + let body: PatchModelBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const patch: Parameters[2] = {}; + if (body.title !== undefined) { + if (typeof body.title !== "string" || !body.title.trim() || body.title.length > TITLE_MAX) { + return badRequest(c, "invalid_title", { max: TITLE_MAX }); + } + patch.title = body.title.trim(); + } + if (body.description !== undefined) { + if (body.description !== null && (typeof body.description !== "string" || body.description.length > DESC_MAX)) { + return badRequest(c, "invalid_description", { max: DESC_MAX }); + } + patch.description = body.description === null ? null : (body.description as string).trim() || null; + } + if (body.visibility !== undefined) { + if (!isModelVisibility(body.visibility)) return badRequest(c, "invalid_visibility"); + patch.visibility = body.visibility; + } + if (body.status !== undefined) { + if (body.status !== "active" && body.status !== "disabled") { + return badRequest(c, "invalid_status"); + } + patch.status = body.status; + } + + const updated = await updateModel(c.env.DB, model.id, patch); + return c.json({ model: publicModel(updated ?? model) }); +} + +const LIST_DEFAULT = 20; +const LIST_MAX = 50; + +/** GET /v1/models?kind=&limit=&before= — public catalogue. */ +export async function listModelsHandler(c: Context<{ Bindings: Env }>) { + const url = new URL(c.req.url); + const kindRaw = url.searchParams.get("kind"); + let kind: ModelKind | undefined; + if (kindRaw) { + if (!isModelKind(kindRaw)) return badRequest(c, "invalid_kind"); + kind = kindRaw; + } + const limitRaw = url.searchParams.get("limit"); + let limit = LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > LIST_MAX) { + return badRequest(c, "invalid_limit", { max: LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listPublicModels(c.env.DB, { kind, limit, before }); + return c.json({ + models: rows.map((r) => publicModel(r, r)), + next_before: rows.length === limit ? rows[rows.length - 1].updated_at : null, + }); +} + +/** GET /v1/models/mine — the caller's own models, any visibility/status. */ +export async function myModelsHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const rows = await listModelsByOwner(c.env.DB, session.uid); + return c.json({ models: rows.map((r) => publicModel(r, r)) }); +} + +/** GET /v1/models/:slug — detail + version history. */ +export async function getModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + + const viewer = await maybeAuthUser(c); + const isOwner = !!viewer && viewer.uid === model.owner_id; + if (model.visibility === "private" && !isOwner) { + return c.json({ error: "not_found" }, 404); + } + + const versions = await listModelVersions(c.env.DB, model.id); + return c.json({ + model: publicModel(model), + versions: versions.map((v) => publicModelVersion(v, isOwner)), + }); +} + +interface PublishVersionBody { + provider_model_id?: unknown; + system_prompt?: unknown; + params_schema?: unknown; + price_tokens?: unknown; + // Training lineage — see migrations/0019_custom_model_provider.sql. + // Only meaningful (and, for weights_ref, required) when the model's + // provider is `fal_custom`. + training_method?: unknown; + base_model?: unknown; + dataset_note?: unknown; + weights_ref?: unknown; +} + +const BASE_MODEL_MAX = 100; +const DATASET_NOTE_MAX = 2000; +const WEIGHTS_REF_MAX = 300; + +/** POST /v1/models/:slug/versions — owner-only, append-only publish. */ +export async function publishVersionHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.owner_id !== session.uid) return c.json({ error: "forbidden" }, 403); + + let body: PublishVersionBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const providerModelId = + typeof body.provider_model_id === "string" ? body.provider_model_id.trim() : ""; + if (!providerModelId || providerModelId.length > 200) { + return badRequest(c, "invalid_provider_model_id"); + } + + let systemPrompt: string | null = null; + if (body.system_prompt !== undefined && body.system_prompt !== null) { + if (typeof body.system_prompt !== "string" || body.system_prompt.length > SYSTEM_PROMPT_MAX) { + return badRequest(c, "invalid_system_prompt", { max: SYSTEM_PROMPT_MAX }); + } + systemPrompt = body.system_prompt; + } + + let paramsSchemaJson: string | null = null; + if (body.params_schema !== undefined && body.params_schema !== null) { + let serialized: string; + try { + serialized = JSON.stringify(body.params_schema); + } catch { + return badRequest(c, "invalid_params_schema"); + } + if (serialized.length > PARAMS_JSON_MAX) { + return badRequest(c, "params_schema_too_large", { max: PARAMS_JSON_MAX }); + } + paramsSchemaJson = serialized; + } + + const priceTokens = + typeof body.price_tokens === "number" ? Math.trunc(body.price_tokens) : NaN; + if (!Number.isFinite(priceTokens) || priceTokens < 0 || priceTokens > PRICE_MAX) { + return badRequest(c, "invalid_price_tokens", { max: PRICE_MAX }); + } + + let trainingMethod: string | null = null; + if (body.training_method !== undefined && body.training_method !== null) { + if (!isTrainingMethod(body.training_method)) { + return badRequest(c, "invalid_training_method"); + } + trainingMethod = body.training_method; + } + + let baseModel: string | null = null; + if (body.base_model !== undefined && body.base_model !== null) { + if (typeof body.base_model !== "string" || body.base_model.length > BASE_MODEL_MAX) { + return badRequest(c, "invalid_base_model", { max: BASE_MODEL_MAX }); + } + baseModel = body.base_model.trim() || null; + } + + let datasetNote: string | null = null; + if (body.dataset_note !== undefined && body.dataset_note !== null) { + if (typeof body.dataset_note !== "string" || body.dataset_note.length > DATASET_NOTE_MAX) { + return badRequest(c, "invalid_dataset_note", { max: DATASET_NOTE_MAX }); + } + datasetNote = body.dataset_note.trim() || null; + } + + let weightsRef: string | null = null; + if (body.weights_ref !== undefined && body.weights_ref !== null) { + if (typeof body.weights_ref !== "string" || body.weights_ref.length > WEIGHTS_REF_MAX) { + return badRequest(c, "invalid_weights_ref", { max: WEIGHTS_REF_MAX }); + } + weightsRef = body.weights_ref.trim() || null; + } + // fal_custom has no catalogue fallback the way `provider_model_id` + // alone works for anthropic/workers_ai — without a weights pointer + // the version would publish successfully and then fail every render. + if (model.provider === "fal_custom" && !weightsRef) { + return badRequest(c, "weights_ref_required_for_fal_custom"); + } + + const version = await insertModelVersion(c.env.DB, { + modelId: model.id, + providerModelId, + systemPrompt, + paramsSchemaJson, + priceTokens, + trainingMethod, + baseModel, + datasetNote, + weightsRef, + }); + return c.json({ version: publicModelVersion(version, true) }, 201); +} + +// --------------------------------------------------------------------------- +// Render jobs +// --------------------------------------------------------------------------- + +/** + * Share of price_tokens credited to the model's owner on a successful + * run. Skipped when the renderer IS the owner (see the `earn` guard + * below) — otherwise running your own model would mint tokens out of + * a debit/credit pair on the same account, net-zero in balance but + * inflating lifetime_earned for nothing real. + */ +const OWNER_EARN_BPS = 7000; // 70% + +function ownerEarn(priceTokens: number): number { + return Math.floor((priceTokens * OWNER_EARN_BPS) / 10_000); +} + +interface RenderBody { + version_id?: unknown; + prompt?: unknown; + params?: unknown; + project_id?: unknown; + seed?: unknown; + idempotency_key?: unknown; +} + +function randomSeed(): string { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * POST /v1/models/:slug/render + * + * Debit → run → settle. Tokens are debited BEFORE the provider call so + * a burst of concurrent requests can't spend more than the caller's + * balance; a failed run is refunded in full as a second ledger entry + * (see migrations/0018_token_service.sql header) rather than reversing + * the first. + */ +export async function renderHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `render:${session.uid}`, + limit: 30, + windowSeconds: 3600, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.status !== "active") return c.json({ error: "model_disabled" }, 409); + if (model.visibility === "private" && model.owner_id !== session.uid) { + return c.json({ error: "not_found" }, 404); + } + + let body: RenderBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt || prompt.length > PROMPT_MAX) { + return badRequest(c, "invalid_prompt", { max: PROMPT_MAX }); + } + + let params: Record | null = null; + if (body.params !== undefined && body.params !== null) { + if (typeof body.params !== "object" || Array.isArray(body.params)) { + return badRequest(c, "invalid_params"); + } + let serialized: string; + try { + serialized = JSON.stringify(body.params); + } catch { + return badRequest(c, "invalid_params"); + } + if (serialized.length > PARAMS_JSON_MAX) { + return badRequest(c, "params_too_large", { max: PARAMS_JSON_MAX }); + } + params = body.params as Record; + } + + let projectId: number | null = null; + if (body.project_id !== undefined && body.project_id !== null) { + projectId = typeof body.project_id === "number" ? body.project_id : NaN; + if (!Number.isFinite(projectId) || projectId < 1) return badRequest(c, "invalid_project_id"); + const project = await getProjectById(c.env.DB, projectId); + if (!project || project.owner_id !== session.uid) { + return badRequest(c, "invalid_project_id"); + } + } + + const seed = + typeof body.seed === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(body.seed) + ? body.seed + : randomSeed(); + + const idempotencyKey = + typeof body.idempotency_key === "string" && body.idempotency_key.length > 0 + ? `render:${session.uid}:${body.idempotency_key.slice(0, 128)}` + : `render:${session.uid}:${crypto.randomUUID()}`; + + const existingJob = await getJobByIdempotencyKey(c.env.DB, idempotencyKey); + if (existingJob) { + return c.json({ job: publicJob(existingJob), replayed: true }); + } + + const version = await getLatestModelVersion(c.env.DB, model.id); + if (!version) return c.json({ error: "no_published_version" }, 409); + + const debit = await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: -version.price_tokens, + kind: "debit", + idempotencyKey: `${idempotencyKey}:debit`, + refKind: "render_job", + refId: null, + memo: `render: ${model.title}`, + }); + if (debit.status === "insufficient") { + return c.json( + { error: "insufficient_balance", balance: debit.balance, shortfall: debit.shortfall }, + 402, + ); + } + + const job = await insertJob(c.env.DB, { + userId: session.uid, + modelVersionId: version.id, + projectId, + seed, + paramsJson: params ? JSON.stringify(params) : null, + promptHash: await sha256Hex(prompt), + priceTokens: version.price_tokens, + ownerEarnTokens: ownerEarn(version.price_tokens), + idempotencyKey, + }); + + try { + const result = await runInference(c.env, { + kind: model.kind as ModelKind, + provider: model.provider as never, + providerModelId: version.provider_model_id, + systemPrompt: version.system_prompt, + prompt, + params, + seed, + weightsRef: version.weights_ref, + }); + + let outputKey: string | null = null; + if (result.outputKind === "image" && result.bytes) { + if (!c.env.CAPTURES) { + throw new InferenceError("R2 CAPTURES bucket is not bound", "storage_unconfigured"); + } + outputKey = `renders/${session.uid}/${job.id}-${seed}.png`; + await c.env.CAPTURES.put(outputKey, result.bytes, { + httpMetadata: { contentType: result.contentType ?? "image/png" }, + customMetadata: { + user_id: String(session.uid), + job_id: String(job.id), + model_slug: model.slug, + }, + }); + } + + const finished = await finishJob(c.env.DB, job.id, { + status: "succeeded", + outputKind: result.outputKind, + outputText: result.text, + outputKey, + outputHash: result.outputHash, + }); + + // Owner earn-out on success only — a failed job (refunded below) + // never pays the model owner for compute that produced nothing. + if (job.owner_earn_tokens > 0 && model.owner_id !== session.uid) { + try { + await applyLedgerEntry(c.env.DB, { + userId: model.owner_id, + delta: job.owner_earn_tokens, + kind: "earn", + idempotencyKey: `${idempotencyKey}:earn`, + refKind: "render_job", + refId: job.id, + memo: `render earn: ${model.title}`, + }); + } catch (e) { + // Best-effort: the renderer's job already succeeded and their + // tokens were correctly spent. A missed owner credit is + // recoverable from the ledger's ref_id; it must not roll back + // a job the caller already sees as done. + console.error("owner_earn_failed", e); + } + } + + return c.json({ job: publicJob(finished ?? job) }, 201); + } catch (e) { + const code = e instanceof InferenceError ? e.code : "render_failed"; + const failed = await finishJob(c.env.DB, job.id, { + status: "failed", + errorCode: code, + }); + try { + await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: version.price_tokens, + kind: "refund", + idempotencyKey: `${idempotencyKey}:refund`, + refKind: "render_job", + refId: job.id, + memo: `refund: ${model.title} (${code})`, + }); + } catch (refundErr) { + console.error("refund_failed", refundErr); + } + console.error("render_failed", e); + return c.json({ error: code, job: publicJob(failed ?? job) }, code === "provider_unconfigured" ? 503 : 502); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +const JOB_LIST_DEFAULT = 20; +const JOB_LIST_MAX = 50; + +/** GET /v1/jobs?limit=&before= — the caller's own render jobs. */ +export async function myJobsHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const url = new URL(c.req.url); + const limitRaw = url.searchParams.get("limit"); + let limit = JOB_LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > JOB_LIST_MAX) { + return badRequest(c, "invalid_limit", { max: JOB_LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listJobsByUser(c.env.DB, session.uid, limit, before); + return c.json({ + jobs: rows.map(publicJob), + next_before: rows.length === limit ? rows[rows.length - 1].id : null, + }); +} + +/** GET /v1/jobs/:id — owner-only job detail. */ +export async function getJobHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const id = parseInt(c.req.param("id") || "", 10); + if (!id || Number.isNaN(id)) return badRequest(c, "invalid_id"); + const job = await getJobById(c.env.DB, id); + if (!job) return c.json({ error: "not_found" }, 404); + if (job.user_id !== session.uid) return c.json({ error: "forbidden" }, 403); + return c.json({ job: publicJob(job) }); +} diff --git a/workers/api/src/tokens/handlers.ts b/workers/api/src/tokens/handlers.ts new file mode 100644 index 0000000..383e0e7 --- /dev/null +++ b/workers/api/src/tokens/handlers.ts @@ -0,0 +1,219 @@ +import type { Context } from "hono"; +import { createPublicClient, getAddress, http, type Hex } from "viem"; +import type { Env } from "../types"; +import type { AuthVariables } from "../auth/middleware"; +import { getAuthUser } from "../auth/middleware"; +import { checkRateLimit } from "../lib/rateLimit"; +import { + getAccount, + listLedger, + publicLedgerEntry, + listActivePacks, + getPackById, + publicPack, + claimPurchase, + applyLedgerEntry, +} from "../db/tokens"; + +/** + * The token-purchase counterpart to `chainConfig` in projects/mint.ts. + * Deliberately not shared with it: mint reads GA_FACTORY_ADDRESS, this + * reads TOKEN_TREASURY_ADDRESS — different unconfigured states should + * fail independently rather than one env var silently gating the other. + */ +function treasuryConfig(env: Env) { + const treasury = env.TOKEN_TREASURY_ADDRESS; + const chainId = env.GA_CHAIN_ID ? parseInt(env.GA_CHAIN_ID, 10) : NaN; + const rpcUrl = env.GA_RPC_URL ?? ""; + return { treasury, chainId, rpcUrl }; +} + +function badRequest(c: Context, error: string, detail?: unknown) { + return c.json({ error, detail }, 400); +} + +function isTxHash(s: unknown): s is Hex { + return typeof s === "string" && /^0x[a-fA-F0-9]{64}$/.test(s); +} + +/** GET /v1/tokens/account — balance + lifetime counters for the caller. */ +export async function tokenAccountHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const account = await getAccount(c.env.DB, session.uid); + return c.json({ + balance: account.balance, + lifetime_purchased: account.lifetime_purchased, + lifetime_spent: account.lifetime_spent, + lifetime_earned: account.lifetime_earned, + }); +} + +const LEDGER_LIST_DEFAULT = 30; +const LEDGER_LIST_MAX = 100; + +/** GET /v1/tokens/ledger?limit=&before= — the caller's own history. */ +export async function tokenLedgerHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const url = new URL(c.req.url); + const limitRaw = url.searchParams.get("limit"); + let limit = LEDGER_LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > LEDGER_LIST_MAX) { + return badRequest(c, "invalid_limit", { max: LEDGER_LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listLedger(c.env.DB, session.uid, limit, before); + return c.json({ + entries: rows.map(publicLedgerEntry), + next_before: rows.length === limit ? rows[rows.length - 1].id : null, + }); +} + +/** GET /v1/tokens/packs — public catalogue of purchasable token packs. */ +export async function listPacksHandler(c: Context<{ Bindings: Env }>) { + const packs = await listActivePacks(c.env.DB); + const cfg = treasuryConfig(c.env); + return c.json({ + packs: packs.map(publicPack), + purchase_configured: !!cfg.treasury && !Number.isNaN(cfg.chainId), + chain_id: Number.isNaN(cfg.chainId) ? null : cfg.chainId, + treasury_address: cfg.treasury ?? null, + }); +} + +interface ConfirmBody { + pack_id?: unknown; + tx_hash?: unknown; +} + +/** + * POST /v1/tokens/purchase/confirm + * + * No payment processor: the client sends ETH directly to + * TOKEN_TREASURY_ADDRESS on the configured chain, then posts the tx + * hash here. The Worker fetches the transaction + receipt from the + * public RPC and verifies: + * - the tx succeeded + * - it was sent TO the treasury address + * - its value covers the pack's price_wei + * before crediting the account. Trusting only the tx hash (never a + * client-supplied amount) means a malicious client can't credit + * themselves tokens they didn't pay for. Mirrors the verification + * shape of projects/mint.ts confirmDeploy/confirmMint. + */ +export async function confirmPurchaseHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `tokens:purchase:${session.uid}`, + limit: 20, + windowSeconds: 3600, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + const cfg = treasuryConfig(c.env); + if (!cfg.treasury || Number.isNaN(cfg.chainId)) { + return c.json({ error: "purchase_unconfigured" }, 503); + } + + let body: ConfirmBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const packId = typeof body.pack_id === "number" ? body.pack_id : NaN; + if (!Number.isFinite(packId) || packId < 1) return badRequest(c, "invalid_pack_id"); + if (!isTxHash(body.tx_hash)) return badRequest(c, "invalid_tx_hash"); + + const pack = await getPackById(c.env.DB, packId); + if (!pack) return c.json({ error: "pack_not_found" }, 404); + + const expectedTreasury = getAddress(cfg.treasury as Hex); + const client = createPublicClient({ transport: http(cfg.rpcUrl) }); + + let fromAddress: Hex; + let valueWei: bigint; + try { + const [tx, receipt] = await Promise.all([ + client.getTransaction({ hash: body.tx_hash }), + client.getTransactionReceipt({ hash: body.tx_hash }), + ]); + if (receipt.status !== "success") return badRequest(c, "tx_reverted", 409); + if (!tx.to || getAddress(tx.to) !== expectedTreasury) { + return badRequest(c, "wrong_recipient", 409); + } + if (tx.value < BigInt(pack.price_wei)) { + return badRequest(c, "underpaid", { + required_wei: pack.price_wei, + sent_wei: tx.value.toString(), + }); + } + fromAddress = tx.from; + valueWei = tx.value; + } catch (e) { + console.error("purchase confirm verify failed", e); + return c.json({ error: "rpc_unavailable" }, 503); + } + + const purchase = await claimPurchase(c.env.DB, { + userId: session.uid, + packId: pack.id, + chainId: cfg.chainId, + txHash: body.tx_hash, + fromAddress, + valueWei: valueWei.toString(), + tokens: pack.tokens, + }); + if (!purchase) { + // UNIQUE(chain_id, tx_hash) already claimed — this tx was already + // redeemed (by this user or, if stolen, by whoever posted it first). + return c.json({ error: "already_claimed" }, 409); + } + + const ledger = await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: pack.tokens, + kind: "purchase", + idempotencyKey: `purchase:${cfg.chainId}:${body.tx_hash.toLowerCase()}`, + refKind: "purchase", + refId: purchase.id, + memo: `${pack.title} pack`, + }); + // A purchase credit can never be "insufficient" — it's additive — so + // the only real outcomes are applied/replayed. Both carry the entry. + if (ledger.status === "insufficient") { + return c.json({ error: "ledger_error" }, 500); + } + + const account = await getAccount(c.env.DB, session.uid); + return c.json( + { + purchase: { + id: purchase.id, + pack: publicPack(pack), + tx_hash: purchase.tx_hash, + tokens: purchase.tokens, + }, + ledger_entry: publicLedgerEntry(ledger.entry), + balance: account.balance, + }, + 201, + ); +} diff --git a/workers/api/src/types.ts b/workers/api/src/types.ts index 3d5a9ae..508bfc2 100644 --- a/workers/api/src/types.ts +++ b/workers/api/src/types.ts @@ -63,6 +63,39 @@ export interface Env { ADMIN_HANDLES?: string; // csv allowlist for /v1/internal/* UPTIME_PUBLIC_BASE?: string; UPTIME_PROJECT_PROBE_URL?: string; + // --- Render-token service --------------------------------------------- + // Claude API key for the `anthropic` render-model provider (code + // generation). Set via `wrangler secret put ANTHROPIC_API_KEY + // --env production`. When unset, jobs against an `anthropic`-provider + // model fail with 503 `provider_unconfigured` — no silent fallback. + ANTHROPIC_API_KEY?: string; + // Cloudflare Workers AI binding. Powers the `workers_ai` render-model + // provider (image/texture generation). Hand-typed rather than pulled + // from @cloudflare/workers-types because the pinned SDK version + // predates the Ai binding type; the shape below is the subset this + // Worker actually calls. + AI?: { + run(model: string, input: Record): Promise; + }; + // fal.ai API key for the `fal_custom` render-model provider — + // creator-trained custom art models (fine-tune / LoRA on a diffusion + // base) that Workers AI can't host, since its LoRA fine-tuning only + // covers text model_types. Set via `wrangler secret put FAL_KEY + // --env production`. Unset: jobs against a `fal_custom` model fail + // with 503 `provider_unconfigured`. + FAL_KEY?: string; + // Address that receives ETH for token-pack purchases. Non-secret (an + // on-chain address is public by nature) so it lives in [vars]. Until + // set, POST /v1/tokens/purchase/confirm returns 503 + // `purchase_unconfigured`. Reuses GA_CHAIN_ID / GA_RPC_URL — the same + // chain the mint flow already reads receipts from. + TOKEN_TREASURY_ADDRESS?: string; + // OPT-IN ONLY, mirrors GITHUB_MOCK / PINNING_MOCK. When exactly "1", + // runInference() returns deterministic canned output with no network + // call — makes the render pipeline exercisable in `wrangler dev` and + // in scripts/smoke_api.mjs without an API key or Workers AI binding. + // Production must NEVER set RENDER_MOCK. + RENDER_MOCK?: string; } export interface ProjectMintState { diff --git a/workers/api/test/units.test.mjs b/workers/api/test/units.test.mjs index f66ff4e..1a534fa 100644 --- a/workers/api/test/units.test.mjs +++ b/workers/api/test/units.test.mjs @@ -31,6 +31,8 @@ export { sanitizeRoute, minuteBucket } from ${JSON.stringify(join(srcDir, "lib", export { buildFtsQuery } from ${JSON.stringify(join(srcDir, "db", "search"))}; export { parseAllowlist } from ${JSON.stringify(join(srcDir, "auth", "admin"))}; export { parseFeedCursor, encodeFeedCursor } from ${JSON.stringify(join(srcDir, "db", "events"))}; +export { lifetimeDeltas } from ${JSON.stringify(join(srcDir, "db", "tokens"))}; +export { providerAllowedForKind, isTrainingMethod } from ${JSON.stringify(join(srcDir, "db", "render"))}; `, ); @@ -254,3 +256,96 @@ test("feed cursor rejects malformed input rather than poisoning SQL", () => { assert.equal(m.parseFeedCursor(bad), null, `expected null for ${JSON.stringify(bad)}`); } }); + +// --------------------------------------------------------------------------- +// Render-token ledger — lifetime counters. +// +// Regression this pins: a refund must NOT reduce lifetime_spent (a +// refunded render would otherwise read as having spent a negative +// amount), and only a genuine 'debit' should ever advance it. +// --------------------------------------------------------------------------- +test("lifetimeDeltas: purchase and grant both count as purchased", () => { + assert.deepEqual(m.lifetimeDeltas("purchase", 500), { + purchased: 500, + spent: 0, + earned: 0, + }); + assert.deepEqual(m.lifetimeDeltas("grant", 200), { + purchased: 200, + spent: 0, + earned: 0, + }); +}); + +test("lifetimeDeltas: debit counts as spent using the positive magnitude", () => { + assert.deepEqual(m.lifetimeDeltas("debit", -30), { + purchased: 0, + spent: 30, + earned: 0, + }); +}); + +test("lifetimeDeltas: refund advances nothing — it is not a negative spend", () => { + assert.deepEqual(m.lifetimeDeltas("refund", 30), { + purchased: 0, + spent: 0, + earned: 0, + }); +}); + +test("lifetimeDeltas: earn counts as earned only", () => { + assert.deepEqual(m.lifetimeDeltas("earn", 21), { + purchased: 0, + spent: 0, + earned: 21, + }); +}); + +test("lifetimeDeltas: adjust never touches a lifetime counter", () => { + assert.deepEqual(m.lifetimeDeltas("adjust", 999), { + purchased: 0, + spent: 0, + earned: 0, + }); + assert.deepEqual(m.lifetimeDeltas("adjust", -999), { + purchased: 0, + spent: 0, + earned: 0, + }); +}); + +// --------------------------------------------------------------------------- +// Render-model provider/kind gating. +// +// Regression this prevents: registering an image model against the +// code-only `anthropic` provider (or vice versa) would only fail once +// someone had already been charged for a job against it. +// --------------------------------------------------------------------------- +test("providerAllowedForKind: code models accept anthropic and mock only", () => { + assert.equal(m.providerAllowedForKind("code", "anthropic"), true); + assert.equal(m.providerAllowedForKind("code", "mock"), true); + assert.equal(m.providerAllowedForKind("code", "workers_ai"), false); +}); + +test("providerAllowedForKind: image models accept workers_ai, fal_custom, and mock", () => { + assert.equal(m.providerAllowedForKind("image", "workers_ai"), true); + assert.equal(m.providerAllowedForKind("image", "fal_custom"), true); + assert.equal(m.providerAllowedForKind("image", "mock"), true); + assert.equal(m.providerAllowedForKind("image", "anthropic"), false); +}); + +// fal_custom is the creator-trained-model lane (fine-tune/LoRA on a +// diffusion base) — see migrations/0019_custom_model_provider.sql for +// why it can't run through Workers AI. +test("providerAllowedForKind: code models still reject fal_custom", () => { + assert.equal(m.providerAllowedForKind("code", "fal_custom"), false); +}); + +test("isTrainingMethod: accepts the four documented methods only", () => { + assert.equal(m.isTrainingMethod("lora"), true); + assert.equal(m.isTrainingMethod("dreambooth"), true); + assert.equal(m.isTrainingMethod("full_finetune"), true); + assert.equal(m.isTrainingMethod("prompt_recipe"), true); + assert.equal(m.isTrainingMethod("magic"), false); + assert.equal(m.isTrainingMethod(undefined), false); +}); diff --git a/workers/api/wrangler.toml b/workers/api/wrangler.toml index e151011..3bd3ed8 100644 --- a/workers/api/wrangler.toml +++ b/workers/api/wrangler.toml @@ -55,6 +55,17 @@ bucket_name = "generatedart-assets-dev" [images] binding = "IMAGES" +# Render-token service: image-model provider (Task: render-token +# service). Deliberately NOT bound here — Workers AI has no local +# simulator, so wrangler dev would silently switch to remote mode and +# require a CLOUDFLARE_API_TOKEN just to boot, breaking local dev for +# everyone who hasn't set one up. The `workers_ai` provider path is +# gated on `env.AI` being present (see src/ai/inference.ts) and returns +# 503 provider_unconfigured when it's absent, same as every other +# optional binding in this file. Set RENDER_MOCK=1 in .dev.vars to +# exercise the render pipeline locally instead; the binding is real in +# [env.production.ai] below. + # ============================================================================ # Production environment (api.generatedart.com) # ============================================================================ @@ -105,6 +116,9 @@ bucket_name = "generatedart-assets" [env.production.images] binding = "IMAGES" +[env.production.ai] +binding = "AI" + # --- Async render + IPFS pin jobs — uncomment with Task #4 / Task #5 -------- # [[env.production.queues.producers]] # binding = "RENDER_QUEUE" @@ -144,6 +158,18 @@ GA_CHAIN_ID = "84532" GA_RPC_URL = "https://sepolia.base.org" # GA_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000000" +# --- Render-token service ---------------------------------------------- +# Address that receives ETH for token-pack purchases (reuses GA_CHAIN_ID +# / GA_RPC_URL above — same chain the mint flow reads receipts from). +# Non-secret — an on-chain address is public. Until set, POST +# /v1/tokens/purchase/confirm returns 503 purchase_unconfigured. +# ANTHROPIC_API_KEY is a secret, set via: +# wrangler secret put ANTHROPIC_API_KEY --env production +# Until set, render jobs against an `anthropic`-provider model return +# 503 provider_unconfigured. RENDER_MOCK must NEVER be set in +# production (see src/ai/inference.ts). +# TOKEN_TREASURY_ADDRESS = "0x0000000000000000000000000000000000000000" + # --- Task #20: Observability & error monitoring ----------------------------- # All four are optional — when unset the relevant surface no-ops: # * SENTRY_DSN → Sentry envelope POST is skipped