# Molta — full documentation Collaboration platform for game/app developers and remote contractors. ## 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 | Component | What it is | | --- | --- | | **Web portal** | Next.js 14 (App Router) app on Vercel. Auth0 login. The collaboration UI + all APIs. | | **Database & storage** | Supabase Postgres (metadata/workflow) + Supabase Storage (asset files, private bucket, signed URLs). | | **Runtime SDK** | `MoltaKit` — a Swift package the *game* embeds to pull finalized assets. Other platforms speak the same HTTP contract. | | **CLI** | `molta` — a zero-dependency Node CLI to seed assets, bake production builds, and manage schema versions. | | **Companion app** | A 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 ``` | Status | Meaning | | --- | --- | | `placeholder` | Developer seeded a stand-in; awaiting the contractor's first upload. | | `in_progress` | Contractor has uploaded a **draft** (not yet shared) or changes were requested. | | `submitted` | Contractor **shared** the work; waiting on developer review. | | `in_app` | Developer published the version live for **preview** ("See in app"). | | `done` | Developer **signed off**; the contractor is released. | | `rejected` | Developer 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 tokens** → **Generate**. Copy the `apt_…` value (shown once). ### 3. Connect the CLI and seed assets ```bash 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: ```swift 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: ```bash 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 assets** — *Add 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 ```bash npm install -g molta # or run directly: node packages/cli/bin/molta.js 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 | Command | Purpose | | --- | --- | | `login --url --token ` | Validate + save credentials | | `whoami` | Show the target portal, access code, asset count, schema version | | `seed [--dir ] [--dry-run] [--bump \| --schema-version ]` | Upsert sections/groups/assets, upload placeholders, optionally bump version | | `push --key --file

[--final]` | Upload a single asset version | | `bake [--out

] [--require-final]` | Download published assets to bundle in a production build | | `status` | Production-readiness report (all assets done? schema version) | | `bump-version [--to ]` | Bump (or set) the asset schema version | ### Examples ```bash # 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 ` applies it idempotently. ### Full schema ```jsonc { // 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 | Field | How | | --- | --- | | `key` | lower_snake_case, stable, unique. Pick carefully — never renamed. | | `type` | from usage/extension: `image`, `sound` (short SFX), `music` (loops), `level`, `model`, `font`, … | | `section` / `group` | logical grouping; group frames/atlases that ship together. | | `description` | what a contractor should make — subject, style, references, in-game use. | | `requirements` | concrete specs: image `width`/`height`/`format`/`max_kb`; audio `duration_sec`/`loop`/`sample_rate`/`channels`; video `fps`. | | `placeholder` | path 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 ```swift 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: | Mode | `sync()` behaviour | | --- | --- | | `.development` | Downloads the latest published assets from the portal. | | `.production` | Loads 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//`; 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 ```bash 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: ```swift #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: ```swift 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) ```bash 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) ```swift let portal = MoltaClient(baseURL: url, accessCode: "428193", supportedSchemaVersion: 3) // what THIS build supports ``` ### Handling the error ```swift 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: ```json { "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 `url`s, 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 & path | Purpose | | --- | --- | | `GET /api/v1/cli/project` | Project name, access code, asset count, schema version | | `POST /api/v1/cli/seed` | Upsert sections/groups/assets (+ optional `schema_version`/`bump`) | | `POST /api/v1/cli/upload` | Multipart upload of a placeholder/asset file | | `POST /api/v1/cli/versions` | Register an uploaded file as a version | | `GET /api/v1/cli/bake` | Every 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 ` (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 ```bash 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:///auth/callback`, Allowed Logout URL `https://`. 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-build` → `scripts/migrate.mjs` → `next build`. ### Environment variables | Variable | Required | Purpose | | --- | --- | --- | | `AUTH0_DOMAIN` | yes | Auth0 tenant domain (bare host, no `https://`) | | `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET` | yes | Web app credentials | | `AUTH0_SECRET` | yes | `openssl rand -hex 32` | | `APP_BASE_URL` | yes | Canonical URL, e.g. `https://app.vercel.app` | | `AUTH0_AUDIENCE` | for iOS app | Auth0 API identifier (enables Bearer auth) | | `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` | yes | Supabase project (server-only) | | `SUPABASE_ASSET_BUCKET` | no | Storage bucket name (default `assets`) | | `SUPABASE_DB_URL` | recommended | Direct Postgres URI → enables build-time migration | | `TOKEN_HASH_PEPPER` | yes | `openssl rand -hex 32` — pepper for API-token hashes | | `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / `APNS_HOST` | for push | APNs 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 | Table | Notable columns | | --- | --- | | `projects` | `access_code` (6-digit), `schema_version`, `owner_id` | | `sections` | `name`, `assignee_id` | | `asset_groups` | `name`, `section_id` | | `assets` | `asset_key` (stable), `type`, `status`, `requirements` (jsonb), `group_id`, `current_version_id` | | `asset_versions` | `version_number`, `status`, `checksum`, `is_placeholder`, `storage_path`, `metadata` (jsonb) | | `messages` | thread = `group_id` \| `asset_id` \| both null (project); `kind` (message/event), `status`, `attachment_path` | | `requests` | `title`, `body`, `status` (open/resolved/declined) | | `project_members` | `invited_email`, `role` (developer/contractor), `status` | | `api_tokens` | `token_hash`, `prefix` (CLI/M2M) | | `device_tokens` | `token`, `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.