Technical doc: Developer Guide
Table of contents
- Goals & Principles
- Architecture overview (high level)
- Request / response contract (frontend ↔ backend)
- Auth & session rules (tokens, cookies, verification)
- Modal + overlay patterns (ModalManager, Confirm)
- Frontend structure & conventions (managers, UI, DI)
- Backend structure & conventions (models, schemas, utils, routers)
- Security considerations (XSS, CSRF, token lifecycle)
- Email verification flow (generate → send → verify)
- Adding a new module: step-by-step checklist
- Tests, migrations, and deployment notes
- Appendices: snippets & examples
- Pre-deployment steps
- TODOs and recommended follow ups
1. Goals & principles (short)
- Single responsibility: one job per file/module. Keep helpers tiny and well-named.
- Predictability: naming conventions & folder layout are fixed - follow them.
- Separation of concerns: business logic in core/util/*, routing in routers/*, schemas in core/schemas.py.
- Composition over inheritance on the frontend (ModalManager + modal instances, e.g. DI of SideDrawer etc.).
- Security-first: validate/escape on both backend and frontend; treat front-end validation as UX, backend as authorization/last-guard.
- Accessibility & UX: keyboard, ARIA, visible loading states, focus management and alert announcements.
2. Architecture overview
Backend
- FastAPI + SQLAlchemy.
- core/ holds models, schemas, utilities (business logic).
- routers/ holds HTTP endpoints; they call core/util/* functions.
Frontend
- Templates: Jinja2 in frontend/templates.
- Vanilla JS modules under frontend/assets/scripts/:
- managers/ — controllers (per-component).
- ui/ — reusable widgets: Alerts, ModalManager, Confirm, UI helpers.
- utils/ — api.js (request wrapper), auth.js, etc.
- main.js wires managers and injects dependencies (DI).
3. Request/response contract
All frontend code expects the central request() helper which always returns a standardized object:
{
success: boolean,
status: number,
data?: any, // parsed object for 2xx responses
error?: string // human message or machine message
}
Backend endpoints should return JSON compatible with request() expectations. Client-side code branches on success. Do not bypass request().
request() must:
- Detect non-JSON bodies safely.
- Parse JSON only if content-type indicates JSON.
Return { success:false, status: xxx, error: <message> } on errors.
4. Auth & session rules
Tokens & cookies (current / preferred):
- access_token (HttpOnly cookie): short-lived, used to authenticate requests.
- refresh_token (HttpOnly cookie): used to refresh access token (rotate on use).
- session cookie: optional (signed token). If present, treat the user as logged in; stored sub = user id (string).
- JWT payload uses "sub": str(user_id), "exp", and "type" when applicable ("refresh", "email_confirmation"...).
Important rules:
- Keep substring in tokens (some JWT libraries validate type).
- Always decode tokens with algorithms=[settings.JWT_ALGORITHM] and settings.SECRET_KEY.
- Protect token generation functions to always stringify sub.
- Invalidate refresh tokens server-side on logout if you're tracking them (recommended).
- For critical changes (password or username), consider rotating session tokens or forcing re-login.
Auth endpoints (examples):
- POST /auth/login: returns cookie access_token via response.set_cookie(...).
- POST /auth/logout: clear auth cookies, optionally revoke refresh token.
- GET /auth/me: return current user (based on access_token cookie).
- POST /auth/register: create user + send confirmation email (do not auto-login).
- POST /auth/verify-confirmation-token: verify confirmation token.
- POST /auth/forgot-password: send reset link.
- POST /auth/reset-password: verify reset token and set new password.
Frontend AuthApi methods must use credentials: "include" when server relies on cookies.
5. Modals & Overlay (ModalManager pattern)
Goal: one global overlay, consistent open/close, ESC and click-outside handling, focus management, and automatic close of side drawer.
Core pieces:
- ModalManager (singleton): registers all DOM modal roots (or instances) and provides open(id) / close(id) / closeAll(); shows/hides global overlay; closes SideDrawer when opening a modal.
- Modal instances (e.g., AuthEditModal, ConfirmModal) implement .open(initialData) and .close(); composition: pass modalManager in their constructor.
- ConfirmModal should be a class with show(opts) that returns Promise (resolve true on OK).
Behavior:
- ModalManager.open(el):
- call closeAll(), hide side drawer, show overlay, show modal (add .show), set focus to first interactive element (via Accessibility.focusFirstInteractive).
- ModalManager.closeAll():
- hide all modals and overlay, restore side drawer state.
Accessibility:
- Overlay has .show class;
When the modal opens, focus first [data-autofocus] or first non-disabled input/button. - Escape and overlay click closes modal.
Usage example in main.js:
const modalManager = new ModalManager({ sideDrawer });
modalManager.init();
const authEditModal = new AuthEditModal({ modalManager });
const auth = new AuthsManager({ authEditModal });
6. Frontend structure & conventions
Files & roles:
- managers/* : per-page controllers. Expose init() and receive dependencies via DI.
- ui/* : pure UI widgets: Alerts, ConfirmModal, ModalManager, UI (helpers like setButtonLoading, markInvalid, setRequiredForVisibleFields), Accessibility.
- utils/api.js : request() and API wrappers: AuthApi, ProjectsApi, etc.
- dom.js : central DOM references; use getters or document.querySelectorAll once on load and expose them in a structured object.
DI pattern:
- Create instances in main.js and pass them to managers instead of global access.
- Example: new AuthsManager({ sideDrawer, authEditModal, modalManager }).
Forms pattern:
- For multi-mode auth UI, prefer multiple <form id="form-login">, <form id="form-register"> etc., rather than toggling required inputs inside a single form.
- Attach submit handlers to each form, or to the container with delegated logic (consistent and tested).
- Before submission, call UI.setRequiredForVisibleFields(container) to avoid "invalid control not focusable" error.
Validators:
- UI validators (in UiHelpers) must return { valid: boolean, error: string | null }. Frontend only for UX; backend must re-validate.
- Example: validateUsername() should consult window.FORBIDDEN_USERNAMES set (loaded asynchronously), and return human-friendly error messages.
Forbidden usernames:
- Keep canonical list server-side (Python set).
- Expose GET /auth/forbidden-usernames endpoint that returns {"forbidden": ["admin", ...]}.
Frontend loads it once (non-blocking) and caches in window.FORBIDDEN_USERNAMES. If unavailable, fall back to the empty set and re-try.
7. Backend structure & conventions
Preferred layout:
- core/models.py : SQLAlchemy models (single file or split by domain).
- core/schemas.py : pydantic models for all endpoints (request/response).
- core/util/*.py : business logic functions (create_user, update_user, list_projects...). Routers call these.
- routers/*.py : routing / parameter/dep resolution only; do not place business logic here.
Model pattern for new entities:
- Fields: id (PK), created_at, updated_at, active, owner_id (FK to users.id), data (JSON/text) as needed.
Ownership:
- Mutating endpoints must enforce owner_id == current_user.id. You can return 404 for non-owners (security-through-obscurity pattern), or 403—use current project convention.
Schemas:
- Use Pydantic field_validator for per-field validation, but treat Pydantic as input validation only use the core/util/* validator for more complex checks like uniqueness.
User update pattern:
- In users_service.update_user:
- If username is updated, normalize (strip/lower?) and check uniqueness (exclude current id).
- Validate username via validate_username() (server-side).
- If password changes, hash with hash_password() and optionally rotate session token or revoke refresh tokens.
Forbidden usernames:
- Keep RAW_FORBIDDEN_USERNAMES in a util module and convert to a set() of lower-case words for fast lookup.
Email sending:
- All email sending should go through a single helper send_email(schemas.EmailRequest). Higher-level functions like send_reset_password() and send_confirmation_email() call it with appropriate HTML or templates.
- For attachments: MessageSchema(..., attachments=[]) expects a list — always pass an empty list [] if no attachments, not None. (This is the fix for your FastMail error.)
- Example fix in send_email:
attachments = [data.attachment] if data.attachment else [] message = MessageSchema(..., attachments=attachments)
8. Security checklist (important)
Backend:
- Validate all inputs server-side (lengths, character classes, forbidden names).
- Make the username unique at DB level (unique index) AND in service code (check first).
- Hash passwords with a modern algorithm (bcrypt/argon2) and salt properly.
- Rate limit login / forgot-password endpoints to prevent abuse.
- Revoke or rotate refresh tokens on logout / password change.
- For email content, escape user-supplied values; send only pre-constructed safe HTML templates.
Client:
- Avoid innerHTML for untrusted data; use textContent or sanitized rendering.
- Token links should use HTTPS in production.
- Do not expose secret keys in the front end.
- CSP headers recommended on production.
Cookies:
- HttpOnly, Secure, SameSite=Lax (or Strict if appropriate).
- Use short-lived access tokens; refresh tokens rotate.
CSP / XSS:
- Sanitize any user-provided HTML before storing/displaying.
9. Email verification & reset flows
Registration → Email verification
- 4Client POST /auth/register with validated email/username/password.
- Server creates a user with is_verified=False.
- Server generates an email token:
payload = {"sub": str(user_id), "exp": now + minutes, "type": "email_confirmation"}
token = jwt_encode(payload, SECRET_KEY, algorithm=ALGORITHM)
- Server calls send_confirmation_email(user, token), which calls send_email(schema); send_email must always pass attachments=[] or None→[].
- Frontend shows a confirmation alert and instructs the user to check the inbox.
- When user clicks the link (front page with ?verify_token=...), the frontend should call POST /auth/verify-confirmation-token with { token }
- Server decodes token, checks type, finds user, sets is_verified=True. Return success.
Password reset
- Similar pattern but type: "reset". Reset endpoint should accept token + new_password JSON body and update password after validating token and password policy.
10. Add a new module (step-by-step)
Backend
- Add model in core/models.py.
- Add Pydantic schemas in core/schemas.py (XCreate, XUpdate, XOut).
- Implement core/util/x.py functions (list, get, create, update, delete). Enforce owner checks inside update & delete.
- Create /routers/x.py with endpoints and include in main.py.
- Use Alembic migration.
Frontend
- Add frontend/templates/components/x.html with placeholders and DOM IDs.
- Add frontend/assets/scripts/managers/xManager.js implementing init().
- Add XApi methods in utils/api.js.
- Wire up main.js to instantiate new XManager({...deps...}).
- Use Alerts, Confirm, ModalManager, UI helpers.
11. Tests, migrations & deployment
Migrations: use Alembic for DB schema changes. Add migration whenever models change.
Tests:
- API tests for auth flows (register → verify → login).
- Integration tests for DB operations (create/update/ownership).
Deployment:
- Set MODE = "PROD".
- Ensure JWT_SECRET and mail creds are in environment variables.
- TLS (HTTPS) required for cookies secure=True.
- Consider applying CSP header and HSTS.
12. Useful snippets & gotchas
send_email attachments fix (FastMail)
async def send_email(data: schemas.EmailRequest):
try:
attachments = [data.attachment] if data.attachment else []
message = MessageSchema(
subject=data.subject,
recipients=[data.recipient or settings.ADMIN_EMAIL],
body=data.body,
subtype=MessageType.html,
attachments=attachments,
)
fm = FastMail(conf_email)
await fm.send_message(message)
return {"message": "Email sent successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
Verify token route: accept JSON body (avoid 422 missing query param)
@router.post("/verify-confirmation-token")
def verify_email(payload: dict, db: Session = Depends(get_db)):
token = payload.get("token")
if not token:
raise HTTPException(status_code=400, detail="token is required")
verified = auth_service.verify_confirmation_token(db, token)
return {"success": verified}
(Or better: use a Pydantic schema: class TokenPayload(BaseModel): token: str.)
Frontend: call POST with JSON
AuthApi.verifyConfirmationToken = (formData) =>
request(`${API.authApi}/verify-confirmation-token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
credentials: "include"
});
UiHelpers.validateUsername should be sync or return a Promise (if consult remote), but frontend code should await optionally:
let validationResult = UiHelpers.validateUsername(username);
if (validationResult instanceof Promise) validationResult = await validationResult;
13. Pre-deployment steps
Security
- Add DB-level unique indexes on users.email and users.username (plus proper migration).
- Implement refresh-token rotation and revocation table; ensure logout invalidates refresh token.
- Consider migration from stateless → stateful refresh tokens
- XSS protection
- Server side
- Sanitize user input
- Encode output (escape HTML)
- Consider strict Content Security Policy (CSP) if feasible
- Client side
- No dangerous innerHTML
- Use textContent
- Validate project names / descriptions
- RegExp whitelist for names
- API rejects unsafe strings
- Server side
- Rate limits: login/register/reset.
- Brute-force protections (including cloudflare)
- XSS protections
- Honey pots
14. TODOs & recommended follow-ups
- Implement UX/UI accessibility to a reasonable level.
- Before deployment, harden app security measures (see step 13).
- Implement focus-trap inside modals (for accessibility).
- Consider adding a small E2E tests.
- Responsive email templates
- Forms: If validation fails, focus the first invalid field
Note: because you auto-remove the .invalid class, use setTimeout(() => field.focus())