Blame
|
1 | --- |
||||||
| 2 | category: reference |
|||||||
| 3 | tags: |
|||||||
| 4 | - phase-2 |
|||||||
| 5 | - username |
|||||||
| 6 | - routing |
|||||||
| 7 | last_updated: 2026-03-14 |
|||||||
| 8 | --- |
|||||||
| 9 | ||||||||
| 10 | # P2-2/3: Username-Based URLs for Multi-Tenant Routing |
|||||||
| 11 | ||||||||
| 12 | ## Summary |
|||||||
| 13 | ||||||||
| 14 | Added username field to the Users table and updated the resolver to route by `{username}.wikibot.io` instead of `{user_id}.wikibot.io`. Internal storage paths (EFS, wiki_id, ACL keys) remain UUID-based. |
|||||||
| 15 | ||||||||
| 16 | ## Changes |
|||||||
| 17 | ||||||||
| 18 | ### Infrastructure (`infra/components/dynamodb.py`) |
|||||||
| 19 | - Added `username` attribute (type S) to Users table |
|||||||
| 20 | - Added `username-index` GSI (hash_key: `username`, projection: ALL) |
|||||||
| 21 | ||||||||
| 22 | ### User Model (`app/models/user.py`) |
|||||||
| 23 | - `RESERVED_USERNAMES` — 18 reserved names (admin, www, api, dev, mcp, wiki, etc.) |
|||||||
| 24 | - `validate_username(username)` — lowercase alphanumeric + hyphens, 3-30 chars, no leading/trailing hyphens; returns `(valid, error)` |
|||||||
| 25 | - `get_by_username(username)` — GSI query on `username-index` |
|||||||
| 26 | - `set_username(user_id, username)` — validates format, checks reserved list, checks uniqueness via GSI, then updates |
|||||||
| 27 | ||||||||
| 28 | ### Auth Middleware (`app/auth/middleware.py`) |
|||||||
| 29 | - `exchange_auth_code()` return dict now includes `needs_username: bool` (true when user has no username set) |
|||||||
| 30 | ||||||||
| 31 | ### Management API (`app/management/routes.py`) |
|||||||
| 32 | - New endpoint: `POST /admin/username` with body `{"username": "..."}` |
|||||||
| 33 | - Returns 200 on success, 400 for invalid/reserved, 409 for taken |
|||||||
| 34 | - Normalizes input to lowercase before validation |
|||||||
| 35 | ||||||||
| 36 | ### CLI (`app/cli/main.py`) |
|||||||
| 37 | - After `wikibot login`, if `needs_username` is true, prompts user for username and calls `POST /admin/username` |
|||||||
| 38 | - `_set_username()` helper handles the API call and output |
|||||||
| 39 | ||||||||
| 40 | ### Resolver (`app/otterwiki/resolver.py`) |
|||||||
| 41 | - `_parse_host()` now extracts username (not user_id) from subdomain — docstrings updated |
|||||||
| 42 | - `__call__()` uses `get_by_username(username)` instead of `get(user_id)` |
|||||||
| 43 | - Extracts `user_id` from the looked-up user record for downstream UUID-based paths |
|||||||
| 44 | - 404 if username not found |
|||||||
| 45 | ||||||||
| 46 | ## Test Coverage |
|||||||
| 47 | ||||||||
| 48 | - **Username validation**: valid formats, too short/long, leading/trailing hyphens, uppercase, special chars, reserved names, empty |
|||||||
| 49 | - **GSI lookup**: `get_by_username` found and not found |
|||||||
| 50 | - **set_username**: success, invalid format, reserved, duplicate, idempotent |
|||||||
| 51 | - **POST /admin/username**: success, invalid, reserved, taken, unauthenticated, empty, case normalization |
|||||||
| 52 | - **needs_username**: new user gets `true`, existing user with username gets `false` |
|||||||
| 53 | - **Resolver**: all existing tests updated to use username subdomain; new test verifying UUID-based internal paths |
|||||||
| 54 | ||||||||
| 55 | All 230 runnable tests pass. 15 pre-existing `fastmcp` import failures unrelated. |
|||||||
| 56 | ||||||||
| 57 | ## Branch |
|||||||
| 58 | ||||||||
| 59 | Committed on `worktree-agent-a245f93f` (commit `8d37f26`), based on `phase-2`. |
|||||||