The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.
Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.
Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):
- register/list addresses — register is idempotent for the same consumer,
409 address_taken for another consumer in the tenant; list marks which
addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
never read from the body; 403 identity_not_owned if that identity is not
a registered address owned by the caller's consumer; 404 unknown_address
(naming the missing ones) if any recipient does not exist in the tenant;
writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
first; find: full text via websearch_to_tsquery), both requiring the
identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
reading never consumes or marks, and reads scribe_messages directly
rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
caller's tenant, or addressed to an identity the caller isn't reading
as, answers 404 not_found — indistinguishable from a nonexistent entry.
Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).
Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
136 lines
4.2 KiB
Python
136 lines
4.2 KiB
Python
"""
|
|
Main FastAPI application for Core Code API
|
|
"""
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from contextlib import asynccontextmanager
|
|
|
|
from src.shared.config import get_settings
|
|
from src.shared.logging import setup_logging, get_logger
|
|
from src.shared.database import get_database
|
|
from src.shared.security import initialize_oidc
|
|
|
|
# Import domain controllers
|
|
from src.domains.health import health_controller
|
|
from src.domains.auth import auth_controller
|
|
from src.domains.tools import tools_controller
|
|
from src.domains.infrastructure import infrastructure_controller
|
|
from src.domains.housekeeping import housekeeping_controller
|
|
from src.domains.static import static_controller
|
|
from src.domains.dashboard import dashboard_controller
|
|
from src.domains.book import book_controller
|
|
from src.domains.scribe import scribe_controller
|
|
|
|
# Initialize settings
|
|
settings = get_settings()
|
|
|
|
# Setup logging
|
|
setup_logging(settings.log_level)
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Application lifespan manager for startup/shutdown events
|
|
|
|
Args:
|
|
app: FastAPI application instance
|
|
"""
|
|
# Startup
|
|
logger.info("=" * 60)
|
|
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
|
logger.info(f"Debug mode: {settings.debug}")
|
|
logger.info(f"Log level: {settings.log_level}")
|
|
logger.info("=" * 60)
|
|
|
|
# Check database connectivity
|
|
database = get_database()
|
|
db_healthy = await database.health_check()
|
|
if db_healthy:
|
|
logger.info("Database connection successful")
|
|
else:
|
|
logger.warning("Database connection failed - auth features may not work")
|
|
|
|
# Initialize security (OIDC authentication)
|
|
initialize_oidc(settings)
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down application")
|
|
await database.close()
|
|
|
|
|
|
# Create FastAPI application
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.app_version,
|
|
description="""
|
|
Core Code API - Infrastructure management and home automation API.
|
|
|
|
## Features
|
|
|
|
- **Infrastructure Management** - Container and stack management via Portainer
|
|
- **Home Automation** - Device control via Home Assistant
|
|
- **Dashboard** - Quick links and widget management
|
|
- **Tools** - DNS lookup and utilities
|
|
|
|
See `/docs` for the full API reference.
|
|
""",
|
|
docs_url="/docs",
|
|
redoc_url=None,
|
|
openapi_url="/openapi.json",
|
|
lifespan=lifespan,
|
|
debug=settings.debug,
|
|
swagger_ui_init_oauth={
|
|
"clientId": settings.oidc_audiences[0] if settings.oidc_audiences else "core-api",
|
|
"usePkceWithAuthorizationCodeGrant": True,
|
|
} if settings.oidc_enabled else None
|
|
)
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_credentials=settings.cors_credentials,
|
|
allow_methods=settings.cors_methods,
|
|
allow_headers=settings.cors_headers,
|
|
)
|
|
|
|
|
|
# Include domain routers
|
|
app.include_router(health_controller.router) # / and /health
|
|
app.include_router(auth_controller.router) # /auth/*
|
|
app.include_router(tools_controller.router) # /tools/*
|
|
app.include_router(infrastructure_controller.router) # /infrastructure/*
|
|
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
|
app.include_router(static_controller.router) # /static/*
|
|
app.include_router(dashboard_controller.router) # /dashboard/*
|
|
app.include_router(book_controller.router) # /book/*
|
|
app.include_router(scribe_controller.router) # /scribe/*
|
|
|
|
|
|
# Global exception handler
|
|
@app.exception_handler(Exception)
|
|
async def global_exception_handler(request, exc):
|
|
"""
|
|
Catch-all exception handler for unhandled errors
|
|
|
|
Args:
|
|
request: The request that caused the exception
|
|
exc: The exception instance
|
|
|
|
Returns:
|
|
JSON error response
|
|
"""
|
|
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"detail": "Internal server error",
|
|
"type": type(exc).__name__
|
|
}
|
|
)
|