diff --git a/.agents/skills/alembic-migrations/SKILL.md b/.agents/skills/alembic-migrations/SKILL.md deleted file mode 100644 index e707bb5..0000000 --- a/.agents/skills/alembic-migrations/SKILL.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: alembic-migrations -description: Use when changing PostgreSQL schema or persisted data in this repository, including SQLAlchemy model updates, Alembic revisions, autogeneration review, and migration verification. Pair with endpoint or service work when application behavior also changes. ---- - -# Alembic Migrations - -1. Inspect `alembic/env.py`, `papyrus/models`, and the code that depends on the schema change before generating a revision. -2. Update SQLAlchemy models first. Ensure new or changed models are imported from `papyrus.models` so `Base.metadata` is visible to Alembic. -3. Generate a revision with `uv run alembic revision --autogenerate -m ""`, then review the generated upgrade and downgrade manually. -4. Do not trust autogenerate blindly. Confirm column types, defaults, nullability, indexes, foreign keys, and constraint names. -5. Keep revisions small, deterministic, and reversible when practical. -6. For destructive changes or data backfills, require explicit user approval and document the assumptions inline in the migration. -7. Apply the migration with `uv run alembic upgrade head` and run the narrowest relevant tests. -8. If the schema changed, verify the consuming application code and tests changed in the same work. - -Return the revision id, what changed, how it was verified, and any rollback caveats. diff --git a/.agents/skills/backend-review/SKILL.md b/.agents/skills/backend-review/SKILL.md deleted file mode 100644 index c7e2b23..0000000 --- a/.agents/skills/backend-review/SKILL.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: backend-review -description: Use when reviewing FastAPI and Postgres backend changes in this repository for bugs, regressions, migration risk, SQLAlchemy issues, API contract drift, and missing tests. Do not use for feature implementation unless the user explicitly asked for review-plus-fix work. ---- - -# Backend Review - -1. Default to a code review mindset. Prioritize correctness, security, behavior regressions, data integrity, migration safety, and missing tests. -2. Inspect changed routes, services, schemas, models, migrations, and matching tests together. -3. Check especially for: - - business logic left in route handlers instead of `papyrus/services` - - behavior changes without test coverage - - schema changes without Alembic revisions - - migration changes without downgrade or verification - - async SQLAlchemy session, transaction, or query bugs - - auth, validation, or serialization drift -4. Report findings first with file references and concrete impact. -5. Avoid style-only comments unless they hide a real correctness or maintainability problem. -6. If no findings remain, say so explicitly and mention any residual testing or verification gaps. - -Return findings ordered by severity, then open questions or assumptions, then a short change summary. diff --git a/.agents/skills/fastapi-endpoints/SKILL.md b/.agents/skills/fastapi-endpoints/SKILL.md deleted file mode 100644 index d3901d8..0000000 --- a/.agents/skills/fastapi-endpoints/SKILL.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: fastapi-endpoints -description: Use when adding or modifying FastAPI endpoints in this repository, including route handlers, schema wiring, router registration, service-layer delegation, and endpoint tests. Pair with the migration skill when persisted schema changes are involved. ---- - -# FastAPI Endpoints - -1. Inspect the relevant route module in `papyrus/api/routes/`, the matching schemas in `papyrus/schemas/`, any existing service module in `papyrus/services/`, and the nearest tests in `tests/api/routes/`. -2. Keep handlers thin. They should handle dependency injection, request parsing, service calls, error translation, and response shaping only. -3. Put business rules, query orchestration, and transaction-aware logic in `papyrus/services/.py`. -4. Reuse or add Pydantic schemas for request and response models. Keep explicit return types. -5. If you add a new router module, register it in `papyrus/api/routes/__init__.py`. -6. Add or update route tests for the changed behavior. Add service tests when logic moves into `papyrus/services/`. -7. If the endpoint needs a schema change, pair this skill with `alembic-migrations`. -8. Before finishing, run the narrowest relevant checks: `uv run pytest ...`, `uv run ruff check ...`, and the current typecheck command for the touched scope. - -Return a short summary that names the touched routes, services, tests, and any follow-up risk. diff --git a/.agents/skills/postgres-sqlalchemy-debugging/SKILL.md b/.agents/skills/postgres-sqlalchemy-debugging/SKILL.md deleted file mode 100644 index c94a3ba..0000000 --- a/.agents/skills/postgres-sqlalchemy-debugging/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: postgres-sqlalchemy-debugging -description: Use when diagnosing PostgreSQL, async SQLAlchemy, or Alembic issues in this repository, including bad queries, session and transaction bugs, metadata drift, migration mismatches, and test database failures. ---- - -# Postgres And SQLAlchemy Debugging - -1. Reproduce first. Capture the failing command, stack trace, SQL, endpoint, or pytest target. -2. Inspect the relevant layers in order: caller route or service, `papyrus/core/database.py`, schemas or models, migration state, and `tests/conftest.py` when the failure is test-only. -3. Sort the failure into one of these buckets before changing code: - - model or schema drift versus migration drift - - session lifecycle or transaction boundary bugs - - async SQLAlchemy misuse - - query-shape bugs such as joins, filters, pagination, or serialization mismatches - - environment or configuration issues involving Docker Postgres or database URLs -4. Prefer the smallest fix that fully explains the failure. -5. Keep route handlers thin. Move query and persistence fixes into `papyrus/services` or model-level helpers where possible. -6. When the root cause is schema drift, pair this skill with `alembic-migrations`. -7. Add a regression test for any behavior change. -8. Verify with the narrowest relevant command, such as the failing pytest target or `uv run alembic upgrade head`. - -Return the root cause, fix, verification, and any remaining uncertainty. diff --git a/.codex/agents/backend-reviewer.toml b/.codex/agents/backend-reviewer.toml deleted file mode 100644 index 617ecdb..0000000 --- a/.codex/agents/backend-reviewer.toml +++ /dev/null @@ -1,12 +0,0 @@ -name = "backend_reviewer" -description = "Read-only reviewer for FastAPI and Postgres backend changes in this repository." -model_reasoning_effort = "high" -sandbox_mode = "read-only" -developer_instructions = """ -Review backend changes like an owner. -Prioritize correctness, security, behavior regressions, data integrity, migration safety, SQLAlchemy session and query issues, and missing tests. -Treat missing migrations for schema changes and missing tests for behavior changes as real findings. -Lead with concrete findings that cite files and explain impact. -Avoid style-only feedback unless it hides a bug or a maintenance risk. -Do not make code changes. -""" diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 59df92e..0000000 --- a/.codex/config.toml +++ /dev/null @@ -1,14 +0,0 @@ -profile = "repo_safe" -project_doc_max_bytes = 65536 - -[profiles.repo_safe] -approval_policy = "on-request" -sandbox_mode = "workspace-write" -personality = "pragmatic" - -[sandbox_workspace_write] -network_access = false - -[agents] -max_threads = 4 -max_depth = 1 diff --git a/.env.example b/.env.example index d2382da..9ce5d9e 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DEBUG=false HOST=0.0.0.0 PORT=8080 API_PREFIX=/v1 -CORS_ORIGINS=["http://localhost:3000"] +CORS_ORIGINS='["http://papyrus.localhost:3000"]' SECRET_KEY= ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -12,16 +12,24 @@ RATE_LIMIT_GENERAL=100 RATE_LIMIT_UPLOAD=10 RATE_LIMIT_BATCH=20 -# Public server URL used in OAuth callbacks and email links. +# Public server URL used in OAuth callbacks and API links. # Local desktop testing: http://localhost:8080 # Mobile/device or real Google testing: use a public HTTPS URL or tunnel. PUBLIC_BASE_URL=http://localhost:8080 +# Public Flutter app URL used in user-facing email links. +# Local papyrus.localhost testing requires: +# 127.0.0.1 papyrus.localhost +# ::1 papyrus.localhost +APP_PUBLIC_BASE_URL=http://papyrus.localhost:3000 + # Google OAuth web-application credentials. # Authorized redirect URI should be: # /v1/auth/oauth/google/callback GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= +OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["localhost","127.0.0.1"] OAUTH_STATE_EXPIRE_MINUTES=10 AUTH_EXCHANGE_CODE_EXPIRE_MINUTES=5 EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES=1440 @@ -54,6 +62,9 @@ POWERSYNC_JWT_PUBLIC_KEY_FILE=.local/powersync/public.pem POWERSYNC_JWT_PRIVATE_KEY= POWERSYNC_JWT_PUBLIC_KEY= POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev +POWERSYNC_JWT_PREVIOUS_PUBLIC_KEY= +POWERSYNC_JWT_PREVIOUS_PUBLIC_KEY_FILE= +POWERSYNC_JWT_PREVIOUS_KEY_ID= POWERSYNC_JWT_AUDIENCE=powersync-dev POWERSYNC_TOKEN_EXPIRE_MINUTES=5 POWERSYNC_SERVICE_URL=http://localhost:8081 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bc76c9..51c97dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [master] pull_request: jobs: diff --git a/.gitignore b/.gitignore index 9fe3350..8a227ce 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ logs/ # OS .DS_Store Thumbs.db +.codex +.agents \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d46e767..5bd018a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.12-slim AS builder WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -COPY pyproject.toml uv.lock ./ +COPY pyproject.toml uv.lock README.md ./ RUN uv sync --frozen --no-dev FROM python:3.12-slim AS runtime diff --git a/README.md b/README.md index 09d01dd..23fcca7 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,39 @@ -# Papyrus server +# Papyrus Server -REST API server for Papyrus, a cross platform book management application. +FastAPI backend for Papyrus authentication, metadata, file storage, and +PowerSync-backed synchronization. -## Getting started +## Auth And Sync -Install dependencies: +- Email/password auth uses `POST /v1/auth/register` and + `POST /v1/auth/login`. +- Google auth starts at `GET /v1/auth/oauth/google/start` and finishes through + `POST /v1/auth/exchange-code`. +- PowerSync credentials come from `POST /v1/auth/powersync-token`. +- PowerSync uploads use `POST /v1/sync/powersync-upload`. -```bash -uv sync --extra dev -``` - -Run the database: - -```bash -docker compose up -d database mailpit powersync-storage powersync -``` - -Run database migrations: - -```bash -uv run alembic upgrade head -``` +Read the focused guides: -Run the server: +- [Flutter auth and PowerSync integration](docs/flutter-auth-integration.md) +- [Authentication testing](docs/auth-testing.md) +- [PowerSync sandbox](docs/powersync-sandbox.md) -```bash -uv run uvicorn papyrus.main:app --reload --port 8080 -``` +## Local Setup -Run the dev-pages asset server with live TS/SCSS reload: +Run from `server/`: ```bash +uv sync --extra dev +./scripts/bootstrap_local.sh npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev ``` -Generate local PowerSync keys for auth testing: - -```bash -./scripts/generate_dev_powersync_keys.sh -``` - -Initialize the local PowerSync source role and publication after migrations: - -```bash -./scripts/setup_local_powersync.sh -``` - -## Development +The bootstrap is idempotent: it creates development keys when missing, starts +the databases, applies Alembic migrations, configures logical replication, and +starts the healthy server and pinned PowerSync services. -Run tests: +## Checks ```bash uv run pytest --cov --cov-report html diff --git a/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py b/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py new file mode 100644 index 0000000..50b6d08 --- /dev/null +++ b/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py @@ -0,0 +1,58 @@ +"""add powersync domain tables + +Revision ID: a1d7c2f4e8b9 +Revises: 89143b2dc5b3 +Create Date: 2026-05-09 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1d7c2f4e8b9" +down_revision: str | Sequence[str] | None = "89143b2dc5b3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "books", + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("subtitle", sa.String(length=500), nullable=True), + sa.Column("author", sa.String(length=255), nullable=True), + sa.Column("co_authors", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("isbn", sa.String(length=32), nullable=True), + sa.Column("isbn13", sa.String(length=32), nullable=True), + sa.Column("publisher", sa.String(length=255), nullable=True), + sa.Column("language", sa.String(length=16), nullable=True), + sa.Column("page_count", sa.Integer(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("cover_image_url", sa.String(length=2048), nullable=True), + sa.Column("reading_status", sa.String(length=32), nullable=True), + sa.Column("current_page", sa.Integer(), nullable=True), + sa.Column("current_position", sa.Float(), nullable=True), + sa.Column("current_cfi", sa.Text(), nullable=True), + sa.Column("is_favorite", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("rating", sa.Integer(), nullable=True), + sa.Column("custom_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("book_id"), + ) + op.create_index(op.f("ix_books_owner_user_id"), "books", ["owner_user_id"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_books_owner_user_id"), table_name="books") + op.drop_table("books") diff --git a/docker-compose.yml b/docker-compose.yml index 5287f22..e9b0b25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: - "8025:8025" powersync: - image: journeyapps/powersync-service:latest + image: journeyapps/powersync-service:1.23.0 restart: unless-stopped command: ["start", "-r", "unified"] extra_hosts: @@ -60,6 +60,17 @@ services: condition: service_healthy powersync-storage: condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:8080/probes/liveness').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))", + ] + interval: 5s + timeout: 2s + retries: 15 server: build: . @@ -74,6 +85,17 @@ services: condition: service_started command: > sh -c "alembic upgrade head && papyrus-server" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)", + ] + interval: 5s + timeout: 3s + retries: 15 volumes: postgres_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md index 0bc29d1..04aa49b 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -1,37 +1,15 @@ -# Authentication Testing Setup +# Authentication Testing -This repo can now support a full local auth test loop: +Use this runbook to test Papyrus auth, local email delivery, and Google OAuth. -- email/password login -- refresh and logout flows -- verification and password-reset emails -- PowerSync token minting -- Google OAuth browser flow after you add Google credentials +## Setup -For Flutter client integration guidance, see [`flutter-auth-integration.md`](flutter-auth-integration.md). - -For the self-hosted PowerSync sync sandbox and two-client validation workflow, see [`powersync-sandbox.md`](powersync-sandbox.md). - -## Local Setup - -1. Start local dependencies: - -```bash -docker compose up -d database mailpit -``` - -1. Generate local PowerSync signing keys: - -```bash -./scripts/generate_dev_powersync_keys.sh -``` - -1. Make sure `.env` contains the auth values shown in `.env.example`. - -Recommended local values when the API runs on the host with `uvicorn`: +Create `.env` from `.env.example` and set these values: ```dotenv PUBLIC_BASE_URL=http://localhost:8080 +APP_PUBLIC_BASE_URL=http://papyrus.localhost:3000 +CORS_ORIGINS=["http://papyrus.localhost:3000"] EMAIL_DELIVERY_ENABLED=true SMTP_HOST=127.0.0.1 SMTP_PORT=1025 @@ -39,49 +17,53 @@ SMTP_USE_TLS=false SMTP_USE_SSL=false SMTP_FROM_EMAIL=noreply@papyrus.local SMTP_FROM_NAME=Papyrus +GOOGLE_OAUTH_CLIENT_ID=... +GOOGLE_OAUTH_CLIENT_SECRET=... +OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["localhost","127.0.0.1"] POWERSYNC_JWT_PRIVATE_KEY_FILE=.local/powersync/private.pem POWERSYNC_JWT_PUBLIC_KEY_FILE=.local/powersync/public.pem POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev POWERSYNC_JWT_AUDIENCE=powersync-dev ``` -If the API runs inside Docker instead of on the host, set `SMTP_HOST=mailpit` and `POSTGRES_HOST=database`. +Add local app host entries: -1. Apply migrations and run the API: - -```bash -uv run alembic upgrade head -uv run uvicorn papyrus.main:app --reload +```text +127.0.0.1 papyrus.localhost +::1 papyrus.localhost ``` -1. Run the dev-pages asset server for live TypeScript and SCSS reload: +Run from `server/`: ```bash +uv sync --extra dev +./scripts/generate_dev_powersync_keys.sh +docker compose up -d database mailpit +uv run alembic upgrade head +uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev ``` -If you do not want to run Vite, build the sandbox assets once instead: +## Local Pages -```bash -npm --prefix frontend/dev-pages run build -``` - -## Useful Local Pages - -- API index: `http://localhost:8080/` +- Auth sandbox: `http://localhost:8080/__dev/auth-sandbox` +- Mailpit inbox: `http://localhost:8025` - Swagger UI: `http://localhost:8080/docs` - ReDoc: `http://localhost:8080/redoc` -- Dev auth sandbox: `http://localhost:8080/__dev/auth-sandbox` -- Mailpit inbox UI: `http://localhost:8025` -## SMTP End-to-End Testing +## Email Testing + +Use the auth sandbox to trigger: + +- registration verification email +- resend verification email +- password reset email -Mailpit is a local SMTP sink. No real mailbox is needed. +Open `http://localhost:8025` and inspect the delivered messages. -- Trigger `forgot password` or `resend verification` from the sandbox or API. -- Open `http://localhost:8025` to inspect the delivered messages. -- For the opt-in smoke test, use any recipient address: +Run the SMTP smoke test: ```bash RUN_SMTP_SMOKE_TEST=true \ @@ -89,86 +71,30 @@ AUTH_SMOKE_EMAIL_RECIPIENT=smoke@papyrus.local \ uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q ``` -## Google OAuth Setup - -Papyrus uses a server-owned browser OAuth flow. The Flutter app opens: - -- `GET /v1/auth/oauth/google/start` - -Google redirects back to the server callback: +## Google OAuth -- `GET /v1/auth/oauth/google/callback` +Create a Google OAuth client: -The server then redirects to your app callback URI with a one-time Papyrus exchange code. +- Application type: `Web application` +- Authorized redirect URI: + `http://localhost:8080/v1/auth/oauth/google/callback` -### What To Create In Google Cloud +Keep the OAuth consent screen in testing mode and add the testing Google +account as a test user. -Create an OAuth client with: - -- Client type: `Web application` -- Redirect URI: - - local desktop testing: `http://localhost:8080/v1/auth/oauth/google/callback` - - public tunnel/device testing: `https:///v1/auth/oauth/google/callback` - -Authorized JavaScript origins are not required for this backend-owned redirect flow. If the Google UI requires one for localhost testing, use: - -- `http://localhost:8080` - -Set the resulting values in `.env`: - -```dotenv -GOOGLE_OAUTH_CLIENT_ID=... -GOOGLE_OAUTH_CLIENT_SECRET=... -PUBLIC_BASE_URL=http://localhost:8080 -``` - -For mobile-device testing or any device where the browser cannot reach your workstation as `localhost`, use a public HTTPS base URL and set `PUBLIC_BASE_URL` to that exact value. - -### Localhost vs Public Testing - -- Desktop same-machine testing: - - `PUBLIC_BASE_URL=http://localhost:8080` - - Google redirect URI: `http://localhost:8080/v1/auth/oauth/google/callback` -- Mobile emulator, physical phone, or shared test device: - - expose the backend through a public HTTPS URL - - set `PUBLIC_BASE_URL=https://` - - Google redirect URI: `https:///v1/auth/oauth/google/callback` - -The callback URI must match Google exactly, including scheme, host, port, path, and trailing slash behavior. - -### OAuth Consent Screen Notes - -For development: - -- keep the app in testing mode -- add your Google account under test users if Google requires it - -Papyrus only requests basic identity scopes: +Papyrus requests these scopes: - `openid` - `email` - `profile` -## Google Smoke Test - -The Google smoke test now validates a live Papyrus session produced by a successful Google browser login. +Test the browser flow: -Recommended workflow: - -1. Complete a real Google login in the auth sandbox. -2. Copy the access token or refresh token from the sandbox. -3. Run the smoke test against the running server. - -Access-token-only mode: - -```bash -RUN_GOOGLE_SMOKE_TEST=true \ -AUTH_SMOKE_SERVER_BASE_URL=http://localhost:8080 \ -AUTH_SMOKE_GOOGLE_ACCESS_TOKEN= \ -uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q -``` - -Refresh-token mode is more durable and also validates token rotation: +1. Open `http://localhost:8080/__dev/auth-sandbox`. +2. Start Google login. +3. Complete the Google browser flow. +4. Copy the refresh token from the sandbox. +5. Run the smoke test: ```bash RUN_GOOGLE_SMOKE_TEST=true \ @@ -177,11 +103,5 @@ AUTH_SMOKE_GOOGLE_REFRESH_TOKEN= \ uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q ``` -If both are provided, the test tries the access token first and falls back to refresh if the access token is expired. - -Notes: - -- refresh-token mode rotates the provided refresh token, so the old token will stop working after the test -- on success, the test prints `AUTH_SMOKE_ROTATED_REFRESH_TOKEN=...`; use that value for the next manual run -- if you only provide an access token, the test is non-destructive but depends on that token still being unexpired -- `AUTH_SMOKE_SERVER_BASE_URL` defaults to `PUBLIC_BASE_URL` if omitted +The smoke test prints `AUTH_SMOKE_ROTATED_REFRESH_TOKEN=...`. Use that refresh +token for the next run. diff --git a/docs/flutter-auth-integration.md b/docs/flutter-auth-integration.md index 129247a..eba292a 100644 --- a/docs/flutter-auth-integration.md +++ b/docs/flutter-auth-integration.md @@ -1,502 +1,190 @@ -# Flutter Authentication Integration +# Flutter Auth And PowerSync Integration -This guide describes how a Flutter app should integrate with the Papyrus server authentication that already exists in this backend. +Use this guide to connect the Flutter client to Papyrus authentication and +PowerSync. -It covers: +## Overview -- Android -- iOS -- macOS -- Windows -- Linux -- Flutter web +- Offline mode is local-only and does not create a server session. +- Email/password auth uses Papyrus auth endpoints. +- Google auth uses a Papyrus-owned browser OAuth flow. +- The Flutter client stores Papyrus refresh tokens and keeps access tokens in + memory. +- PowerSync receives short-lived JWTs from Papyrus. +- Local PowerSync writes upload through the Papyrus server. -Papyrus is the authentication authority. The Flutter app should not talk to Google directly as its main auth API. Instead: +## Runtime Flow -- email/password uses Papyrus auth endpoints directly -- Google login uses a Papyrus-owned browser OAuth flow -- PowerSync uses Papyrus-issued PowerSync tokens after Papyrus authentication succeeds +1. Flutter builds `PapyrusApiConfig.fromEnvironment()`. +2. `AuthProvider.bootstrap()` asks `AuthRepository` for a stored refresh token. +3. With a stored refresh token, Flutter calls `POST /v1/auth/refresh`. +4. After auth succeeds, `main.dart` connects `PapyrusPowerSyncService`. +5. PowerSync calls `PapyrusPowerSyncConnector.fetchCredentials()`. +6. The connector calls `POST /v1/auth/powersync-token`. +7. PowerSync connects to `POWERSYNC_SERVICE_URL` with the Papyrus-issued JWT. +8. PowerSync reads user-scoped rows from Postgres through + `server/powersync/sync-config.yaml`. +9. Local PowerSync writes upload through `POST /v1/sync/powersync-upload`. +10. The server validates ownership and writes the mutations to Postgres. -## Architecture +## Offline Mode -Papyrus is designed for an offline-first client. +`AuthProvider.setOfflineMode(true)` clears Papyrus tokens and loads local data. +PowerSync disconnects and clears the synced local database. App routes stay +available through `AuthProvider.isOfflineMode`. -- The app can remain unauthenticated while the user is using only local features. -- The app authenticates only when the user enables cloud-backed features such as sync. -- The server owns all account state, sessions, refresh-token rotation, Google identity linking, and PowerSync token minting. -- Google is only an upstream identity provider. The Flutter app should never send Google access tokens or ID tokens to Papyrus APIs unless the backend contract explicitly changes in the future. +## Email And Password Auth -## Recommended Flutter Packages +Login: -Use these packages as the baseline: - -- `dio` for API calls and interceptors -- `flutter_secure_storage` for storing the Papyrus refresh token on Android, iOS, macOS, Windows, and Linux -- `flutter_web_auth_2` for browser-based Google OAuth login and callback handling -- `app_links` only if you need deeper custom scheme or universal-link handling than `flutter_web_auth_2` already provides - -If your app already standardizes on `http` instead of `dio`, the HTTP contract stays the same. The main reason to prefer `dio` here is interceptor support for bearer-token attachment and one-time refresh retry. - -## Backend Endpoints - -The Flutter app should integrate with these server endpoints: - -- `POST /v1/auth/register` -- `POST /v1/auth/login` -- `POST /v1/auth/refresh` -- `POST /v1/auth/logout` -- `POST /v1/auth/logout-all` -- `GET /v1/auth/oauth/google/start` -- `POST /v1/auth/exchange-code` -- `POST /v1/auth/link/google/start` -- `POST /v1/auth/link/google/complete` -- `POST /v1/auth/powersync-token` -- `GET /v1/users/me` - -Related endpoints that are usually needed in a real client flow: - -- `POST /v1/auth/resend-verification` -- `POST /v1/auth/verify-email` -- `POST /v1/auth/forgot-password` -- `POST /v1/auth/reset-password` -- `POST /v1/users/me/change-password` - -## Tokens And Storage - -Papyrus returns: - -- `access_token`: short-lived bearer token for normal API requests -- `refresh_token`: long-lived token used to get a new access token -- `expires_in`: access-token lifetime in seconds -- `user`: authenticated user profile - -Recommended storage model: - -- Keep `access_token` in memory only. -- Store `refresh_token` in `flutter_secure_storage` on Android, iOS, macOS, Windows, and Linux. -- On Flutter web, do not assume secure local storage is equivalent to native secure storage. Prefer in-memory access tokens and carefully managed refresh behavior. If you persist a refresh token in web storage, treat that as a weaker security posture and scope it accordingly. -- Every successful refresh rotates the refresh token. The client must overwrite the previously stored refresh token every time `POST /v1/auth/refresh` succeeds. - -## Request And Response Shapes - -### Register - -`POST /v1/auth/register` +```http +POST /v1/auth/login +``` ```json { "email": "reader@example.com", "password": "SecureP@ss123", - "display_name": "Reader", "client_type": "mobile", - "device_label": "pixel-9" -} -``` - -Successful response: - -```json -{ - "access_token": "", - "refresh_token": "", - "token_type": "Bearer", - "expires_in": 3600, - "user": { - "user_id": "11111111-1111-1111-1111-111111111111", - "email": "reader@example.com", - "display_name": "Reader", - "avatar_url": null, - "email_verified": false, - "created_at": "2026-03-28T12:00:00Z", - "last_login_at": "2026-03-28T12:00:00Z" - } + "device_label": "flutter-android" } ``` -### Login +Registration uses `POST /v1/auth/register` with the same client metadata and a +`display_name`. -`POST /v1/auth/login` - -```json -{ - "email": "reader@example.com", - "password": "SecureP@ss123", - "client_type": "desktop", - "device_label": "macbook-air" -} -``` +Auth responses include: -Response shape is the same as register. +- `access_token`: Papyrus bearer token kept in memory. +- `refresh_token`: opaque token stored by `TokenStore`. +- `expires_in`: access token lifetime in seconds. +- `user`: authenticated user profile. -### Refresh +## Google Auth -`POST /v1/auth/refresh` +Flutter opens the Papyrus OAuth start URL: -```json -{ - "refresh_token": "" -} +```text +GET /v1/auth/oauth/google/start?redirect_uri= ``` -Response shape is also the same as register. The returned `refresh_token` replaces the old one. - -### Exchange Papyrus Browser Code - -After a successful Google browser flow, Papyrus redirects back to the app with a Papyrus one-time `code`. The app then calls: - -`POST /v1/auth/exchange-code` +Google returns to Papyrus: -```json -{ - "code": "", - "client_type": "web", - "device_label": "chrome" -} +```text +GET /v1/auth/oauth/google/callback ``` -Response shape is the same as register. - -### Current User - -`GET /v1/users/me` - -Requires: +Papyrus redirects back to the Flutter callback with a one-time code. Flutter +exchanges that code for Papyrus tokens: ```http -Authorization: Bearer +POST /v1/auth/exchange-code ``` -Response: - ```json { - "user_id": "11111111-1111-1111-1111-111111111111", - "email": "reader@example.com", - "display_name": "Reader", - "avatar_url": null, - "email_verified": true, - "created_at": "2026-03-28T12:00:00Z", - "last_login_at": "2026-03-28T12:15:00Z" + "code": "", + "client_type": "web", + "device_label": "flutter-web" } ``` -### PowerSync Token - -`POST /v1/auth/powersync-token` +Flutter callback URIs: -Requires bearer auth. +- Mobile: `papyrus://auth/callback` +- Linux and Windows: `http://localhost:43821/auth/callback` +- Web: `/auth/callback` -Response: +Google Cloud redirect URI: -```json -{ - "token": "", - "expires_in": 300 -} +```text +http://localhost:8080/v1/auth/oauth/google/callback ``` -## Flutter Client Structure - -Keep the client split into a few clear responsibilities. - -### `AuthRepository` - -The repository should own: - -- register -- login -- refresh -- logout -- logout all -- Google login start and exchange-code completion -- Google link start and complete -- fetch current user -- fetch PowerSync token - -### `TokenStore` - -The token store should own: - -- current in-memory `accessToken` -- persisted `refreshToken` -- loading the persisted refresh token at app startup -- replacing the stored refresh token after refresh -- clearing both tokens on sign-out or unrecoverable auth failure - -### Auth State - -Use an explicit auth state model instead of only checking whether a token exists. - -Recommended states: - -- `signedOut` -- `authenticating` -- `signedIn` -- `refreshing` -- `authError` - -At minimum, `signedIn` should hold: - -- current user -- current access token in memory -- current refresh token presence - -## Email And Password Flow - -### Register - -1. User enables cloud features and chooses sign-up. -2. App calls `POST /v1/auth/register`. -3. App keeps `access_token` in memory. -4. App stores `refresh_token` in secure storage on native/desktop. -5. App transitions to authenticated state and may immediately call `GET /v1/users/me`. - -### Login - -1. App calls `POST /v1/auth/login`. -2. Store tokens the same way as register. -3. Treat the returned `user` as the initial authenticated profile. - -### Refresh Behavior +## Token Refresh -The HTTP client should: +`AuthRepository` owns token refresh. -1. attach `Authorization: Bearer ` to protected requests -2. if a protected request returns `401`, attempt exactly one refresh -3. call `POST /v1/auth/refresh` with the stored refresh token -4. replace both in-memory access token and stored refresh token with the returned values -5. retry the original request once -6. if refresh fails, clear tokens and transition to signed-out +- `bootstrap()` refreshes during startup with the stored refresh token. +- `createPowerSyncToken()` retries once after a Papyrus `401`. +- `uploadPowerSyncBatch()` retries once after a Papyrus `401`. +- Successful refresh responses replace the stored refresh token. +- Failed refresh clears tokens and signs the user out. -Do not allow multiple simultaneous refresh operations. Use a single in-flight refresh guard so concurrent `401` responses wait on the same refresh result. +## PowerSync Tokens -### Logout +Flutter requests a PowerSync JWT from Papyrus: -Use: - -- `POST /v1/auth/logout` to invalidate the current session -- `POST /v1/auth/logout-all` to invalidate all sessions - -After either call: - -- clear in-memory access token -- clear stored refresh token -- transition to signed-out - -## Google OAuth Flow - -Papyrus uses a server-owned browser flow. - -The Flutter app should not use `google_sign_in` as the primary integration path here. The server already owns: - -- the Google client ID and secret -- the Google callback -- identity verification -- account linking rules - -### Native And Desktop Flow - -Use a callback URI owned by the app, for example: - -- `papyrus://auth/callback` - -Recommended flow with `flutter_web_auth_2`: - -1. Build the Papyrus start URL: - - `GET /v1/auth/oauth/google/start?redirect_uri=papyrus://auth/callback` -2. Open that URL in the system browser with `flutter_web_auth_2`. -3. Google authenticates the user. -4. Papyrus receives the Google callback at `/v1/auth/oauth/google/callback`. -5. Papyrus redirects to `papyrus://auth/callback?code=` or `papyrus://auth/callback?error=`. -6. The app extracts `code` from the callback URL. -7. The app calls `POST /v1/auth/exchange-code`. -8. Papyrus returns normal auth tokens and the user profile. - -Example Dart shape: - -```dart -final result = await FlutterWebAuth2.authenticate( - url: '$baseUrl/v1/auth/oauth/google/start?redirect_uri=${Uri.encodeComponent('papyrus://auth/callback')}', - callbackUrlScheme: 'papyrus', -); - -final callbackUri = Uri.parse(result); -final code = callbackUri.queryParameters['code']; -final error = callbackUri.queryParameters['error']; +```http +POST /v1/auth/powersync-token +Authorization: Bearer ``` -If `error` is present, treat the login as failed and do not call `/v1/auth/exchange-code`. - -### Flutter Web Flow - -For Flutter web, the callback should be owned by the web app, for example: - -- `https://app.example.com/auth/callback` - -The flow is the same, except the app callback is an HTTPS route in the web app instead of a custom scheme. - -1. User clicks “Continue with Google”. -2. App navigates the browser to: - - `GET /v1/auth/oauth/google/start?redirect_uri=https://app.example.com/auth/callback` -3. After Google auth, Papyrus redirects to: - - `https://app.example.com/auth/callback?code=` -4. The Flutter web route reads the `code`. -5. The app calls `POST /v1/auth/exchange-code`. -6. The app stores tokens according to the web storage policy chosen by the app. - -Important distinction: - -- the Flutter app callback URI is the `redirect_uri` query parameter passed to Papyrus -- the Google redirect URI configured in Google Cloud must point to the Papyrus backend callback -- these are different URLs and should not be confused - -### Backend And Google Configuration - -The backend callback is always the Papyrus server callback: - -- `https:///v1/auth/oauth/google/callback` - -That server callback must match: - -- `PUBLIC_BASE_URL` -- the Google Cloud OAuth redirect URI configuration - -The Flutter app callback must not be registered as the Google redirect URI. Papyrus redirects to the app callback only after Papyrus has already completed the Google exchange. - -## Google Account Linking - -Linking Google to an existing Papyrus account requires an already authenticated Papyrus session. - -Flow: - -1. App is already signed in with Papyrus. -2. App calls `POST /v1/auth/link/google/start` with: - ```json { - "redirect_uri": "papyrus://auth/callback" + "token": "", + "expires_in": 300 } ``` -3. Papyrus returns: +Papyrus signs PowerSync tokens with RS256: -```json -{ - "authorization_url": "https://accounts.google.com/..." -} -``` +- `sub`: Papyrus user id. +- `aud`: `POWERSYNC_JWT_AUDIENCE`. +- `type`: `powersync`. +- `iat`: issued-at timestamp. +- `exp`: expiration timestamp. +- `kid`: `POWERSYNC_JWT_KEY_ID` header. -4. App opens `authorization_url` in the browser. -5. After browser completion, Papyrus redirects back to the app with a one-time Papyrus `code`. -6. App calls `POST /v1/auth/link/google/complete` with that code. +PowerSync validates tokens through: -```json -{ - "code": "" -} +```http +GET /v1/auth/jwks ``` -Important rule: - -- Papyrus does not auto-link by email - -If a Google account has the same email as an existing Papyrus account but is not linked yet, the user must first authenticate to Papyrus and then explicitly link Google. - -## PowerSync Integration - -After Papyrus authentication succeeds, the app should fetch a PowerSync token from Papyrus: - -- `POST /v1/auth/powersync-token` - -Use the Papyrus access token to authenticate that request. - -The PowerSync token is separate from the Papyrus API access token: - -- Papyrus API token authenticates calls to the Papyrus backend -- PowerSync token authenticates calls to PowerSync - -Do not send Google tokens directly to PowerSync. - -Recommended client behavior: +## PowerSync Data Flow -- request a fresh PowerSync token on PowerSync startup -- refresh it when PowerSync needs new credentials -- keep PowerSync token handling separate from the main Papyrus refresh-token flow +Pull: -## Failure Handling +1. PowerSync validates the client JWT. +2. `sync-config.yaml` selects rows with + `WHERE owner_user_id::text = auth.user_id()`. +3. PowerSync sends user rows to the Flutter local PowerSync database. +4. Flutter repositories watch the local PowerSync database. +5. `DataStore` receives a read snapshot for existing feature providers; books + are never independently persisted in memory. -The Flutter app should handle these cases explicitly. +Upload: -### Protected API Returns `401` +1. Flutter writes to the local PowerSync database. +2. PowerSync queues CRUD mutations. +3. `PapyrusPowerSyncConnector.uploadData()` reads queued transactions. +4. The connector posts the batch to `POST /v1/sync/powersync-upload`. +5. The server applies the complete CRUD transaction atomically after ownership + and field validation. +6. PowerSync replication sends committed changes to connected clients. -- try refresh once -- if refresh succeeds, retry the failed request once -- if refresh fails, clear tokens and sign the user out +The production sync contract currently contains only `books`. Additional +entities should be added as complete vertical slices: PostgreSQL model and +migration, sync stream, upload validation, client schema, repository, and +end-to-end tests. -### Session Revocation +Guest and authenticated libraries use separate local databases: -Papyrus validates sessions server-side. Existing access tokens can stop working immediately after: +- `papyrus-guest.db` is local-only and persists until the user deletes it. +- `papyrus-account.db` connects to PowerSync and is cleared on logout or + account switch. +- Guest records are never merged into an authenticated account. -- logout -- logout all -- password change -- password reset -- account disablement +## Local Flutter Command -The client should treat `401` or `403` after those operations as expected behavior, not as a transport problem. +Run from `client/app/`: -### Google Flow Returns `error` - -If the app callback contains: - -- `?error=...` - -then the app should: - -- surface a user-friendly auth failure -- not call `/v1/auth/exchange-code` -- keep the current auth state unchanged unless the login flow was replacing an existing session intentionally - -### Refresh Rotation - -Refresh tokens rotate. If the app fails to persist the newly returned refresh token, the next refresh may fail because the previously stored token is stale. - -This is one of the most important client integration details in this auth design. - -## Local Development - -For local backend testing: - -- API docs: `http://localhost:8080/docs` -- auth sandbox: `http://localhost:8080/__dev/auth-sandbox` -- Mailpit: `http://localhost:8025` - -Use the sandbox to verify: - -- email/password register and login -- Google browser login -- exchange-code to tokens -- refresh rotation -- `/users/me` -- PowerSync token minting - -See [`auth-testing.md`](auth-testing.md) for: - -- local server setup -- Mailpit and SMTP testing -- Google Cloud setup for the backend callback -- provider smoke tests - -## Suggested Flutter Integration Order - -Implement the client in this order: +```bash +flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines +``` -1. email/password register and login -2. token store and refresh interceptor -3. `/users/me` bootstrap on app launch -4. logout and logout-all -5. Google browser login with exchange-code completion -6. Google account linking -7. PowerSync token integration +## Related Docs -This keeps the auth foundation simple before layering in browser and sync-specific behavior. +- [Authentication testing](auth-testing.md) +- [PowerSync sandbox](powersync-sandbox.md) diff --git a/docs/powersync-sandbox.md b/docs/powersync-sandbox.md index b7f7961..39f0acf 100644 --- a/docs/powersync-sandbox.md +++ b/docs/powersync-sandbox.md @@ -1,25 +1,13 @@ # PowerSync Sandbox -This repo includes a debug-only PowerSync sandbox that validates the full local flow: +Use this runbook to validate local Papyrus auth, PowerSync JWT minting, upload +handling, and two-client replication. -- Papyrus authentication -- Papyrus-issued PowerSync JWTs -- self-hosted PowerSync service -- client writes through the PowerSync upload queue -- replication back into another browser client +The sandbox writes to `powersync_demo_items`. -The sandbox uses a dedicated demo table, not the real books domain. +## Setup -## What Gets Created - -- source table: `powersync_demo_items` -- PowerSync debug page: `/__dev/powersync-sandbox` -- source snapshot API: `/__dev/powersync-demo/items` -- upload endpoint used by the PowerSync queue: `/__dev/powersync-demo/upload` - -## Required Local Configuration - -Make sure `.env` includes the PowerSync values from [`.env.example`](../.env.example), especially: +Set the PowerSync values in `.env`: ```dotenv POWERSYNC_JWT_PRIVATE_KEY_FILE=.local/powersync/private.pem @@ -35,119 +23,55 @@ POWERSYNC_STORAGE_USER=powersync_storage_user POWERSYNC_STORAGE_PASSWORD=powersync_storage_password ``` -If the API runs inside Docker instead of on the host, point `POWERSYNC_JWKS_URI` at the container-to-container API URL instead of `host.docker.internal`. - -## First-Time Setup - -1. Generate local PowerSync signing keys: - -```bash -./scripts/generate_dev_powersync_keys.sh -``` - -1. Start the local dependencies: - -```bash -docker compose up -d database mailpit powersync-storage -``` - -1. Apply migrations: - -```bash -uv run alembic upgrade head -``` - -1. Create the PowerSync replication role and publication: - -```bash -./scripts/setup_local_powersync.sh -``` - -1. Start the backend on the host: - -```bash -uv run uvicorn papyrus.main:app --reload --port 8080 -``` - -1. Start the sandbox asset dev server for live TS/SCSS reload: +Run from `server/`: ```bash +uv sync --extra dev +./scripts/bootstrap_local.sh npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev ``` -1. Start the PowerSync service: - -```bash -docker compose up -d powersync -``` +Use `--host 0.0.0.0` so the PowerSync container reaches the JWKS endpoint at +`host.docker.internal:8080`. -## Useful Local URLs +## URLs -- API index: `http://localhost:8080/` -- PowerSync sandbox: `http://localhost:8080/__dev/powersync-sandbox` +- Sandbox: `http://localhost:8080/__dev/powersync-sandbox` - Client one: `http://localhost:8080/__dev/powersync-sandbox?client=one` - Client two: `http://localhost:8080/__dev/powersync-sandbox?client=two` -- Swagger UI: `http://localhost:8080/docs` -- Mailpit: `http://localhost:8025` +- Source snapshot: `http://localhost:8080/__dev/powersync-demo/items` - PowerSync service: `http://localhost:8081` +- Mailpit inbox: `http://localhost:8025` -## Manual Validation Flow +## Validation 1. Open `client=one` and `client=two` in separate tabs. -1. Register or log in as the same user in both tabs. -1. In one tab, connect PowerSync. -1. In the other tab, connect PowerSync. -1. Create a demo item in either tab. -1. Confirm the item appears in: - - the local synced list in the creating tab - - the server source snapshot - - the local synced list in the second tab without a manual refresh -1. Update the item from the second tab. -1. Confirm the first tab updates automatically. -1. Delete the item from either tab. -1. Confirm it disappears from both tabs and the source snapshot. - -Repeat the same flow once after signing in through Google OAuth to confirm that Papyrus-issued PowerSync credentials work for provider-authenticated users too. - -## What Proves The Integration +2. Register or log in as the same user in both tabs. +3. Connect PowerSync in both tabs. +4. Create a demo item in `client=one`. +5. Confirm the item appears in `client=one`, the source snapshot, and + `client=two`. +6. Update the item in `client=two`. +7. Confirm the updated item appears in `client=one`. +8. Delete the item. +9. Confirm the item disappears from both clients and the source snapshot. -This sandbox is working correctly when all of the following are true: +Passing validation proves: -- the page can authenticate through Papyrus -- `POST /v1/auth/powersync-token` returns a valid PowerSync JWT -- local item writes are visible in the source snapshot -- the second client receives replicated changes automatically -- updates and deletes also replicate back into the other client +- Papyrus login works. +- `POST /v1/auth/powersync-token` returns a usable PowerSync JWT. +- PowerSync uploads reach Postgres. +- Replication delivers committed changes to another client. -## Resetting Local State +## Reset -If you change the PowerSync config, replication setup, or local browser database and the sandbox gets into a bad state: - -1. Stop the stack: - -```bash -docker compose down -``` - -1. Remove local Docker volumes if needed: +Run from `server/`: ```bash docker compose down -v +./scripts/bootstrap_local.sh +npm --prefix frontend/dev-pages run dev ``` -1. Clear the browser data for the sandbox page, or use a different `?client=` name. -1. Re-run: - - `docker compose up -d database mailpit powersync-storage` - - `uv run alembic upgrade head` - - `./scripts/setup_local_powersync.sh` - - `uv run uvicorn papyrus.main:app --reload --port 8080` - - `npm --prefix frontend/dev-pages run dev` - - `docker compose up -d powersync` - -## Notes - -- The source database table is managed with Alembic. -- The PowerSync publication and replication role are not managed with Alembic; they are initialized by [`scripts/setup_local_powersync.sh`](../scripts/setup_local_powersync.sh). -- The sandbox is debug-only and should not be exposed in production mode. -- If you prefer built assets over the Vite dev server, run `npm --prefix frontend/dev-pages run build` and set `DEV_PAGES_USE_VITE=false`. +Clear browser storage for `http://localhost:8080/__dev/powersync-sandbox`. diff --git a/papyrus/api/routes/auth.py b/papyrus/api/routes/auth.py index 0afb6d3..f390328 100644 --- a/papyrus/api/routes/auth.py +++ b/papyrus/api/routes/auth.py @@ -12,6 +12,7 @@ from papyrus.api.deps import CurrentSessionId, CurrentUserId from papyrus.config import get_settings from papyrus.core.database import get_db +from papyrus.core.rate_limit import limiter from papyrus.schemas.auth import ( AuthorizationUrlResponse, AuthTokens, @@ -36,6 +37,10 @@ DBSession = Annotated[AsyncSession, Depends(get_db)] +def _auth_rate_limit() -> str: + return f"{get_settings().rate_limit_auth}/minute" + + def _auth_tokens_response(result: auth_service.AuthResult) -> AuthTokens: return AuthTokens( access_token=result.access_token, @@ -56,13 +61,15 @@ def _public_callback_url(request: Request, route_name: str) -> str: route_parts = urlsplit(route_url) public_parts = urlsplit(public_base_url.rstrip("/")) - return urlunsplit(( - public_parts.scheme, - public_parts.netloc, - route_parts.path, - route_parts.query, - route_parts.fragment, - )) + return urlunsplit( + ( + public_parts.scheme, + public_parts.netloc, + route_parts.path, + route_parts.query, + route_parts.fragment, + ) + ) @router.post( @@ -71,13 +78,14 @@ def _public_callback_url(request: Request, route_name: str) -> str: status_code=status.HTTP_201_CREATED, summary="Register a new user account", ) +@limiter.limit(_auth_rate_limit) async def register_user( - request: RegisterRequest, - http_request: Request, + request: Request, + payload: RegisterRequest, db: DBSession, ) -> AuthTokens: """Create a new user account with email and password.""" - result = await auth_service.register_user(db, request, http_request.headers.get("user-agent")) + result = await auth_service.register_user(db, payload, request.headers.get("user-agent")) return _auth_tokens_response(result) @@ -86,13 +94,14 @@ async def register_user( response_model=AuthTokens, summary="Login with email and password", ) +@limiter.limit(_auth_rate_limit) async def login_user( - request: LoginRequest, - http_request: Request, + request: Request, + payload: LoginRequest, db: DBSession, ) -> AuthTokens: """Authenticate a user with email and password credentials.""" - result = await auth_service.login_user(db, request, http_request.headers.get("user-agent")) + result = await auth_service.login_user(db, payload, request.headers.get("user-agent")) return _auth_tokens_response(result) @@ -100,6 +109,7 @@ async def login_user( "/oauth/google/start", summary="Start Google OAuth login flow", ) +@limiter.limit(_auth_rate_limit) async def google_oauth_start( request: Request, redirect_uri: str = Query(..., description="App callback URI for the browser flow"), @@ -118,6 +128,7 @@ async def google_oauth_start( name="google_oauth_callback", summary="Complete the Google OAuth browser callback", ) +@limiter.limit(_auth_rate_limit) async def google_oauth_callback( request: Request, db: DBSession, @@ -142,13 +153,14 @@ async def google_oauth_callback( response_model=AuthTokens, summary="Exchange an OAuth browser code for Papyrus tokens", ) +@limiter.limit(_auth_rate_limit) async def exchange_code( - request: OAuthExchangeRequest, - http_request: Request, + request: Request, + payload: OAuthExchangeRequest, db: DBSession, ) -> AuthTokens: """Exchange a one-time OAuth handoff code for Papyrus tokens.""" - result = await auth_service.exchange_login_code(db, request, http_request.headers.get("user-agent")) + result = await auth_service.exchange_login_code(db, payload, request.headers.get("user-agent")) return _auth_tokens_response(result) @@ -157,12 +169,14 @@ async def exchange_code( response_model=AuthTokens, summary="Refresh access token", ) +@limiter.limit(_auth_rate_limit) async def refresh_token( - request: RefreshTokenRequest, + request: Request, + payload: RefreshTokenRequest, db: DBSession, ) -> AuthTokens: """Exchange a valid refresh token for a new access token.""" - result = await auth_service.refresh_tokens(db, request) + result = await auth_service.refresh_tokens(db, payload) return _auth_tokens_response(result) @@ -260,9 +274,14 @@ async def powersync_jwks() -> dict[str, list[dict[str, object]]]: response_model=MessageResponse, summary="Verify email address", ) -async def verify_email(request: VerifyEmailRequest, db: DBSession) -> MessageResponse: +@limiter.limit(_auth_rate_limit) +async def verify_email( + request: Request, + payload: VerifyEmailRequest, + db: DBSession, +) -> MessageResponse: """Verify the user's email address using a one-time token.""" - message = await auth_service.verify_email_token(db, request.token) + message = await auth_service.verify_email_token(db, payload.token) return MessageResponse(message=message) @@ -271,12 +290,14 @@ async def verify_email(request: VerifyEmailRequest, db: DBSession) -> MessageRes response_model=MessageResponse, summary="Resend verification email", ) +@limiter.limit(_auth_rate_limit) async def resend_verification( - request: ResendVerificationRequest, + request: Request, + payload: ResendVerificationRequest, db: DBSession, ) -> MessageResponse: """Issue a new verification token when email delivery is enabled.""" - message = await auth_service.resend_verification_email(db, request.email) + message = await auth_service.resend_verification_email(db, payload.email) return MessageResponse(message=message) @@ -285,12 +306,14 @@ async def resend_verification( response_model=MessageResponse, summary="Request password reset", ) +@limiter.limit(_auth_rate_limit) async def forgot_password( - request: ForgotPasswordRequest, + request: Request, + payload: ForgotPasswordRequest, db: DBSession, ) -> MessageResponse: """Issue a password reset token when email delivery is enabled.""" - message = await auth_service.begin_password_reset(db, request.email) + message = await auth_service.begin_password_reset(db, payload.email) return MessageResponse(message=message) @@ -299,10 +322,12 @@ async def forgot_password( response_model=MessageResponse, summary="Reset password with token", ) +@limiter.limit(_auth_rate_limit) async def reset_password( - request: ResetPasswordRequest, + request: Request, + payload: ResetPasswordRequest, db: DBSession, ) -> MessageResponse: """Reset the user's password using a one-time token.""" - message = await auth_service.reset_password(db, request.token, request.password) + message = await auth_service.reset_password(db, payload.token, payload.password) return MessageResponse(message=message) diff --git a/papyrus/api/routes/sync.py b/papyrus/api/routes/sync.py index ca6fc1b..84dfdb3 100644 --- a/papyrus/api/routes/sync.py +++ b/papyrus/api/routes/sync.py @@ -1,178 +1,33 @@ -"""Sync routes.""" +"""PowerSync upload routes.""" -from datetime import UTC, datetime -from uuid import uuid4 +from typing import Annotated -from fastapi import APIRouter, Response, status +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId -from papyrus.schemas.sync import ( - CreateMetadataServerConfigRequest, - MetadataServerConfig, - ServerType, - SyncAccepted, - SyncChanges, - SyncConflictResponse, - SyncPushRequest, - SyncPushResponse, - SyncStatus, - SyncStatusEnum, -) +from papyrus.config import get_settings +from papyrus.core.database import get_db +from papyrus.core.rate_limit import limiter +from papyrus.schemas.sync import PowerSyncUploadRequest, PowerSyncUploadResponse +from papyrus.services import sync as sync_service router = APIRouter() - - -@router.get( - "/status", - response_model=SyncStatus, - summary="Get sync status", -) -async def get_sync_status(user_id: CurrentUserId) -> SyncStatus: - """Return current sync status for the user.""" - return SyncStatus( - status=SyncStatusEnum.IDLE, - last_sync_at=datetime.now(UTC), - pending_changes=0, - ) - - -@router.get( - "/changes", - response_model=SyncChanges, - summary="Pull changes from server", -) -async def pull_changes( - user_id: CurrentUserId, - since: datetime | None = None, - device_id: str | None = None, -) -> SyncChanges: - """Pull changes from the server since the specified timestamp.""" - return SyncChanges( - changes=[], - server_timestamp=datetime.now(UTC), - ) - - -@router.post( - "/changes", - response_model=SyncPushResponse, - summary="Push changes to server", -) -async def push_changes( - user_id: CurrentUserId, - request: SyncPushRequest, -) -> SyncPushResponse: - """Push local changes to the server.""" - accepted = [] - - for change in request.changes: - accepted.append( - SyncAccepted( - entity_type=change.entity_type, - entity_id=change.entity_id, - new_version=(change.version or 0) + 1, - ) - ) - - return SyncPushResponse( - accepted=accepted, - rejected=[], - server_timestamp=datetime.now(UTC), - ) - - -@router.get( - "/conflicts", - response_model=SyncConflictResponse, - summary="Get unresolved conflicts", -) -async def get_sync_conflicts(user_id: CurrentUserId) -> SyncConflictResponse: - """Return any unresolved sync conflicts.""" - return SyncConflictResponse(conflicts=[]) - - -@router.post( - "/force", - status_code=status.HTTP_204_NO_CONTENT, - summary="Force full sync", -) -async def force_sync(user_id: CurrentUserId) -> Response: - """Force a full sync, re-downloading all data.""" - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -# Metadata server configuration routes -@router.get( - "/config", - response_model=MetadataServerConfig | None, - summary="Get metadata server configuration", -) -async def get_metadata_server_config(user_id: CurrentUserId) -> MetadataServerConfig | None: - """Return the current metadata server configuration.""" - return MetadataServerConfig( - config_id=uuid4(), - server_url="https://api.papyrus.app", - server_type=ServerType.OFFICIAL, - is_connected=True, - sync_enabled=True, - sync_interval_seconds=30, - last_sync_at=datetime.now(UTC), - sync_status=SyncStatusEnum.IDLE, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) +DBSession = Annotated[AsyncSession, Depends(get_db)] @router.post( - "/config", - response_model=MetadataServerConfig, - status_code=status.HTTP_201_CREATED, - summary="Configure metadata server", + "/powersync-upload", + response_model=PowerSyncUploadResponse, + summary="Upload PowerSync client-side mutations", ) -async def create_metadata_server_config( +@limiter.limit(lambda: f"{get_settings().rate_limit_batch}/minute") +async def upload_powersync_changes( + request: Request, user_id: CurrentUserId, - request: CreateMetadataServerConfigRequest, -) -> MetadataServerConfig: - """Configure a metadata server connection.""" - return MetadataServerConfig( - config_id=uuid4(), - server_url=str(request.server_url), - server_type=request.server_type, - is_connected=False, - sync_enabled=request.sync_enabled, - sync_interval_seconds=request.sync_interval_seconds, - sync_status=SyncStatusEnum.IDLE, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) - - -@router.delete( - "/config", - status_code=status.HTTP_204_NO_CONTENT, - summary="Remove metadata server configuration", -) -async def delete_metadata_server_config(user_id: CurrentUserId) -> Response: - """Remove the metadata server configuration.""" - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.post( - "/config/test", - response_model=MetadataServerConfig, - summary="Test metadata server connection", -) -async def test_metadata_server_connection(user_id: CurrentUserId) -> MetadataServerConfig: - """Test the connection to the configured metadata server.""" - return MetadataServerConfig( - config_id=uuid4(), - server_url="https://api.papyrus.app", - server_type=ServerType.OFFICIAL, - is_connected=True, - sync_enabled=True, - sync_interval_seconds=30, - last_sync_at=datetime.now(UTC), - sync_status=SyncStatusEnum.IDLE, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) + payload: PowerSyncUploadRequest, + db: DBSession, +) -> PowerSyncUploadResponse: + """Apply one PowerSync CRUD transaction atomically.""" + applied_count = await sync_service.apply_powersync_upload_batch(db, user_id, payload.batch) + return PowerSyncUploadResponse(applied_count=applied_count) diff --git a/papyrus/config.py b/papyrus/config.py index dc86cbe..786eef3 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -30,8 +30,11 @@ class Settings(BaseSettings): rate_limit_upload: int rate_limit_batch: int public_base_url: str | None = None + app_public_base_url: str | None = None google_oauth_client_id: str | None = None google_oauth_client_secret: str | None = None + oauth_allowed_redirect_schemes: list[str] = ["papyrus"] + oauth_allowed_redirect_hosts: list[str] = [] oauth_state_expire_minutes: int = 10 auth_exchange_code_expire_minutes: int = 5 email_verification_token_expire_minutes: int = 1440 @@ -49,6 +52,9 @@ class Settings(BaseSettings): powersync_jwt_private_key_file: str | None = None powersync_jwt_public_key: str | None = None powersync_jwt_public_key_file: str | None = None + powersync_jwt_previous_public_key: str | None = None + powersync_jwt_previous_public_key_file: str | None = None + powersync_jwt_previous_key_id: str | None = None powersync_jwt_key_id: str = "papyrus-powersync-v1" powersync_jwt_audience: str | None = None powersync_token_expire_minutes: int = 5 @@ -100,6 +106,54 @@ def normalize_api_prefix(cls, value: str) -> str: def normalize_dev_pages_vite_url(cls, value: str) -> str: return value.strip().rstrip("/") + @field_validator( + "public_base_url", + "app_public_base_url", + "google_oauth_client_id", + "google_oauth_client_secret", + "smtp_host", + "smtp_username", + "smtp_password", + "smtp_from_email", + "smtp_from_name", + "powersync_jwt_private_key", + "powersync_jwt_private_key_file", + "powersync_jwt_public_key", + "powersync_jwt_public_key_file", + "powersync_jwt_previous_public_key", + "powersync_jwt_previous_public_key_file", + "powersync_jwt_previous_key_id", + "powersync_jwt_audience", + "powersync_jwks_uri", + "powersync_source_role", + "powersync_source_password", + "powersync_storage_db", + "powersync_storage_user", + "powersync_storage_password", + mode="before", + ) + @classmethod + def normalize_optional_string(cls, value: str | None) -> str | None: + if value is None: + return None + + if isinstance(value, str) and not value.strip(): + return None + + return value + + @field_validator("oauth_allowed_redirect_schemes", mode="before") + @classmethod + def normalize_oauth_allowed_redirect_schemes(cls, value: list[str] | str) -> list[str]: + raw_values = value.split(",") if isinstance(value, str) else value + return [item.strip().lower() for item in raw_values if item.strip()] + + @field_validator("oauth_allowed_redirect_hosts", mode="before") + @classmethod + def normalize_oauth_allowed_redirect_hosts(cls, value: list[str] | str) -> list[str]: + raw_values = value.split(",") if isinstance(value, str) else value + return [item.strip().lower() for item in raw_values if item.strip()] + @computed_field # type: ignore[prop-decorator] @property def database_url(self) -> str: @@ -111,7 +165,7 @@ def database_url(self) -> str: @computed_field # type: ignore[prop-decorator] @property def powersync_jwt_private_key_path(self) -> Path | None: - if self.powersync_jwt_private_key_file is None: + if self.powersync_jwt_private_key_file is None or not self.powersync_jwt_private_key_file.strip(): return None return Path(self.powersync_jwt_private_key_file) @@ -119,11 +173,22 @@ def powersync_jwt_private_key_path(self) -> Path | None: @computed_field # type: ignore[prop-decorator] @property def powersync_jwt_public_key_path(self) -> Path | None: - if self.powersync_jwt_public_key_file is None: + if self.powersync_jwt_public_key_file is None or not self.powersync_jwt_public_key_file.strip(): return None return Path(self.powersync_jwt_public_key_file) + @computed_field # type: ignore[prop-decorator] + @property + def powersync_jwt_previous_public_key_path(self) -> Path | None: + if ( + self.powersync_jwt_previous_public_key_file is None + or not self.powersync_jwt_previous_public_key_file.strip() + ): + return None + + return Path(self.powersync_jwt_previous_public_key_file) + @computed_field # type: ignore[prop-decorator] @property def dev_pages_manifest_file(self) -> Path: diff --git a/papyrus/core/rate_limit.py b/papyrus/core/rate_limit.py new file mode 100644 index 0000000..5b8c3e2 --- /dev/null +++ b/papyrus/core/rate_limit.py @@ -0,0 +1,6 @@ +"""Shared application rate limiter.""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/papyrus/core/security.py b/papyrus/core/security.py index 60c618f..8334630 100644 --- a/papyrus/core/security.py +++ b/papyrus/core/security.py @@ -23,7 +23,7 @@ def _normalize_pem(value: str) -> str: def _load_pem_configured_value(value: str | None, file_path: Path | None) -> str | None: - if value is not None: + if value is not None and value.strip(): return _normalize_pem(value) if file_path is None: @@ -63,6 +63,33 @@ def _get_powersync_public_key() -> Any: return _get_powersync_private_key().public_key() +@lru_cache +def _get_powersync_previous_public_key() -> Any | None: + settings = get_settings() + public_key_pem = _load_pem_configured_value( + settings.powersync_jwt_previous_public_key, + settings.powersync_jwt_previous_public_key_path, + ) + + if public_key_pem is None: + return None + + return serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + + +def _public_key_to_jwk(public_key: Any, key_id: str) -> dict[str, Any]: + jwk = RSAAlgorithm.to_jwk(public_key, as_dict=True) + + jwk.update( + { + "kid": key_id, + "alg": "RS256", + "use": "sig", + } + ) + return jwk + + def _create_signed_token( data: dict[str, Any], token_type: str, @@ -152,13 +179,10 @@ def create_powersync_token(user_id: str, expires_delta: timedelta | None = None) def get_powersync_jwks() -> dict[str, list[dict[str, Any]]]: settings = get_settings() - jwk = RSAAlgorithm.to_jwk(_get_powersync_public_key(), as_dict=True) + keys = [_public_key_to_jwk(_get_powersync_public_key(), settings.powersync_jwt_key_id)] + previous_public_key = _get_powersync_previous_public_key() - jwk.update( - { - "kid": settings.powersync_jwt_key_id, - "alg": "RS256", - "use": "sig", - } - ) - return {"keys": [jwk]} + if previous_public_key is not None and settings.powersync_jwt_previous_key_id is not None: + keys.append(_public_key_to_jwk(previous_public_key, settings.powersync_jwt_previous_key_id)) + + return {"keys": keys} diff --git a/papyrus/main.py b/papyrus/main.py index 1339875..f3db84d 100644 --- a/papyrus/main.py +++ b/papyrus/main.py @@ -11,17 +11,16 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded -from slowapi.util import get_remote_address from papyrus.api.routes import api_router, include_debug_routers from papyrus.config import get_settings from papyrus.core.dev_pages import DEV_PAGES_DIST_DIR, DEV_PAGES_STATIC_URL from papyrus.core.exceptions import AppError +from papyrus.core.rate_limit import limiter settings = get_settings() -limiter = Limiter(key_func=get_remote_address) def configure_logging() -> None: diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 9008638..1f92bfb 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -1,6 +1,7 @@ from papyrus.core.database import Base from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity from papyrus.models.powersync_demo import PowerSyncDemoItem +from papyrus.models.sync import SyncBook from papyrus.models.user import User __all__ = [ @@ -10,6 +11,7 @@ "EmailActionToken", "PasswordCredential", "PowerSyncDemoItem", + "SyncBook", "User", "UserIdentity", ] diff --git a/papyrus/models/sync.py b/papyrus/models/sync.py new file mode 100644 index 0000000..8fb8de5 --- /dev/null +++ b/papyrus/models/sync.py @@ -0,0 +1,43 @@ +"""Persisted books synced through PowerSync.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class SyncBook(Base): + """Book source row synchronized to the owning user's clients.""" + + __tablename__ = "books" + + book_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + owner_user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + subtitle: Mapped[str | None] = mapped_column(String(500), nullable=True) + author: Mapped[str | None] = mapped_column(String(255), nullable=True) + co_authors: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + isbn: Mapped[str | None] = mapped_column(String(32), nullable=True) + isbn13: Mapped[str | None] = mapped_column(String(32), nullable=True) + publisher: Mapped[str | None] = mapped_column(String(255), nullable=True) + language: Mapped[str | None] = mapped_column(String(16), nullable=True) + page_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + cover_image_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + reading_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + current_page: Mapped[int | None] = mapped_column(Integer, nullable=True) + current_position: Mapped[float | None] = mapped_column(Float, nullable=True) + current_cfi: Mapped[str | None] = mapped_column(Text, nullable=True) + is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + rating: Mapped[int | None] = mapped_column(Integer, nullable=True) + custom_metadata: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True) + added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 5243e32..3bfae0d 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -1,155 +1,66 @@ -"""Sync-related schemas.""" - -from datetime import datetime -from enum import StrEnum -from typing import Any -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, HttpUrl - - -class ServerType(StrEnum): - """Metadata server type.""" - - OFFICIAL = "official" - SELF_HOSTED = "self_hosted" - - -class SyncStatusEnum(StrEnum): - """Sync status values.""" - - IDLE = "idle" - SYNCING = "syncing" - ERROR = "error" - - -class ResolutionStrategy(StrEnum): - """Conflict resolution strategy.""" - - LATEST_WINS = "latest_wins" - MERGE = "merge" - MANUAL = "manual" - - -class SyncOperation(StrEnum): - """Sync operation type.""" - - CREATE = "create" - UPDATE = "update" - DELETE = "delete" - - -class MetadataServerConfig(BaseModel): - """Metadata server configuration response.""" - - model_config = ConfigDict(from_attributes=True) - - config_id: UUID - server_url: HttpUrl | str - server_type: ServerType | None = None - is_connected: bool = False - sync_enabled: bool = True - sync_interval_seconds: int | None = None - last_sync_at: datetime | None = None - sync_status: SyncStatusEnum | None = None - sync_error_message: str | None = None - created_at: datetime | None = None - updated_at: datetime | None = None - - -class CreateMetadataServerConfigRequest(BaseModel): - """Metadata server configuration creation request.""" - - server_url: HttpUrl | str = Field(..., description="URL of the metadata server") - server_type: ServerType | None = None - sync_enabled: bool = True - sync_interval_seconds: int = Field(30, ge=10, le=3600) - auth_token: str | None = Field(None, description="JWT or API token for authentication") - - -class SyncChange(BaseModel): - """Individual sync change.""" - - entity_type: str - entity_id: UUID - operation: SyncOperation - data: dict[str, Any] | None = None - version: int | None = None - updated_at: datetime | None = None - device_id: str | None = None - - -class SyncChanges(BaseModel): - """Sync changes response.""" - - changes: list[SyncChange] - server_timestamp: datetime - - -class SyncPushChange(BaseModel): - """Individual change in push request.""" - - entity_type: str - entity_id: UUID - operation: SyncOperation - data: dict[str, Any] - timestamp: datetime - version: int | None = None - - -class SyncPushRequest(BaseModel): - """Sync push request.""" - - changes: list[SyncPushChange] - device_id: str - - -class SyncAccepted(BaseModel): - """Accepted sync change.""" - - entity_type: str - entity_id: UUID - new_version: int - - -class SyncRejected(BaseModel): - """Rejected sync change.""" - - entity_type: str - entity_id: UUID - reason: str - - -class SyncPushResponse(BaseModel): - """Sync push response.""" - - accepted: list[SyncAccepted] - rejected: list[SyncRejected] - server_timestamp: datetime - - -class SyncConflict(BaseModel): - """Sync conflict detail.""" - - entity_type: str - entity_id: UUID - local_version: int - server_version: int - server_data: dict[str, Any] | None = None - resolved_data: dict[str, Any] | None = None - resolution_strategy: ResolutionStrategy | None = None - - -class SyncConflictResponse(BaseModel): - """Sync conflicts response.""" - - conflicts: list[SyncConflict] - - -class SyncStatus(BaseModel): - """Sync status response.""" - - status: SyncStatusEnum - last_sync_at: datetime | None = None - pending_changes: int | None = None - error_message: str | None = None +"""PowerSync upload schemas.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +BOOK_UPLOAD_FIELDS = frozenset( + { + "title", + "subtitle", + "author", + "co_authors", + "isbn", + "isbn13", + "publisher", + "language", + "page_count", + "description", + "cover_image_url", + "reading_status", + "current_page", + "current_position", + "current_cfi", + "is_favorite", + "rating", + "custom_metadata", + "added_at", + "owner_user_id", + "updated_at", + } +) + + +class PowerSyncCrudMutation(BaseModel): + """Single books-table mutation uploaded from the PowerSync queue.""" + + model_config = ConfigDict(populate_by_name=True) + + table: Literal["books"] = Field(alias="type") + op: Literal["PUT", "PATCH", "DELETE", "put", "patch", "delete"] + id: str + op_id: int | None = Field(default=None, alias="op_id") + tx_id: int | None = None + op_data: dict[str, Any] | None = Field(default=None, alias="data") + + @field_validator("op_data") + @classmethod + def reject_unknown_book_fields(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: + if value is None: + return None + unknown = value.keys() - BOOK_UPLOAD_FIELDS + if unknown: + raise ValueError(f"Unsupported book fields: {', '.join(sorted(unknown))}") + return value + + +class PowerSyncUploadRequest(BaseModel): + """One PowerSync CRUD transaction.""" + + batch: list[PowerSyncCrudMutation] + + +class PowerSyncUploadResponse(BaseModel): + """Summary of an applied PowerSync upload transaction.""" + + applied_count: int diff --git a/papyrus/services/auth/_core.py b/papyrus/services/auth/_core.py index dac9196..c8ef003 100644 --- a/papyrus/services/auth/_core.py +++ b/papyrus/services/auth/_core.py @@ -221,6 +221,22 @@ def _build_api_url(path: str) -> str | None: return f"{base}{prefix}{path}" +def _build_app_url(path: str, query: dict[str, str] | None = None) -> str | None: + app_public_base_url = get_settings().app_public_base_url + + if app_public_base_url is None: + return None + + base = app_public_base_url.rstrip("/") + normalized_path = path if path.startswith("/") else f"/{path}" + url = f"{base}{normalized_path}" + + if query: + return f"{url}?{urlencode(query)}" + + return url + + def _verification_email_body(token: str) -> str: verify_url = _build_api_url("/auth/verify-email") lines = [ @@ -241,18 +257,19 @@ def _verification_email_body(token: str) -> str: def _password_reset_email_body(token: str) -> str: - reset_url = _build_api_url("/auth/reset-password") + settings = get_settings() + reset_url = _build_app_url("/reset-password", {"token": token}) lines = [ - "Use this token to reset your Papyrus password.", + "Reset your Papyrus password.", "", - f"Reset token: {token}", + f"This link expires in {settings.password_reset_token_expire_minutes} minutes.", ] if reset_url is not None: lines.extend( [ "", - f"Submit this token and your new password to: {reset_url}", + reset_url, ] ) diff --git a/papyrus/services/auth/email_flows.py b/papyrus/services/auth/email_flows.py index cba2df2..58fc16a 100644 --- a/papyrus/services/auth/email_flows.py +++ b/papyrus/services/auth/email_flows.py @@ -65,14 +65,16 @@ async def begin_password_reset(session: AsyncSession, email: str) -> str: if user is None: return "If the email is registered, a reset link has been sent" - if not email_service.is_email_delivery_configured(): + settings = get_settings() + + if not email_service.is_email_delivery_configured() or settings.app_public_base_url is None: return "Password reset is not configured on this server" token = await _issue_email_action_token( session, user.user_id, PASSWORD_RESET_ACTION, - get_settings().password_reset_token_expire_minutes, + settings.password_reset_token_expire_minutes, ) email_service.send_email( diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index 99014f2..dba3bd0 100644 --- a/papyrus/services/auth/google.py +++ b/papyrus/services/auth/google.py @@ -4,7 +4,7 @@ import json from urllib.error import HTTPError, URLError -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit from urllib.request import Request, urlopen from uuid import UUID @@ -45,13 +45,15 @@ def build_authorization_url(self, callback_uri: str, state: str) -> str: if settings.google_oauth_client_id is None: raise ValidationError("Google OAuth is not configured") - query = urlencode({ - "client_id": settings.google_oauth_client_id, - "redirect_uri": callback_uri, - "response_type": "code", - "scope": "openid email profile", - "state": state, - }) + query = urlencode( + { + "client_id": settings.google_oauth_client_id, + "redirect_uri": callback_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + } + ) return f"{GOOGLE_AUTHORIZATION_URL}?{query}" @@ -61,13 +63,15 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden if settings.google_oauth_client_id is None or settings.google_oauth_client_secret is None: raise ValidationError("Google OAuth is not configured") - payload = urlencode({ - "code": code, - "client_id": settings.google_oauth_client_id, - "client_secret": settings.google_oauth_client_secret, - "redirect_uri": callback_uri, - "grant_type": "authorization_code", - }).encode("utf-8") + payload = urlencode( + { + "code": code, + "client_id": settings.google_oauth_client_id, + "client_secret": settings.google_oauth_client_secret, + "redirect_uri": callback_uri, + "grant_type": "authorization_code", + } + ).encode("utf-8") request = Request( GOOGLE_TOKEN_URL, @@ -111,6 +115,51 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden google_oauth_client = GoogleOAuthClient() +def _configured_base_hosts() -> set[str]: + settings = get_settings() + hosts: set[str] = set() + + for base_url in (settings.public_base_url, settings.app_public_base_url): + if base_url is None: + continue + + hostname = urlsplit(base_url).hostname + if hostname is not None: + hosts.add(hostname.lower()) + + return hosts + + +def _is_allowed_oauth_redirect_uri(redirect_uri: str) -> bool: + settings = get_settings() + parsed = urlsplit(redirect_uri) + scheme = parsed.scheme.lower() + + if scheme in settings.oauth_allowed_redirect_schemes: + return True + + if scheme not in {"http", "https"}: + return False + + hostname = parsed.hostname.lower() if parsed.hostname is not None else None + + if hostname is None: + return False + + allowed_hosts = set(settings.oauth_allowed_redirect_hosts) + allowed_hosts.update(_configured_base_hosts()) + + if settings.debug: + allowed_hosts.update({"localhost", "127.0.0.1", "test"}) + + return hostname in allowed_hosts + + +def _validate_oauth_redirect_uri(redirect_uri: str) -> None: + if not _is_allowed_oauth_redirect_uri(redirect_uri): + raise ValidationError("OAuth redirect URI is not allowed") + + def _build_google_state(redirect_uri: str, mode: str, user_id: UUID | None = None) -> str: payload: dict[str, str] = {"redirect_uri": redirect_uri, "mode": mode} @@ -121,11 +170,14 @@ def _build_google_state(redirect_uri: str, mode: str, user_id: UUID | None = Non def build_google_login_authorization_url(redirect_uri: str, callback_uri: str) -> str: + _validate_oauth_redirect_uri(redirect_uri) state = _build_google_state(redirect_uri, mode="login") return google_oauth_client.build_authorization_url(callback_uri, state) async def build_google_link_authorization_url(user_id: UUID, redirect_uri: str, callback_uri: str) -> str: + _validate_oauth_redirect_uri(redirect_uri) + return google_oauth_client.build_authorization_url( callback_uri, _build_google_state(redirect_uri, mode="link", user_id=user_id), @@ -253,6 +305,7 @@ async def handle_google_callback( async def complete_google_link(session: AsyncSession, user_id: UUID, code: str) -> User: from papyrus.services.auth._core import _consume_exchange_code + exchange_code = await _consume_exchange_code(session, code, purpose="link_google") if exchange_code.user_id != user_id: diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py new file mode 100644 index 0000000..4876532 --- /dev/null +++ b/papyrus/services/sync.py @@ -0,0 +1,221 @@ +"""Books-only PowerSync upload service.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.core.exceptions import ForbiddenError, ValidationError +from papyrus.models import SyncBook +from papyrus.schemas.sync import PowerSyncCrudMutation + +BOOK_FIELDS = frozenset( + { + "title", + "subtitle", + "author", + "co_authors", + "isbn", + "isbn13", + "publisher", + "language", + "page_count", + "description", + "cover_image_url", + "reading_status", + "current_page", + "current_position", + "current_cfi", + "is_favorite", + "rating", + "custom_metadata", + "added_at", + "owner_user_id", + "updated_at", + } +) +SERVER_CONTROLLED_FIELDS = frozenset({"owner_user_id", "updated_at"}) + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _uuid(value: object, field_name: str) -> UUID: + try: + return UUID(str(value)) + except ValueError as exc: + raise ValidationError(f"{field_name} must be a valid UUID") from exc + + +def _validate_payload(payload: dict[str, object]) -> dict[str, object]: + unknown = payload.keys() - BOOK_FIELDS + if unknown: + raise ValidationError(f"Unsupported book fields: {', '.join(sorted(unknown))}") + return {key: value for key, value in payload.items() if key not in SERVER_CONTROLLED_FIELDS} + + +def _optional_text(payload: dict[str, object], key: str, default: str | None = None) -> str | None: + if key not in payload: + return default + value = payload[key] + return None if value is None else str(value) + + +def _required_text(payload: dict[str, object], key: str, default: str | None = None) -> str: + value = _optional_text(payload, key, default) + if value is None or not value: + raise ValidationError(f"{key} is required") + return value + + +def _optional_int(payload: dict[str, object], key: str, default: int | None = None) -> int | None: + if key not in payload: + return default + value = payload[key] + if value is None: + return None + if not isinstance(value, int | float | str) or isinstance(value, bool): + raise ValidationError(f"{key} must be an integer") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValidationError(f"{key} must be an integer") from exc + + +def _optional_float(payload: dict[str, object], key: str, default: float | None = None) -> float | None: + if key not in payload: + return default + value = payload[key] + if value is None: + return None + if not isinstance(value, int | float | str) or isinstance(value, bool): + raise ValidationError(f"{key} must be a number") + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValidationError(f"{key} must be a number") from exc + + +def _optional_bool(payload: dict[str, object], key: str, default: bool = False) -> bool: + if key not in payload: + return default + value = payload[key] + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _optional_datetime(payload: dict[str, object], key: str, default: datetime) -> datetime: + if key not in payload: + return default + value = payload[key] + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as exc: + raise ValidationError(f"{key} must be an ISO datetime") from exc + + +def _optional_string_list(payload: dict[str, object], key: str, default: list[str] | None = None) -> list[str] | None: + if key not in payload: + return default + value = payload[key] + if value is None: + return None + if not isinstance(value, list): + raise ValidationError(f"{key} must be a list") + return [str(item) for item in value] + + +def _optional_json_object( + payload: dict[str, object], key: str, default: dict[str, object] | None = None +) -> dict[str, object] | None: + if key not in payload: + return default + value = payload[key] + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError(f"{key} must be an object") + return value + + +async def _get_owned_book(session: AsyncSession, user_id: UUID, book_id: UUID) -> SyncBook | None: + book = await session.get(SyncBook, book_id) + if book is not None and book.owner_user_id != user_id: + raise ForbiddenError("Cannot access another user's book") + return book + + +async def apply_powersync_upload_batch( + session: AsyncSession, + user_id: UUID, + batch: list[PowerSyncCrudMutation], +) -> int: + """Apply one PowerSync CRUD transaction and commit it atomically.""" + applied_count = 0 + try: + for mutation in batch: + applied_count += await _apply_book_mutation(session, user_id, mutation) + await session.commit() + except Exception: + await session.rollback() + raise + return applied_count + + +async def _apply_book_mutation( + session: AsyncSession, + user_id: UUID, + mutation: PowerSyncCrudMutation, +) -> int: + book_id = _uuid(mutation.id, "id") + operation = mutation.op.upper() + + if operation == "DELETE": + book = await _get_owned_book(session, user_id, book_id) + if book is None: + return 0 + await session.delete(book) + return 1 + + payload = _validate_payload(mutation.op_data or {}) + book = await _get_owned_book(session, user_id, book_id) + now = _now() + + if book is None: + book = SyncBook( + book_id=book_id, + owner_user_id=user_id, + title=_required_text(payload, "title", "Untitled Book"), + added_at=_optional_datetime(payload, "added_at", now), + updated_at=now, + ) + session.add(book) + + book.title = _required_text(payload, "title", book.title) + book.subtitle = _optional_text(payload, "subtitle", book.subtitle) + book.author = _optional_text(payload, "author", book.author) + book.co_authors = _optional_string_list(payload, "co_authors", book.co_authors) + book.isbn = _optional_text(payload, "isbn", book.isbn) + book.isbn13 = _optional_text(payload, "isbn13", book.isbn13) + book.publisher = _optional_text(payload, "publisher", book.publisher) + book.language = _optional_text(payload, "language", book.language) + book.page_count = _optional_int(payload, "page_count", book.page_count) + book.description = _optional_text(payload, "description", book.description) + book.cover_image_url = _optional_text(payload, "cover_image_url", book.cover_image_url) + book.reading_status = _optional_text(payload, "reading_status", book.reading_status) + book.current_page = _optional_int(payload, "current_page", book.current_page) + book.current_position = _optional_float(payload, "current_position", book.current_position) + book.current_cfi = _optional_text(payload, "current_cfi", book.current_cfi) + book.is_favorite = _optional_bool(payload, "is_favorite", book.is_favorite) + book.rating = _optional_int(payload, "rating", book.rating) + book.custom_metadata = _optional_json_object(payload, "custom_metadata", book.custom_metadata) + book.updated_at = now + return 1 diff --git a/powersync/sync-config.yaml b/powersync/sync-config.yaml index 2b7c7a7..ad50e6d 100644 --- a/powersync/sync-config.yaml +++ b/powersync/sync-config.yaml @@ -2,6 +2,35 @@ config: edition: 2 streams: + books: + auto_subscribe: true + query: | + SELECT + book_id AS id, + owner_user_id::text AS owner_user_id, + title, + subtitle, + author, + co_authors::text AS co_authors, + isbn, + isbn13, + publisher, + language, + page_count, + description, + cover_image_url, + reading_status, + current_page, + current_position, + current_cfi, + is_favorite, + rating, + custom_metadata::text AS custom_metadata, + added_at::text AS added_at, + updated_at::text AS updated_at + FROM books + WHERE owner_user_id::text = auth.user_id() + demo_items: auto_subscribe: true query: | diff --git a/scripts/bootstrap_local.sh b/scripts/bootstrap_local.sh new file mode 100755 index 0000000..893b3c7 --- /dev/null +++ b/scripts/bootstrap_local.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh + +set -eu + +if [ ! -f ".env" ]; then + cp .env.example .env +fi + +if [ -f ".env" ]; then + set -a + . ./.env + set +a +fi + +if [ ! -f "${POWERSYNC_JWT_PRIVATE_KEY_FILE:-.local/powersync/private.pem}" ] || + [ ! -f "${POWERSYNC_JWT_PUBLIC_KEY_FILE:-.local/powersync/public.pem}" ]; then + ./scripts/generate_dev_powersync_keys.sh +fi + +docker compose up -d --wait database powersync-storage mailpit +uv run alembic upgrade head +./scripts/setup_local_powersync.sh +docker compose up -d --build --wait server powersync + +printf '%s\n' "Papyrus API: http://localhost:${PORT:-8080}" +printf '%s\n' "PowerSync: http://localhost:${POWERSYNC_SERVICE_PORT:-8081}" +printf '%s\n' "Mailpit: http://localhost:8025" diff --git a/scripts/setup_local_powersync.sh b/scripts/setup_local_powersync.sh index b5bd943..5a5ae88 100755 --- a/scripts/setup_local_powersync.sh +++ b/scripts/setup_local_powersync.sh @@ -29,19 +29,16 @@ END \$\$; GRANT USAGE ON SCHEMA public TO "${POWERSYNC_SOURCE_ROLE}"; +GRANT SELECT ON TABLE public.books TO "${POWERSYNC_SOURCE_ROLE}"; GRANT SELECT ON TABLE public.powersync_demo_items TO "${POWERSYNC_SOURCE_ROLE}"; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO "${POWERSYNC_SOURCE_ROLE}"; DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') THEN - CREATE PUBLICATION powersync FOR TABLE public.powersync_demo_items; - ELSIF NOT EXISTS ( - SELECT 1 - FROM pg_publication_tables - WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'powersync_demo_items' - ) THEN - ALTER PUBLICATION powersync ADD TABLE public.powersync_demo_items; + CREATE PUBLICATION powersync FOR TABLE public.books, public.powersync_demo_items; + ELSE + ALTER PUBLICATION powersync SET TABLE public.books, public.powersync_demo_items; END IF; END \$\$; diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index 821410d..e4c6afa 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -15,6 +15,7 @@ from papyrus.config import get_settings from papyrus.core import security as security_module from papyrus.core.exceptions import ServiceUnavailableError +from papyrus.core.rate_limit import limiter from papyrus.core.security import generate_opaque_token, hash_opaque_token from papyrus.models import AuthExchangeCode, EmailActionToken, User, UserIdentity from papyrus.services import auth as auth_service @@ -82,6 +83,7 @@ def configured_email_delivery(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str monkeypatch.setattr(settings, "email_delivery_enabled", True) monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") monkeypatch.setattr(settings, "smtp_from_email", "noreply@example.test") + monkeypatch.setattr(settings, "app_public_base_url", "http://papyrus.localhost:3000") sent_messages: list[tuple[str, str, str]] = [] def fake_send_email(recipient: str, subject: str, body: str) -> None: @@ -230,6 +232,31 @@ async def test_logout_current_session_revokes_refresh_token(client: AsyncClient) assert protected_response.status_code == 401 +async def test_login_is_rate_limited(client: AsyncClient): + """Credential endpoints enforce the configured per-IP auth limit.""" + limiter._storage.reset() + register_response = await client.post( + "/v1/auth/register", + json={ + "email": "rate-limit@example.com", + "password": "SecureP@ss123", + "display_name": "Rate Limited", + }, + ) + assert register_response.status_code == 201 + + responses = [ + await client.post( + "/v1/auth/login", + json={"email": "rate-limit@example.com", "password": "SecureP@ss123"}, + ) + for _ in range(6) + ] + + assert [response.status_code for response in responses[:5]] == [200] * 5 + assert responses[5].status_code == 429 + + async def test_logout_all_revokes_other_sessions(client: AsyncClient): """Test logout-all revokes all refresh-token backed sessions.""" register_response = await client.post( @@ -379,6 +406,37 @@ async def test_google_oauth_start_requires_configuration( assert response.json()["error"]["code"] == "VALIDATION_ERROR" +async def test_google_oauth_start_rejects_unallowed_redirect_uri( + client: AsyncClient, + configured_google: None, +): + """Test Google OAuth cannot redirect exchange codes to arbitrary origins.""" + response = await client.get( + "/v1/auth/oauth/google/start", + params={"redirect_uri": "https://evil.example.test/auth/callback"}, + follow_redirects=False, + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "VALIDATION_ERROR" + + +async def test_google_oauth_start_allows_configured_web_redirect_host( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + configured_google: None, +): + """Test configured Flutter web callback hosts are allowed.""" + settings = get_settings() + monkeypatch.setattr(settings, "oauth_allowed_redirect_hosts", ["app.example.test"]) + + response = await client.get( + "/v1/auth/oauth/google/start", + params={"redirect_uri": "https://app.example.test/auth/callback"}, + follow_redirects=False, + ) + assert response.status_code == 302 + + async def test_google_link_flow_links_identity_to_existing_user( client: AsyncClient, auth_headers: dict[str, str], @@ -565,6 +623,33 @@ async def test_forgot_password_returns_configuration_message( assert response.json()["message"] == "Password reset is not configured on this server" +async def test_forgot_password_requires_app_public_base_url( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + configured_email_delivery: list[tuple[str, str, str]], +): + """Test forgot password does not send linkless email when app URL is missing.""" + register_response = await client.post( + "/v1/auth/register", + json={ + "email": "test@example.com", + "password": "SecureP@ss123", + "display_name": "Test User", + }, + ) + assert register_response.status_code == 201 + settings = get_settings() + monkeypatch.setattr(settings, "app_public_base_url", None) + + response = await client.post( + "/v1/auth/forgot-password", + json={"email": "test@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Password reset is not configured on this server" + assert configured_email_delivery == [] + + async def test_forgot_password_sends_email_when_configured( client: AsyncClient, configured_email_delivery: list[tuple[str, str, str]], @@ -590,7 +675,10 @@ async def test_forgot_password_sends_email_when_configured( recipient, subject, body = configured_email_delivery[0] assert recipient == "test@example.com" assert subject == "Reset your Papyrus password" - assert "Reset token:" in body + assert "http://papyrus.localhost:3000/reset-password?token=" in body + assert "This link expires in 60 minutes." in body + assert "Reset token:" not in body + assert "/v1/auth/reset-password" not in body async def test_forgot_password_send_failure_returns_service_unavailable( @@ -651,6 +739,15 @@ async def test_reset_password(client: AsyncClient, db_session: AsyncSession): assert response.status_code == 200 assert response.json()["message"] == "Password has been reset successfully" + login_response = await client.post( + "/v1/auth/login", + json={ + "email": "reset@example.com", + "password": "NewSecureP@ss123", + }, + ) + assert login_response.status_code == 200 + async def test_reset_password_revokes_existing_access_token( client: AsyncClient, diff --git a/tests/api/routes/test_health.py b/tests/api/routes/test_health.py index eb3d45f..9a018be 100644 --- a/tests/api/routes/test_health.py +++ b/tests/api/routes/test_health.py @@ -10,9 +10,9 @@ async def test_index_lists_available_pages(prod_client: AsyncClient): data = response.json() pages = {page["name"]: page["path"] for page in data["pages"]} assert data["name"] == "Papyrus Server API" - assert pages["docs"] == "/docs" - assert pages["redoc"] == "/redoc" - assert pages["openapi"] == "/openapi.json" + assert pages["docs"] == "http://localhost:8080/docs" + assert pages["redoc"] == "http://localhost:8080/redoc" + assert pages["openapi"] == "http://localhost:8080/openapi.json" assert "auth_sandbox" not in pages @@ -22,8 +22,8 @@ async def test_index_lists_debug_pages(debug_client: AsyncClient): assert response.status_code == 200 data = response.json() pages = {page["name"]: page["path"] for page in data["pages"]} - assert pages["auth_sandbox"] == "/__dev/auth-sandbox" - assert pages["powersync_sandbox"] == "/__dev/powersync-sandbox" + assert pages["auth_sandbox"] == "http://localhost:8080/__dev/auth-sandbox" + assert pages["powersync_sandbox"] == "http://localhost:8080/__dev/powersync-sandbox" async def test_health_check(client: AsyncClient): diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index a06e5e9..48a5b5f 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -1,94 +1,251 @@ """Tests for sync endpoints.""" from datetime import UTC, datetime -from uuid import uuid4 +from uuid import UUID, uuid4 from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession - -async def test_get_sync_status(client: AsyncClient, auth_headers: dict[str, str]): - """Test getting sync status.""" - response = await client.get("/v1/sync/status", headers=auth_headers) - assert response.status_code == 200 - data = response.json() - assert "status" in data +from papyrus.models import SyncBook, User -async def test_pull_changes(client: AsyncClient, auth_headers: dict[str, str]): - """Test pulling changes from server.""" - response = await client.get("/v1/sync/changes", headers=auth_headers) - assert response.status_code == 200 - data = response.json() - assert "changes" in data - assert "server_timestamp" in data +async def test_legacy_sync_routes_are_removed(client: AsyncClient, auth_headers: dict[str, str]): + """PowerSync is the only supported synchronization contract.""" + assert (await client.get("/v1/sync/status", headers=auth_headers)).status_code == 404 + assert (await client.get("/v1/sync/changes", headers=auth_headers)).status_code == 404 + assert (await client.post("/v1/sync/changes", headers=auth_headers, json={})).status_code == 404 + assert (await client.get("/v1/sync/conflicts", headers=auth_headers)).status_code == 404 + assert (await client.post("/v1/sync/force", headers=auth_headers)).status_code == 404 + assert (await client.get("/v1/sync/config", headers=auth_headers)).status_code == 404 -async def test_push_changes(client: AsyncClient, auth_headers: dict[str, str]): - """Test pushing changes to server.""" - now = datetime.now(UTC) +async def test_powersync_upload_applies_book_mutation( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, +): + """Test production PowerSync upload endpoint applies owned book mutations.""" + book_id = str(uuid4()) response = await client.post( - "/v1/sync/changes", + "/v1/sync/powersync-upload", headers=auth_headers, json={ - "changes": [ + "batch": [ { - "entity_type": "book", - "entity_id": str(uuid4()), - "operation": "update", - "data": {"title": "Updated Title"}, - "timestamp": now.isoformat(), + "type": "books", + "op": "PUT", + "id": book_id, + "data": { + "title": "PowerSync Book", + "author": "PowerSync Author", + }, } - ], - "device_id": "device_123", + ] }, ) assert response.status_code == 200 - data = response.json() - assert "accepted" in data - assert "rejected" in data - + assert response.json()["applied_count"] == 1 + + book = await db_session.get(SyncBook, UUID(book_id)) + assert book is not None + assert book.owner_user_id == UUID(auth_user["user_id"]) + assert book.title == "PowerSync Book" + + +async def test_powersync_upload_applies_book_patch( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, +): + """Test production PowerSync upload endpoint patches owned book mutations.""" + book = SyncBook( + book_id=uuid4(), + owner_user_id=UUID(auth_user["user_id"]), + title="Original Title", + author="Original Author", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(book) + await db_session.commit() + book_id = book.book_id -async def test_get_sync_conflicts(client: AsyncClient, auth_headers: dict[str, str]): - """Test getting sync conflicts.""" - response = await client.get("/v1/sync/conflicts", headers=auth_headers) + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PATCH", + "id": str(book.book_id), + "data": {"title": "Patched Title"}, + } + ] + }, + ) assert response.status_code == 200 - data = response.json() - assert "conflicts" in data - - -async def test_force_sync(client: AsyncClient, auth_headers: dict[str, str]): - """Test forcing a full sync.""" - response = await client.post("/v1/sync/force", headers=auth_headers) - assert response.status_code == 204 - + assert response.json()["applied_count"] == 1 + + db_session.expire_all() + patched_book = await db_session.get(SyncBook, book_id) + assert patched_book is not None + assert patched_book.title == "Patched Title" + assert patched_book.author == "Original Author" + + +async def test_powersync_upload_applies_book_delete( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, +): + """Test production PowerSync upload endpoint deletes owned books.""" + book = SyncBook( + book_id=uuid4(), + owner_user_id=UUID(auth_user["user_id"]), + title="Delete Me", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(book) + await db_session.commit() + book_id = book.book_id -async def test_get_metadata_server_config(client: AsyncClient, auth_headers: dict[str, str]): - """Test getting metadata server configuration.""" - response = await client.get("/v1/sync/config", headers=auth_headers) + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={"batch": [{"type": "books", "op": "DELETE", "id": str(book.book_id)}]}, + ) assert response.status_code == 200 + assert response.json()["applied_count"] == 1 + + db_session.expire_all() + deleted_book = await db_session.get(SyncBook, book_id) + assert deleted_book is None -async def test_create_metadata_server_config(client: AsyncClient, auth_headers: dict[str, str]): - """Test creating metadata server configuration.""" +async def test_powersync_upload_rejects_unsupported_table( + client: AsyncClient, + auth_headers: dict[str, str], +): + """Test production PowerSync upload endpoint rejects unsupported tables.""" response = await client.post( - "/v1/sync/config", + "/v1/sync/powersync-upload", + headers=auth_headers, + json={"batch": [{"type": "shelves", "op": "PUT", "id": str(uuid4()), "data": {"name": "Shelf"}}]}, + ) + assert response.status_code == 422 + + +async def test_powersync_upload_rejects_partial_future_tables( + client: AsyncClient, + auth_headers: dict[str, str], +): + """Annotations and reading sessions are not part of the books-only contract.""" + for table in ("annotations", "reading_sessions"): + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={"batch": [{"type": table, "op": "PUT", "id": str(uuid4()), "data": {}}]}, + ) + assert response.status_code == 422 + + +async def test_powersync_upload_rejects_unknown_book_fields( + client: AsyncClient, + auth_headers: dict[str, str], +): + response = await client.post( + "/v1/sync/powersync-upload", headers=auth_headers, json={ - "server_url": "https://api.papyrus.app", - "sync_enabled": True, - "sync_interval_seconds": 30, + "batch": [ + { + "type": "books", + "op": "PUT", + "id": str(uuid4()), + "data": {"title": "Book", "unexpected": "value"}, + } + ] }, ) - assert response.status_code == 201 + assert response.status_code == 422 -async def test_delete_metadata_server_config(client: AsyncClient, auth_headers: dict[str, str]): - """Test deleting metadata server configuration.""" - response = await client.delete("/v1/sync/config", headers=auth_headers) - assert response.status_code == 204 - +async def test_powersync_upload_controls_owner_and_updated_at( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, +): + book_id = uuid4() + client_timestamp = datetime(2000, 1, 1, tzinfo=UTC) + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PUT", + "id": str(book_id), + "data": { + "title": "Controlled fields", + "owner_user_id": str(uuid4()), + "updated_at": client_timestamp.isoformat(), + }, + } + ] + }, + ) -async def test_test_metadata_server_connection(client: AsyncClient, auth_headers: dict[str, str]): - """Test testing metadata server connection.""" - response = await client.post("/v1/sync/config/test", headers=auth_headers) assert response.status_code == 200 + db_session.expire_all() + book = await db_session.get(SyncBook, book_id) + assert book is not None + assert book.owner_user_id == UUID(auth_user["user_id"]) + assert book.updated_at > client_timestamp + + +async def test_powersync_upload_rejects_cross_user_book_mutation( + client: AsyncClient, + auth_headers: dict[str, str], + db_session: AsyncSession, +): + """Test production PowerSync upload endpoint rejects cross-user writes.""" + other_user = User( + display_name="Other User", + primary_email="other-sync@example.com", + primary_email_verified=True, + last_login_at=datetime.now(UTC), + ) + db_session.add(other_user) + await db_session.flush() + foreign_book = SyncBook( + book_id=uuid4(), + owner_user_id=other_user.user_id, + title="Foreign Book", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(foreign_book) + await db_session.commit() + + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PATCH", + "id": str(foreign_book.book_id), + "data": {"title": "Unauthorized Edit"}, + } + ] + }, + ) + assert response.status_code == 403 diff --git a/tests/conftest.py b/tests/conftest.py index cd3796c..02d458d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from papyrus.config import get_settings from papyrus.core.database import get_db +from papyrus.core.rate_limit import limiter from papyrus.core.security import create_access_token, generate_opaque_token, hash_opaque_token, hash_password from papyrus.main import create_app from papyrus.main import settings as app_settings @@ -161,6 +162,8 @@ async def prod_client( test_session_maker: async_sessionmaker[AsyncSession], ) -> AsyncGenerator[AsyncClient, None]: monkeypatch.setattr(app_settings, "debug", False) + monkeypatch.setattr(app_settings, "public_base_url", "http://localhost:8080") + monkeypatch.setattr(app_settings, "app_public_base_url", "http://papyrus.localhost:3000") prod_app = create_app() async def override_get_db() -> AsyncGenerator[AsyncSession, None]: @@ -181,6 +184,8 @@ async def debug_client( test_session_maker: async_sessionmaker[AsyncSession], ) -> AsyncGenerator[AsyncClient, None]: monkeypatch.setattr(app_settings, "debug", True) + monkeypatch.setattr(app_settings, "public_base_url", "http://localhost:8080") + monkeypatch.setattr(app_settings, "app_public_base_url", "http://papyrus.localhost:3000") debug_app = create_app() async def override_get_db() -> AsyncGenerator[AsyncSession, None]: @@ -252,3 +257,9 @@ async def auth_user( @pytest_asyncio.fixture async def auth_headers(auth_user: dict[str, str]) -> dict[str, str]: return {"Authorization": f"Bearer {auth_user['access_token']}"} + + +@pytest.fixture(autouse=True) +def reset_rate_limiter() -> None: + """Keep per-IP rate-limit state isolated between tests.""" + limiter._storage.reset() diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 75d420e..99325fb 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -50,8 +50,12 @@ def test_create_powersync_token_supports_file_based_keys( monkeypatch.setattr(settings, "powersync_jwt_public_key_file", str(public_key_path)) monkeypatch.setattr(settings, "powersync_jwt_audience", "powersync-dev") monkeypatch.setattr(settings, "powersync_jwt_key_id", "papyrus-powersync-dev") + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key", None) + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key_file", None) + monkeypatch.setattr(settings, "powersync_jwt_previous_key_id", None) security_module._get_powersync_private_key.cache_clear() security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() try: token, expires_in = security_module.create_powersync_token("user-123") @@ -65,8 +69,81 @@ def test_create_powersync_token_supports_file_based_keys( finally: security_module._get_powersync_private_key.cache_clear() security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() assert expires_in > 0 assert payload["sub"] == "user-123" assert payload["type"] == "powersync" assert jwks["keys"][0]["kid"] == "papyrus-powersync-dev" + + +def test_powersync_keys_ignore_blank_inline_values( + monkeypatch: pytest.MonkeyPatch, + powersync_key_files: tuple[Path, Path], +) -> None: + """Blank inline PowerSync key env values fall back to configured key files.""" + private_key_path, public_key_path = powersync_key_files + settings = get_settings() + monkeypatch.setattr(settings, "powersync_jwt_private_key", "") + monkeypatch.setattr(settings, "powersync_jwt_public_key", "") + monkeypatch.setattr(settings, "powersync_jwt_private_key_file", str(private_key_path)) + monkeypatch.setattr(settings, "powersync_jwt_public_key_file", str(public_key_path)) + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key", "") + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key_file", "") + monkeypatch.setattr(settings, "powersync_jwt_previous_key_id", "") + monkeypatch.setattr(settings, "powersync_jwt_audience", "powersync-dev") + monkeypatch.setattr(settings, "powersync_jwt_key_id", "papyrus-powersync-dev") + security_module._get_powersync_private_key.cache_clear() + security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() + + try: + token, expires_in = security_module.create_powersync_token("user-123") + jwks = security_module.get_powersync_jwks() + finally: + security_module._get_powersync_private_key.cache_clear() + security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() + + assert token + assert expires_in > 0 + assert [key["kid"] for key in jwks["keys"]] == ["papyrus-powersync-dev"] + + +def test_powersync_jwks_includes_previous_public_key_for_rotation( + monkeypatch: pytest.MonkeyPatch, + powersync_key_files: tuple[Path, Path], + tmp_path: Path, +) -> None: + """JWKS exposes current and previous public keys during rotation.""" + private_key_path, public_key_path = powersync_key_files + previous_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + previous_public_key_path = tmp_path / "previous_public.pem" + previous_public_key_path.write_bytes( + previous_private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + + settings = get_settings() + monkeypatch.setattr(settings, "powersync_jwt_private_key", None) + monkeypatch.setattr(settings, "powersync_jwt_public_key", None) + monkeypatch.setattr(settings, "powersync_jwt_private_key_file", str(private_key_path)) + monkeypatch.setattr(settings, "powersync_jwt_public_key_file", str(public_key_path)) + monkeypatch.setattr(settings, "powersync_jwt_key_id", "current-key") + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key", None) + monkeypatch.setattr(settings, "powersync_jwt_previous_public_key_file", str(previous_public_key_path)) + monkeypatch.setattr(settings, "powersync_jwt_previous_key_id", "previous-key") + security_module._get_powersync_private_key.cache_clear() + security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() + + try: + jwks = security_module.get_powersync_jwks() + finally: + security_module._get_powersync_private_key.cache_clear() + security_module._get_powersync_public_key.cache_clear() + security_module._get_powersync_previous_public_key.cache_clear() + + assert [key["kid"] for key in jwks["keys"]] == ["current-key", "previous-key"] diff --git a/tests/services/test_auth.py b/tests/services/test_auth.py index 6d4ce03..c6988d6 100644 --- a/tests/services/test_auth.py +++ b/tests/services/test_auth.py @@ -27,6 +27,25 @@ def test_auth_package_facade_exports_public_surface() -> None: assert callable(auth_service.register_user) +def test_google_login_allows_configured_app_public_base_url_redirect( + monkeypatch: pytest.MonkeyPatch, + configured_google: None, +) -> None: + """Web OAuth callbacks may return to the configured Flutter app origin.""" + settings = get_settings() + monkeypatch.setattr(settings, "app_public_base_url", "http://papyrus.localhost:3000") + monkeypatch.setattr(settings, "oauth_allowed_redirect_hosts", ["localhost", "127.0.0.1"]) + + authorization_url = auth_service.build_google_login_authorization_url( + "http://papyrus.localhost:3000/auth/callback", + "http://localhost:8080/v1/auth/oauth/google/callback", + ) + + assert parse_qs(urlparse(authorization_url).query)["redirect_uri"] == [ + "http://localhost:8080/v1/auth/oauth/google/callback" + ] + + async def _create_user_with_password( session: AsyncSession, *, diff --git a/tests/services/test_sync.py b/tests/services/test_sync.py new file mode 100644 index 0000000..ea3a086 --- /dev/null +++ b/tests/services/test_sync.py @@ -0,0 +1,113 @@ +"""Service tests for production PowerSync upload handling.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from pydantic import ValidationError as PydanticValidationError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from papyrus.core.exceptions import ForbiddenError +from papyrus.models import SyncBook, User +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.services import sync as sync_service + + +async def _create_user(session: AsyncSession, email: str) -> User: + user = User( + display_name=email.split("@", 1)[0], + primary_email=email, + primary_email_verified=True, + last_login_at=datetime.now(UTC), + ) + session.add(user) + await session.flush() + return user + + +async def _create_book(session: AsyncSession, user: User, title: str = "Existing Book") -> SyncBook: + book = SyncBook( + book_id=uuid4(), + owner_user_id=user.user_id, + title=title, + author="Author", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(book) + await session.flush() + return book + + +async def test_apply_powersync_upload_batch_handles_book_mutations( + test_session_maker: async_sessionmaker[AsyncSession], +): + """PowerSync upload batches create books.""" + async with test_session_maker() as session: + user = await _create_user(session, "sync@example.com") + book_id = str(uuid4()) + applied_count = await sync_service.apply_powersync_upload_batch( + session, + user.user_id, + [ + PowerSyncCrudMutation( + type="books", + op="PUT", + id=book_id, + data={ + "title": "Synced Book", + "author": "Sync Author", + "reading_status": "in_progress", + "current_position": 0.4, + }, + ), + ], + ) + + assert applied_count == 1 + book = await session.get(SyncBook, book_id) + assert book is not None + assert book.title == "Synced Book" + + +async def test_apply_powersync_upload_batch_rejects_cross_user_book_update( + test_session_maker: async_sessionmaker[AsyncSession], +): + """PowerSync upload handling enforces row ownership.""" + async with test_session_maker() as session: + owner = await _create_user(session, "owner@example.com") + intruder = await _create_user(session, "intruder@example.com") + book = await _create_book(session, owner) + await session.commit() + + with pytest.raises(ForbiddenError): + await sync_service.apply_powersync_upload_batch( + session, + intruder.user_id, + [ + PowerSyncCrudMutation( + type="books", + op="PATCH", + id=str(book.book_id), + data={"title": "Intruder Edit"}, + ) + ], + ) + + +async def test_apply_powersync_upload_batch_rejects_unknown_book_fields( + test_session_maker: async_sessionmaker[AsyncSession], +): + """The backend rejects fields outside the books upload contract.""" + async with test_session_maker() as session: + await _create_user(session, "missing-book@example.com") + + with pytest.raises(PydanticValidationError): + PowerSyncCrudMutation( + type="books", + op="PUT", + id=str(uuid4()), + data={"title": "Book", "unexpected": "value"}, + ) diff --git a/tests/test_env_files.py b/tests/test_env_files.py new file mode 100644 index 0000000..1698137 --- /dev/null +++ b/tests/test_env_files.py @@ -0,0 +1,36 @@ +"""Tests for local environment template drift.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _env_keys(path: Path) -> list[str]: + keys: list[str] = [] + + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + + keys.append(stripped.split("=", 1)[0]) + + return keys + + +def test_local_env_keys_match_example_when_env_exists() -> None: + """Keep local .env aligned with the checked-in template without reading values.""" + env_path = REPO_ROOT / ".env" + + if not env_path.exists(): + pytest.skip("local .env is not present") + + example_keys = _env_keys(REPO_ROOT / ".env.example") + env_keys = _env_keys(env_path) + + assert env_keys == example_keys diff --git a/tests/test_models.py b/tests/test_models.py index f42e413..d2cd862 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ EmailActionToken, PasswordCredential, PowerSyncDemoItem, + SyncBook, User, UserIdentity, ) @@ -21,6 +22,7 @@ def test_auth_models_are_registered_with_metadata() -> None: exchange_codes_table = Base.metadata.tables["auth_exchange_codes"] email_tokens_table = Base.metadata.tables["email_action_tokens"] powersync_demo_items_table = Base.metadata.tables["powersync_demo_items"] + books_table = Base.metadata.tables["books"] assert users_table is User.__table__ assert identities_table is UserIdentity.__table__ assert password_credentials_table is PasswordCredential.__table__ @@ -28,6 +30,7 @@ def test_auth_models_are_registered_with_metadata() -> None: assert exchange_codes_table is AuthExchangeCode.__table__ assert email_tokens_table is EmailActionToken.__table__ assert powersync_demo_items_table is PowerSyncDemoItem.__table__ + assert books_table is SyncBook.__table__ assert set(users_table.columns.keys()) == { "user_id", @@ -47,3 +50,6 @@ def test_auth_models_are_registered_with_metadata() -> None: "created_at", "updated_at", } + assert {"book_id", "owner_user_id", "title", "updated_at"}.issubset(books_table.columns.keys()) + assert "annotations" not in Base.metadata.tables + assert "reading_sessions" not in Base.metadata.tables