Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,20 @@ repos:
types: [python]
pass_filenames: false
stages: [pre-commit]

- id: web-lint
name: web lint
entry: pnpm --dir web lint
language: system
files: ^web/
types_or: [javascript, jsx, ts, tsx, css]
pass_filenames: false

- id: web-build
name: web build
entry: pnpm --dir web build
language: system
files: ^web/
types_or: [javascript, jsx, ts, tsx, css, json]
pass_filenames: false
stages: [pre-commit]
28 changes: 2 additions & 26 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,10 @@ up-attached:
down:
docker compose -f deploy/docker-compose.yml down

# View logs from all services
logs:
docker compose -f deploy/docker-compose.yml logs -f

# View logs from a specific service
logs-service service:
# View logs for a service (e.g., just logs server, just logs db)
logs service:
docker compose -f deploy/docker-compose.yml logs -f {{service}}

# View server logs
server-logs:
docker compose -f deploy/docker-compose.yml logs -f server

# View last N lines of server logs (default: 100)
server-logs-tail lines="100":
docker compose -f deploy/docker-compose.yml logs --tail {{lines}} server

# View server logs with timestamps
server-logs-time:
docker compose -f deploy/docker-compose.yml logs -f -t server

# View server logs since a time (e.g., "10m", "1h", "2024-01-01")
server-logs-since since:
docker compose -f deploy/docker-compose.yml logs -f --since {{since}} server

# Shell into the server container
server-shell:
docker compose -f deploy/docker-compose.yml exec server bash
Expand Down Expand Up @@ -106,10 +86,6 @@ db-up:
db-down:
docker compose -f deploy/docker-compose.yml stop db

# View database logs
db-logs:
docker compose -f deploy/docker-compose.yml logs -f db

# Connect to PostgreSQL
db-connect:
docker compose -f deploy/docker-compose.yml exec db psql -U postgres -d osa
Expand Down
9 changes: 7 additions & 2 deletions deploy/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ services:
context: ../server
dockerfile: Dockerfile
target: builder
ports:
- "8000:8000"
volumes:
- ../server:/app
- /app/.venv
environment:
OSA_DATABASE__URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-osa}@db:5432/${POSTGRES_DB:-osa}
OSA_DATA_DIR: /data
OSA_CONFIG_FILE: /app/osa.yaml
OSA_LOGGING__LEVEL: ${LOG_LEVEL:-DEBUG}
WATCHFILES_FORCE_POLLING: "true"
command: uvicorn osa.application.api.rest.app:app --host 0.0.0.0 --port 8000 --reload
entrypoint: []
command: ["sh", "-c", "/app/.venv/bin/alembic upgrade head && /app/.venv/bin/uvicorn osa.application.api.rest.app:app --host 0.0.0.0 --port 8000 --reload"]
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/api/v1/health"]
interval: 10s
Expand All @@ -36,4 +40,5 @@ services:
API_URL: http://server:8000
ports:
- "3000:3000"
command: pnpm dev
entrypoint: []
command: ["pnpm", "dev"]
76 changes: 76 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# OSA Server Configuration - Secrets and Environment-Specific Values
# Copy this file to .env and fill in your values
#
# Relationship with osa.yaml:
# - .env: SECRETS (credentials, JWT secrets) - never commit to git
# - osa.yaml: APPLICATION CONFIG (sources, indexes) - can be version-controlled
#
# Priority (highest to lowest):
# 1. Environment variables
# 2. .env file
# 3. OSA_CONFIG_FILE (osa.yaml)
#
# Auth secrets MUST go in .env (not osa.yaml) for security

# =============================================================================
# ORCiD OAuth Configuration
# =============================================================================
# Register at: https://sandbox.orcid.org/developer-tools (sandbox)
# Register at: https://orcid.org/developer-tools (production)

# ORCiD OAuth client ID (e.g., APP-XXXXXXXXXXXX)
OSA_AUTH__ORCID__CLIENT_ID=

# ORCiD OAuth client secret
OSA_AUTH__ORCID__CLIENT_SECRET=

# Use ORCiD sandbox for development (default: true)
# Set to false for production
OSA_AUTH__ORCID__SANDBOX=true

# =============================================================================
# JWT Configuration
# =============================================================================

