Molta.dev
DocsLog in

Molta documentation

Everything you need to integrate, deploy, or extend Molta — written to be implementable by a developer or an AI. For AI ingestion, the same content is available as a single plaintext file at /docs/llms.txt.

Overview & architecture

Molta is a collaboration system for game/app developers working with remote contractors (artists, sound designers). Developers define the assets a game needs, brief them with requirements + placeholders, invite contractors to upload new versions, review and finalize them, and stream the finished assets into the app at runtime via a 6-digit access code — or bake them into a production build.

Components

ComponentWhat it is
Web portalNext.js 14 (App Router) app on Vercel. Auth0 login. The collaboration UI + all APIs.
Database & storageSupabase Postgres (metadata/workflow) + Supabase Storage (asset files, private bucket, signed URLs).
Runtime SDKMoltaKit — a Swift package the game embeds to pull finalized assets. Other platforms speak the same HTTP contract.
CLImolta — a zero-dependency Node CLI to seed assets, bake production builds, and manage schema versions.
Companion appA SwiftUI iOS app for collaborating on the go (conversations, push, photo upload, review).

How the pieces talk

 Developer ─┐ Auth0 login                Contractor ─┐ Auth0 login
            ▼                                          ▼
        ┌──────────────────────────────────────────────────┐
        │   Web portal (Next.js / Vercel)                   │
        │   Auth0 · Supabase Postgres · Supabase Storage    │
        └───┬───────────────┬───────────────────┬───────────┘
       6-digit code    Bearer apt_ token     Auth0 Bearer JWT
            │               │                     │
            ▼               ▼                     ▼
      MoltaKit   molta CLI      iOS companion app
      (game runtime)   (seed / bake)         (collaborate on phone)

Three distinct auth paths

Molta deliberately uses different credentials for different clients:

  • Developers & contractors use Auth0 (web) — and the iOS companion app uses Auth0 native login (Bearer access token).
  • The game's runtime SDK uses the portal's 6-digit access code (a read key) — end users never log in.
  • The CLI / automation uses a hashed API token (apt_…, machine-to-machine).

Repository layout

apps/portal           Next.js web app + all APIs (deploy this to Vercel)
supabase/schema.sql   Postgres schema (source of truth for the data model)
swift/MoltaKit  Runtime Swift SDK the game embeds
apps/ios              SwiftUI companion app (XcodeGen project)
packages/cli          molta CLI
examples/sample-game  Galaxy Raiders — a runnable demo + assets

Core concepts

Portal (project)

One portal per game/app. It owns sections, groups, assets, members, an immutable 6-digit access code, an asset schema version, and conversations.

Asset

A single deliverable the game needs — an image, video, audio clip, music track, sound effect, level, model, font, text, or data file. Each asset has:

  • a stable asset_key (lower_snake_case, e.g. hero_idle) — the id the SDK uses; never rename it.
  • a type, a brief (free-text description), and structured requirements (dimensions, duration, loop, format, size budget…).
  • a lifecycle status and an ordered list of versions (uploads).

Section & group

  • Sections are loose organizational buckets ("Character Art", "Soundtrack").
  • Groups bundle assets that must ship together (e.g. animation frames). The contractor must upload every asset in a group before they can Share it, and the whole group is reviewed as a unit. An ungrouped asset behaves like a group of one.

Asset lifecycle

placeholder → in_progress → submitted → in_app → done
                                   └──────────────┴──→ rejected
StatusMeaning
placeholderDeveloper seeded a stand-in; awaiting the contractor's first upload.
in_progressContractor has uploaded a draft (not yet shared) or changes were requested.
submittedContractor shared the work; waiting on developer review.
in_appDeveloper published the version live for preview ("See in app").
doneDeveloper signed off; the contractor is released.
rejectedDeveloper rejected the current version.

Upload ≠ submit. Uploading creates a draft (in_progress). The contractor explicitly clicks Share (group-scoped) to move work to submitted.

Two publish actions. See in app publishes a version for preview; Done signs it off. Both mark the underlying version "finalized", which is what the runtime SDK serves.

Version

