MiMo Docs
🌐
Technical User manual ← App

MiMo (Mind & Motion) β€” Technical Documentation

An aesthetic, airy single-page app built around a central Bubble that holds everything you think (Mind) and everything you move on (Motion).

For end users, see USER_MANUAL.md.

Keep this document (and USER_MANUAL.md) up to date whenever the app changes. If you add a feature, flag, endpoint, or setting, reflect it here in the same commit.


Tech Stack

Layer Technology
Backend Node.js 22+ (ESM), Express 4, built-in node:sqlite (SQLite)
Auth scrypt password hashing + HMAC-signed JWT (no external libs)
SSO openid-client (Authentik OIDC, PKCE support)
Frontend React 19 + Vite 7, custom CSS (no UI framework)
Runtime deps express, cors, dotenv, openid-client, react, react-dom
Dev deps vite, @vitejs/plugin-react, concurrently

There is no external database server β€” data lives in a single local SQLite file (.data/bubble.db) via Node's built-in node:sqlite.


Commands reference

Command What it does
npm run dev Run API (node --watch) + Vite dev server together.
npm run dev:server Run just the API with auto-reload.
npm run dev:web Run just Vite (--host to expose on the network).
npm run build Build the client into ./dist.
npm start Run the API in production mode (serves ./dist).
node scripts/generate-ambient.mjs Regenerate the built-in ambient sound files.
node scripts/generate-sfx.mjs Regenerate the built-in UI sound-effect files.
node scripts/admin.mjs … Manage registration + invite codes (see Administration).

Production

Deploying (with Docker)

