Properties
category: spec
tags: [architecture, resolver, multi-tenant, auth]
last_updated: 2026-03-17
confidence: high

Resolver (TenantResolver)

The resolver is the WSGI middleware that makes robot.wtf multi-tenant. Every request to a wiki subdomain passes through it. It lives in app/resolver.py.

What it does, in order

For every HTTP request to {slug}.robot.wtf:

  1. Parse host → extract wiki slug from subdomain
  2. Look up wiki → query robot.db wikis table by slug
  3. Swap storage → patch otterwiki's module-level storage singleton in every module that imported it (yes, really — otterwiki uses module globals, not dependency injection)
  4. Swap database → replace SQLAlchemy engine with the per-wiki wiki.db, reload preferences via update_app_config()
  5. Authenticate → JWT cookie, bearer token, or anonymous
  6. Derive permissions → owner gets ADMIN; per-wiki user table flags derive READ/WRITE/UPLOAD; fallback is READ for authenticated users
  7. Apply access restrictions → per-wiki READ_ACCESS/WRITE_ACCESS/ATTACHMENT_ACCESS preferences can strip permissions (ANONYMOUS/REGISTERED/APPROVED levels)
  8. Inject proxy headersx-otterwiki-email, x-otterwiki-name, x-otterwiki-permissions
  9. Delegate → pass modified environ to the wrapped otterwiki WSGI app

Why it's complex

Otterwiki was designed as a single-tenant app. It uses module-level globals for storage and a single SQLAlchemy database. The resolver makes it multi-tenant by swapping these globals on every request. This is inherently fragile and is the source of most platform bugs.

The storage swap problem

Otterwiki's storage (a GitStorage instance) is imported by value into ~8 modules at import time. Swapping the module-level variable in otterwiki.server doesn't affect the copies in otterwiki.wiki, otterwiki.helper, etc. So the resolver patches all of them:

otterwiki.server.storage = storage
otterwiki.wiki.storage = storage
otterwiki.helper.storage = storage
# ... 5 more modules, plus plugin state dicts

If a new otterwiki module imports storage and we don't patch it, that module sees the wrong wiki's data.

The database swap problem

SQLAlchemy's Flask extension (flask_sqlalchemy) creates an engine at app init time and caches it. The resolver reaches into db._app_engines to swap the engine directly. This is a private API that could break on any Flask-SQLAlchemy upgrade.

After swapping the engine, the resolver calls otterwiki.server.update_app_config() which does SELECT * FROM preferences and writes each row into app.config. This is how per-wiki preferences (READ_ACCESS, SITE_NAME, SITE_ICON, etc.) take effect.

The multi-worker problem

With gunicorn's preload_app = True and multiple workers, each worker has its own copy of app.config. When one worker handles a preference change (e.g., user changes READ_ACCESS via admin UI), only that worker's app.config is updated. Other workers see stale values until they call update_app_config().

Fix (2026-03-17): The fast path in _swap_database() now calls update_app_config() even when the engine URL already matches. This means every request does a SELECT * FROM preferences — a small SQLite read that's fast under WAL mode but worth noting as a performance characteristic.

The default database problem

Otterwiki creates a default SQLAlchemy database at startup (before any request). The settings.cfg used to point this at /tmp/otterwiki_default.db, but Otterwiki's own default is sqlite:///:memory:. The problem: update_app_config() runs at startup against this default DB, and any preference rows in it overwrite settings.cfg values. This is why SITE_ICON set in settings.cfg was silently overridden to empty string.

Fix (2026-03-17): Removed the SQLALCHEMY_DATABASE_URI override from settings.cfg, letting Otterwiki use its in-memory default. All platform preferences (SITE_ICON, SITE_LOGO, access levels, etc.) are seeded into per-wiki DBs via _init_wiki_db(), where update_app_config() actually reads them.

Rule: Never use settings.cfg for preferences that update_app_config() manages. The DB always wins. See also the memory entry feedback_otterwiki_config_override.md.

Key data structures

Per-wiki SQLite DB (/srv/data/wikis/{slug}/wiki.db)

Seeded by _init_wiki_db() with INSERT OR IGNORE (idempotent, never overwrites user changes):

Preference Default Purpose
READ_ACCESS REGISTERED Who can read (ANONYMOUS/REGISTERED/APPROVED)
WRITE_ACCESS REGISTERED Who can write
ATTACHMENT_ACCESS REGISTERED Who can upload
AUTH_METHOD PROXY_HEADER Always proxy header in platform mode
DISABLE_REGISTRATION True Platform handles registration
AUTO_APPROVAL False Safety net
SITE_ICON https://robot.wtf/static/robot.wtf.svg Default favicon
SITE_LOGO https://robot.wtf/static/robot.wtf.svg Default nav icon

Also seeds the wiki owner into the user table as admin.

Auth result dict

Returned by _resolve_auth(), consumed by the middleware:

{
    "proxy_headers": {"x-otterwiki-email": ..., "x-otterwiki-name": ..., "x-otterwiki-permissions": ...},
    "is_authenticated": bool,
    "is_bearer_token": bool,
    "per_wiki_user": {"is_admin": bool, "is_approved": bool, "allow_read": bool, ...} | None,
}

Auth paths

Credential Path Permissions
JWT (Authorization header) _resolve_jwt() Owner → ADMIN; per-wiki user flags; fallback READ
JWT (cookie) authenticate_from_cookie() Same as above
Bearer token (opaque) _resolve_bearer_token() Editor role (READ+WRITE+UPLOAD), wiki-scoped
Internal API key Direct match ADMIN (MCP sidecar → REST API)
Anonymous No credentials READ, subject to access restrictions

Bearer tokens bypass per-wiki access restrictions (they're already scoped to a specific wiki by the token itself).

Access restriction flow

After permissions are derived from auth, they're filtered by _apply_wiki_access_restrictions():

  • ANONYMOUS → no filter
  • REGISTERED → unauthenticated users lose READ/WRITE/UPLOAD
  • APPROVED → unauthenticated OR non-approved users lose READ/WRITE/UPLOAD
  • ADMIN → never stripped

If an unauthenticated browser request has no remaining read permissions and READ_ACCESS != ANONYMOUS, the resolver redirects to /auth/login?return_to={original_url}.

Known limitations

  1. Module patching is fragile. Any new otterwiki module that imports storage at the top level needs to be added to _swap_storage().
  2. db._app_engines is a private API. Flask-SQLAlchemy updates could break the engine swap.
  3. One SELECT * per request for preferences reload. Acceptable for current scale but would need caching for high throughput.
  4. Disk quota enforcement is incomplete. disk_usage_bytes is always 0 (the wiki stats plugin isn't implemented yet), so quota checks are effectively dead code.