Every upload is an asset_version with a version_number, checksum (sha256), mime type, size, and an is_placeholder flag. The runtime SDK serves each asset's most recent finalized version, falling back to a placeholder so the app always has something. Submitted-but-unpublished uploads are never served.

Access code

A portal's permanent 6-digit code. The runtime SDK uses it (read-only) to fetch the published manifest. Safe to ship in your app.

Schema version

An integer on the portal = the minimum app build required to handle its current assets. Bump it via the CLI when you ship new asset types; the SDK refuses to sync (and tells the user to update) if the portal's version exceeds what the app build supports. See Schema versioning.

Conversations & requests

  • Conversations are message threads scoped to a group, an ungrouped asset, or the whole project. Status changes (upload/share/review) post highlighted activity lines into the thread. Image attachments are supported.
  • Requests are contractor → developer asks ("add support for sprite atlases") with an open/resolved/declined lifecycle.

Quickstart

This gets a developer from zero to a live portal with assets streaming into a game. (To deploy your own instance of Molta, see Deployment & env.)

1. Create a portal

Log in to the portal, click + New portal, name it. Note its 6-digit access code on the project page.

2. Generate an API token

Project page → CLI / API tokensGenerate. Copy the apt_… value (shown once).

3. Connect the CLI and seed assets

npm install -g molta     # or: node packages/cli/bin/molta.js …
molta login --url https://molta.dev --token apt_xxxxxxxx
molta seed game-assets.manifest.json

This creates sections/groups/assets with briefs + requirements and uploads any placeholder files. See Seed manifest for the file format, or AI seeding to have an AI generate it from your codebase.

4. Invite a contractor

Project page → Team → invite by email. They log in with that email (Auth0) and see their task queue. They upload versions and Share them.

5. Review & finalize

As the developer you'll see "Waiting on your review". Open an asset → See in app to preview live, then Done to sign off (or Request changes / Reject).

6. Stream assets into your game

Add the Swift SDK and connect with the access code:

import MoltaKit

let portal = MoltaClient(
    baseURL: URL(string: "https://molta.dev")!,
    accessCode: "428193")

let assets = try await portal.sync()              // downloads new/changed assets
if let url = portal.localURL(forKey: "hero_ship") { /* load from disk */ }

7. Ship to production

When everything is Done, bake the assets into the binary so production has no network dependency:

molta status                                   # READY / NOT READY
molta bake --out MoltaBaked --require-final

Add the MoltaBaked folder to your app target. Release builds load the bundled assets; the downloader is compiled out. See Production baking.

Using the portal (web)

Developer workflow

  1. Create a portal and (optionally) sections and groups.
  2. Define assetsAdd asset with a name, stable asset_key, type, brief, and JSON requirements. Or bulk-seed via the CLI.
  3. Seed placeholders so the game runs while art is in progress.
  4. Invite contractors (Team panel) and optionally assign assets/sections.
  5. Review — the project page surfaces "Waiting on your review" and "In app — preview & sign off". On an asset:
    • 📲 See in app — publish the version live for preview (status in_app).
    • ✓ Done — sign off; releases the contractor (status done).
    • ↩ Request changes / ✕ Reject — send it back.
    • An optional note posts into the conversation with the decision.
  6. Manage tokens (CLI / API tokens panel) and resolve requests filed by contractors.

