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:
- Parse host → extract wiki slug from subdomain
- Look up wiki → query
robot.dbwikis table by slug - Swap storage → patch otterwiki's module-level
storagesingleton in every module that imported it (yes, really — otterwiki uses module globals, not dependency injection) - Swap database → replace SQLAlchemy engine with the per-wiki
wiki.db, reload preferences viaupdate_app_config() - Authenticate → JWT cookie, bearer token, or anonymous
- Derive permissions → owner gets ADMIN; per-wiki user table flags derive READ/WRITE/UPLOAD; fallback is READ for authenticated users
- Apply access restrictions → per-wiki READ_ACCESS/WRITE_ACCESS/ATTACHMENT_ACCESS preferences can strip permissions (ANONYMOUS/REGISTERED/APPROVED levels)
- Inject proxy headers →
x-otterwiki-email,x-otterwiki-name,x-otterwiki-permissions - Delegate → pass modified
environto 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
- Module patching is fragile. Any new otterwiki module that imports
storageat the top level needs to be added to_swap_storage(). db._app_enginesis a private API. Flask-SQLAlchemy updates could break the engine swap.- One
SELECT *per request for preferences reload. Acceptable for current scale but would need caching for high throughput. - Disk quota enforcement is incomplete.
disk_usage_bytesis always 0 (the wiki stats plugin isn't implemented yet), so quota checks are effectively dead code.
Related pages
- Design/Auth — Auth architecture (superseded sections, but ATProto OAuth is current)
- Design/VPS_Architecture — Overall platform architecture
- Design/Admin_Panel_Reenablement — Per-wiki admin UI, which the resolver enables
- Plans/Disk_Usage_Caps — Planned fix for the quota dead code
