diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py
index cbc828731..6072e43fd 100644
--- a/routes/api_token_routes.py
+++ b/routes/api_token_routes.py
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
from core.database import get_db_session, ApiToken
from core.middleware import require_admin
-from src.auth_helpers import get_current_user
+from src.auth_helpers import get_current_user, require_user
MAX_NAME_LEN = 100
DEFAULT_SCOPES = "chat"
@@ -206,4 +206,82 @@ def setup_api_token_routes() -> APIRouter:
_invalidate_cache(request)
return {"status": "deleted"}
+ # ── Self-serve endpoints (cookie-only, owner forced to current user) ──
+
+ @router.get("/tokens/self")
+ def list_self_tokens(request: Request):
+ """List the current user's own API tokens. Cookie session only."""
+ user = require_user(request)
+ with get_db_session() as db:
+ tokens = db.query(ApiToken).filter(
+ ApiToken.owner == user, ApiToken.is_active == True # noqa: E712
+ ).all()
+ return [
+ {
+ "id": t.id,
+ "name": t.name,
+ "owner": getattr(t, "owner", None),
+ "token_prefix": t.token_prefix,
+ "scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()],
+ "is_active": t.is_active,
+ "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
+ "created_at": t.created_at.isoformat() if t.created_at else None,
+ }
+ for t in tokens
+ ]
+
+ @router.post("/tokens/self")
+ def create_self_token(
+ request: Request,
+ name: str = Form(""),
+ scopes: str = Form(None),
+ profile: str = Form(None),
+ ):
+ """Create an API token for the current user. Cookie session only."""
+ user = require_user(request)
+ name = name.strip()[:MAX_NAME_LEN]
+ if not name:
+ raise HTTPException(400, "Token name is required")
+ scope_list = _normalize_scopes(scopes, profile)
+ scopes_value = ",".join(scope_list)
+
+ raw_token = "ody_" + secrets.token_urlsafe(32)
+ token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
+ token_id = str(uuid.uuid4())[:8]
+
+ with get_db_session() as db:
+ db.add(ApiToken(
+ id=token_id,
+ owner=user,
+ name=name,
+ token_hash=token_hash,
+ token_prefix=raw_token[:8],
+ scopes=scopes_value,
+ is_active=True,
+ ))
+ _invalidate_cache(request)
+
+ return {
+ "id": token_id,
+ "name": name,
+ "owner": user,
+ "token": raw_token,
+ "token_prefix": raw_token[:8],
+ "scopes": scope_list,
+ }
+
+ @router.delete("/tokens/self/{token_id}")
+ def delete_self_token(request: Request, token_id: str):
+ """Delete one of the current user's own API tokens. Cookie session only."""
+ user = require_user(request)
+ with get_db_session() as db:
+ token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
+ # Return 404 for both "not found" and "not yours" so callers
+ # can't probe for the existence of another user's token ids.
+ if not token or token.owner != user:
+ raise HTTPException(404, "Token not found")
+ db.delete(token)
+ _invalidate_cache(request)
+ return {"status": "deleted"}
+
return router
diff --git a/static/index.html b/static/index.html
index 8257660fe..cf77499f9 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1999,6 +1999,34 @@
+
+
Personal API Tokens
+
Create tokens to access your Odysseus data from scripts, apps, or external tools. Tokens are scoped — only grant the permissions your use case needs.
+
+
+
+
+
+
+
+
+
Select which data this token can access. Leave all unchecked for chat-only access.
+
+
+
+
+
+
Copy this token now — it won't be shown again.
+
+
+
+
+
diff --git a/static/js/settings.js b/static/js/settings.js
index 540acff00..9143ac043 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -2130,6 +2130,169 @@ async function initShortcuts() {
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
+// ── Personal API Tokens (Account tab) ──
+const SELF_TOKEN_SCOPES = [
+ { key: 'chat', label: 'Chat', detail: 'Chat and companion access' },
+ { key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
+ { key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
+ { key: 'documents:read', label: 'Documents', detail: 'Read documents' },
+ { key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
+ { key: 'email:read', label: 'Email', detail: 'Read email' },
+ { key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts' },
+ { key: 'email:send', label: 'Email send', detail: 'Send email directly' },
+ { key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events' },
+ { key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
+ { key: 'memory:read', label: 'Memory', detail: 'Read memory' },
+ { key: 'memory:write', label: 'Memory write', detail: 'Write memory' },
+ { key: 'cookbook:read', label: 'Cookbook', detail: 'Read model inventory and presets' },
+ { key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Start and stop model servers' },
+];
+
+function initSelfApiTokens() {
+ const listEl = el('settings-api-tokens-list');
+ const createPanel = el('settings-api-tokens-create');
+ const newBtn = el('settings-api-token-new-btn');
+ const createBtn = el('settings-api-token-create-btn');
+ const nameInput = el('settings-api-token-name');
+ const scopesEl = el('settings-api-token-scopes');
+ const msgEl = el('settings-api-token-create-msg');
+ const revealEl = el('settings-api-token-reveal');
+ const tokenValueEl = el('settings-api-token-value');
+ const copyBtn = el('settings-api-token-copy-btn');
+
+ if (!listEl || !newBtn) return;
+
+ // Build scope checkboxes once
+ scopesEl.innerHTML = SELF_TOKEN_SCOPES.map(s => `
+
+ `).join('');
+
+ async function loadTokens() {
+ try {
+ const res = await fetch('/api/tokens/self', { credentials: 'same-origin' });
+ const tokens = await res.json();
+ if (!tokens.length) {
+ listEl.innerHTML = 'No tokens yet
';
+ return;
+ }
+ listEl.innerHTML = tokens.map(t => {
+ const scopes = (t.scopes || []).join(', ') || 'chat';
+ return `
+
+
${esc(t.name)}
+
+ ${esc(t.token_prefix)}...
+ ${esc(scopes)}
+ ${t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : 'Never used'}
+
+
+
+
`;
+ }).join('');
+
+ // Wire revoke buttons
+ listEl.querySelectorAll('.self-token-revoke').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const tokenId = btn.dataset.tokenId;
+ if (!await window.styledConfirm('Revoke this token? Any scripts or apps using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
+ try {
+ const r = await fetch(`/api/tokens/self/${tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
+ if (r.ok) loadTokens();
+ } catch (_) {}
+ });
+ });
+ } catch (_) {
+ listEl.innerHTML = 'Could not load tokens
';
+ }
+ }
+
+ loadTokens();
+
+ // New token button
+ newBtn.addEventListener('click', () => {
+ createPanel.style.display = '';
+ newBtn.style.display = 'none';
+ nameInput.value = '';
+ nameInput.focus();
+ revealEl.style.display = 'none';
+ msgEl.textContent = '';
+ scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
+ });
+
+ // Cancel create — hide panel, show button
+ const cancelCreate = () => {
+ createPanel.style.display = 'none';
+ newBtn.style.display = '';
+ revealEl.style.display = 'none';
+ msgEl.textContent = '';
+ };
+
+ // Create token
+ createBtn.addEventListener('click', async () => {
+ const name = nameInput.value.trim();
+ if (!name) { msgEl.textContent = 'Token name is required'; msgEl.style.color = 'var(--red)'; return; }
+ const checked = Array.from(scopesEl.querySelectorAll('.self-token-scope:checked')).map(cb => cb.value);
+ const fd = new FormData();
+ fd.append('name', name);
+ if (checked.length) fd.append('scopes', checked.join(','));
+ msgEl.textContent = '';
+ msgEl.style.color = '';
+ createBtn.disabled = true;
+ try {
+ const res = await fetch('/api/tokens/self', { method: 'POST', body: fd, credentials: 'same-origin' });
+ const data = await res.json();
+ if (res.ok) {
+ tokenValueEl.textContent = data.token;
+ revealEl.style.display = '';
+ nameInput.value = '';
+ scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
+ loadTokens();
+ } else {
+ msgEl.textContent = data.detail || 'Failed';
+ msgEl.style.color = 'var(--red)';
+ }
+ } catch (_) {
+ msgEl.textContent = 'Request failed';
+ msgEl.style.color = 'var(--red)';
+ } finally {
+ createBtn.disabled = false;
+ }
+ });
+
+ // Copy button
+ if (copyBtn) {
+ const COPY_ICON = '';
+ const CHECK_ICON = '';
+ copyBtn.addEventListener('click', () => {
+ const val = tokenValueEl.textContent;
+ navigator.clipboard.writeText(val).then(() => {
+ copyBtn.innerHTML = CHECK_ICON;
+ copyBtn.style.color = 'var(--accent, var(--red))';
+ copyBtn.style.opacity = '1';
+ setTimeout(() => {
+ copyBtn.innerHTML = COPY_ICON;
+ copyBtn.style.color = '';
+ copyBtn.style.opacity = '';
+ }, 1600);
+ });
+ });
+ }
+
+ // Allow Enter key in name input to create
+ nameInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ createBtn.click();
+ }
+ });
+}
+
function initAccount() {
// Populate user info
fetch('/api/auth/status', { credentials: 'same-origin' })
@@ -2287,6 +2450,9 @@ function initAccount() {
render2FA();
}
+ // Personal API Tokens
+ initSelfApiTokens();
+
// Logout
const logoutBtn = el('settings-logout-btn');
if (logoutBtn) {
diff --git a/tests/test_api_token_routes.py b/tests/test_api_token_routes.py
index 40afc2226..b7af68c8f 100644
--- a/tests/test_api_token_routes.py
+++ b/tests/test_api_token_routes.py
@@ -576,3 +576,173 @@ def test_update_token_normal_object_still_works(monkeypatch, token_routes_mod):
assert token.name == "updated"
assert resp["name"] == "updated"
invalidator.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# 8. Self-serve endpoints — cookie-only, owner forced to session user
+# ---------------------------------------------------------------------------
+
+
+def _self_req(username: str, *, is_api_token: bool = False, invalidator=None):
+ """A request whose state carries a cookie user (or a bearer API token)."""
+ app_state = SimpleNamespace(
+ auth_manager=_admin_mgr(True),
+ )
+ if invalidator is not None:
+ app_state.invalidate_token_cache = invalidator
+ return SimpleNamespace(
+ state=SimpleNamespace(
+ current_user="api" if is_api_token else username,
+ api_token=is_api_token,
+ api_token_owner=username if is_api_token else None,
+ ),
+ headers={},
+ app=SimpleNamespace(state=app_state),
+ )
+
+
+# -- bearer rejection --
+
+
+def test_self_serve_list_rejects_bearer_token(monkeypatch, token_routes_mod):
+ """GET /tokens/self with a bearer API token → 403."""
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ monkeypatch.setenv("LOCALHOST_BYPASS", "false")
+ mod = token_routes_mod
+ list_self = _get_handler(mod, "GET", "/tokens/self")
+ req = _self_req("alice", is_api_token=True)
+ with pytest.raises(HTTPException) as exc:
+ list_self(request=req)
+ assert exc.value.status_code == 403
+
+
+def test_self_serve_create_rejects_bearer_token(monkeypatch, token_routes_mod):
+ """POST /tokens/self with a bearer API token → 403."""
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ monkeypatch.setenv("LOCALHOST_BYPASS", "false")
+ mod = token_routes_mod
+ create_self = _get_handler(mod, "POST", "/tokens/self")
+ req = _self_req("alice", is_api_token=True)
+ with pytest.raises(HTTPException) as exc:
+ create_self(request=req, name="my-token")
+ assert exc.value.status_code == 403
+
+
+def test_self_serve_delete_rejects_bearer_token(monkeypatch, token_routes_mod):
+ """DELETE /tokens/self/{id} with a bearer API token → 403."""
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ monkeypatch.setenv("LOCALHOST_BYPASS", "false")
+ mod = token_routes_mod
+ delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
+ req = _self_req("alice", is_api_token=True)
+ with pytest.raises(HTTPException) as exc:
+ delete_self(request=req, token_id="abc12345")
+ assert exc.value.status_code == 403
+
+
+# -- owner isolation (list only returns own tokens) --
+
+
+def test_list_self_only_returns_own_tokens(monkeypatch, token_routes_mod):
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ mod = token_routes_mod
+
+ now = datetime.datetime(2024, 1, 1, 0, 0)
+ row_alice = SimpleNamespace(
+ id="a001", name="Alice token", owner="alice", token_prefix="ody_al",
+ scopes="chat", is_active=True, last_used_at=now, created_at=now,
+ )
+ row_bob = SimpleNamespace(
+ id="b001", name="Bob token", owner="bob", token_prefix="ody_bo",
+ scopes="chat", is_active=True, last_used_at=now, created_at=now,
+ )
+
+ fake_session = MagicMock()
+ # The handler filters on owner == user, so the mock query chain must
+ # return only alice's row after the filter. We mock .all() to return
+ # the union, but the real DB filter would exclude bob. Simulate it by
+ # having the mock return only alice's row.
+ fake_session.query.return_value.filter.return_value.all.return_value = [row_alice]
+ monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
+
+ req = _self_req("alice")
+ list_self = _get_handler(mod, "GET", "/tokens/self")
+ result = list_self(request=req)
+
+ assert len(result) == 1
+ assert result[0]["id"] == "a001"
+ assert result[0]["owner"] == "alice"
+
+
+# -- delete not-yours returns 404 (not 403) to avoid existence oracle --
+
+
+def test_delete_self_not_yours_returns_404(monkeypatch, token_routes_mod):
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ mod = token_routes_mod
+
+ # Token exists but belongs to bob, not alice.
+ fake_token = SimpleNamespace(id="bob123", owner="bob", name="bob-token")
+ fake_session = MagicMock()
+ fake_session.query.return_value.filter.return_value.first.return_value = fake_token
+ monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
+
+ req = _self_req("alice")
+ delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
+ with pytest.raises(HTTPException) as exc:
+ delete_self(request=req, token_id="bob123")
+ assert exc.value.status_code == 404
+
+
+# -- unknown scope rejected --
+
+
+def test_create_self_rejects_unknown_scope(monkeypatch, token_routes_mod):
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ mod = token_routes_mod
+
+ fake_session = MagicMock()
+ monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
+
+ req = _self_req("alice")
+ create_self = _get_handler(mod, "POST", "/tokens/self")
+ with pytest.raises(HTTPException) as exc:
+ create_self(request=req, name="bad-token", scopes="admin,shell")
+ assert exc.value.status_code == 400
+
+
+# -- owner forced from session (not client) --
+
+
+def test_create_self_forces_owner_from_session(monkeypatch, token_routes_mod):
+ monkeypatch.setenv("AUTH_ENABLED", "true")
+ mod = token_routes_mod
+
+ fake_suffix = "FAKESUFFIX_XXXXXXXXXXXXXXXXXXXXXXXXXX"
+ fake_uuid_str = "abcd1234-0000-0000-0000-000000000000"
+ monkeypatch.setattr(_secrets_mod, "token_urlsafe", lambda n: fake_suffix)
+ monkeypatch.setattr(_uuid_mod, "uuid4", lambda: SimpleNamespace(__str__=lambda self: fake_uuid_str))
+ monkeypatch.setattr(mod, "bcrypt", SimpleNamespace(
+ hashpw=lambda pw, salt: b"$2b$12$FAKEHASH",
+ gensalt=lambda: b"fakesalt",
+ ))
+
+ captured = {}
+ class _FakeApiToken:
+ def __init__(self, **kw):
+ captured.update(kw)
+ self.__dict__.update(kw)
+ monkeypatch.setattr(mod, "ApiToken", _FakeApiToken)
+
+ fake_session = MagicMock()
+ monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
+
+ invalidator = MagicMock()
+ req = _self_req("alice", invalidator=invalidator)
+ create_self = _get_handler(mod, "POST", "/tokens/self")
+ resp = create_self(request=req, name="my-token")
+
+ # Owner must be "alice" (from session), never from client input
+ assert resp["owner"] == "alice"
+ assert captured["owner"] == "alice"
+ invalidator.assert_called_once()