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).
-druns detached and--buildrebuilds the image β always pass--buildafter a code change, otherwise Compose keeps serving the old image. Set the other secrets the same way (OIDC_*, and optionallyADMIN_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-datapersists 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 reachhttp://<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 asAuthorization: Bearer <token>. - Run just the backend during development with
npm run dev:server(auto-reloads), ornpm startin 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
- Frontend (Vite): http://localhost:5173 (proxies
/api,/sound,/sfx,/docsto :4000) - API (Express): http://localhost:4000
- Docs: http://localhost:4000/docs (also reachable from the ? icon under the sound toggle in the app)
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
Create a JSON file in
server/locales/named<code>.json, e.g.de.json:{ "name": "Deutsch", "strings": { "app.title": "Blase", "app.tagline": "β¦" } }That's it β the language automatically appears in Profile β Language. Files are served via
GET /api/localesandGET /api/locales/:code.
Removing a file removes the language from the list. No code changes required.
Adding a background sound
- Drop an audio file (
.wav,.mp3,.ogg,.m4a,.flac,.aac) intoserver/public/sound/. - 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:
- Drop an audio file (
.wav,.mp3,.ogg,.m4a,.flac,.aac) intoserver/public/sfx/. - 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/passwordGET /api/auth/sso/login,GET /api/auth/sso/callback
Authenticated (Authorization: Bearer <token>), scoped to the logged-in user:
GET/PUT /api/profileGET/POST /api/priorities,PUT/DELETE /api/priorities/:idGET/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;POSTbody{ title, category, type, description }βtypeisfeatureorbug,categoryis 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; includeslikes,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/usersGET /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:
- Set
JWT_SECRETto 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. - 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.
- Run with
NODE_ENV=productionso the server emits the missing-secret warning and any production-only checks. - Close registration once your users exist, and hand out invite codes
instead β see Administration. Keep
JWT_SECRETand 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.
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).
- Redirect / callback URL:
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/ |
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 usedocker 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 withcrypto.randomBytes(18)and stored as a hashable value ininvite_codes; - single-use β consuming it stamps
used_at/used_byand 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 offonly 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
opensslstraight intohash-passwordwould 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 (theecho "$PW"line) must be kept secret.docker-compose /
.env: setADMIN_USERNAMEplus eitherADMIN_PASSWORD(plaintext) orADMIN_PASSWORD_HASH(pre-computedsalt: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.adminandblocked.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.
userslists every account withlast_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).--jsonalso includes the fullsteps_currentnames and the current wave's title/type.statsaggregates 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(whenJWT_SECRETis 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.gitignoreand.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/.datato 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
stepsandtaskstables 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 EXISTSfor fresh installs.ALTER TABLE ADD COLUMN(guarded byPRAGMA table_info) for existing DBs.- A one-time
PRAGMA user_versionflip keeps legacy defaults consistent.
Never delete
.data/. It holds all user content. Deployments must keep the database file (in Docker, the mountedbubble-datavolume) intact across upgrades β additive migrations mean old data keeps working with new code.