Contractor workflow

  1. Log in with the invited email. Your dashboard shows the portal.
  2. The project page shows "Up next" and a to-do queue of your assets with briefs, requirements, and status.
  3. Open an asset → Upload a new version (this is a draft — it doesn't notify the developer yet; the row shows a green "✓ draft").
  4. Share to send for review. Sharing is group-scoped: every asset in the group must have an uploaded draft before Share enables (n/total uploaded).
  5. Converse on the asset/group/project, attach screenshots, and file requests for code/app changes you need from the developer.

Conversations

Each asset page (and group page) has a conversation in the right pane; the project page has a portal-wide conversation. Threads are scoped to the group (grouped assets share one), the asset (ungrouped), or the project. Status changes appear as highlighted activity entries. You can attach images.

The right-hand panel (project page)

Top to bottom: Requests to developer, Portal conversation, then (developer only) the access code, team, and CLI / API tokens.

CLI reference

molta is a zero-dependency Node CLI (Node ≥ 18.17, macOS/Linux/Windows) for seeding, baking, and versioning. It authenticates with a project API token (apt_…) generated in the portal.

Install & authenticate

npm install -g molta
# or run directly: node packages/cli/bin/molta.js <command>

molta login --url https://molta.dev --token apt_xxxxxxxx
molta whoami

Config is saved to ~/.molta/config.json (mode 600). You can instead set MOLTA_URL and MOLTA_TOKEN env vars.

Commands

CommandPurpose
login --url <u> --token <apt_…>Validate + save credentials
whoamiShow the target portal, access code, asset count, schema version
seed <manifest.json> [--dir <root>] [--dry-run] [--bump | --schema-version <N>]Upsert sections/groups/assets, upload placeholders, optionally bump version
push --key <k> --file <p> [--final]Upload a single asset version
bake [--out <dir>] [--require-final]Download published assets to bundle in a production build
statusProduction-readiness report (all assets done? schema version)
bump-version [--to <N>]Bump (or set) the asset schema version

Examples

# Seed from a manifest, resolving placeholder files relative to ./art
molta seed game.manifest.json --dir ./art

# Validate without sending anything
molta seed game.manifest.json --dry-run

# Seed new asset types and bump the schema version in one push
molta seed new-types.manifest.json --bump

# Bake finalized assets for production, failing if any aren't "done"
molta bake --out MoltaBaked --require-final

# Quick CI gate: is everything finalized?
molta status

seed is idempotent (matched by asset_key) — rerun freely after editing the manifest. See Seed manifest for the file format, Production baking for bake details, and Schema versioning for --bump / bump-version.

Seed manifest reference

The seed manifest is a JSON file describing the sections, groups, and assets a portal should contain. molta seed <manifest.json> applies it idempotently.

Full schema

{
  // Optional. Set/raise the portal's asset schema version (see Schema versioning).
  "schema_version": 2,

  // Optional. Loose organizational buckets.
  "sections": [
    { "name": "Character Art", "description": "optional" }
  ],

  // Optional. Assets that must ship together (frames, etc.).
  "groups": [
    { "name": "Explosion animation", "section": "Character Art",
      "description": "frames delivered as a unit" }
  ],

  // Required. One entry per logical asset.
  "assets": [
    {
      "key": "hero_ship",            // REQUIRED, lower_snake_case, stable SDK id
      "name": "Hero ship sprite",    // REQUIRED, human label
      "type": "image",               // image|video|audio|music|sound|level|model|font|text|data|other
      "section": "Character Art",    // optional; created if missing
      "group": "Explosion animation",// optional group name; created if missing
      "description": "What you want — style, mood, references, where it's used.",
      "requirements": {              // optional, free-form; these keys render nicely:
        "width": 256, "height": 256, "format": "png", "max_kb": 120,
        "duration_sec": 60, "loop": true, "fps": 30,
        "sample_rate": 44100, "channels": 2
      },
      "placeholder": "placeholders/hero_ship.png"  // optional path, relative to --dir
    }
  ]
}

Field rules

  • key — required, must match ^[a-z0-9_]+$, unique within the portal, and permanent (the SDK keys off it; never rename).
  • name — required.
  • type — one of the listed enum values; defaults to other.
  • requirements — any JSON object. The portal renders common keys (width, height, format, duration_sec, loop, max_kb, fps, sample_rate, channels, notes) with friendly labels; others pass through.
  • placeholder — path to a file uploaded as the asset's initial placeholder version. Resolved relative to --dir (or the manifest's folder).
  • group — the contractor must upload all assets in a group before they can share it.

Tips

  • Keep seed idempotent runs the source of truth; re-run after edits.
  • Use --dry-run to validate (key casing, types, missing placeholder files) before sending anything.
  • The schema doc here, the CLI validator (validateManifest), and the server handler (lib/seed.ts) are kept in sync — all three accept the same shape.

