From 478042ce018df14f8d6fe21d014f4b42604f1b30 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 9 May 2026 16:17:41 +0300 Subject: [PATCH 1/7] feat: update auth models --- .agents/skills/alembic-migrations/SKILL.md | 17 - .agents/skills/backend-review/SKILL.md | 21 - .agents/skills/fastapi-endpoints/SKILL.md | 17 - .../postgres-sqlalchemy-debugging/SKILL.md | 22 - .codex/agents/backend-reviewer.toml | 12 - .codex/config.toml | 14 - .env.example | 5 + README.md | 2 +- ...1d7c2f4e8b9_add_powersync_domain_tables.py | 109 +++++ docs/auth-testing.md | 2 + docs/flutter-auth-integration.md | 18 +- docs/powersync-sandbox.md | 3 + papyrus/api/routes/sync.py | 24 +- papyrus/config.py | 25 ++ papyrus/core/security.py | 42 +- papyrus/models/__init__.py | 4 + papyrus/models/sync.py | 88 ++++ papyrus/schemas/sync.py | 27 +- papyrus/services/auth/google.py | 80 +++- papyrus/services/sync.py | 391 ++++++++++++++++++ powersync/sync-config.yaml | 68 +++ scripts/setup_local_powersync.sh | 43 +- tests/api/routes/test_auth.py | 31 ++ tests/api/routes/test_sync.py | 80 +++- tests/core/test_security.py | 39 ++ tests/services/test_sync.py | 155 +++++++ tests/test_models.py | 12 + 27 files changed, 1209 insertions(+), 142 deletions(-) delete mode 100644 .agents/skills/alembic-migrations/SKILL.md delete mode 100644 .agents/skills/backend-review/SKILL.md delete mode 100644 .agents/skills/fastapi-endpoints/SKILL.md delete mode 100644 .agents/skills/postgres-sqlalchemy-debugging/SKILL.md delete mode 100644 .codex/agents/backend-reviewer.toml delete mode 100644 .codex/config.toml create mode 100644 alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py create mode 100644 papyrus/models/sync.py create mode 100644 papyrus/services/sync.py create mode 100644 tests/services/test_sync.py 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..da3c539 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,8 @@ PUBLIC_BASE_URL=http://localhost:8080 # /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 +56,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/README.md b/README.md index 09d01dd..98fbd2c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ uv sync --extra dev Run the database: ```bash -docker compose up -d database mailpit powersync-storage powersync +docker compose up database mailpit powersync-storage powersync ``` Run database migrations: 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..c4f1c1c --- /dev/null +++ b/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py @@ -0,0 +1,109 @@ +"""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) + + op.create_table( + "annotations", + sa.Column("annotation_id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("selected_text", sa.Text(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("highlight_color", sa.String(length=16), server_default="#FFEB3B", nullable=False), + sa.Column("start_position", sa.Text(), nullable=False), + sa.Column("end_position", sa.Text(), nullable=False), + sa.Column("chapter_title", sa.String(length=255), nullable=True), + sa.Column("chapter_index", sa.Integer(), nullable=True), + sa.Column("page_number", sa.Integer(), nullable=True), + sa.Column("created_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(["book_id"], ["books.book_id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("annotation_id"), + ) + op.create_index(op.f("ix_annotations_book_id"), "annotations", ["book_id"], unique=False) + op.create_index(op.f("ix_annotations_owner_user_id"), "annotations", ["owner_user_id"], unique=False) + + op.create_table( + "reading_sessions", + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("start_position", sa.Float(), nullable=True), + sa.Column("end_position", sa.Float(), nullable=True), + sa.Column("pages_read", sa.Integer(), nullable=True), + sa.Column("duration_minutes", sa.Integer(), nullable=True), + sa.Column("device_type", sa.String(length=64), nullable=True), + sa.Column("device_name", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["book_id"], ["books.book_id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("session_id"), + ) + op.create_index(op.f("ix_reading_sessions_book_id"), "reading_sessions", ["book_id"], unique=False) + op.create_index(op.f("ix_reading_sessions_owner_user_id"), "reading_sessions", ["owner_user_id"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_reading_sessions_owner_user_id"), table_name="reading_sessions") + op.drop_index(op.f("ix_reading_sessions_book_id"), table_name="reading_sessions") + op.drop_table("reading_sessions") + + op.drop_index(op.f("ix_annotations_owner_user_id"), table_name="annotations") + op.drop_index(op.f("ix_annotations_book_id"), table_name="annotations") + op.drop_table("annotations") + + op.drop_index(op.f("ix_books_owner_user_id"), table_name="books") + op.drop_table("books") diff --git a/docs/auth-testing.md b/docs/auth-testing.md index 0bc29d1..ce38b98 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -120,6 +120,8 @@ Set the resulting values in `.env`: GOOGLE_OAUTH_CLIENT_ID=... GOOGLE_OAUTH_CLIENT_SECRET=... PUBLIC_BASE_URL=http://localhost:8080 +OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["localhost","127.0.0.1"] ``` 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. diff --git a/docs/flutter-auth-integration.md b/docs/flutter-auth-integration.md index 129247a..7cbab53 100644 --- a/docs/flutter-auth-integration.md +++ b/docs/flutter-auth-integration.md @@ -51,6 +51,7 @@ The Flutter app should integrate with these server endpoints: - `POST /v1/auth/link/google/start` - `POST /v1/auth/link/google/complete` - `POST /v1/auth/powersync-token` +- `POST /v1/sync/powersync-upload` - `GET /v1/users/me` Related endpoints that are usually needed in a real client flow: @@ -378,7 +379,7 @@ Flow: } ``` -3. Papyrus returns: +1. Papyrus returns: ```json { @@ -386,9 +387,9 @@ Flow: } ``` -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. +1. App opens `authorization_url` in the browser. +2. After browser completion, Papyrus redirects back to the app with a one-time Papyrus `code`. +3. App calls `POST /v1/auth/link/google/complete` with that code. ```json { @@ -422,6 +423,15 @@ Recommended client behavior: - 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 +- send `database.getNextCrudTransaction()` batches to `POST /v1/sync/powersync-upload` + +The first production PowerSync tables are: + +- `books` +- `annotations` +- `reading_sessions` + +The upload endpoint accepts PowerSync CRUD mutations for those tables and applies owner checks server-side. The client should still populate `owner_user_id` in the local rows for query convenience, but the backend ignores client-supplied ownership for authorization and uses the authenticated Papyrus user. ## Failure Handling diff --git a/docs/powersync-sandbox.md b/docs/powersync-sandbox.md index b7f7961..ce32f5e 100644 --- a/docs/powersync-sandbox.md +++ b/docs/powersync-sandbox.md @@ -9,6 +9,8 @@ This repo includes a debug-only PowerSync sandbox that validates the full local - replication back into another browser client The sandbox uses a dedicated demo table, not the real books domain. +The production sync configuration now also includes the first Flutter app tables: +`books`, `annotations`, and `reading_sessions`. ## What Gets Created @@ -149,5 +151,6 @@ docker compose down -v - 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). +- Re-run `scripts/setup_local_powersync.sh` after applying migrations so the replication role and publication include the production sync tables. - 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`. diff --git a/papyrus/api/routes/sync.py b/papyrus/api/routes/sync.py index ca6fc1b..01a0cce 100644 --- a/papyrus/api/routes/sync.py +++ b/papyrus/api/routes/sync.py @@ -1,14 +1,19 @@ """Sync routes.""" from datetime import UTC, datetime +from typing import Annotated from uuid import uuid4 -from fastapi import APIRouter, Response, status +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId +from papyrus.core.database import get_db from papyrus.schemas.sync import ( CreateMetadataServerConfigRequest, MetadataServerConfig, + PowerSyncUploadRequest, + PowerSyncUploadResponse, ServerType, SyncAccepted, SyncChanges, @@ -18,8 +23,10 @@ SyncStatus, SyncStatusEnum, ) +from papyrus.services import sync as sync_service router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] @router.get( @@ -81,6 +88,21 @@ async def push_changes( ) +@router.post( + "/powersync-upload", + response_model=PowerSyncUploadResponse, + summary="Upload PowerSync client-side mutations", +) +async def upload_powersync_changes( + user_id: CurrentUserId, + request: PowerSyncUploadRequest, + db: DBSession, +) -> PowerSyncUploadResponse: + """Apply a PowerSync upload queue batch to production source tables.""" + applied_count = await sync_service.apply_powersync_upload_batch(db, user_id, request.batch) + return PowerSyncUploadResponse(applied_count=applied_count) + + @router.get( "/conflicts", response_model=SyncConflictResponse, diff --git a/papyrus/config.py b/papyrus/config.py index dc86cbe..4447ee5 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -32,6 +32,8 @@ class Settings(BaseSettings): 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 +51,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 +105,18 @@ def normalize_api_prefix(cls, value: str) -> str: def normalize_dev_pages_vite_url(cls, value: str) -> str: return value.strip().rstrip("/") + @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: @@ -124,6 +141,14 @@ def powersync_jwt_public_key_path(self) -> Path | 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: + 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/security.py b/papyrus/core/security.py index 60c618f..72da292 100644 --- a/papyrus/core/security.py +++ b/papyrus/core/security.py @@ -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/models/__init__.py b/papyrus/models/__init__.py index 9008638..bad38f7 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 SyncAnnotation, SyncBook, SyncReadingSession from papyrus.models.user import User __all__ = [ @@ -10,6 +11,9 @@ "EmailActionToken", "PasswordCredential", "PowerSyncDemoItem", + "SyncAnnotation", + "SyncBook", + "SyncReadingSession", "User", "UserIdentity", ] diff --git a/papyrus/models/sync.py b/papyrus/models/sync.py new file mode 100644 index 0000000..4ece17d --- /dev/null +++ b/papyrus/models/sync.py @@ -0,0 +1,88 @@ +"""Persisted domain models used by 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 synced to clients through PowerSync.""" + + __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()) + + +class SyncAnnotation(Base): + """Annotation source row synced to clients through PowerSync.""" + + __tablename__ = "annotations" + + annotation_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 + ) + book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) + selected_text: Mapped[str] = mapped_column(Text, nullable=False) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + highlight_color: Mapped[str] = mapped_column( + String(16), nullable=False, default="#FFEB3B", server_default="#FFEB3B" + ) + start_position: Mapped[str] = mapped_column(Text, nullable=False) + end_position: Mapped[str] = mapped_column(Text, nullable=False) + chapter_title: Mapped[str | None] = mapped_column(String(255), nullable=True) + chapter_index: Mapped[int | None] = mapped_column(Integer, nullable=True) + page_number: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_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()) + + +class SyncReadingSession(Base): + """Reading session source row synced to clients through PowerSync.""" + + __tablename__ = "reading_sessions" + + session_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 + ) + book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) + start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + start_position: Mapped[float | None] = mapped_column(Float, nullable=True) + end_position: Mapped[float | None] = mapped_column(Float, nullable=True) + pages_read: Mapped[int | None] = mapped_column(Integer, nullable=True) + duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + device_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_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..adbf2ef 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, HttpUrl @@ -153,3 +153,28 @@ class SyncStatus(BaseModel): last_sync_at: datetime | None = None pending_changes: int | None = None error_message: str | None = None + + +class PowerSyncCrudMutation(BaseModel): + """Single CRUD mutation uploaded from the PowerSync client queue.""" + + model_config = ConfigDict(populate_by_name=True) + + table: Literal["books", "annotations", "reading_sessions"] = 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") + + +class PowerSyncUploadRequest(BaseModel): + """PowerSync upload queue batch.""" + + batch: list[PowerSyncCrudMutation] + + +class PowerSyncUploadResponse(BaseModel): + """Summary of an applied PowerSync upload batch.""" + + applied_count: int diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index 99014f2..e05435a 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,48 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden google_oauth_client = GoogleOAuthClient() +def _public_base_host() -> str | None: + public_base_url = get_settings().public_base_url + + if public_base_url is None: + return None + + return urlsplit(public_base_url).hostname + + +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) + public_base_host = _public_base_host() + + if public_base_host is not None: + allowed_hosts.add(public_base_host.lower()) + + 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 +167,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 +302,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..9e39a25 --- /dev/null +++ b/papyrus/services/sync.py @@ -0,0 +1,391 @@ +"""Service-layer sync and PowerSync upload logic.""" + +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 SyncAnnotation, SyncBook, SyncReadingSession +from papyrus.schemas.sync import PowerSyncCrudMutation + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _operation(value: str) -> str: + return value.upper() + + +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 _optional_text(payload: dict[str, object], key: str, default: str | None = None) -> str | None: + if key not in payload: + return default + + value = payload[key] + + if value is None: + return None + + return 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 isinstance(value, int): + return value + + if isinstance(value, float): + return int(value) + + if not isinstance(value, str): + raise ValidationError(f"{key} must be an integer") + + try: + return int(value) + except 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 isinstance(value, int | float): + return float(value) + + if not isinstance(value, str): + raise ValidationError(f"{key} must be a number") + + try: + return float(value) + except 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 | None = None) -> datetime | None: + if key not in payload: + return default + + value = payload[key] + + if value is None: + return None + + 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 a PowerSync CRUD batch to production source tables.""" + applied_count = 0 + + for mutation in batch: + table = mutation.table + operation = _operation(mutation.op) + + if table == "books": + applied_count += await _apply_book_mutation(session, user_id, mutation, operation) + continue + + if table == "annotations": + applied_count += await _apply_annotation_mutation(session, user_id, mutation, operation) + continue + + if table == "reading_sessions": + applied_count += await _apply_reading_session_mutation(session, user_id, mutation, operation) + continue + + raise ValidationError("Unsupported PowerSync upload table") + + await session.commit() + return applied_count + + +async def _apply_book_mutation( + session: AsyncSession, + user_id: UUID, + mutation: PowerSyncCrudMutation, + operation: str, +) -> int: + book_id = _uuid(mutation.id, "id") + + 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 + + if operation not in {"PUT", "PATCH"}: + raise ValidationError("Unsupported PowerSync upload operation") + + 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) or now, + updated_at=_optional_datetime(payload, "updated_at", now) or 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 = _optional_datetime(payload, "updated_at", now) or now + return 1 + + +async def _apply_annotation_mutation( + session: AsyncSession, + user_id: UUID, + mutation: PowerSyncCrudMutation, + operation: str, +) -> int: + annotation_id = _uuid(mutation.id, "id") + + if operation == "DELETE": + annotation = await session.get(SyncAnnotation, annotation_id) + + if annotation is None: + return 0 + + if annotation.owner_user_id != user_id: + raise ForbiddenError("Cannot delete another user's annotation") + + await session.delete(annotation) + return 1 + + if operation not in {"PUT", "PATCH"}: + raise ValidationError("Unsupported PowerSync upload operation") + + payload = mutation.op_data or {} + annotation = await session.get(SyncAnnotation, annotation_id) + now = _now() + + if annotation is not None and annotation.owner_user_id != user_id: + raise ForbiddenError("Cannot modify another user's annotation") + + book_id = _uuid( + payload.get("book_id") if annotation is None else payload.get("book_id", annotation.book_id), "book_id" + ) + await _require_owned_book(session, user_id, book_id) + + if annotation is None: + annotation = SyncAnnotation( + annotation_id=annotation_id, + owner_user_id=user_id, + book_id=book_id, + selected_text=_required_text(payload, "selected_text"), + highlight_color=_required_text(payload, "highlight_color", "#FFEB3B"), + start_position=_required_text(payload, "start_position"), + end_position=_required_text(payload, "end_position"), + created_at=_optional_datetime(payload, "created_at", now) or now, + updated_at=_optional_datetime(payload, "updated_at", now) or now, + ) + session.add(annotation) + + annotation.book_id = book_id + annotation.selected_text = _required_text(payload, "selected_text", annotation.selected_text) + annotation.note = _optional_text(payload, "note", annotation.note) + annotation.highlight_color = _required_text(payload, "highlight_color", annotation.highlight_color) + annotation.start_position = _required_text(payload, "start_position", annotation.start_position) + annotation.end_position = _required_text(payload, "end_position", annotation.end_position) + annotation.chapter_title = _optional_text(payload, "chapter_title", annotation.chapter_title) + annotation.chapter_index = _optional_int(payload, "chapter_index", annotation.chapter_index) + annotation.page_number = _optional_int(payload, "page_number", annotation.page_number) + annotation.updated_at = _optional_datetime(payload, "updated_at", now) or now + return 1 + + +async def _apply_reading_session_mutation( + session: AsyncSession, + user_id: UUID, + mutation: PowerSyncCrudMutation, + operation: str, +) -> int: + session_id = _uuid(mutation.id, "id") + + if operation == "DELETE": + reading_session = await session.get(SyncReadingSession, session_id) + + if reading_session is None: + return 0 + + if reading_session.owner_user_id != user_id: + raise ForbiddenError("Cannot delete another user's reading session") + + await session.delete(reading_session) + return 1 + + if operation not in {"PUT", "PATCH"}: + raise ValidationError("Unsupported PowerSync upload operation") + + payload = mutation.op_data or {} + reading_session = await session.get(SyncReadingSession, session_id) + now = _now() + + if reading_session is not None and reading_session.owner_user_id != user_id: + raise ForbiddenError("Cannot modify another user's reading session") + + book_id = _uuid( + payload.get("book_id") if reading_session is None else payload.get("book_id", reading_session.book_id), + "book_id", + ) + await _require_owned_book(session, user_id, book_id) + + if reading_session is None: + start_time = _optional_datetime(payload, "start_time") + + if start_time is None: + raise ValidationError("start_time is required") + + reading_session = SyncReadingSession( + session_id=session_id, + owner_user_id=user_id, + book_id=book_id, + start_time=start_time, + created_at=_optional_datetime(payload, "created_at", now) or now, + ) + session.add(reading_session) + + reading_session.book_id = book_id + reading_session.start_time = ( + _optional_datetime(payload, "start_time", reading_session.start_time) or reading_session.start_time + ) + reading_session.end_time = _optional_datetime(payload, "end_time", reading_session.end_time) + reading_session.start_position = _optional_float(payload, "start_position", reading_session.start_position) + reading_session.end_position = _optional_float(payload, "end_position", reading_session.end_position) + reading_session.pages_read = _optional_int(payload, "pages_read", reading_session.pages_read) + reading_session.duration_minutes = _optional_int(payload, "duration_minutes", reading_session.duration_minutes) + reading_session.device_type = _optional_text(payload, "device_type", reading_session.device_type) + reading_session.device_name = _optional_text(payload, "device_name", reading_session.device_name) + return 1 + + +async def _require_owned_book(session: AsyncSession, user_id: UUID, book_id: UUID) -> SyncBook: + book = await _get_owned_book(session, user_id, book_id) + + if book is None: + raise ValidationError("Referenced book does not exist") + + return book diff --git a/powersync/sync-config.yaml b/powersync/sync-config.yaml index 2b7c7a7..66ce4ca 100644 --- a/powersync/sync-config.yaml +++ b/powersync/sync-config.yaml @@ -2,6 +2,74 @@ 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() + + annotations: + auto_subscribe: true + query: | + SELECT + annotation_id AS id, + owner_user_id::text AS owner_user_id, + book_id::text AS book_id, + selected_text, + note, + highlight_color, + start_position, + end_position, + chapter_title, + chapter_index, + page_number, + created_at::text AS created_at, + updated_at::text AS updated_at + FROM annotations + WHERE owner_user_id::text = auth.user_id() + + reading_sessions: + auto_subscribe: true + query: | + SELECT + session_id AS id, + owner_user_id::text AS owner_user_id, + book_id::text AS book_id, + start_time::text AS start_time, + end_time::text AS end_time, + start_position, + end_position, + pages_read, + duration_minutes, + device_type, + device_name, + created_at::text AS created_at + FROM reading_sessions + WHERE owner_user_id::text = auth.user_id() + demo_items: auto_subscribe: true query: | diff --git a/scripts/setup_local_powersync.sh b/scripts/setup_local_powersync.sh index b5bd943..5227d78 100755 --- a/scripts/setup_local_powersync.sh +++ b/scripts/setup_local_powersync.sh @@ -29,19 +29,48 @@ 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.annotations TO "${POWERSYNC_SOURCE_ROLE}"; +GRANT SELECT ON TABLE public.reading_sessions 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.annotations, public.reading_sessions, public.powersync_demo_items; + ELSE + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'books' + ) THEN + ALTER PUBLICATION powersync ADD TABLE public.books; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'annotations' + ) THEN + ALTER PUBLICATION powersync ADD TABLE public.annotations; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'reading_sessions' + ) THEN + ALTER PUBLICATION powersync ADD TABLE public.reading_sessions; + END IF; + + IF 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; + END IF; END IF; END \$\$; diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index 821410d..8f7eee4 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -379,6 +379,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], diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index a06e5e9..2ab540e 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -1,9 +1,12 @@ """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 + +from papyrus.models import SyncBook, User async def test_get_sync_status(client: AsyncClient, auth_headers: dict[str, str]): @@ -48,6 +51,81 @@ async def test_push_changes(client: AsyncClient, auth_headers: dict[str, str]): assert "rejected" in data +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/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PUT", + "id": book_id, + "data": { + "title": "PowerSync Book", + "author": "PowerSync Author", + }, + } + ] + }, + ) + assert response.status_code == 200 + 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_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 + + 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) diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 75d420e..8b50eaf 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -70,3 +70,42 @@ def test_create_powersync_token_supports_file_based_keys( assert payload["sub"] == "user-123" assert payload["type"] == "powersync" assert jwks["keys"][0]["kid"] == "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_sync.py b/tests/services/test_sync.py new file mode 100644 index 0000000..f67c778 --- /dev/null +++ b/tests/services/test_sync.py @@ -0,0 +1,155 @@ +"""Service tests for production PowerSync upload handling.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from papyrus.core.exceptions import ForbiddenError, ValidationError +from papyrus.models import SyncAnnotation, SyncBook, SyncReadingSession, 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_domain_mutations( + test_session_maker: async_sessionmaker[AsyncSession], +): + """PowerSync upload batches create and update the first production sync tables.""" + async with test_session_maker() as session: + user = await _create_user(session, "sync@example.com") + book_id = str(uuid4()) + annotation_id = str(uuid4()) + reading_session_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, + }, + ), + PowerSyncCrudMutation( + type="annotations", + op="PUT", + id=annotation_id, + data={ + "book_id": book_id, + "selected_text": "Important passage", + "highlight_color": "#FFEB3B", + "start_position": "cfi-start", + "end_position": "cfi-end", + }, + ), + PowerSyncCrudMutation( + type="reading_sessions", + op="PUT", + id=reading_session_id, + data={ + "book_id": book_id, + "start_time": "2026-05-09T12:00:00+00:00", + "end_time": "2026-05-09T12:30:00+00:00", + "pages_read": 12, + }, + ), + ], + ) + + assert applied_count == 3 + book = await session.get(SyncBook, book_id) + annotation = await session.get(SyncAnnotation, annotation_id) + reading_session = await session.get(SyncReadingSession, reading_session_id) + assert book is not None + assert annotation is not None + assert reading_session is not None + assert book.title == "Synced Book" + assert annotation.book_id == book.book_id + assert reading_session.book_id == book.book_id + + +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_missing_referenced_book( + test_session_maker: async_sessionmaker[AsyncSession], +): + """Child sync rows must reference an owned book.""" + async with test_session_maker() as session: + user = await _create_user(session, "missing-book@example.com") + + with pytest.raises(ValidationError): + await sync_service.apply_powersync_upload_batch( + session, + user.user_id, + [ + PowerSyncCrudMutation( + type="annotations", + op="PUT", + id=str(uuid4()), + data={ + "book_id": str(uuid4()), + "selected_text": "Orphaned", + "start_position": "start", + "end_position": "end", + }, + ) + ], + ) diff --git a/tests/test_models.py b/tests/test_models.py index f42e413..b17c564 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,9 @@ EmailActionToken, PasswordCredential, PowerSyncDemoItem, + SyncAnnotation, + SyncBook, + SyncReadingSession, User, UserIdentity, ) @@ -21,6 +24,9 @@ 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"] + annotations_table = Base.metadata.tables["annotations"] + reading_sessions_table = Base.metadata.tables["reading_sessions"] assert users_table is User.__table__ assert identities_table is UserIdentity.__table__ assert password_credentials_table is PasswordCredential.__table__ @@ -28,6 +34,9 @@ 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 annotations_table is SyncAnnotation.__table__ + assert reading_sessions_table is SyncReadingSession.__table__ assert set(users_table.columns.keys()) == { "user_id", @@ -47,3 +56,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 {"annotation_id", "owner_user_id", "book_id", "selected_text"}.issubset(annotations_table.columns.keys()) + assert {"session_id", "owner_user_id", "book_id", "start_time"}.issubset(reading_sessions_table.columns.keys()) From 3be6314877ee55df88e6e75160be7647984f66ca Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 9 May 2026 23:58:09 +0300 Subject: [PATCH 2/7] Fix PowerSync server configuration --- README.md | 5 +- docs/auth-testing.md | 2 +- docs/powersync-sandbox.md | 8 +++- papyrus/config.py | 44 ++++++++++++++++-- papyrus/core/security.py | 2 +- tests/api/routes/test_sync.py | 87 +++++++++++++++++++++++++++++++++++ tests/core/test_security.py | 38 +++++++++++++++ tests/test_env_files.py | 36 +++++++++++++++ 8 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 tests/test_env_files.py diff --git a/README.md b/README.md index 98fbd2c..32bd077 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,12 @@ uv run alembic upgrade head Run the server: ```bash -uv run uvicorn papyrus.main:app --reload --port 8080 +uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 ``` +The `--host 0.0.0.0` flag is required for the Dockerized PowerSync service to +reach the backend JWKS endpoint through `host.docker.internal`. + Run the dev-pages asset server with live TS/SCSS reload: ```bash diff --git a/docs/auth-testing.md b/docs/auth-testing.md index ce38b98..f547e76 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -51,7 +51,7 @@ If the API runs inside Docker instead of on the host, set `SMTP_HOST=mailpit` an ```bash uv run alembic upgrade head -uv run uvicorn papyrus.main:app --reload +uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 ``` 1. Run the dev-pages asset server for live TypeScript and SCSS reload: diff --git a/docs/powersync-sandbox.md b/docs/powersync-sandbox.md index ce32f5e..8d74591 100644 --- a/docs/powersync-sandbox.md +++ b/docs/powersync-sandbox.md @@ -68,9 +68,13 @@ uv run alembic upgrade head 1. Start the backend on the host: ```bash -uv run uvicorn papyrus.main:app --reload --port 8080 +uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 ``` +PowerSync runs in Docker and fetches JWKS from `host.docker.internal:8080`, so +the backend must listen on all host interfaces. Binding Uvicorn to its default +`127.0.0.1` causes PowerSync JWT validation to fail with `JWKS request failed`. + 1. Start the sandbox asset dev server for live TS/SCSS reload: ```bash @@ -143,7 +147,7 @@ docker compose down -v - `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` + - `uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080` - `npm --prefix frontend/dev-pages run dev` - `docker compose up -d powersync` diff --git a/papyrus/config.py b/papyrus/config.py index 4447ee5..848439c 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -105,6 +105,41 @@ 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", + "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]: @@ -128,7 +163,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) @@ -136,7 +171,7 @@ 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) @@ -144,7 +179,10 @@ def powersync_jwt_public_key_path(self) -> Path | None: @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: + 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) diff --git a/papyrus/core/security.py b/papyrus/core/security.py index 72da292..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: diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index 2ab540e..74c68d0 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -85,6 +85,93 @@ async def test_powersync_upload_applies_book_mutation( 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 + + 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 + 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 + + 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_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/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_cross_user_book_mutation( client: AsyncClient, auth_headers: dict[str, str], diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 8b50eaf..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,6 +69,7 @@ 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" @@ -72,6 +77,39 @@ def test_create_powersync_token_supports_file_based_keys( 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], 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 From 5786a13c74504ce9ef0ab53ade87a62eb730044d Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 1 Jun 2026 20:01:28 +0300 Subject: [PATCH 3/7] docs: update powersync integration guide --- README.md | 63 ++-- docs/auth-testing.md | 171 +++------- docs/flutter-auth-integration.md | 547 ++++++------------------------- docs/powersync-sandbox.md | 150 +++------ 4 files changed, 211 insertions(+), 720 deletions(-) diff --git a/README.md b/README.md index 32bd077..7313266 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,43 @@ -# 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 -``` +Read the focused guides: -Run the database: +- [Flutter auth and PowerSync integration](docs/flutter-auth-integration.md) +- [Authentication testing](docs/auth-testing.md) +- [PowerSync sandbox](docs/powersync-sandbox.md) -```bash -docker compose up database mailpit powersync-storage powersync -``` +## Local Setup -Run database migrations: +Run from `server/`: ```bash +uv sync --extra dev +./scripts/generate_dev_powersync_keys.sh +docker compose up -d database mailpit powersync-storage uv run alembic upgrade head -``` - -Run the server: - -```bash +./scripts/setup_local_powersync.sh uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 -``` - -The `--host 0.0.0.0` flag is required for the Dockerized PowerSync service to -reach the backend JWKS endpoint through `host.docker.internal`. - -Run the dev-pages asset server with live TS/SCSS reload: - -```bash +docker compose up -d powersync 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 +Use `--host 0.0.0.0` so the PowerSync container reaches the JWKS endpoint at +`host.docker.internal:8080`. -Run tests: +## Checks ```bash uv run pytest --cov --cov-report html diff --git a/docs/auth-testing.md b/docs/auth-testing.md index f547e76..0fb2625 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -1,34 +1,10 @@ -# 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 @@ -39,49 +15,46 @@ 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`. - -1. Apply migrations and run the API: +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 -``` - -1. Run the dev-pages asset server for live TypeScript and SCSS reload: - -```bash 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 -Mailpit is a local SMTP sink. No real mailbox is needed. +Use the auth sandbox to trigger: -- 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: +- registration verification email +- resend verification email +- password reset email + +Open `http://localhost:8025` and inspect the delivered messages. + +Run the SMTP smoke test: ```bash RUN_SMTP_SMOKE_TEST=true \ @@ -89,88 +62,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: - -- `GET /v1/auth/oauth/google/callback` - -The server then redirects to your app callback URI with a one-time Papyrus exchange code. - -### What To Create In Google Cloud - -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 -OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] -OAUTH_ALLOWED_REDIRECT_HOSTS=["localhost","127.0.0.1"] -``` - -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. +## Google OAuth -### OAuth Consent Screen Notes +Create a Google OAuth client: -For development: +- Application type: `Web application` +- Authorized redirect URI: + `http://localhost:8080/v1/auth/oauth/google/callback` -- keep the app in testing mode -- add your Google account under test users if Google requires it +Keep the OAuth consent screen in testing mode and add the testing Google +account as a test user. -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. - -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: +Test the browser flow: -```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 \ @@ -179,11 +94,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 7cbab53..db22b78 100644 --- a/docs/flutter-auth-integration.md +++ b/docs/flutter-auth-integration.md @@ -1,512 +1,183 @@ -# 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` -- `POST /v1/sync/powersync-upload` -- `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" + "device_label": "flutter-android" } ``` -Successful response: +Registration uses `POST /v1/auth/register` with the same client metadata and a +`display_name`. -```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" - } -} -``` - -### Login - -`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. +Google returns to Papyrus: -### 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` - -```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 +Flutter callback URIs: -`POST /v1/auth/powersync-token` +- Mobile: `papyrus://auth/callback` +- Linux and Windows: `http://localhost:43821/auth/callback` +- Web: `/auth/callback` -Requires bearer auth. +Google Cloud redirect URI: -Response: - -```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 - -The HTTP client should: - -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 +## Token Refresh -Do not allow multiple simultaneous refresh operations. Use a single in-flight refresh guard so concurrent `401` responses wait on the same refresh result. +`AuthRepository` owns token refresh. -### Logout +- `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. -Use: +## PowerSync Tokens -- `POST /v1/auth/logout` to invalidate the current session -- `POST /v1/auth/logout-all` to invalidate all sessions +Flutter requests a PowerSync JWT from Papyrus: -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 } ``` -1. 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. -1. App opens `authorization_url` in the browser. -2. After browser completion, Papyrus redirects back to the app with a one-time Papyrus `code`. -3. 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: +## PowerSync Data Flow -- `POST /v1/auth/powersync-token` +Pull: -Use the Papyrus access token to authenticate that request. +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 watches local tables and updates `DataStore`. -The PowerSync token is separate from the Papyrus API access token: +Upload: -- Papyrus API token authenticates calls to the Papyrus backend -- PowerSync token authenticates calls to PowerSync +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 supported mutations after ownership checks. +6. PowerSync replication sends committed changes to connected clients. -Do not send Google tokens directly to PowerSync. - -Recommended client behavior: - -- 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 -- send `database.getNextCrudTransaction()` batches to `POST /v1/sync/powersync-upload` - -The first production PowerSync tables are: +Synced source tables: - `books` - `annotations` - `reading_sessions` -The upload endpoint accepts PowerSync CRUD mutations for those tables and applies owner checks server-side. The client should still populate `owner_user_id` in the local rows for query convenience, but the backend ignores client-supplied ownership for authorization and uses the authenticated Papyrus user. - -## Failure Handling - -The Flutter app should handle these cases explicitly. - -### Protected API Returns `401` - -- try refresh once -- if refresh succeeds, retry the failed request once -- if refresh fails, clear tokens and sign the user out - -### Session Revocation - -Papyrus validates sessions server-side. Existing access tokens can stop working immediately after: +## Local Flutter Command -- logout -- logout all -- password change -- password reset -- account disablement +Run from `client/app/`: -The client should treat `401` or `403` after those operations as expected behavior, not as a transport problem. - -### 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 \ + --dart-define=PAPYRUS_API_BASE_URL=http://localhost:8080 \ + --dart-define=POWERSYNC_SERVICE_URL=http://localhost:8081 +``` -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 8d74591..4980891 100644 --- a/docs/powersync-sandbox.md +++ b/docs/powersync-sandbox.md @@ -1,27 +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. -The production sync configuration now also includes the first Flutter app tables: -`books`, `annotations`, and `reading_sessions`. +## 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 @@ -37,124 +23,64 @@ 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: +Run from `server/`: ```bash +uv sync --extra dev ./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 --host 0.0.0.0 --port 8080 -``` - -PowerSync runs in Docker and fetches JWKS from `host.docker.internal:8080`, so -the backend must listen on all host interfaces. Binding Uvicorn to its default -`127.0.0.1` causes PowerSync JWT validation to fail with `JWKS request failed`. - -1. Start the sandbox asset dev server for live TS/SCSS reload: - -```bash npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev -``` - -1. Start the PowerSync service: - -```bash docker compose up -d powersync ``` -## Useful Local URLs +Use `--host 0.0.0.0` so the PowerSync container reaches the JWKS endpoint at +`host.docker.internal:8080`. + +## 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 +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 --host 0.0.0.0 --port 8080 +npm --prefix frontend/dev-pages run dev +docker compose up -d powersync ``` -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 --host 0.0.0.0 --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). -- Re-run `scripts/setup_local_powersync.sh` after applying migrations so the replication role and publication include the production sync tables. -- 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`. From a50e5796cd47975660dadc2844000a333e48291d Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 5 Jun 2026 01:35:30 +0300 Subject: [PATCH 4/7] feat: update environment configuration for local testing --- .env.example | 10 +++++-- docs/auth-testing.md | 9 ++++++ docs/flutter-auth-integration.md | 4 +-- papyrus/config.py | 2 ++ papyrus/services/auth/_core.py | 25 ++++++++++++++--- papyrus/services/auth/email_flows.py | 6 ++-- tests/api/routes/test_auth.py | 42 +++++++++++++++++++++++++++- 7 files changed, 86 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index da3c539..ed5bf37 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,11 +12,17 @@ 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 diff --git a/docs/auth-testing.md b/docs/auth-testing.md index 0fb2625..04aa49b 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -8,6 +8,8 @@ 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 @@ -25,6 +27,13 @@ POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev POWERSYNC_JWT_AUDIENCE=powersync-dev ``` +Add local app host entries: + +```text +127.0.0.1 papyrus.localhost +::1 papyrus.localhost +``` + Run from `server/`: ```bash diff --git a/docs/flutter-auth-integration.md b/docs/flutter-auth-integration.md index db22b78..18e9b07 100644 --- a/docs/flutter-auth-integration.md +++ b/docs/flutter-auth-integration.md @@ -172,9 +172,7 @@ Synced source tables: Run from `client/app/`: ```bash -flutter run \ - --dart-define=PAPYRUS_API_BASE_URL=http://localhost:8080 \ - --dart-define=POWERSYNC_SERVICE_URL=http://localhost:8081 +flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines ``` ## Related Docs diff --git a/papyrus/config.py b/papyrus/config.py index 848439c..786eef3 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -30,6 +30,7 @@ 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"] @@ -107,6 +108,7 @@ def normalize_dev_pages_vite_url(cls, value: str) -> str: @field_validator( "public_base_url", + "app_public_base_url", "google_oauth_client_id", "google_oauth_client_secret", "smtp_host", 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/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index 8f7eee4..c51291e 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -82,6 +82,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: @@ -596,6 +597,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]], @@ -621,7 +649,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( @@ -682,6 +713,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, From 2e9d3f9f89b3083fd725ac8cd2c87baeb9747f3f Mon Sep 17 00:00:00 2001 From: Eoic Date: Thu, 25 Jun 2026 00:19:30 +0300 Subject: [PATCH 5/7] feat: complete PowerSync integration --- .env.example | 2 +- .gitignore | 2 + Dockerfile | 2 +- README.md | 12 +- ...1d7c2f4e8b9_add_powersync_domain_tables.py | 51 ---- docker-compose.yml | 24 +- docs/flutter-auth-integration.md | 21 +- docs/powersync-sandbox.md | 13 +- papyrus/api/routes/auth.py | 77 +++-- papyrus/api/routes/sync.py | 187 +----------- papyrus/core/rate_limit.py | 6 + papyrus/main.py | 5 +- papyrus/models/__init__.py | 4 +- papyrus/models/sync.py | 49 +-- papyrus/schemas/sync.py | 198 +++--------- papyrus/services/sync.py | 282 ++++-------------- powersync/sync-config.yaml | 39 --- scripts/bootstrap_local.sh | 27 ++ scripts/setup_local_powersync.sh | 36 +-- tests/api/routes/test_auth.py | 26 ++ tests/api/routes/test_health.py | 10 +- tests/api/routes/test_sync.py | 164 +++++----- tests/conftest.py | 7 + tests/services/test_sync.py | 74 +---- tests/test_models.py | 10 +- 25 files changed, 381 insertions(+), 947 deletions(-) create mode 100644 papyrus/core/rate_limit.py create mode 100755 scripts/bootstrap_local.sh diff --git a/.env.example b/.env.example index ed5bf37..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://papyrus.localhost:3000"] +CORS_ORIGINS='["http://papyrus.localhost:3000"]' SECRET_KEY= ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 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 7313266..23fcca7 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,14 @@ Run from `server/`: ```bash uv sync --extra dev -./scripts/generate_dev_powersync_keys.sh -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 --host 0.0.0.0 --port 8080 -docker compose up -d powersync +./scripts/bootstrap_local.sh npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev ``` -Use `--host 0.0.0.0` so the PowerSync container reaches the JWKS endpoint at -`host.docker.internal:8080`. +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. ## Checks diff --git a/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py b/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py index c4f1c1c..50b6d08 100644 --- a/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py +++ b/alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py @@ -51,59 +51,8 @@ def upgrade() -> None: ) op.create_index(op.f("ix_books_owner_user_id"), "books", ["owner_user_id"], unique=False) - op.create_table( - "annotations", - sa.Column("annotation_id", sa.Uuid(), nullable=False), - sa.Column("owner_user_id", sa.Uuid(), nullable=False), - sa.Column("book_id", sa.Uuid(), nullable=False), - sa.Column("selected_text", sa.Text(), nullable=False), - sa.Column("note", sa.Text(), nullable=True), - sa.Column("highlight_color", sa.String(length=16), server_default="#FFEB3B", nullable=False), - sa.Column("start_position", sa.Text(), nullable=False), - sa.Column("end_position", sa.Text(), nullable=False), - sa.Column("chapter_title", sa.String(length=255), nullable=True), - sa.Column("chapter_index", sa.Integer(), nullable=True), - sa.Column("page_number", sa.Integer(), nullable=True), - sa.Column("created_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(["book_id"], ["books.book_id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("annotation_id"), - ) - op.create_index(op.f("ix_annotations_book_id"), "annotations", ["book_id"], unique=False) - op.create_index(op.f("ix_annotations_owner_user_id"), "annotations", ["owner_user_id"], unique=False) - - op.create_table( - "reading_sessions", - sa.Column("session_id", sa.Uuid(), nullable=False), - sa.Column("owner_user_id", sa.Uuid(), nullable=False), - sa.Column("book_id", sa.Uuid(), nullable=False), - sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), - sa.Column("end_time", sa.DateTime(timezone=True), nullable=True), - sa.Column("start_position", sa.Float(), nullable=True), - sa.Column("end_position", sa.Float(), nullable=True), - sa.Column("pages_read", sa.Integer(), nullable=True), - sa.Column("duration_minutes", sa.Integer(), nullable=True), - sa.Column("device_type", sa.String(length=64), nullable=True), - sa.Column("device_name", sa.String(length=255), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint(["book_id"], ["books.book_id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("session_id"), - ) - op.create_index(op.f("ix_reading_sessions_book_id"), "reading_sessions", ["book_id"], unique=False) - op.create_index(op.f("ix_reading_sessions_owner_user_id"), "reading_sessions", ["owner_user_id"], unique=False) - def downgrade() -> None: """Downgrade schema.""" - op.drop_index(op.f("ix_reading_sessions_owner_user_id"), table_name="reading_sessions") - op.drop_index(op.f("ix_reading_sessions_book_id"), table_name="reading_sessions") - op.drop_table("reading_sessions") - - op.drop_index(op.f("ix_annotations_owner_user_id"), table_name="annotations") - op.drop_index(op.f("ix_annotations_book_id"), table_name="annotations") - op.drop_table("annotations") - 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/flutter-auth-integration.md b/docs/flutter-auth-integration.md index 18e9b07..eba292a 100644 --- a/docs/flutter-auth-integration.md +++ b/docs/flutter-auth-integration.md @@ -150,7 +150,9 @@ Pull: 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 watches local tables and updates `DataStore`. +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. Upload: @@ -158,14 +160,21 @@ Upload: 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 supported mutations after ownership checks. +5. The server applies the complete CRUD transaction atomically after ownership + and field validation. 6. PowerSync replication sends committed changes to connected clients. -Synced source tables: +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. -- `books` -- `annotations` -- `reading_sessions` +Guest and authenticated libraries use separate local databases: + +- `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. ## Local Flutter Command diff --git a/docs/powersync-sandbox.md b/docs/powersync-sandbox.md index 4980891..39f0acf 100644 --- a/docs/powersync-sandbox.md +++ b/docs/powersync-sandbox.md @@ -27,14 +27,9 @@ Run from `server/`: ```bash uv sync --extra dev -./scripts/generate_dev_powersync_keys.sh -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 --host 0.0.0.0 --port 8080 +./scripts/bootstrap_local.sh npm --prefix frontend/dev-pages install npm --prefix frontend/dev-pages run dev -docker compose up -d powersync ``` Use `--host 0.0.0.0` so the PowerSync container reaches the JWKS endpoint at @@ -75,12 +70,8 @@ Run from `server/`: ```bash docker compose down -v -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 --host 0.0.0.0 --port 8080 +./scripts/bootstrap_local.sh npm --prefix frontend/dev-pages run dev -docker compose up -d powersync ``` 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 01a0cce..84dfdb3 100644 --- a/papyrus/api/routes/sync.py +++ b/papyrus/api/routes/sync.py @@ -1,200 +1,33 @@ -"""Sync routes.""" +"""PowerSync upload routes.""" -from datetime import UTC, datetime from typing import Annotated -from uuid import uuid4 -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId +from papyrus.config import get_settings from papyrus.core.database import get_db -from papyrus.schemas.sync import ( - CreateMetadataServerConfigRequest, - MetadataServerConfig, - PowerSyncUploadRequest, - PowerSyncUploadResponse, - ServerType, - SyncAccepted, - SyncChanges, - SyncConflictResponse, - SyncPushRequest, - SyncPushResponse, - SyncStatus, - SyncStatusEnum, -) +from papyrus.core.rate_limit import limiter +from papyrus.schemas.sync import PowerSyncUploadRequest, PowerSyncUploadResponse from papyrus.services import sync as sync_service router = APIRouter() DBSession = Annotated[AsyncSession, Depends(get_db)] -@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.post( "/powersync-upload", response_model=PowerSyncUploadResponse, summary="Upload PowerSync client-side mutations", ) +@limiter.limit(lambda: f"{get_settings().rate_limit_batch}/minute") async def upload_powersync_changes( + request: Request, user_id: CurrentUserId, - request: PowerSyncUploadRequest, + payload: PowerSyncUploadRequest, db: DBSession, ) -> PowerSyncUploadResponse: - """Apply a PowerSync upload queue batch to production source tables.""" - applied_count = await sync_service.apply_powersync_upload_batch(db, user_id, request.batch) + """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) - - -@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), - ) - - -@router.post( - "/config", - response_model=MetadataServerConfig, - status_code=status.HTTP_201_CREATED, - summary="Configure metadata server", -) -async def create_metadata_server_config( - 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), - ) 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/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 bad38f7..1f92bfb 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -1,7 +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 SyncAnnotation, SyncBook, SyncReadingSession +from papyrus.models.sync import SyncBook from papyrus.models.user import User __all__ = [ @@ -11,9 +11,7 @@ "EmailActionToken", "PasswordCredential", "PowerSyncDemoItem", - "SyncAnnotation", "SyncBook", - "SyncReadingSession", "User", "UserIdentity", ] diff --git a/papyrus/models/sync.py b/papyrus/models/sync.py index 4ece17d..8fb8de5 100644 --- a/papyrus/models/sync.py +++ b/papyrus/models/sync.py @@ -1,4 +1,4 @@ -"""Persisted domain models used by PowerSync.""" +"""Persisted books synced through PowerSync.""" from __future__ import annotations @@ -13,7 +13,7 @@ class SyncBook(Base): - """Book source row synced to clients through PowerSync.""" + """Book source row synchronized to the owning user's clients.""" __tablename__ = "books" @@ -41,48 +41,3 @@ class SyncBook(Base): 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()) - - -class SyncAnnotation(Base): - """Annotation source row synced to clients through PowerSync.""" - - __tablename__ = "annotations" - - annotation_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 - ) - book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) - selected_text: Mapped[str] = mapped_column(Text, nullable=False) - note: Mapped[str | None] = mapped_column(Text, nullable=True) - highlight_color: Mapped[str] = mapped_column( - String(16), nullable=False, default="#FFEB3B", server_default="#FFEB3B" - ) - start_position: Mapped[str] = mapped_column(Text, nullable=False) - end_position: Mapped[str] = mapped_column(Text, nullable=False) - chapter_title: Mapped[str | None] = mapped_column(String(255), nullable=True) - chapter_index: Mapped[int | None] = mapped_column(Integer, nullable=True) - page_number: Mapped[int | None] = mapped_column(Integer, nullable=True) - created_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()) - - -class SyncReadingSession(Base): - """Reading session source row synced to clients through PowerSync.""" - - __tablename__ = "reading_sessions" - - session_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 - ) - book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) - start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - start_position: Mapped[float | None] = mapped_column(Float, nullable=True) - end_position: Mapped[float | None] = mapped_column(Float, nullable=True) - pages_read: Mapped[int | None] = mapped_column(Integer, nullable=True) - duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) - device_type: Mapped[str | None] = mapped_column(String(64), nullable=True) - device_name: Mapped[str | None] = mapped_column(String(255), nullable=True) - created_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 adbf2ef..3bfae0d 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -1,180 +1,66 @@ -"""Sync-related schemas.""" +"""PowerSync upload schemas.""" -from datetime import datetime -from enum import StrEnum from typing import Any, Literal -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 +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 CRUD mutation uploaded from the PowerSync client queue.""" + """Single books-table mutation uploaded from the PowerSync queue.""" model_config = ConfigDict(populate_by_name=True) - table: Literal["books", "annotations", "reading_sessions"] = Field(alias="type") + 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): - """PowerSync upload queue batch.""" + """One PowerSync CRUD transaction.""" batch: list[PowerSyncCrudMutation] class PowerSyncUploadResponse(BaseModel): - """Summary of an applied PowerSync upload batch.""" + """Summary of an applied PowerSync upload transaction.""" applied_count: int diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py index 9e39a25..4876532 100644 --- a/papyrus/services/sync.py +++ b/papyrus/services/sync.py @@ -1,4 +1,4 @@ -"""Service-layer sync and PowerSync upload logic.""" +"""Books-only PowerSync upload service.""" from __future__ import annotations @@ -8,18 +8,41 @@ from sqlalchemy.ext.asyncio import AsyncSession from papyrus.core.exceptions import ForbiddenError, ValidationError -from papyrus.models import SyncAnnotation, SyncBook, SyncReadingSession +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 _operation(value: str) -> str: - return value.upper() - - def _uuid(value: object, field_name: str) -> UUID: try: return UUID(str(value)) @@ -27,99 +50,72 @@ def _uuid(value: object, field_name: str) -> UUID: 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] - - if value is None: - return None - - return str(value) + 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 isinstance(value, int): - return value - - if isinstance(value, float): - return int(value) - - if not isinstance(value, str): + if not isinstance(value, int | float | str) or isinstance(value, bool): raise ValidationError(f"{key} must be an integer") - try: return int(value) - except ValueError as exc: + 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 isinstance(value, int | float): - return float(value) - - if not isinstance(value, str): + if not isinstance(value, int | float | str) or isinstance(value, bool): raise ValidationError(f"{key} must be a number") - try: return float(value) - except ValueError as exc: + 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 | None = None) -> datetime | None: +def _optional_datetime(payload: dict[str, object], key: str, default: datetime) -> datetime: if key not in payload: return default - value = payload[key] - - if value is None: - return None - if isinstance(value, datetime): return value - try: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) except ValueError as exc: @@ -129,43 +125,31 @@ def _optional_datetime(payload: dict[str, object], key: str, default: datetime | 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, + 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 @@ -174,28 +158,15 @@ async def apply_powersync_upload_batch( user_id: UUID, batch: list[PowerSyncCrudMutation], ) -> int: - """Apply a PowerSync CRUD batch to production source tables.""" + """Apply one PowerSync CRUD transaction and commit it atomically.""" applied_count = 0 - - for mutation in batch: - table = mutation.table - operation = _operation(mutation.op) - - if table == "books": - applied_count += await _apply_book_mutation(session, user_id, mutation, operation) - continue - - if table == "annotations": - applied_count += await _apply_annotation_mutation(session, user_id, mutation, operation) - continue - - if table == "reading_sessions": - applied_count += await _apply_reading_session_mutation(session, user_id, mutation, operation) - continue - - raise ValidationError("Unsupported PowerSync upload table") - - await session.commit() + 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 @@ -203,23 +174,18 @@ async def _apply_book_mutation( session: AsyncSession, user_id: UUID, mutation: PowerSyncCrudMutation, - operation: str, ) -> 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 - if operation not in {"PUT", "PATCH"}: - raise ValidationError("Unsupported PowerSync upload operation") - - payload = mutation.op_data or {} + payload = _validate_payload(mutation.op_data or {}) book = await _get_owned_book(session, user_id, book_id) now = _now() @@ -228,8 +194,8 @@ async def _apply_book_mutation( book_id=book_id, owner_user_id=user_id, title=_required_text(payload, "title", "Untitled Book"), - added_at=_optional_datetime(payload, "added_at", now) or now, - updated_at=_optional_datetime(payload, "updated_at", now) or now, + added_at=_optional_datetime(payload, "added_at", now), + updated_at=now, ) session.add(book) @@ -251,141 +217,5 @@ async def _apply_book_mutation( 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 = _optional_datetime(payload, "updated_at", now) or now - return 1 - - -async def _apply_annotation_mutation( - session: AsyncSession, - user_id: UUID, - mutation: PowerSyncCrudMutation, - operation: str, -) -> int: - annotation_id = _uuid(mutation.id, "id") - - if operation == "DELETE": - annotation = await session.get(SyncAnnotation, annotation_id) - - if annotation is None: - return 0 - - if annotation.owner_user_id != user_id: - raise ForbiddenError("Cannot delete another user's annotation") - - await session.delete(annotation) - return 1 - - if operation not in {"PUT", "PATCH"}: - raise ValidationError("Unsupported PowerSync upload operation") - - payload = mutation.op_data or {} - annotation = await session.get(SyncAnnotation, annotation_id) - now = _now() - - if annotation is not None and annotation.owner_user_id != user_id: - raise ForbiddenError("Cannot modify another user's annotation") - - book_id = _uuid( - payload.get("book_id") if annotation is None else payload.get("book_id", annotation.book_id), "book_id" - ) - await _require_owned_book(session, user_id, book_id) - - if annotation is None: - annotation = SyncAnnotation( - annotation_id=annotation_id, - owner_user_id=user_id, - book_id=book_id, - selected_text=_required_text(payload, "selected_text"), - highlight_color=_required_text(payload, "highlight_color", "#FFEB3B"), - start_position=_required_text(payload, "start_position"), - end_position=_required_text(payload, "end_position"), - created_at=_optional_datetime(payload, "created_at", now) or now, - updated_at=_optional_datetime(payload, "updated_at", now) or now, - ) - session.add(annotation) - - annotation.book_id = book_id - annotation.selected_text = _required_text(payload, "selected_text", annotation.selected_text) - annotation.note = _optional_text(payload, "note", annotation.note) - annotation.highlight_color = _required_text(payload, "highlight_color", annotation.highlight_color) - annotation.start_position = _required_text(payload, "start_position", annotation.start_position) - annotation.end_position = _required_text(payload, "end_position", annotation.end_position) - annotation.chapter_title = _optional_text(payload, "chapter_title", annotation.chapter_title) - annotation.chapter_index = _optional_int(payload, "chapter_index", annotation.chapter_index) - annotation.page_number = _optional_int(payload, "page_number", annotation.page_number) - annotation.updated_at = _optional_datetime(payload, "updated_at", now) or now - return 1 - - -async def _apply_reading_session_mutation( - session: AsyncSession, - user_id: UUID, - mutation: PowerSyncCrudMutation, - operation: str, -) -> int: - session_id = _uuid(mutation.id, "id") - - if operation == "DELETE": - reading_session = await session.get(SyncReadingSession, session_id) - - if reading_session is None: - return 0 - - if reading_session.owner_user_id != user_id: - raise ForbiddenError("Cannot delete another user's reading session") - - await session.delete(reading_session) - return 1 - - if operation not in {"PUT", "PATCH"}: - raise ValidationError("Unsupported PowerSync upload operation") - - payload = mutation.op_data or {} - reading_session = await session.get(SyncReadingSession, session_id) - now = _now() - - if reading_session is not None and reading_session.owner_user_id != user_id: - raise ForbiddenError("Cannot modify another user's reading session") - - book_id = _uuid( - payload.get("book_id") if reading_session is None else payload.get("book_id", reading_session.book_id), - "book_id", - ) - await _require_owned_book(session, user_id, book_id) - - if reading_session is None: - start_time = _optional_datetime(payload, "start_time") - - if start_time is None: - raise ValidationError("start_time is required") - - reading_session = SyncReadingSession( - session_id=session_id, - owner_user_id=user_id, - book_id=book_id, - start_time=start_time, - created_at=_optional_datetime(payload, "created_at", now) or now, - ) - session.add(reading_session) - - reading_session.book_id = book_id - reading_session.start_time = ( - _optional_datetime(payload, "start_time", reading_session.start_time) or reading_session.start_time - ) - reading_session.end_time = _optional_datetime(payload, "end_time", reading_session.end_time) - reading_session.start_position = _optional_float(payload, "start_position", reading_session.start_position) - reading_session.end_position = _optional_float(payload, "end_position", reading_session.end_position) - reading_session.pages_read = _optional_int(payload, "pages_read", reading_session.pages_read) - reading_session.duration_minutes = _optional_int(payload, "duration_minutes", reading_session.duration_minutes) - reading_session.device_type = _optional_text(payload, "device_type", reading_session.device_type) - reading_session.device_name = _optional_text(payload, "device_name", reading_session.device_name) + book.updated_at = now return 1 - - -async def _require_owned_book(session: AsyncSession, user_id: UUID, book_id: UUID) -> SyncBook: - book = await _get_owned_book(session, user_id, book_id) - - if book is None: - raise ValidationError("Referenced book does not exist") - - return book diff --git a/powersync/sync-config.yaml b/powersync/sync-config.yaml index 66ce4ca..ad50e6d 100644 --- a/powersync/sync-config.yaml +++ b/powersync/sync-config.yaml @@ -31,45 +31,6 @@ streams: FROM books WHERE owner_user_id::text = auth.user_id() - annotations: - auto_subscribe: true - query: | - SELECT - annotation_id AS id, - owner_user_id::text AS owner_user_id, - book_id::text AS book_id, - selected_text, - note, - highlight_color, - start_position, - end_position, - chapter_title, - chapter_index, - page_number, - created_at::text AS created_at, - updated_at::text AS updated_at - FROM annotations - WHERE owner_user_id::text = auth.user_id() - - reading_sessions: - auto_subscribe: true - query: | - SELECT - session_id AS id, - owner_user_id::text AS owner_user_id, - book_id::text AS book_id, - start_time::text AS start_time, - end_time::text AS end_time, - start_position, - end_position, - pages_read, - duration_minutes, - device_type, - device_name, - created_at::text AS created_at - FROM reading_sessions - 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 5227d78..5a5ae88 100755 --- a/scripts/setup_local_powersync.sh +++ b/scripts/setup_local_powersync.sh @@ -30,47 +30,15 @@ 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.annotations TO "${POWERSYNC_SOURCE_ROLE}"; -GRANT SELECT ON TABLE public.reading_sessions 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.books, public.annotations, public.reading_sessions, public.powersync_demo_items; + CREATE PUBLICATION powersync FOR TABLE public.books, public.powersync_demo_items; ELSE - IF NOT EXISTS ( - SELECT 1 - FROM pg_publication_tables - WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'books' - ) THEN - ALTER PUBLICATION powersync ADD TABLE public.books; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM pg_publication_tables - WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'annotations' - ) THEN - ALTER PUBLICATION powersync ADD TABLE public.annotations; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM pg_publication_tables - WHERE pubname = 'powersync' AND schemaname = 'public' AND tablename = 'reading_sessions' - ) THEN - ALTER PUBLICATION powersync ADD TABLE public.reading_sessions; - END IF; - - IF 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; - END IF; + 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 c51291e..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 @@ -231,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( 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 74c68d0..48a5b5f 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -9,46 +9,14 @@ from papyrus.models import SyncBook, User -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 - - -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_push_changes(client: AsyncClient, auth_headers: dict[str, str]): - """Test pushing changes to server.""" - now = datetime.now(UTC) - response = await client.post( - "/v1/sync/changes", - headers=auth_headers, - json={ - "changes": [ - { - "entity_type": "book", - "entity_id": str(uuid4()), - "operation": "update", - "data": {"title": "Updated Title"}, - "timestamp": now.isoformat(), - } - ], - "device_id": "device_123", - }, - ) - assert response.status_code == 200 - data = response.json() - assert "accepted" in data - assert "rejected" 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_powersync_upload_applies_book_mutation( @@ -172,6 +140,76 @@ async def test_powersync_upload_rejects_unsupported_table( 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={ + "batch": [ + { + "type": "books", + "op": "PUT", + "id": str(uuid4()), + "data": {"title": "Book", "unexpected": "value"}, + } + ] + }, + ) + assert response.status_code == 422 + + +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(), + }, + } + ] + }, + ) + + 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], @@ -211,49 +249,3 @@ async def test_powersync_upload_rejects_cross_user_book_mutation( }, ) assert response.status_code == 403 - - -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) - 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 - - -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) - assert response.status_code == 200 - - -async def test_create_metadata_server_config(client: AsyncClient, auth_headers: dict[str, str]): - """Test creating metadata server configuration.""" - response = await client.post( - "/v1/sync/config", - headers=auth_headers, - json={ - "server_url": "https://api.papyrus.app", - "sync_enabled": True, - "sync_interval_seconds": 30, - }, - ) - assert response.status_code == 201 - - -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_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 diff --git a/tests/conftest.py b/tests/conftest.py index cd3796c..087d458 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 @@ -252,3 +253,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/services/test_sync.py b/tests/services/test_sync.py index f67c778..ea3a086 100644 --- a/tests/services/test_sync.py +++ b/tests/services/test_sync.py @@ -6,10 +6,11 @@ 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, ValidationError -from papyrus.models import SyncAnnotation, SyncBook, SyncReadingSession, User +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 @@ -40,16 +41,13 @@ async def _create_book(session: AsyncSession, user: User, title: str = "Existing return book -async def test_apply_powersync_upload_batch_handles_domain_mutations( +async def test_apply_powersync_upload_batch_handles_book_mutations( test_session_maker: async_sessionmaker[AsyncSession], ): - """PowerSync upload batches create and update the first production sync tables.""" + """PowerSync upload batches create books.""" async with test_session_maker() as session: user = await _create_user(session, "sync@example.com") book_id = str(uuid4()) - annotation_id = str(uuid4()) - reading_session_id = str(uuid4()) - applied_count = await sync_service.apply_powersync_upload_batch( session, user.user_id, @@ -65,42 +63,13 @@ async def test_apply_powersync_upload_batch_handles_domain_mutations( "current_position": 0.4, }, ), - PowerSyncCrudMutation( - type="annotations", - op="PUT", - id=annotation_id, - data={ - "book_id": book_id, - "selected_text": "Important passage", - "highlight_color": "#FFEB3B", - "start_position": "cfi-start", - "end_position": "cfi-end", - }, - ), - PowerSyncCrudMutation( - type="reading_sessions", - op="PUT", - id=reading_session_id, - data={ - "book_id": book_id, - "start_time": "2026-05-09T12:00:00+00:00", - "end_time": "2026-05-09T12:30:00+00:00", - "pages_read": 12, - }, - ), ], ) - assert applied_count == 3 + assert applied_count == 1 book = await session.get(SyncBook, book_id) - annotation = await session.get(SyncAnnotation, annotation_id) - reading_session = await session.get(SyncReadingSession, reading_session_id) assert book is not None - assert annotation is not None - assert reading_session is not None assert book.title == "Synced Book" - assert annotation.book_id == book.book_id - assert reading_session.book_id == book.book_id async def test_apply_powersync_upload_batch_rejects_cross_user_book_update( @@ -128,28 +97,17 @@ async def test_apply_powersync_upload_batch_rejects_cross_user_book_update( ) -async def test_apply_powersync_upload_batch_rejects_missing_referenced_book( +async def test_apply_powersync_upload_batch_rejects_unknown_book_fields( test_session_maker: async_sessionmaker[AsyncSession], ): - """Child sync rows must reference an owned book.""" + """The backend rejects fields outside the books upload contract.""" async with test_session_maker() as session: - user = await _create_user(session, "missing-book@example.com") - - with pytest.raises(ValidationError): - await sync_service.apply_powersync_upload_batch( - session, - user.user_id, - [ - PowerSyncCrudMutation( - type="annotations", - op="PUT", - id=str(uuid4()), - data={ - "book_id": str(uuid4()), - "selected_text": "Orphaned", - "start_position": "start", - "end_position": "end", - }, - ) - ], + 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_models.py b/tests/test_models.py index b17c564..d2cd862 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,9 +7,7 @@ EmailActionToken, PasswordCredential, PowerSyncDemoItem, - SyncAnnotation, SyncBook, - SyncReadingSession, User, UserIdentity, ) @@ -25,8 +23,6 @@ def test_auth_models_are_registered_with_metadata() -> None: 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"] - annotations_table = Base.metadata.tables["annotations"] - reading_sessions_table = Base.metadata.tables["reading_sessions"] assert users_table is User.__table__ assert identities_table is UserIdentity.__table__ assert password_credentials_table is PasswordCredential.__table__ @@ -35,8 +31,6 @@ def test_auth_models_are_registered_with_metadata() -> None: assert email_tokens_table is EmailActionToken.__table__ assert powersync_demo_items_table is PowerSyncDemoItem.__table__ assert books_table is SyncBook.__table__ - assert annotations_table is SyncAnnotation.__table__ - assert reading_sessions_table is SyncReadingSession.__table__ assert set(users_table.columns.keys()) == { "user_id", @@ -57,5 +51,5 @@ def test_auth_models_are_registered_with_metadata() -> None: "updated_at", } assert {"book_id", "owner_user_id", "title", "updated_at"}.issubset(books_table.columns.keys()) - assert {"annotation_id", "owner_user_id", "book_id", "selected_text"}.issubset(annotations_table.columns.keys()) - assert {"session_id", "owner_user_id", "book_id", "start_time"}.issubset(reading_sessions_table.columns.keys()) + assert "annotations" not in Base.metadata.tables + assert "reading_sessions" not in Base.metadata.tables From 251fa9ef9dec4890c7c706931aee3b4b3f091f1a Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 26 Jun 2026 23:26:52 +0300 Subject: [PATCH 6/7] Fix Google OAuth app redirect allowlist --- papyrus/services/auth/google.py | 21 ++++++++++++--------- tests/services/test_auth.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index e05435a..dba3bd0 100644 --- a/papyrus/services/auth/google.py +++ b/papyrus/services/auth/google.py @@ -115,13 +115,19 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden google_oauth_client = GoogleOAuthClient() -def _public_base_host() -> str | None: - public_base_url = get_settings().public_base_url +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 - if public_base_url is None: - return None + hostname = urlsplit(base_url).hostname + if hostname is not None: + hosts.add(hostname.lower()) - return urlsplit(public_base_url).hostname + return hosts def _is_allowed_oauth_redirect_uri(redirect_uri: str) -> bool: @@ -141,10 +147,7 @@ def _is_allowed_oauth_redirect_uri(redirect_uri: str) -> bool: return False allowed_hosts = set(settings.oauth_allowed_redirect_hosts) - public_base_host = _public_base_host() - - if public_base_host is not None: - allowed_hosts.add(public_base_host.lower()) + allowed_hosts.update(_configured_base_hosts()) if settings.debug: allowed_hosts.update({"localhost", "127.0.0.1", "test"}) 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, *, From 02d56a102637f46b17ed1697f0176a1e0a5a87a0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 26 Jun 2026 23:39:59 +0300 Subject: [PATCH 7/7] Fix CI test environment defaults --- .github/workflows/ci.yml | 1 + tests/conftest.py | 4 ++++ 2 files changed, 5 insertions(+) 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/tests/conftest.py b/tests/conftest.py index 087d458..02d458d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -162,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]: @@ -182,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]: