Skip to content

Commit d7aee2f

Browse files
StuMasonclaude
andauthored
feat: API key authentication and Alembic migrations (#5)
* feat: Laravel SaaS integration - Alembic migrations and API key auth Prepare polar-flow-server as a secure backend for Laravel SaaS integration. ## Changes ### Database Migrations (Alembic) - Initialize Alembic with async SQLAlchemy support - Create initial schema migration capturing 16 existing tables - Add api_keys table migration for authentication - Remove create_all() - rely on migrations for schema management - Add migration version check on startup ### API Key Authentication - Add APIKey model for database-backed authentication - Create api_key_guard for Litestar route protection - Support X-API-Key header and Bearer token auth - Config-based API_KEY for simple self-hosted deployments - All data endpoints now require authentication ### Docker Improvements - Add .dockerignore for smaller, faster builds - Add health checks to docker-compose.yml - Create docker-compose.prod.yml for production - Update Dockerfile with curl for health checks ### Testing - Add scripts/test_e2e.py for end-to-end testing - All 8 tests passing (auth, endpoints, health) ## Laravel Integration Laravel can now connect using: ```php Http::withHeaders([ 'X-API-Key' => config('services.polar_flow.api_key'), ])->get('http://polar-flow-server:8000/users/{$polarUserId}/sleep'); ``` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Simplify deployment - no required env vars - Make API_KEY optional (if not set, auth is disabled) - Simplify docker-compose.prod.yml to just work out of the box - Update README with clearer deployment instructions - Production deployment is now: curl + docker-compose up -d Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: Add Docker Hub publish workflow Automatically builds and pushes to Docker Hub: - On push to main: tagged as `latest` and `sha-xxxxx` - On release: tagged with version (e.g., `0.1.0`, `0.1`) Requires secrets: - DOCKERHUB_USERNAME - DOCKERHUB_TOKEN Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: Extract version from pyproject.toml for Docker tags Tags both latest and version (e.g., 0.1.0) on every push to main. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: Format files with ruff * ci: Improve Claude code review to be thorough - Full fetch depth for context - Detailed prompt asking for specific review criteria - Write permission to post comments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Resolve ruff lint issues (import sorting, type annotations) - Sort imports per ruff I001 rules - Use modern X | Y type annotations (UP007) - Import Sequence from collections.abc (UP035) - Use datetime.UTC alias (UP017) - Remove unnecessary f-string prefix (F541) - Add noqa comments for necessary E402 violations in alembic/env.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Add type parameters to ASGIConnection for mypy strict mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Add proper initial migration with all table definitions The previous empty migration assumed tables existed from create_all(). This new migration properly creates all 16 tables for fresh deployments. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9a82515 commit d7aee2f

File tree

21 files changed

+1502
-27
lines changed

21 files changed

+1502
-27
lines changed

.dockerignore

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Git
2+
.git
3+
.github
4+
.gitignore
5+
6+
# Python
7+
.venv
8+
__pycache__
9+
*.pyc
10+
*.pyo
11+
*.pyd
12+
.Python
13+
*.egg-info
14+
dist
15+
build
16+
eggs
17+
*.egg
18+
.eggs
19+
20+
# Testing and linting
21+
.pytest_cache
22+
.mypy_cache
23+
.ruff_cache
24+
.coverage
25+
htmlcov
26+
coverage.xml
27+
*.cover
28+
29+
# Documentation (built by mkdocs)
30+
site/
31+
32+
# IDE
33+
.idea
34+
.vscode
35+
*.swp
36+
*.swo
37+
38+
# Environment
39+
.env
40+
.env.*
41+
!.env.example
42+
43+
# Local development
44+
*.db
45+
*.sqlite
46+
data/
47+
48+
# Tests
49+
tests/
50+
51+
# Documentation source (keep docs/ for reference, but site/ is built output)
52+
# docs/
53+
54+
# Misc
55+
*.md
56+
!README.md
57+
!CHANGELOG.md
58+
*.log
59+
.DS_Store
60+
Thumbs.db

.github/workflows/publish.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Publish Docker Image
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
env:
8+
REGISTRY: docker.io
9+
IMAGE_NAME: stumason/polar-flow-server
10+
11+
jobs:
12+
build-and-push:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Get version from pyproject.toml
23+
id: version
24+
run: |
25+
VERSION=$(grep -m1 'version = ' pyproject.toml | cut -d'"' -f2)
26+
echo "version=$VERSION" >> $GITHUB_OUTPUT
27+
echo "Version: $VERSION"
28+
29+
- name: Set up Docker Buildx
30+
uses: docker/setup-buildx-action@v3
31+
32+
- name: Log in to Docker Hub
33+
uses: docker/login-action@v3
34+
with:
35+
username: ${{ secrets.DOCKERHUB_USERNAME }}
36+
password: ${{ secrets.DOCKERHUB_TOKEN }}
37+
38+
- name: Build and push
39+
uses: docker/build-push-action@v5
40+
with:
41+
context: .
42+
push: true
43+
tags: |
44+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
45+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
46+
cache-from: type=gha
47+
cache-to: type=gha,mode=max

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
FROM python:3.12-slim
22

3+
# Install system dependencies (curl for health checks, psycopg dependencies)
4+
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
curl \
6+
&& rm -rf /var/lib/apt/lists/*
7+
38
# Install uv
49
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
510

README.md

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,29 @@ Polar API → polar-flow SDK → Sync Service → PostgreSQL
3333

3434
## Quick Start
3535

36-
### 1. Get Polar API Credentials
36+
### Option 1: Docker (Recommended)
3737

38-
1. Go to [admin.polaraccesslink.com](https://admin.polaraccesslink.com)
39-
2. Create a new client
40-
3. Set redirect URI to `http://localhost:8000/admin/oauth/callback`
41-
4. Note your `CLIENT_ID` and `CLIENT_SECRET`
38+
```bash
39+
# Pull and run
40+
curl -O https://raw.githubusercontent.com/StuMason/polar-flow-server/main/docker-compose.prod.yml
41+
docker-compose -f docker-compose.prod.yml up -d
42+
43+
# That's it. Open http://localhost:8000/admin
44+
```
4245

43-
### 2. Start with Docker Compose
46+
### Option 2: From Source
4447

4548
```bash
4649
git clone https://github.com/StuMason/polar-flow-server.git
4750
cd polar-flow-server
4851
docker-compose up -d
4952
```
5053

51-
### 3. Connect Your Polar Account
54+
### Setup
5255

5356
1. Open http://localhost:8000/admin
54-
2. Enter your Polar OAuth credentials
55-
3. Click "Connect with Polar" to authorize
57+
2. Get Polar credentials from [admin.polaraccesslink.com](https://admin.polaraccesslink.com) (set redirect URI to `http://localhost:8000/admin/oauth/callback`)
58+
3. Enter credentials and click "Connect with Polar"
5659
4. Hit "Sync Now" to pull your data
5760

5861
The server syncs data every hour automatically.
@@ -109,15 +112,23 @@ SYNC_DAYS_LOOKBACK=28
109112
curl http://localhost:8000/health
110113

111114
# Get sleep data (last 7 days)
112-
curl "http://localhost:8000/api/users/{user_id}/sleep?days=7"
115+
curl "http://localhost:8000/users/{user_id}/sleep?days=7"
116+
117+
# Get activity data
118+
curl "http://localhost:8000/users/{user_id}/activity?days=7"
113119

114-
# Get sleep for specific date
115-
curl "http://localhost:8000/api/users/{user_id}/sleep/2026-01-10"
120+
# Get nightly recharge (HRV)
121+
curl "http://localhost:8000/users/{user_id}/recharge?days=7"
116122

117-
# Trigger manual sync (via admin panel recommended)
118-
curl -X POST http://localhost:8000/admin/sync
123+
# Get exercises
124+
curl "http://localhost:8000/users/{user_id}/exercises?days=30"
125+
126+
# Export summary
127+
curl "http://localhost:8000/users/{user_id}/export/summary?days=30"
119128
```
120129

130+
**Optional Authentication:** Set `API_KEY` environment variable to require `X-API-Key` header on all data endpoints. If not set, endpoints are open.
131+
121132
## Development
122133

123134
```bash
@@ -140,6 +151,28 @@ uv run mypy src/polar_flow_server
140151
uv run ruff check src/
141152
```
142153

154+
## Production Deployment
155+
156+
Deploy anywhere that runs Docker:
157+
158+
```bash
159+
# Download and run
160+
curl -O https://raw.githubusercontent.com/StuMason/polar-flow-server/main/docker-compose.prod.yml
161+
docker-compose -f docker-compose.prod.yml up -d
162+
```
163+
164+
**Coolify, Railway, Render, etc.** - Point at the GitHub repo, it builds from the Dockerfile.
165+
166+
**Database migrations** run automatically on startup.
167+
168+
### Optional Environment Variables
169+
170+
| Variable | Description | Default |
171+
|----------|-------------|---------|
172+
| `API_KEY` | Require authentication on data endpoints | None (open) |
173+
| `SYNC_INTERVAL_HOURS` | Auto-sync frequency | 1 |
174+
| `LOG_LEVEL` | Logging verbosity | INFO |
175+
143176
## Multi-Tenancy
144177

145178
The server supports multiple users out of the box:
@@ -149,8 +182,6 @@ The server supports multiple users out of the box:
149182
- Self-hosted: typically one user
150183
- SaaS: many users, same codebase
151184

152-
For SaaS deployment, set `DEPLOYMENT_MODE=saas` and provide `ENCRYPTION_KEY`.
153-
154185
## Built With
155186

156187
- [polar-flow](https://github.com/StuMason/polar-flow) - Python SDK for Polar AccessLink API

alembic.ini

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts.
5+
# this is typically a path given in POSIX (e.g. forward slashes)
6+
# format, relative to the token %(here)s which refers to the location of this
7+
# ini file
8+
script_location = %(here)s/alembic
9+
10+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11+
# Uncomment the line below if you want the files to be prepended with date and time
12+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13+
# for all available tokens
14+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
16+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17+
18+
# sys.path path, will be prepended to sys.path if present.
19+
# defaults to the current working directory. for multiple paths, the path separator
20+
# is defined by "path_separator" below.
21+
prepend_sys_path = .
22+
23+
24+
# timezone to use when rendering the date within the migration file
25+
# as well as the filename.
26+
# If specified, requires the tzdata library which can be installed by adding
27+
# `alembic[tz]` to the pip requirements.
28+
# string value is passed to ZoneInfo()
29+
# leave blank for localtime
30+
# timezone =
31+
32+
# max length of characters to apply to the "slug" field
33+
# truncate_slug_length = 40
34+
35+
# set to 'true' to run the environment during
36+
# the 'revision' command, regardless of autogenerate
37+
# revision_environment = false
38+
39+
# set to 'true' to allow .pyc and .pyo files without
40+
# a source .py file to be detected as revisions in the
41+
# versions/ directory
42+
# sourceless = false
43+
44+
# version location specification; This defaults
45+
# to <script_location>/versions. When using multiple version
46+
# directories, initial revisions must be specified with --version-path.
47+
# The path separator used here should be the separator specified by "path_separator"
48+
# below.
49+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50+
51+
# path_separator; This indicates what character is used to split lists of file
52+
# paths, including version_locations and prepend_sys_path within configparser
53+
# files such as alembic.ini.
54+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
55+
# to provide os-dependent path splitting.
56+
#
57+
# Note that in order to support legacy alembic.ini files, this default does NOT
58+
# take place if path_separator is not present in alembic.ini. If this
59+
# option is omitted entirely, fallback logic is as follows:
60+
#
61+
# 1. Parsing of the version_locations option falls back to using the legacy
62+
# "version_path_separator" key, which if absent then falls back to the legacy
63+
# behavior of splitting on spaces and/or commas.
64+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
65+
# behavior of splitting on spaces, commas, or colons.
66+
#
67+
# Valid values for path_separator are:
68+
#
69+
# path_separator = :
70+
# path_separator = ;
71+
# path_separator = space
72+
# path_separator = newline
73+
#
74+
# Use os.pathsep. Default configuration used for new projects.
75+
path_separator = os
76+
77+
# set to 'true' to search source files recursively
78+
# in each "version_locations" directory
79+
# new in Alembic version 1.10
80+
# recursive_version_locations = false
81+
82+
# the output encoding used when revision files
83+
# are written from script.py.mako
84+
# output_encoding = utf-8
85+
86+
# database URL. This is consumed by the user-maintained env.py script only.
87+
# other means of configuring database URLs may be customized within the env.py
88+
# file.
89+
# Database URL loaded from settings in env.py
90+
sqlalchemy.url =
91+
92+
93+
[post_write_hooks]
94+
# post_write_hooks defines scripts or Python functions that are run
95+
# on newly generated revision scripts. See the documentation for further
96+
# detail and examples
97+
98+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
99+
# hooks = black
100+
# black.type = console_scripts
101+
# black.entrypoint = black
102+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
103+
104+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
105+
# hooks = ruff
106+
# ruff.type = module
107+
# ruff.module = ruff
108+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
109+
110+
# Alternatively, use the exec runner to execute a binary found on your PATH
111+
# hooks = ruff
112+
# ruff.type = exec
113+
# ruff.executable = ruff
114+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
115+
116+
# Logging configuration. This is also consumed by the user-maintained
117+
# env.py script only.
118+
[loggers]
119+
keys = root,sqlalchemy,alembic
120+
121+
[handlers]
122+
keys = console
123+
124+
[formatters]
125+
keys = generic
126+
127+
[logger_root]
128+
level = WARNING
129+
handlers = console
130+
qualname =
131+
132+
[logger_sqlalchemy]
133+
level = WARNING
134+
handlers =
135+
qualname = sqlalchemy.engine
136+
137+
[logger_alembic]
138+
level = INFO
139+
handlers =
140+
qualname = alembic
141+
142+
[handler_console]
143+
class = StreamHandler
144+
args = (sys.stderr,)
145+
level = NOTSET
146+
formatter = generic
147+
148+
[formatter_generic]
149+
format = %(levelname)-5.5s [%(name)s] %(message)s
150+
datefmt = %H:%M:%S

alembic/README

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Generic single-database configuration.

0 commit comments

Comments
 (0)