AI seeding

Molta is designed so an AI (e.g. Claude Code) can analyze a game's codebase, extract its assets, write requirements, and seed a portal in one shot.

Prerequisites

The developer has created a portal, generated an API token, and run molta login (or set MOLTA_URL / MOLTA_TOKEN).

Prompt to give the AI (inside your game repo)

Analyze this codebase and produce an molta seed manifest (game-assets.manifest.json). Find every art, audio, music, video, level, and font asset the game loads — search for image/sound loading calls, asset catalogs, Resources/ and Assets/ folders, and file references in code. For each, emit an object with a stable lower_snake_case key, a name, the right type, a section, a group if frames ship together, a description of what a contractor should produce, and requirements inferred from how the asset is used (dimensions from the sprite/atlas, duration/loop for audio, format from the file extension). If a current asset file exists, set placeholder to its path. Follow the schema in the portal docs (Seed manifest). Then run molta seed game-assets.manifest.json --dry-run and fix any errors.

What the AI should infer

FieldHow
keylower_snake_case, stable, unique. Pick carefully — never renamed.
typefrom usage/extension: image, sound (short SFX), music (loops), level, model, font, …
section / grouplogical grouping; group frames/atlases that ship together.
descriptionwhat a contractor should make — subject, style, references, in-game use.
requirementsconcrete specs: image width/height/format/max_kb; audio duration_sec/loop/sample_rate/channels; video fps.
placeholderpath to the current asset file, if any.

Rules of thumb

  • One manifest entry per logical asset (dedupe by asset, not file path).
  • Keys are forever — choose them deliberately.
  • Be specific in requirements; it's what the contractor is held to.
  • Re-running seed is safe and idempotent — iterate freely.
  • This page itself, plus /docs/llms.txt, is a single-file source an AI can read to implement an integration end-to-end.

iOS SDK — MoltaKit

The runtime client your game embeds. A dependency-free Swift package (iOS 15+, macOS 12+, tvOS 15+). Other platforms (Android, Unity, web) can implement the same HTTP contract — see API reference.

Install (Swift Package Manager)

Add the repo as a package dependency and add MoltaKit to your target.

Usage

import MoltaKit

let portal = MoltaClient(
    baseURL: URL(string: "https://molta.dev")!,
    accessCode: "428193",                 // shown in the portal UI
    supportedSchemaVersion: 1)            // bump when you add new asset types

// On launch — downloads only what changed (diffed by checksum), caches the rest.
let synced = try await portal.sync()
for asset in synced where asset.didUpdate { print("Updated \(asset.key)") }

// Use any asset by its stable key:
if let url = portal.localURL(forKey: "hero_idle") {
    let image = UIImage(contentsOfFile: url.path)
}
if let wav = portal.data(forKey: "laser_shot") { /* feed your audio engine */ }

Development vs production

The over-the-air downloader is for DEV/TEST builds. The behaviour is chosen automatically:

Modesync() behaviour
.developmentDownloads the latest published assets from the portal.
.productionLoads assets baked into the app bundle; no network.
.automatic (default).development on DEBUG builds, .production otherwise.

