Properties
category: reference
tags:
  - phase-2
  - username
  - routing
last_updated: 2026-03-14

P2-2/3: Username-Based URLs for Multi-Tenant Routing

Summary

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.

Changes

Infrastructure (infra/components/dynamodb.py)

  • Added username attribute (type S) to Users table
  • Added username-index GSI (hash_key: username, projection: ALL)

User Model (app/models/user.py)

  • RESERVED_USERNAMES — 18 reserved names (admin, www, api, dev, mcp, wiki, etc.)
  • validate_username(username) — lowercase alphanumeric + hyphens, 3-30 chars, no leading/trailing hyphens; returns (valid, error)
  • get_by_username(username) — GSI query on username-index
  • set_username(user_id, username) — validates format, checks reserved list, checks uniqueness via GSI, then updates

Auth Middleware (app/auth/middleware.py)

  • exchange_auth_code() return dict now includes needs_username: bool (true when user has no username set)

Management API (app/management/routes.py)

  • New endpoint: POST /admin/username with body {"username": "..."}
  • Returns 200 on success, 400 for invalid/reserved, 409 for taken
  • Normalizes input to lowercase before validation

CLI (app/cli/main.py)

  • After wikibot login, if needs_username is true, prompts user for username and calls POST /admin/username
  • _set_username() helper handles the API call and output

Resolver (app/otterwiki/resolver.py)

  • _parse_host() now extracts username (not user_id) from subdomain — docstrings updated
  • __call__() uses get_by_username(username) instead of get(user_id)
  • Extracts user_id from the looked-up user record for downstream UUID-based paths
  • 404 if username not found

Test Coverage

  • Username validation: valid formats, too short/long, leading/trailing hyphens, uppercase, special chars, reserved names, empty
  • GSI lookup: get_by_username found and not found
  • set_username: success, invalid format, reserved, duplicate, idempotent
  • POST /admin/username: success, invalid, reserved, taken, unauthenticated, empty, case normalization
  • needs_username: new user gets true, existing user with username gets false
  • Resolver: all existing tests updated to use username subdomain; new test verifying UUID-based internal paths

All 230 runnable tests pass. 15 pre-existing fastmcp import failures unrelated.

Branch

Committed on worktree-agent-a245f93f (commit 8d37f26), based on phase-2.