# JWT signing secret - minimum 32 characters required
# Generate with: openssl rand -hex 32
OSA_AUTH__JWT__SECRET=

# Access token expiry in minutes (default: 60)
# OSA_AUTH__JWT__ACCESS_TOKEN_EXPIRE_MINUTES=60

# Refresh token expiry in days (default: 7)
# OSA_AUTH__JWT__REFRESH_TOKEN_EXPIRE_DAYS=7

# =============================================================================
# OAuth Callback URL
# =============================================================================
# Must match the redirect URI registered in ORCiD developer tools

# For development (default derives from request URL)
# OSA_AUTH__CALLBACK_URL=http://localhost:8000/api/v1/auth/callback

# For production
# OSA_AUTH__CALLBACK_URL=https://your-domain.com/api/v1/auth/callback

# =============================================================================
# Frontend URL
# =============================================================================
# URL of the frontend application for OAuth redirects

# For development
OSA_FRONTEND__URL=http://localhost:3000

# For production
# OSA_FRONTEND__URL=https://your-domain.com

# =============================================================================
# Database Configuration
# =============================================================================
# Default: SQLite in ~/.local/share/osa/osa.db

# PostgreSQL example:
# OSA_DATABASE__URL=postgresql+asyncpg://user:password@localhost:5432/osa

# Echo SQL queries (for debugging)
# OSA_DATABASE__ECHO=false
3 changes: 3 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# Stage 1: Builder
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder

# Install curl for healthchecks in dev mode
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Enable bytecode compilation for faster startup
Expand Down
89 changes: 89 additions & 0 deletions server/migrations/versions/add_auth_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""add_auth_tables

Add users, identities, and refresh_tokens tables for authentication.

Revision ID: add_auth_tables
Revises: add_worker_columns
Create Date: 2026-02-04

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "add_auth_tables"
down_revision: Union[str, Sequence[str], None] = "add_worker_columns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add authentication tables."""
# USERS TABLE
op.create_table(
"users",
sa.Column("id", sa.String(), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)

# IDENTITIES TABLE
op.create_table(
"identities",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("provider", sa.String(50), nullable=False),
sa.Column("external_id", sa.String(255), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
ondelete="CASCADE",
),
sa.UniqueConstraint("provider", "external_id", name="uq_identity_provider_external"),
)
op.create_index("ix_identities_user_id", "identities", ["user_id"])

# REFRESH TOKENS TABLE
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("family_id", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
ondelete="CASCADE",
),
)
op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
op.create_index("ix_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"])
op.create_index("ix_refresh_tokens_family_id", "refresh_tokens", ["family_id"])


def downgrade() -> None:
"""Remove authentication tables."""
# REFRESH TOKENS
op.drop_index("ix_refresh_tokens_family_id", table_name="refresh_tokens")
op.drop_index("ix_refresh_tokens_token_hash", table_name="refresh_tokens")
op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens")
op.drop_table("refresh_tokens")

# IDENTITIES
op.drop_index("ix_identities_user_id", table_name="identities")
op.drop_table("identities")

# USERS
op.drop_table("users")
6 changes: 4 additions & 2 deletions server/osa/application/api/rest/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.responses import JSONResponse

from osa.application.api.v1.errors import map_osa_error
from osa.application.api.v1.routes import events, health, records, search, stats, validation
from osa.application.api.v1.routes import auth, events, health, records, search, stats, validation
from osa.application.di import create_container
from osa.config import Config, configure_logging
from osa.domain.shared.error import OSAError
Expand All @@ -32,7 +32,8 @@ async def lifespan(app: FastAPI):

def create_app() -> FastAPI:
"""Create FastAPI application."""
config = Config()
# Pydantic Settings populates from env vars at runtime
config = Config() # type: ignore[call-arg]

# Configure logging early
configure_logging(config.logging)
Expand All @@ -58,6 +59,7 @@ def create_app() -> FastAPI:

# Register v1 routes with /api/v1 prefix
app_instance.include_router(health.router, prefix="/api/v1")
app_instance.include_router(auth.router, prefix="/api/v1")
app_instance.include_router(events.router, prefix="/api/v1")
app_instance.include_router(records.router, prefix="/api/v1")
app_instance.include_router(search.router, prefix="/api/v1")
Expand Down
Loading
Loading