Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from routes.oauth_routes import router as oauth_router
from routes.redirect_routes import router as redirect_router
from routes.static_routes import router as static_router
from shared.app_registry import load_app_registry
from shared.logging import get_logger
from shared.templates import configure_template_globals, templates

Expand Down Expand Up @@ -124,6 +125,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:

await ensure_indexes(app.state.db)

# App registry for the consent/apps system
app.state.app_registry = load_app_registry()

# Warn if session secret is missing when auth is enabled
if settings.jwt and not settings.secret_key:
log.warning(
Expand Down
4 changes: 0 additions & 4 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,6 @@ class AppSettings(BaseSettings):
cors_origins: list[str] = ["*"] # deprecated — kept for backward compat
cors_private_origins: list[str] = []

# Device auth flow - allowed redirect URIs for third-party clients
# (e.g., mobile apps, desktop apps). Browser extensions don't need this.
device_auth_redirect_uris: list[str] = []

# Request body size limit (bytes); 1 MB default
max_content_length: int = 1_048_576

Expand Down
124 changes: 124 additions & 0 deletions config/apps.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# App registry for the spoo.me ecosystem.
#
# Loaded at application startup and cached in app.state.app_registry.
# All apps listed here appear on the dashboard Apps page.
# Only 'live' + 'device_auth' apps can initiate the consent flow.

apps:
spoo-snap:
name: Spoo Snap
icon: spoo-snap.svg
description: Official browser extension for spoo.me
verified: true
status: live
type: device_auth
redirect_uris: []
links:
chrome: https://chrome.google.com/webstore/detail/spoo-snap
firefox: https://addons.mozilla.org/en-US/firefox/addon/spoo-snap
permissions:
- Access your spoo.me account
- Create and manage short URLs
- View your analytics

spoo-desktop:
name: Spoo Desktop
icon: spoo-desktop.svg
description: Official Windows desktop app
verified: true
status: live
type: device_auth
redirect_uris:
- http://localhost:9274/callback
links:
windows: https://apps.microsoft.com/detail/9mtwpjxlb0gr
permissions:
- Access your spoo.me account
- Create and manage short URLs

spoo-discord:
name: Discord Bot
icon: discord-bot.svg
description: Shorten links directly in Discord
verified: true
status: live
type: device_auth
redirect_uris:
- https://discord-bot.spoo.me/callback
links:
invite: https://spoo.me/discord-bot
permissions:
- Access your spoo.me account
- Create short URLs on your behalf

spoo-mobile:
name: Spoo Mobile
icon: spoo-mobile.svg
description: Shorten links on the go
verified: true
status: coming_soon
type: device_auth

spoo-telegram:
name: Telegram Bot
icon: telegram-bot.svg
description: Shorten links in Telegram chats
verified: true
status: coming_soon
type: device_auth

spoo-slack:
name: Slack Bot
icon: slack-bot.svg
description: Shorten links in Slack with /shorten
verified: true
status: coming_soon
type: device_auth

spoo-raycast:
name: Raycast Extension
icon: raycast.svg
description: Shorten links from Raycast
verified: true
status: coming_soon
type: device_auth

spoo-vscode:
name: VS Code Extension
icon: vscode.svg
description: Shorten links from your editor
verified: true
status: coming_soon
type: device_auth

spoo-mcp:
name: MCP Server
icon: mcp.svg
description: AI tool server for Claude, Cursor, and others
verified: true
status: coming_soon
type: device_auth

spoo-zapier:
name: Zapier
icon: zapier.svg
description: Shorten links in no-code workflows
verified: true
status: coming_soon
type: device_auth

spoo-n8n:
name: n8n
icon: n8n.svg
description: Self-hosted automation for link shortening
verified: true
status: coming_soon
type: device_auth

spoo-cli:
name: Spoo CLI
icon: spoo-cli.svg
description: Shorten links from your terminal
verified: true
status: coming_soon
type: device_auth
2 changes: 2 additions & 0 deletions dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
from dependencies.services import (
get_api_key_service,
get_app_grant_repo,
get_auth_service,
get_click_service,
get_contact_service,
Expand All @@ -61,6 +62,7 @@
"check_api_key_scope",
# services
"get_api_key_service",
"get_app_grant_repo",
"get_auth_service",
"get_click_service",
"get_contact_service",
Expand Down
6 changes: 6 additions & 0 deletions dependencies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from infrastructure.geoip import GeoIPService
from infrastructure.webhook.discord import DiscordWebhookProvider
from repositories.api_key_repository import ApiKeyRepository
from repositories.app_grant_repository import AppGrantRepository
from repositories.blocked_url_repository import BlockedUrlRepository
from repositories.click_repository import ClickRepository
from repositories.legacy.emoji_url_repository import EmojiUrlRepository
Expand All @@ -41,6 +42,11 @@
from services.url_service import UrlService


async def get_app_grant_repo(db=Depends(get_db)) -> AppGrantRepository:
"""Return an AppGrantRepository for the current request."""
return AppGrantRepository(db["app-grants"])


async def get_url_service(
db=Depends(get_db),
url_cache: UrlCache = Depends(get_url_cache),
Expand Down
141 changes: 141 additions & 0 deletions repositories/app_grant_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""
Repository for the `app-grants` MongoDB collection.

Tracks user consent grants for registered apps (device auth flow).
Supports soft-delete via revoked_at for analytics and reconnect flows.
"""

from __future__ import annotations

from datetime import datetime, timezone

from bson import ObjectId
from pymongo.asynchronous.collection import AsyncCollection
from pymongo.errors import PyMongoError

from schemas.models.app_grant import AppGrantDoc
from shared.logging import get_logger

log = get_logger(__name__)


class AppGrantRepository:
def __init__(self, collection: AsyncCollection) -> None:
self._col = collection

async def find_active_grant(
self, user_id: ObjectId, app_id: str
) -> AppGrantDoc | None:
"""Find an active (non-revoked) grant for a user and app."""
try:
doc = await self._col.find_one(
{"user_id": user_id, "app_id": app_id, "revoked_at": None}
)
return AppGrantDoc.from_mongo(doc)
except PyMongoError as exc:
log.error(
"app_grant_find_active_failed",
user_id=str(user_id),
app_id=app_id,
error=str(exc),
error_type=type(exc).__name__,
)
raise

async def find_active_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]:
"""Find all active grants for a user."""
try:
cursor = self._col.find({"user_id": user_id, "revoked_at": None})
return [AppGrantDoc.from_mongo(doc) async for doc in cursor]
except PyMongoError as exc:
log.error(
"app_grant_find_active_for_user_failed",
user_id=str(user_id),
error=str(exc),
error_type=type(exc).__name__,
)
raise

async def find_all_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]:
"""Find all grants for a user, including revoked."""
try:
cursor = self._col.find({"user_id": user_id})
return [AppGrantDoc.from_mongo(doc) async for doc in cursor]
except PyMongoError as exc:
log.error(
"app_grant_find_all_for_user_failed",
user_id=str(user_id),
error=str(exc),
error_type=type(exc).__name__,
)
raise

async def create_or_reactivate(self, user_id: ObjectId, app_id: str) -> AppGrantDoc:
"""Create a new grant or reactivate a revoked one.

Uses upsert: if a document exists for (user_id, app_id), clears
revoked_at and updates granted_at. Otherwise inserts a new document.
"""
now = datetime.now(timezone.utc)
try:
doc = await self._col.find_one_and_update(
{"user_id": user_id, "app_id": app_id},
{
"$set": {
"granted_at": now,
"revoked_at": None,
},
"$setOnInsert": {
"user_id": user_id,
"app_id": app_id,
"last_used_at": None,
},
},
upsert=True,
return_document=True,
)
return AppGrantDoc.from_mongo(doc) # type: ignore[return-value]
except PyMongoError as exc:
log.error(
"app_grant_create_or_reactivate_failed",
user_id=str(user_id),
app_id=app_id,
error=str(exc),
error_type=type(exc).__name__,
)
raise

async def revoke(self, user_id: ObjectId, app_id: str) -> bool:
"""Soft-delete a grant by setting revoked_at. Returns True if a grant was revoked."""
try:
result = await self._col.update_one(
{"user_id": user_id, "app_id": app_id, "revoked_at": None},
{"$set": {"revoked_at": datetime.now(timezone.utc)}},
)
return result.modified_count > 0
except PyMongoError as exc:
log.error(
"app_grant_revoke_failed",
user_id=str(user_id),
app_id=app_id,
error=str(exc),
error_type=type(exc).__name__,
)
raise

async def touch_last_used(self, user_id: ObjectId, app_id: str) -> None:
"""Update last_used_at on an active grant."""
try:
await self._col.update_one(
{"user_id": user_id, "app_id": app_id, "revoked_at": None},
{"$set": {"last_used_at": datetime.now(timezone.utc)}},
)
except PyMongoError as exc:
log.error(
"app_grant_touch_last_used_failed",
user_id=str(user_id),
app_id=app_id,
error=str(exc),
error_type=type(exc).__name__,
)
raise
6 changes: 6 additions & 0 deletions repositories/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,10 @@ async def ensure_indexes(db: AsyncDatabase) -> None:
name="ix_latest_unused_by_user",
)

# ── app-grants ─────────────────────────────────────────────────────
app_grants_col = db["app-grants"]
await app_grants_col.create_index([("user_id", 1), ("app_id", 1)], unique=True)
await app_grants_col.create_index([("user_id", 1), ("revoked_at", 1)])
await app_grants_col.create_index([("app_id", 1), ("revoked_at", 1)])

log.info("mongodb_indexes_ensured")
11 changes: 8 additions & 3 deletions repositories/token_repository.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Repository for the `verification-tokens` MongoDB collection.

Tokens are used for email verification, password resets, and OTP flows.
Tokens are used for email verification, password resets, OTP flows, and device auth codes.
All methods are async. Returns VerificationTokenDoc models where applicable.
Errors are logged and re-raised — the service layer decides recovery.
"""
Expand Down Expand Up @@ -153,16 +153,21 @@ async def increment_attempts(self, token_id: ObjectId) -> bool:
raise

async def delete_by_user(
self, user_id: ObjectId, token_type: str | None = None
self,
user_id: ObjectId,
token_type: str | None = None,
app_id: str | None = None,
) -> int:
"""
Delete all tokens for a user, optionally filtered by token type.
Delete all tokens for a user, optionally filtered by token type and app_id.

Returns the number of documents deleted.
"""
query: dict = {"user_id": user_id}
if token_type is not None:
query["token_type"] = token_type
if app_id is not None:
query["app_id"] = app_id
try:
result = await self._col.delete_many(query)
return result.deleted_count
Expand Down
Loading
Loading