Notechondria

Version: 0.1.65 Build Date: 2026-04-23T00:00

What's Changed

Multi-device sessions (backend)

DRF's rest_framework.authtoken is a 1:1 OneToOneField(User) — a user has exactly one active token. That's why the earlier rounds couldn't express "signed in on my phone AND my laptop". 0.1.65 replaces it with a dedicated creators.Session model so a user can have arbitrarily many concurrent sessions and manage each one individually (Telegram "Active sessions" style). Frontend UI for the sessions list + revoke buttons is deferred to docs/TODO.md — this round is backend only. The wire shape (Authorization: Token <40-hex>) is unchanged, so the frontend keeps working without changes today.

  • creators.Session model — many-per-user. Fields: key (40-char hex, unique), user FK, device_label, user_agent, ip_hash, created_at, last_seen_at, revoked_at. Two timeouts baked in as module-level constants so operators can tune without touching view code: SESSION_IDLE_TIMEOUT = 1 day (rolls forward on every authenticated request) and SESSION_ABSOLUTE_TIMEOUT = 3 days (hard cap from created_at). Helper methods: create_for_user (crude User-Agent → device label heuristic), is_active, touch, revoke. Migration 0028_session.py.

  • MultiSessionAuthentication DRF backend added in creators/authentication.py. Same wire shape as TokenAuthentication. Looks up Session.objects.get(key=...), enforces both timeouts via Session.is_active(), rejects revoked rows, and calls session.touch() on every valid request so the idle window rolls forward. Attaches request.auth_session so downstream views (e.g. LogoutApiView) can distinguish "revoke this device" from "revoke all devices".

  • auth_payload returns Session data + multi-device flag. Every login / register / verify / OAuth call now mints a fresh Session tagged with the caller's User-Agent + SHA-256 of the first-hop IP, and the response body gains:

    {
      "token": "<40-hex>",
      "session": {"id": 17, "device_label": "Mac", "created_at": "…", "last_seen_at": "…"},
      "multi_device": true,
      "other_sessions_count": 2,
      "user": { … }
    }
    

    Frontends can show a "You're signed in on 2 other devices" banner the moment a new session appears.

  • Two new endpoints. Both in backend/creators/api.py and wired in backend/notechondria/api_urls.py:

    MethodPathViewPurpose
    GET/api/v1/auth/sessions/SessionListApiViewList all active sessions for the caller. is_current: true flags the row for the token the caller is using. Never leaks key.
    DELETE/api/v1/auth/sessions/<id>/SessionRevokeApiViewRevoke a specific session. Owner-scoped (404 on cross-user attempts). Revoking your current session effectively logs you out of this device.
  • LogoutApiView now revokes only the current session (using request.auth_session), not every token for the user. Signing out on device A no longer signs out device B. Legacy DRF Token cleanup retained as a harmless no-op.

  • ChangePasswordApiView rotates sessions properly. Revokes ALL existing sessions for the user, then mints a fresh Session for the current request so the device that's changing the password doesn't log itself out. Response includes both the fresh token and the session metadata.

  • SessionApiView probe uses Session. Still returns 200 with {"authenticated": false} for missing / malformed / unknown / expired / revoked tokens (per the 0.1.64 root-cause fix) — but the backing lookup now hits Session.objects.get instead of DRF's Token table. On success the response echoes the existing session key + metadata so _restoreSession can continue to use it without a fresh mint.

  • settings.DEFAULT_AUTHENTICATION_CLASSES swaps rest_framework.authentication.TokenAuthentication for creators.authentication.MultiSessionAuthentication as the first entry. ApiKeyAuthentication still follows for the MCP Authorization: Bearer ntc_… path.

Session wipe on deploy

  • Per the owner's direction ("refresh the tokens on deploy is safe for now" after terminating Render and staying on Northflank), backend/entrypoint.sh now deletes every creators.Session row right after bootstrap_platform. On a fresh DB this is a no-op; on a redeploy it forces every device to re-authenticate on next use. The SQL is inside a small inline Python block that tolerates the table not yet existing (first boot before migrations applied).

Files Changed

  • VERSION — bumped 0.1.64 → 0.1.65.
  • backend/creators/models.pySession model + the two timeout constants (SESSION_IDLE_TIMEOUT, SESSION_ABSOLUTE_TIMEOUT).
  • backend/creators/migrations/0028_session.py — new migration.
  • backend/creators/authentication.py — new MultiSessionAuthentication class.
  • backend/creators/api.pyauth_payload mints + returns Session data; LogoutApiView revokes only current; new SessionListApiView + SessionRevokeApiView; SessionApiView probe looks up Session; ChangePasswordApiView revokes all sessions + mints a fresh one.
  • backend/notechondria/api_urls.py/auth/sessions/ + /auth/sessions/<id>/ routes.
  • backend/notechondria/settings.py — DRF DEFAULT_AUTHENTICATION_CLASSES swap.
  • backend/entrypoint.sh — session-wipe-on-deploy.
  • docs/TODO.md — frontend session manager UI (3 apps) and 2FA (password login) captured as deferred work items with concrete scope each.

Known follow-ups (all in docs/TODO.md)

  • Frontend Active Sessions card in the Settings surface across editor / planner / portal. Needs listSessions + revokeSession methods on the shared HTTP client and a multi-device warning banner consuming the new multi_device / other_sessions_count response fields.
  • Two-factor auth for password login (trusted-device approval or email code). Scoped in TODO.md; skip on OAuth by design.
  • Shared-component refactor to keep every file under 1000 LOC — already tracked in TODO's "File-size rule + cross-app sharing" section.

Notes

  • Wire shape is unchanged. Existing clients keep working.
  • Entrypoint's session wipe runs after bootstrap_platform so the admin user exists before the wipe runs (irrelevant ordering for the wipe itself, but keeps the boot log tidy).
  • SESSION_IDLE_TIMEOUT + SESSION_ABSOLUTE_TIMEOUT are Python timedelta constants in creators.models. To change, edit the model file — no migration needed (no field definition depends on these values, they're only read at request time).