The networking code is compiled out of release builds (#if DEBUG || MOLTA_LIVE), so production binaries contain no download logic. Define MOLTA_LIVE to force the downloader on in a release build (e.g. a TestFlight build that should keep updating). See Production baking to exclude the library entirely in production.

How sync works (development)

  1. GET /api/sdk/manifest (header X-Access-Code) returns the published assets, each with a checksum and a short-lived signed download URL.
  2. Assets whose checksum differs from the on-disk cache are downloaded into Caches/Molta/<accessCode>/; the version map is updated.
  3. Unchanged assets are skipped, so repeat launches are cheap and offline-friendly.

Errors

sync() throws MoltaError. Notable case:

  • .appOutOfDate(required:supported:) — the portal's schema version is higher than this build supports. Show its message and prompt the user to update. See Schema versioning.

What's served

Only assets the developer published via See in app or Done (plus initial placeholders) are served. In-review uploads never reach the app.

Production baking

For a production build you bake the finalized assets into the app so the shipped binary has no network or portal dependency.

Bake

molta status                                   # READY / NOT READY report
molta bake --out MoltaBaked --require-final

bake downloads every published asset and writes into the output folder:

  • the asset files,
  • molta-manifest.json (key → file map + checksums),
  • MoltaBaked.swift — a generated, zero-dependency accessor.

--require-final aborts unless every asset is done, so you never ship a placeholder or in-review version. Both bake and status print a clear ✅ READY / ⛔ NOT READY FOR PRODUCTION summary.

Two levels of "no network in production"

(a) Keep MoltaKit linked. In release builds the downloader is already compiled out, so sync() / localURL(forKey:) resolve to the bundled files. Drag the MoltaBaked folder into your app target (a group, so the .swift compiles and the files bundle).

(b) Exclude the library entirely. Use the generated accessor and only import MoltaKit in DEBUG:

#if DEBUG
import MoltaKit
let portal = MoltaClient(baseURL: url, accessCode: "428193")
func assetURL(_ k: String) -> URL? { portal.localURL(forKey: k) }
// …call try await portal.sync() on launch
#else
func assetURL(_ k: String) -> URL? { MoltaBaked.url(forKey: k) }   // no library
#endif

Link the MoltaKit package only in your Debug configuration and the release binary contains none of it.

The generated accessor

MoltaBaked.swift exposes:

MoltaBaked.keys                        // [String]
MoltaBaked.url(forKey: "hero_ship")    // URL? in the bundle
MoltaBaked.data(forKey: "hero_ship")   // Data?

It finds files whether the folder was added as a folder reference or a group.

Schema versioning

Stops an old app build from downloading assets it can't handle. Each portal has a schema_version — the minimum app build required for its current assets.

The contract

  • Server: the portal stores schema_version (starts at 1). Bump it when you ship asset types that need a newer app.
  • App: declares the version it was built to understand via MoltaClient(supportedSchemaVersion:).
  • Gate: on sync(), if the portal's version is higher than the app's, the SDK throws MoltaError.appOutOfDate instead of serving incompatible assets.

Bumping the server version (CLI)

molta bump-version                  # +1
molta seed new-assets.json --bump   # seed new types AND bump together
molta bump-version --to 5           # set explicitly

whoami and status show the current version. You can also set it declaratively with a top-level "schema_version": N in the seed manifest.

Declaring the app version (SDK)

let portal = MoltaClient(baseURL: url, accessCode: "428193",
                               supportedSchemaVersion: 3)   // what THIS build supports

Handling the error

do {
    try await portal.sync()
} catch let error as MoltaError {
    if case .appOutOfDate = error {
        showUpdateRequiredAlert(message: error.localizedDescription)
        // "This version of the app is out of date — please update to the latest version."
    }
}

Typical flow

  1. You add new asset types in the game → ship a new app build with supportedSchemaVersion: 4.
  2. Push the new assets and bump: molta seed new.json --bump (portal → v4).
  3. New app (v4) syncs fine. An old build (v3) hitting the v4 portal gets the "please update" error instead of breaking.

Defaults are back-compatible: existing portals/apps stay at v1 until you bump.

API reference

Three audiences, three auth schemes. All routes are under your portal's base URL.

Versioning. The canonical API surface is /api/v1/.... The unversioned /api/... paths still work as legacy aliases (so older shipped clients keep running), but new integrations should use /api/v1. Examples below use /api/v1.

Runtime SDK API (6-digit access code)

Public, read-only. Used by MoltaKit and any other-platform client.

GET /api/v1/sdk/manifest — the published asset manifest.

  • Auth: header X-Access-Code: 123456 (or ?code=123456).
  • Returns:
{
  "project": "Galaxy Raiders",
  "access_code": "428193",
  "schema_version": 1,
  "generated_at": "2026-06-16T00:00:00Z",
  "assets": [
    {
      "key": "hero_ship", "name": "Hero ship", "type": "image",
      "version": 4, "checksum": "abc123…", "size": 20480,
      "mime_type": "image/png",
      "url": "https://…signed-download-url…",
      "metadata": { "width": 256, "height": 256 },
      "updated_at": "2026-06-16T00:00:00Z"
    }
  ]
}

Each url is a short-lived signed URL. Diff checksum against your cache and download only what changed. Only finalized (or placeholder-fallback) versions appear.

GET /api/v1/sdk/asset/{key} — a single published asset (same auth, same entry shape).

To implement a client on another platform: call manifest, compare checksum per key, download changed urls, cache by key. Respect schema_version vs your build's supported version.

CLI / automation API (Bearer apt_… token)

Project-scoped machine token. Generated in the portal's CLI / API tokens panel. Header: Authorization: Bearer apt_….

Method & pathPurpose
GET /api/v1/cli/projectProject name, access code, asset count, schema version
POST /api/v1/cli/seedUpsert sections/groups/assets (+ optional schema_version/bump)
POST /api/v1/cli/uploadMultipart upload of a placeholder/asset file
POST /api/v1/cli/versionsRegister an uploaded file as a version
GET /api/v1/cli/bakeEvery asset's published version + status + signed URL
POST /api/v1/cli/schema-version{ bump: true } or { version: N }

App / web API (Auth0 session or Bearer access token)

Used by the web UI and the iOS companion app. Authenticated by the Auth0 session cookie (web) or Authorization: Bearer <Auth0 access token> (native). Requires AUTH0_AUDIENCE to be configured for Bearer.

Selected routes (all enforce membership/role):

  • GET /api/projects, GET /api/projects/{id}, GET /api/assets/{id}, GET /api/groups/{id} — JSON reads.
  • POST /api/projects — create a portal.
  • POST /api/projects/{id}/assets · /sections · /groups · /members · /tokens · /requests · /messages · /attachments.
  • POST /api/assets/{id}/upload · /versions · /messages · /share · /status (review actions) · PATCH/DELETE /api/assets/{id}.
  • POST /api/groups/{id}/messages, POST /api/requests/{id} (resolve/decline).
  • POST /api/devices — register an APNs device token.

Route handlers wrap logic in handler() and throw HttpError(status, msg); errors return { "error": "…" } with the status.

iOS companion app

A SwiftUI app (apps/ios) so developers and contractors can work from their phone: browse portals, read & post in conversations with photo/screenshot uploads, take review actions, file requests, and receive push notifications for new messages and asset activity.

Build it

brew install xcodegen
cd apps/ios
xcodegen generate        # → Molta.xcodeproj (gitignored; always generated)
open Molta.xcodeproj

Xcode resolves the Auth0.swift package automatically.

Configure

  1. Auth0 — Native application. Create one; note the Client ID + Domain. Add the callback/logout URLs (Auth0.swift's scheme). Create an Auth0 API whose identifier matches the portal's AUTH0_AUDIENCE.
  2. Config.swift — fill in portalBaseURL, auth0Domain, auth0ClientId, auth0Audience. Set the bundle id in project.yml (then re-run xcodegen).
  3. Push (APNs). Create an APNs key (.p8) in the Apple Developer portal and set the portal env vars APNS_KEY / APNS_KEY_ID / APNS_TEAM_ID / APNS_BUNDLE_ID / APNS_HOST. The app registers its device token on login.

How it authenticates

The app logs in with Auth0 natively and calls the portal API with the Auth0 access token as a Bearer header — the same routes the web UI uses. (The portal verifies the token against Auth0's JWKS; see AUTH0_AUDIENCE in Deployment.)

Push

The backend sends a push to the other project members whenever a message is posted (including upload/share/review activity, which post into the thread) or a request is filed. Push needs a real device (the Simulator can't receive APNs) and the APNS_* env configured; otherwise push is simply disabled.

Scope

Full collaboration loop: projects, conversations (project/asset/group) with image attachments, review actions (See in app / Done / request changes / reject), contractor upload + share, and requests. Member admin and CLI-token management remain web-only.

Deployment & environment

The portal is a Next.js 14 app that deploys to Vercel, backed by Auth0 and Supabase.

1. Supabase

  1. Create a Supabase project; copy the Project URL and service_role key.
  2. Create a private Storage bucket named assets.
  3. The schema is applied automatically on deploy (see Database migration below), or run supabase/schema.sql once in the SQL editor.

2. Auth0

  1. Create a Regular Web Application (for the web portal). Allowed Callback URL https://<app>/auth/callback, Allowed Logout URL https://<app>.
  2. For the iOS companion app, also create a Native application and an API (its identifier = AUTH0_AUDIENCE).

3. Vercel

  1. Import the repo; set Root Directory to apps/portal.
  2. Add the environment variables below.
  3. Deploy. The build runs vercel-buildscripts/migrate.mjsnext build.

Environment variables

VariableRequiredPurpose
AUTH0_DOMAINyesAuth0 tenant domain (bare host, no https://)
AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRETyesWeb app credentials
AUTH0_SECRETyesopenssl rand -hex 32
APP_BASE_URLyesCanonical URL, e.g. https://app.vercel.app
AUTH0_AUDIENCEfor iOS appAuth0 API identifier (enables Bearer auth)
SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEYyesSupabase project (server-only)
SUPABASE_ASSET_BUCKETnoStorage bucket name (default assets)
SUPABASE_DB_URLrecommendedDirect Postgres URI → enables build-time migration
TOKEN_HASH_PEPPERyesopenssl rand -hex 32 — pepper for API-token hashes
APNS_KEY / APNS_KEY_ID / APNS_TEAM_ID / APNS_BUNDLE_ID / APNS_HOSTfor pushAPNs credentials (push disabled if unset)

Database migration on deploy

If SUPABASE_DB_URL is set (a direct Postgres URI — port 5432, not the transaction pooler), the build applies supabase/schema.sql idempotently and ensures the storage bucket exists. Enum additions run as autocommit pre-migrations. If unset, apply the schema manually in the Supabase SQL editor.

Security notes

  • The Supabase service-role key is server-only; never import it into a client component. Authorization is enforced in app code (lib/access.ts).
  • API tokens are stored hashed (sha256 + pepper) and shown once.
  • The runtime SDK endpoint exposes only finalized/placeholder versions via short-lived signed URLs.

Data model

Postgres (Supabase). Identity is owned by Auth0; a mirror users row is keyed by the Auth0 sub. All access is server-side with the service-role key; authorization is enforced in app code. The source of truth is supabase/schema.sql.

users ──┐
        ├─< projects ──< sections
        │       │            │
        │       ├─────< asset_groups
        │       │            │
        │       ├──────< assets >── section, group
        │       │          │
        │       │          ├─< asset_versions   (current_version_id points here)
        │       │          └─< messages (asset thread)
        │       ├─< messages (group / project threads)
        │       ├─< requests
        │       ├─< project_members
        │       └─< api_tokens
        └─< device_tokens

Key tables

TableNotable columns
projectsaccess_code (6-digit), schema_version, owner_id
sectionsname, assignee_id
asset_groupsname, section_id
assetsasset_key (stable), type, status, requirements (jsonb), group_id, current_version_id
asset_versionsversion_number, status, checksum, is_placeholder, storage_path, metadata (jsonb)
messagesthread = group_id | asset_id | both null (project); kind (message/event), status, attachment_path
requeststitle, body, status (open/resolved/declined)
project_membersinvited_email, role (developer/contractor), status
api_tokenstoken_hash, prefix (CLI/M2M)
device_tokenstoken, platform (APNs push targets)

Enums

  • asset_type: image, video, audio, music, sound, level, model, font, text, data, other
  • asset_status: placeholder, in_progress, submitted, in_app, done, finalized, rejected
  • version_status: submitted, finalized, rejected
  • member_role: developer, contractor · member_status: invited, active, revoked
  • request_status: open, resolved, declined

Storage

Asset files and message attachments live in a private Supabase Storage bucket (assets by default). Paths: projects/{projectId}/assets/{assetId}/v{n}/{file} and messages/{projectId}/{uuid}.{ext}. The server serves them via short-lived signed URLs.