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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions .agents/skills/alembic-migrations/SKILL.md

This file was deleted.

21 changes: 0 additions & 21 deletions .agents/skills/backend-review/SKILL.md

This file was deleted.

17 changes: 0 additions & 17 deletions .agents/skills/fastapi-endpoints/SKILL.md

This file was deleted.

22 changes: 0 additions & 22 deletions .agents/skills/postgres-sqlalchemy-debugging/SKILL.md

This file was deleted.

12 changes: 0 additions & 12 deletions .codex/agents/backend-reviewer.toml

This file was deleted.

14 changes: 0 additions & 14 deletions .codex/config.toml

This file was deleted.

15 changes: 13 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,16 +12,24 @@ RATE_LIMIT_GENERAL=100
RATE_LIMIT_UPLOAD=10
RATE_LIMIT_BATCH=20

# Public server URL used in OAuth callbacks and email links.
# Public server URL used in OAuth callbacks and API links.
# Local desktop testing: http://localhost:8080
# Mobile/device or real Google testing: use a public HTTPS URL or tunnel.
PUBLIC_BASE_URL=http://localhost:8080

# Public Flutter app URL used in user-facing email links.
# Local papyrus.localhost testing requires:
# 127.0.0.1 papyrus.localhost
# ::1 papyrus.localhost
APP_PUBLIC_BASE_URL=http://papyrus.localhost:3000

# Google OAuth web-application credentials.
# Authorized redirect URI should be:
# <PUBLIC_BASE_URL>/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
Expand Down Expand Up @@ -54,6 +62,9 @@ POWERSYNC_JWT_PUBLIC_KEY_FILE=.local/powersync/public.pem
POWERSYNC_JWT_PRIVATE_KEY=
POWERSYNC_JWT_PUBLIC_KEY=
POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev
POWERSYNC_JWT_PREVIOUS_PUBLIC_KEY=
POWERSYNC_JWT_PREVIOUS_PUBLIC_KEY_FILE=
POWERSYNC_JWT_PREVIOUS_KEY_ID=
POWERSYNC_JWT_AUDIENCE=powersync-dev
POWERSYNC_TOKEN_EXPIRE_MINUTES=5
POWERSYNC_SERVICE_URL=http://localhost:8081
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
push:
branches: [master]
Comment on lines 4 to +5
pull_request:

jobs:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,5 @@ logs/
# OS
.DS_Store
Thumbs.db
.codex
.agents
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 22 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,39 @@
# Papyrus server
# Papyrus Server

REST API server for Papyrus, a cross platform book management application.
FastAPI backend for Papyrus authentication, metadata, file storage, and
PowerSync-backed synchronization.

## Getting started
## Auth And Sync

Install dependencies:
- Email/password auth uses `POST /v1/auth/register` and
`POST /v1/auth/login`.
- Google auth starts at `GET /v1/auth/oauth/google/start` and finishes through
`POST /v1/auth/exchange-code`.
- PowerSync credentials come from `POST /v1/auth/powersync-token`.
- PowerSync uploads use `POST /v1/sync/powersync-upload`.

```bash
uv sync --extra dev
```

Run the database:

```bash
docker compose up -d database mailpit powersync-storage powersync
```

Run database migrations:

```bash
uv run alembic upgrade head
```
Read the focused guides:

Run the server:
- [Flutter auth and PowerSync integration](docs/flutter-auth-integration.md)
- [Authentication testing](docs/auth-testing.md)
- [PowerSync sandbox](docs/powersync-sandbox.md)

```bash
uv run uvicorn papyrus.main:app --reload --port 8080
```
## Local Setup

Run the dev-pages asset server with live TS/SCSS reload:
Run from `server/`:

```bash
uv sync --extra dev
./scripts/bootstrap_local.sh
npm --prefix frontend/dev-pages install
npm --prefix frontend/dev-pages run dev
```

Generate local PowerSync keys for auth testing:

```bash
./scripts/generate_dev_powersync_keys.sh
```

Initialize the local PowerSync source role and publication after migrations:

```bash
./scripts/setup_local_powersync.sh
```

## Development
The bootstrap is idempotent: it creates development keys when missing, starts
the databases, applies Alembic migrations, configures logical replication, and
starts the healthy server and pinned PowerSync services.

Run tests:
## Checks

```bash
uv run pytest --cov --cov-report html
Expand Down
58 changes: 58 additions & 0 deletions alembic/versions/a1d7c2f4e8b9_add_powersync_domain_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""add powersync domain tables

Revision ID: a1d7c2f4e8b9
Revises: 89143b2dc5b3
Create Date: 2026-05-09 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a1d7c2f4e8b9"
down_revision: str | Sequence[str] | None = "89143b2dc5b3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"books",
sa.Column("book_id", sa.Uuid(), nullable=False),
sa.Column("owner_user_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("subtitle", sa.String(length=500), nullable=True),
sa.Column("author", sa.String(length=255), nullable=True),
sa.Column("co_authors", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("isbn", sa.String(length=32), nullable=True),
sa.Column("isbn13", sa.String(length=32), nullable=True),
sa.Column("publisher", sa.String(length=255), nullable=True),
sa.Column("language", sa.String(length=16), nullable=True),
sa.Column("page_count", sa.Integer(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("cover_image_url", sa.String(length=2048), nullable=True),
sa.Column("reading_status", sa.String(length=32), nullable=True),
sa.Column("current_page", sa.Integer(), nullable=True),
sa.Column("current_position", sa.Float(), nullable=True),
sa.Column("current_cfi", sa.Text(), nullable=True),
sa.Column("is_favorite", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("rating", sa.Integer(), nullable=True),
sa.Column("custom_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("book_id"),
)
op.create_index(op.f("ix_books_owner_user_id"), "books", ["owner_user_id"], unique=False)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_books_owner_user_id"), table_name="books")
op.drop_table("books")
24 changes: 23 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: .
Expand All @@ -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:
Expand Down
Loading
Loading