A multi-stage Dockerfile is included (base image node:24-alpine β€” Node 22's bundled npm 10.9 has a npm ci bug, so the image uses Node 24/npm 11).

Simplest way β€” Docker Compose (a docker-compose.yml is included):

JWT_SECRET=$(openssl rand -hex 32) APP_URL=https://mimo.2kaya.me docker compose up -d --build

APP_URL is your public web URL (used for SSO redirects); JWT_SECRET signs the login tokens β€” set it once and keep it stable. The SQLite database persists in a named volume (mimo-data).

-d runs detached and --build rebuilds the image β€” always pass --build after a code change, otherwise Compose keeps serving the old image. Set the other secrets the same way (OIDC_*, and optionally ADMIN_USERNAME + ADMIN_PASSWORD[_HASH] to bootstrap an admin on first boot β€” see Admin accounts and .env.example).

Or run the image directly:

docker build -t noahs-bubble .
docker run -p 4000:4000 -v bubble-data:/app/.data noahs-bubble
  • App: http://localhost:4000
  • The named volume bubble-data persists the SQLite database across restarts.
  • Pass config via env vars, e.g. -e JWT_SECRET=... -e AUTHENTIK_ISSUER=....

Deploying (without Docker)

npm install
npm run build     # builds the client into ./dist
npm start         # serves ./dist + API on http://localhost:4000

On first run the server creates .data/ (SQLite DB + JWT secret).


Server Sizing

The app is a single Node.js process backed by SQLite (WAL mode) with stateless JWT auth and local file serving β€” which is exactly right for a personal/small deployment. Rough requirements for different scales:

Users vCPU RAM Disk Notes
10 1 512 MB 1 GB Any small VPS (~€5/mo). SQLite is instant.
100 1–2 1 GB 5–10 GB Still trivial. Add daily backups of .data/.
10 000 2–4 2–4 GB 20–50 GB SQLite is fine (read-heavy, WAL). Put nginx/Caddy + HTTPS in front, enable gzip, add monitoring.
1 000 000 8+ (Γ—N) 8 GB Γ—N object storage SQLite single-writer becomes the bottleneck β€” you'd need to move to PostgreSQL, run multiple app instances behind a load balancer (JWT is already stateless), serve sound/sfx from object storage + CDN, and add rate-limiting/Redis. This is a real refactor (DB abstraction + pooling), not a config change.

The step up to PostgreSQL is the main future change for very large scale; the schema and API are already simple enough to port. Until then, keep .data/ backed up and the single SQLite node behind a reverse proxy with HTTPS.


Serving the API for a mobile app

The backend is a plain HTTP API, so a future mobile client (e.g. an Android APK) can connect to it directly β€” the same API the web UI uses.

  • The Express server listens on all interfaces at :4000 (bind host is not restricted), so a phone/emulator on the same network can reach http://<server-ip>:4000/api/….
  • CORS is open (app.use(cors())), so requests from any origin are allowed.
  • Auth is token-based: call POST /api/auth/login (or /api/auth/register, or the SSO flow) to get a JWT, then send it as Authorization: Bearer <token>.
  • Run just the backend during development with npm run dev:server (auto-reloads), or npm start in production.

The mobile client can reuse every endpoint listed in API overview.


Configuration

Copy .env.example to .env. All values are optional except where noted.

Variable Purpose
PORT API port (default 4000).
DATA_DIR Where the SQLite DB lives (default .data/). Use a temp path when testing so real user data is untouched.
APP_URL Public base URL, used for SSO redirects (dev default http://localhost:5173).
JWT_SECRET Signing secret for login tokens. If unset, a random secret is persisted to .data/.jwt-secret.
OIDC_ISSUER OIDC provider discovery URL (enables SSO). AUTHENTIK_ISSUER works as an alias.
OIDC_CLIENT_ID OIDC client id (AUTHENTIK_CLIENT_ID alias).
OIDC_CLIENT_SECRET OIDC client secret β€” omit for a public client β†’ PKCE (AUTHENTIK_CLIENT_SECRET alias).
OIDC_REDIRECT_URI Optional; defaults to <APP_URL>/api/auth/sso/callback (AUTHENTIK_REDIRECT_URI alias).

Development

Quick start

Requirements: Node.js 22+ (for node:sqlite).

npm install
npm run dev

Dependencies

Runtime (dependencies)

  • express β€” HTTP server + routing + static serving.
  • cors β€” CORS headers for the dev server.
  • dotenv β€” loads .env.
  • openid-client β€” Authentik OIDC discovery + code flow.
  • react / react-dom β€” the client UI (bundled by Vite).

Development (devDependencies)

  • vite + @vitejs/plugin-react β€” build/dev server.
  • concurrently β€” run API + Vite together.

There is intentionally no ORM, no bcrypt, no jsonwebtoken: password hashing uses Node's crypto.scrypt, tokens are HMAC-SHA256-signed, and the database is Node's built-in node:sqlite. This keeps the runtime dependency footprint tiny.


Adding a translation

  1. Create a JSON file in server/locales/ named <code>.json, e.g. de.json:

    {
      "name": "Deutsch",
      "strings": { "app.title": "Blase", "app.tagline": "…" }
    }
    
  2. That's it β€” the language automatically appears in Profile β†’ Language. Files are served via GET /api/locales and GET /api/locales/:code.

Removing a file removes the language from the list. No code changes required.


Adding a background sound

  1. Drop an audio file (.wav, .mp3, .ogg, .m4a, .flac, .aac) into server/public/sound/.
  2. It automatically appears in Profile β†’ Ambient sound β†’ Sound (the filename minus extension becomes its label, e.g. ocean-waves.mp3 β†’ "Ocean Waves").

Built-in examples: aurora.wav, tide.wav, ember.wav (regenerate with node scripts/generate-ambient.mjs).


Adding a sound effect (UI sounds)

UI sounds (e.g. the "whoosh" when you drag the bubble) work the same way:

  1. Drop an audio file (.wav, .mp3, .ogg, .m4a, .flac, .aac) into server/public/sfx/.
  2. It automatically appears in Profile β†’ Ambient sound β†’ Sound effect, where each user can pick their preferred one.

Built-in examples: whoosh.wav, chime.wav, blip.wav (regenerate with node scripts/generate-sfx.mjs).


API overview

Public:

  • GET /api/config β€” sounds list, SSO enabled, step cap, registration open/closed.
  • GET /api/locales, GET /api/locales/:code β€” translation catalogs.
  • GET /api/health.
  • GET /api/charity β€” current month's charity + days left (placeholder; shown once per month).

Auth:

  • POST /api/auth/register { username, password, name?, inviteCode? }
  • POST /api/auth/login { username, password }
  • GET /api/auth/me, POST /api/auth/password
  • GET /api/auth/sso/login, GET /api/auth/sso/callback

Authenticated (Authorization: Bearer <token>), scoped to the logged-in user:

  • GET/PUT /api/profile
  • GET/POST /api/priorities, PUT/DELETE /api/priorities/:id
  • GET/POST /api/categories, PUT/DELETE /api/categories/:id (task tags, max 5)
  • GET/POST /api/waves, PUT/DELETE /api/waves/:id (?archived=1)
  • GET/POST /api/steps, PUT/DELETE /api/steps/:id (steps include tasks)
  • POST /api/tasks, PUT/DELETE /api/tasks/:id (priority_id, category_id)
  • GET /api/archive (merged archived waves + tasks)
  • GET /api/insights (all daily insights), GET /api/insights/today?date=YYYY-MM-DD, POST /api/insights { date, answer, elaboration }
  • GET/POST /api/suggestions (app feedback; POST body { title, category, type, description } β€” type is feature or bug, category is a slug from /api/forum/meta)
  • GET/POST /api/friends, PUT/DELETE /api/friends/:id (add by { code }, hide bubble, remove)
  • GET /api/friends/:id/peek (read-only peek at a mutual friend's bubble)

Forum (authenticated β€” login required):

  • GET /api/forum/meta β€” category + type lists (translated to the user's language).
  • GET /api/forum/suggestions?page=1&limit=10 β€” paginated posts, ordered pinned β†’ most-liked β†’ newest; includes likes, comments, liked_by_me.
  • POST /api/forum/suggestions { title, category, type, description } β€” new post.
  • GET /api/forum/suggestions/:id β€” one post + its comments.
  • POST /api/forum/suggestions/:id/like β€” toggle a like.
  • POST /api/forum/suggestions/:id/comments { body } β€” add a comment.
  • PATCH /api/forum/suggestions/:id { pinned } β€” admin only β€” pin/unpin.

Admin (requires an is_admin user β€” see Admin accounts):

  • GET /api/admin/users
  • GET /api/admin/invites, POST /api/admin/invites { count }
  • DELETE /api/admin/invites/:code

Daily insight questions

The questions live in server/insights.js β€” add new ones to the INSIGHT_QUESTIONS array there. When more than one exists, questionFor(userId, date) picks one deterministically per user + day (effectively random across days/users, but stable for the whole day). Each user is asked once per local day at their insight_hour (set in Profile β†’ Daily insight).


Project layout

server/            Express API (ESM)
  index.js         app entry + static serving
  db.js            SQLite schema + additive migrations
  auth.js          scrypt hashing + HMAC-signed tokens
  admin.js         registration gate + invite-code helpers
  oidc.js          Authentik OIDC
  routes.js        profile / priorities / waves / steps / tasks / archive / insights
  insights.js      daily insight questions
  charity.js       monthly charity (placeholder)
  sounds.js        scans server/public/sound/
  i18n.js          scans server/locales/
  locales/         translation files (<code>.json)
  public/sound/    ambient audio files
client/            React + Vite frontend
  src/
    context.jsx    auth + data store
    fonts.js       available display fonts
    sfx.js         Web-Audio sound effects
    components/    Background, Bubble, Stage, WaveNode/Editor/Create,
                   StepColumn/Expanded, TaskCard/Expanded, Archive, Profile, …
scripts/           generate-ambient.mjs, generate-sfx.mjs, admin.mjs (CLI)

Development advice

  • Keep both READMEs updated in the same commit as any behaviour change.
  • Never drop columns/tables in a migration β€” add new ones with safe defaults; existing user data must always survive an upgrade.
  • The frontend is desktop-first but responsive on phones (stacked layout).
  • Spatial drag/pan features live in Stage.jsx/Bubble.jsx; the "drag the bubble to focus Mind/Motion" experiment is intentionally isolated so it can be reverted independently.

Security

Production security

Sessions are HMAC-signed tokens. The signing key is a random secret that is generated once and persisted to .data/.jwt-secret, so logins stay valid across restarts (no re-login after every deploy).

Before exposing the app publicly:

  1. Set JWT_SECRET to a long random value (e.g. openssl rand -hex 32). If unset, the server still works but logs a warning in production mode and relies on the persisted secret β€” set it explicitly so the signing key is deliberate and stable.
  2. Serve over HTTPS. Put the app behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS; the JWT travels in the browser's localStorage and should never cross the network in cleartext.
  3. Run with NODE_ENV=production so the server emits the missing-secret warning and any production-only checks.
  4. Close registration once your users exist, and hand out invite codes instead β€” see Administration. Keep JWT_SECRET and the .data/ directory out of version control.

Single sign-on (OIDC)

The app uses openid-client, so any OIDC-compliant provider works: Authentik, Google, Codeberg, Keycloak, Auth0, Zitadel, and others.

  1. Create an OIDC application in your provider with:

    • Redirect / callback URL: <APP_URL>/api/auth/sso/callback
    • Scopes: openid profile email
    • A confidential client (with a secret), or a public client (no secret β†’ PKCE).
  2. Set in .env:

    OIDC_ISSUER=https://accounts.google.com
    OIDC_CLIENT_ID=…
    OIDC_CLIENT_SECRET=…
    # OIDC_REDIRECT_URI defaults to <APP_URL>/api/auth/sso/callback
    

Common issuer (discovery) URLs:

Provider OIDC_ISSUER
Authentik https://authentik.example/application/o/bubble/
Google https://accounts.google.com
Codeberg (Forgejo) https://codeberg.org
Keycloak https://keycloak.example/realms/myrealm
Auth0 https://YOUR_DOMAIN.auth0.com
Zitadel https://zitadel.example

SSO users are matched by their sub; first sign-in creates a local account automatically. The old AUTHENTIK_* variable names still work as aliases.

/api/auth/sso/login accepts an optional ?redirect=<url> (http(s) or a custom app scheme such as mimo://) so a desktop wrapper can receive the final ?token=… β€” otherwise it defaults to APP_URL (the web app).


Administration

Registration is managed from the command line with the admin tool (node scripts/admin.mjs …, run from the project root β€” no server required, it talks to the SQLite database directly):

There's a shorter wrapper: ./admin-manager.sh … β€” e.g. ./admin-manager.sh make-admin admin --hash '<hash>' or ./admin-manager.sh status. It's also copied into the Docker image, so in production use docker compose exec mimo ./admin-manager.sh ….

Command What it does
node scripts/admin.mjs status Show whether registration is open/closed and invite-code usage.
node scripts/admin.mjs registration on Allow anyone to sign up (default).
node scripts/admin.mjs registration off Require a valid invite code to sign up.
node scripts/admin.mjs invite [count] Generate count (default 1) invite codes.
node scripts/admin.mjs invites List all invite codes and whether each is used.
node scripts/admin.mjs revoke <code> Delete an invite code (unused or used).
node scripts/admin.mjs hash-password Print a scrypt salt:hash (reads the password from stdin).
node scripts/admin.mjs make-admin <user> [pw] Grant admin rights; creates the user if a password is given.
node scripts/admin.mjs make-admin <user> --hash <salt:hash> Grant admin using a pre-computed hash (no plaintext).
node scripts/admin.mjs users [--json] Per-user analytics: last login, waves, steps, tasks.
node scripts/admin.mjs stats [--json] Aggregate stats: medians/means (tasks per step, etc.).

When registration is closed (the settings table row registration_enabled is '0'), the sign-up form shows an extra Invite code field. A code is:

  • a 24-character URL-safe string (e.g. xYz…), generated with crypto.randomBytes(18) and stored as a hashable value in invite_codes;
  • single-use β€” consuming it stamps used_at/used_by and it cannot be used again;
  • revocable at any time with revoke, even before use.

The API reflects the current state in GET /api/config (registrationEnabled), so the client knows whether to render the invite field. Codes are checked server-side on POST /api/auth/register (inviteCode); a closed registration rejects sign-ups with 403 unless the code is valid and unused.

SSO is independent of this gate: registration off only blocks password-based sign-up, not OIDC first sign-in.

Admin accounts

Admin rights are a flag on the user row (users.is_admin, added by an additive migration). Two ways to grant it:

  • CLI (no server required): node scripts/admin.mjs make-admin <username> [password] β€” promotes an existing account, or creates a fresh one when a password (6+ chars) is given.

  • CLI, no plaintext in shell history: generate a hash first, then hand it to make-admin:

    printf 'your-password' | node scripts/admin.mjs hash-password   # prints salt:hash
    node scripts/admin.mjs make-admin <username> --hash '<salt:hash>'
    

    To use a randomly generated password, print it once so you can save it (piping openssl straight into hash-password would create an admin whose password you never see and can't log in with):

    # Generate a random password, show it once (copy & save it), and hash it:
    PW="$(openssl rand -base64 32)"; echo "$PW"; printf '%s' "$PW" | node scripts/admin.mjs hash-password
    # …then create the admin with the printed hash:
    node scripts/admin.mjs make-admin admin --hash '<salt:hash>'
    

    The hash cannot be reversed, so it's safe to paste into the shell/make-admin; only the plaintext (the echo "$PW" line) must be kept secret.

  • docker-compose / .env: set ADMIN_USERNAME plus either ADMIN_PASSWORD (plaintext) or ADMIN_PASSWORD_HASH (pre-computed salt:hash) and the server creates/promotes that account on startup (see .env.example).

Changing an admin's password: make-admin creates the user if it doesn't exist; if it does exist, passing a password/--hash also updates it, so the same command doubles as an admin password reset:

PW="$(openssl rand -base64 32)"; echo "$PW"; printf '%s' "$PW" | node scripts/admin.mjs hash-password
node scripts/admin.mjs make-admin admin --hash '<salt:hash>'   # existing admin β†’ password updated

Admins can also change their own password in-app (Profile β†’ password).

The admin view is a section of the landing page (MiMo-Front) that appears only after an admin signs in: create/revoke invite codes and list users. The client only hides the button for non-admins β€” every admin endpoint is enforced server-side by requireAdmin (valid JWT and is_admin), so no client can bypass the gate.

Admin API (auth + admin only)

These live under /api/admin/* and are rejected with 403 for non-admins:

  • GET /api/admin/users β€” per-user analytics (getUsersAnalytics), incl. admin and blocked.
  • PATCH /api/admin/users/:id { admin?, blocked? } β€” change a user's role (admin on/off) or block/unblock them. Admins cannot modify themselves.
  • DELETE /api/admin/users/:id β€” delete a user + all their data (cascades). Admins cannot delete themselves.
  • GET /api/admin/invites β€” list invite codes + used/created state.
  • POST /api/admin/invites { count } β€” generate 1–100 invite codes.
  • DELETE /api/admin/invites/:code β€” revoke an invite code.

Blocked users are rejected by requireAuth (and password login) with 403, so blocking takes effect immediately, even for already-issued tokens.

Analytics (read-only CLI)

The users and stats commands are read-only and are exported from server/admin.js (getUsersAnalytics, getDashboardStats). The aggregate stats medians/means are deliberately not exposed over HTTP β€” only the per-user list and invite management are available to admins (above), so the aggregate dashboard numbers stay out of the user-facing MiMo server.

  • users lists every account with last_login_at, active/archived waves, current vs total steps, active/done/archived tasks, priorities, SSO flag, and the current wave (the most recently created active wave). --json also includes the full steps_current names and the current wave's title/type.
  • stats aggregates across the whole database: user growth (7d/30d), SSO vs password, inactive accounts, waves/steps/tasks medians & means per user, task completion rate, median tasks per step, and invite-code usage.

last_login_at is stamped on every successful login (password, sign-up, or SSO) via touchLastLogin in server/auth.js; the column is added by an additive migration, so existing accounts simply start with NULL until their next login.


Database

  • File: .data/bubble.db (gitignored), plus a generated JWT secret at .data/.jwt-secret (when JWT_SECRET is unset).
  • Engine: SQLite via Node's built-in node:sqlite (WAL mode, foreign keys on).

How the SQLite file works

bubble.db is a single SQLite database file. Because WAL mode is enabled you'll also see bubble.db-wal and bubble.db-shm next to it β€” those are the write-ahead log and shared-memory index used while the server runs; on a clean shutdown they're folded back into bubble.db. All user content lives in these files, so:

  • Restarting the server (or rebuilding the app) keeps everything, as long as the files aren't deleted.
  • .data/ is in .gitignore and .dockerignore, so it's never committed or baked into the Docker image β€” the app never ships with your data.
  • In Docker, mount a volume at /app/.data to persist across container rebuilds.

To test persistence in development: create some waves/tasks, stop npm run dev, start it again β€” everything is still there. To keep a throwaway test database separate from real data, set DATA_DIR=/tmp/....

Schema

Terminology: the user-facing terms are Spaces (the columns) and Steps (the items inside them). In the database these are the steps and tasks tables respectively β€” the table/column names below are the code names.

users (
  id, username (unique), email, password_hash, name, age, pronouns, wishes,
  language, sound_muted, sfx_muted, sound_id, sfx_id, font_id, steps_mode,
  authentik_sub, created_at, last_login_at (nullable), insight_hour (0-23, default 9),
  friend_code (unique, generated)
)
priorities ( id, user_id β†’ users, name, color, position )
categories ( id, user_id β†’ users, name, color, position )  -- step tags (max 5)
waves (
  id, user_id β†’ users, title, description,
  type ('bullet'|'checklist'|'range'), content (JSON), priority_id β†’ priorities,
  archived, archived_at, position, created_at
)
steps ( id, user_id β†’ users, name, position, workflow (0=custom, 1=intended) )  -- "Spaces"
tasks (  -- "Steps"
  id, user_id β†’ users, step_id β†’ steps (nullable once archived), title,
  type ('task'|'checklist'), subtasks (JSON), priority_id β†’ priorities,
  category_id β†’ categories (nullable), completed, archived, archived_at,
  step_label, position, created_at
)
insights (
  id, user_id β†’ users, date ('YYYY-MM-DD'), question, answer (1-2 words),
  elaboration, answered_at; unique (user_id, date)
)
suggestions ( id, user_id β†’ users, title, category, description, created_at )
friendships ( id, user_id β†’ users, friend_id β†’ users, hidden, created_at, unique(user_id, friend_id) )

Content shapes:

  • wave bullet β†’ { items: string[] }
  • wave checklist β†’ { items: [{ text, done }] }
  • wave range β†’ { min, max, value, history: [{ date, value, max }] }
  • task subtasks β†’ [{ text, done }]

Migrations & data persistence

All migrations are additive. Schema changes run in server/db.js:

  • CREATE TABLE IF NOT EXISTS for fresh installs.
  • ALTER TABLE ADD COLUMN (guarded by PRAGMA table_info) for existing DBs.
  • A one-time PRAGMA user_version flip keeps legacy defaults consistent.

Never delete .data/. It holds all user content. Deployments must keep the database file (in Docker, the mounted bubble-data volume) intact across upgrades β€” additive migrations mean old data keeps working with new code.