Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6e76756
refactor(auth, storage): consolidate user storage and auth logic, upd…
danielpmichalski Apr 20, 2026
7c9f05c
docs: clean up TODO.md, remove outdated items on GDrive configuration…
danielpmichalski Apr 20, 2026
c8f8969
refactor: update routing to use `/v1/auth` path, adjust related confi…
danielpmichalski Apr 20, 2026
02a0913
refactor(storage): rename GDrive references to Google, update schemas…
danielpmichalski Apr 20, 2026
900f760
docs(todo): add note to implement unit tests for api module
danielpmichalski Apr 20, 2026
7ca8d36
refactor(auth, e2e, test): replace JWT-based session management with …
danielpmichalski Apr 20, 2026
61cfe49
fix: drop old indexes, add better-auth dependencies to lockfile
danielpmichalski Apr 20, 2026
6c641d7
fix(db): recreate dropped indexes with consistent names in migrations
danielpmichalski Apr 20, 2026
904e223
fix(auth): replace `??` with `||` in `baseURL` fallback to ensure bro…
danielpmichalski Apr 20, 2026
0bff41c
docs(self-hosting): update OAuth redirect URIs and `.env` configurati…
danielpmichalski Apr 20, 2026
0cbc75c
fix(auth): update wildcard route matching for `/v1/auth/*` endpoints …
danielpmichalski Apr 20, 2026
8e7e309
feat(auth): add support for configurable trusted origins via `.env`
danielpmichalski Apr 20, 2026
c32885b
feat(auth,ci): add `TRUSTED_ORIGINS` support to `.env` and Docker con…
danielpmichalski Apr 20, 2026
db2b40f
docs: update OAuth redirect URIs and add `TRUSTED_ORIGINS` guidance f…
danielpmichalski Apr 20, 2026
6a05a02
fix(auth): update Google login callback to use `window.location.origin`
danielpmichalski Apr 20, 2026
6052b88
fix: rename `gdrive` references to `google` for consistency with prov…
danielpmichalski Apr 20, 2026
04fa56a
docs: update all references from `GDrive` to `Google Drive`
danielpmichalski Apr 20, 2026
73fd04c
fix(api): add configurable idle timeout for server connections
danielpmichalski Apr 20, 2026
6a069af
chore: bump anki-cloud-sync image to v25.09-r6
danielpmichalski Apr 20, 2026
8644733
ci: add Docker Compose smoke test workflow
danielpmichalski Apr 20, 2026
ae01701
chore: bump anki-cloud-sync image to v25.09-r7
danielpmichalski Apr 20, 2026
eccf42d
ci: remove redundant GitHub Container Registry login step
danielpmichalski Apr 20, 2026
b0dc7c4
ci: enable manual workflow dispatch trigger in CI configuration
danielpmichalski Apr 20, 2026
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
30 changes: 22 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,34 @@ TOKEN_ENCRYPTION_KEY=

# ── REST API ─────────────────────────────────────────────────────────────

# Better Auth secret — used to sign sessions (replaces JWT_SECRET)
# Generate: openssl rand -hex 32
BETTER_AUTH_SECRET=

# Public base URL of the API server (used by Better Auth to construct OAuth callback URLs)
# Local dev (Vite proxies /v1 → port 3000): http://localhost:3000
# Prod: https://api.your-domain.com
BETTER_AUTH_URL=http://localhost:3000

# Google OAuth2 app credentials (console.cloud.google.com → APIs & Services → Credentials)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Must exactly match an authorized redirect URI in your Google OAuth app
# Vite dev server (port 5173) proxies /v1/* → API (port 3000), so OAuth cookies land on the right origin
# Add both URIs to Google Cloud Console
GOOGLE_REDIRECT_URI=http://localhost:5173/v1/auth/google/callback
# Better Auth callback (for user login): {BETTER_AUTH_URL}/v1/auth/callback/google
# Add this to Google Cloud Console authorized redirect URIs:
# http://localhost:3000/v1/auth/callback/google (local dev)
# https://api.your-domain.com/v1/auth/callback/google (prod)

# Must exactly match an authorized redirect URI in your Google OAuth app
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/callback
# For Google Drive storage connection (separate OAuth flow):
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/google/callback

# Frontend URL — API callbacks redirect here after OAuth flows complete
# Local (Vite dev server proxies /v1 → port 3000): http://localhost:5173 Prod: https://your-domain.com
# Local (Vite dev server): http://localhost:5173 Prod: https://your-domain.com
FRONTEND_URL=http://localhost:5173

# JWT signing secret — 32-byte hex
# Generate: openssl rand -hex 32
JWT_SECRET=
# Comma-separated trusted origins for Better Auth CSRF protection.
# If unset, falls back to FRONTEND_URL.
# Local dev: http://localhost:5173 Prod: https://app.your-domain.com,https://staging.your-domain.com
TRUSTED_ORIGINS=
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
typecheck:
Expand Down Expand Up @@ -46,3 +47,22 @@ jobs:
tags: ghcr.io/danielpmichalski/anki-cloud:ci
cache-from: type=gha
cache-to: type=gha,mode=max

docker-compose-smoke:
name: Docker Compose smoke test
runs-on: ubuntu-latest
env:
SIDECAR_TOKEN: ci-dummy-token
BETTER_AUTH_SECRET: ci-dummy-secret-32-chars-minimum!!
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: dummy-client-id
GOOGLE_CLIENT_SECRET: dummy-client-secret
GOOGLE_DRIVE_REDIRECT_URI: http://localhost:3000/auth/callback/google-drive
FRONTEND_URL: http://localhost:5173
TOKEN_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
steps:
- uses: actions/checkout@v4
- name: Pull sync server image
run: docker compose pull anki-sync-server
- name: Build API image and create containers
run: docker compose up --no-start --build
24 changes: 12 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Hono on Bun. Full CRUD API with OpenAPI spec auto-generated from Zod schemas. MC
│ │ │ │
│ ┌──────▼─────────────────▼──────────────────────────────┐ │
│ │ Auth & Storage Adapter Layer │ │
│ │ Google OAuth (identity) + GDrive OAuth (storage) │ │
│ │ Google OAuth (identity) + Google Drive OAuth (storage) │ │
│ └──────────────────────────┬────────────────────────────┘ │
│ │ │
│ ┌────────────────┐ ┌────────▼──────┐ │
Expand Down Expand Up @@ -143,7 +143,7 @@ users
storage_connections (
id,
user_id,
provider, -- 'gdrive' | 'dropbox' | 's3' | 'local'
provider, -- 'google' | 'dropbox' | 's3' | 'local'
oauth_token, -- encrypted at rest (AES-256-GCM)
oauth_refresh_token, -- encrypted at rest (AES-256-GCM); null for 'local' provider
folder_path,
Expand All @@ -168,11 +168,11 @@ users_sync_state (
```

**What is NOT stored:** deck data, card content, review history, media files.
All of that lives in the user's GDrive.
All of that lives in the user's Google Drive.

**Redis (ephemeral):**

- Active sync session state (flushed to GDrive on completion)
- Active sync session state (flushed to Google Drive on completion)
- OAuth flow state (PKCE codes, state params — TTL: 10 minutes)
- Rate limiting counters
- API response cache (TTL: configurable)
Expand All @@ -186,7 +186,7 @@ All of that lives in the user's GDrive.
| MCP Server | TypeScript / Hono on Bun | [ADR-0007](docs/decisions/0007-mcp-server-wraps-rest-api-not-direct-db.md) · [ADR-0008](docs/decisions/0008-use-hono-on-bun-for-rest-api-and-mcp-server.md) |
| Persistent DB | SQLite (via Drizzle ORM) | [ADR-0009](docs/decisions/0009-use-sqlite-for-persistent-storage.md) |
| Cache / Sessions | Redis | — |
| Storage backends | GDrive API / Dropbox API / S3 SDK | [ADR-0002](docs/decisions/0002-use-user-owned-cloud-storage-for-deck-data.md) · [ADR-0006](docs/decisions/0006-use-google-drive-as-the-primary-storage-backend.md) |
| Storage backends | Google Drive API / Dropbox API / S3 SDK | [ADR-0002](docs/decisions/0002-use-user-owned-cloud-storage-for-deck-data.md) · [ADR-0006](docs/decisions/0006-use-google-drive-as-the-primary-storage-backend.md) |
| Containerization | Docker + Docker Compose | — |
| CI/CD | GitHub Actions | — |
| Docs: API reference | Scalar (from OpenAPI spec) | — |
Expand Down Expand Up @@ -309,8 +309,8 @@ web UI. Stored as bcrypt hash in `users.sync_password_hash`. Username = email ad
6. Sync server → returns hkey to Anki client (used as session token for all subsequent requests)
7. Anki client → sends requests with hkey in anki-sync header
8. Sync server → looks up hkey in memory map; if missing (restart/failover), re-hydrates from DB
9. Sync server → fetches GDrive OAuth refresh_token from SQLite, exchanges for fresh access_token
10. Sync server → reads/writes collection from/to user's GDrive
9. Sync server → fetches Google Drive OAuth refresh_token from SQLite, exchanges for fresh access_token
10. Sync server → reads/writes collection from/to user's Google Drive
11. Sync server → returns sync response to Anki client
```

Expand All @@ -322,7 +322,7 @@ web UI. Stored as bcrypt hash in `users.sync_password_hash`. Username = email ad
3. LLM → calls MCP tool (e.g. create_flashcard)
4. MCP server → validates API key (lookup in SQLite by hash)
5. MCP server → calls REST API with user context
6. REST API → applies change via storage adapter → writes to GDrive
6. REST API → applies change via storage adapter → writes to Google Drive
```

---
Expand Down Expand Up @@ -401,7 +401,7 @@ docker compose up
```

No external dependencies beyond Docker and a Google OAuth app (for auth).
Storage backend credentials are per-user (their own GDrive etc.).
Storage backend credentials are per-user (their own Google Drive etc.).

---

Expand All @@ -415,7 +415,7 @@ Storage backend credentials are per-user (their own GDrive etc.).
5. **OpenAPI first.** The spec is the contract. SDKs and docs generate from it.
6. **Conventional commits.** Enables automated changelog and semantic versioning.
7. **Do not use "Anki" in the product name.** Registered trademark — legal risk.
8. **Prove the sync → GDrive adapter works before building anything else.**
8. **Prove the sync → Google Drive adapter works before building anything else.**
It's the riskiest assumption. Validate it first.
9. **AI Agents: Never auto-commit code.** When work is complete, inform the user that changes are ready to commit. Let the user handle git commits themselves. This preserves user agency and prevents accidental commits.

Expand All @@ -439,8 +439,8 @@ ADRs live in `docs/decisions/`. Use `adr-tools` to manage them.
## 12. Open Questions (OSS-scoped)

- [ ] **Conflict resolution** — what happens when two devices sync simultaneously?
- [ ] **Media files** — large audio/image files need special handling in GDrive (size limits, latency)
- [ ] **GDrive API rate limits** — need to understand quotas for sync-heavy users
- [ ] **Media files** — large audio/image files need special handling in Google Drive (size limits, latency)
- [ ] **Google Drive API rate limits** — need to understand quotas for sync-heavy users
- [ ] **AnkiMobile compatibility** — verify custom sync URL works with the iOS app

---
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cp .env.example .env # fill in SIDECAR_TOKEN and JWT_SECRET at minimum
Start the full local stack:

```bash
# Standalone mode — no GDrive or OAuth setup required
# Standalone mode — no Google Drive or OAuth setup required
docker compose -f docker-compose.yml -f docker-compose.standalone.yml up
```

Expand Down Expand Up @@ -58,9 +58,9 @@ A `BREAKING CHANGE:` footer (or `!` after the type) triggers a major version bum

```
feat(api): add POST /v1/decks endpoint
fix(auth): handle expired refresh token on GDrive callback
fix(auth): handle expired refresh token on Google Drive callback
docs: add self-hosting guide
chore: bump anki-cloud-sync image to v25.09-r5
chore: bump anki-cloud-sync image to v25.09-r7
feat(sync)!: change hkey derivation algorithm

BREAKING CHANGE: existing sync sessions will be invalidated
Expand Down
41 changes: 26 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ Two independent axes: **mode** (standalone vs cloud) and **image source** (publi
### Standalone mode

No database or cloud storage required. Users defined via `SYNC_USER1` in `.env`.
Good for local development and testing the REST API without GDrive setup.
Good for local development and testing the REST API without Google Drive setup.

```bash
cp .env.example .env # set SIDECAR_TOKEN, JWT_SECRET, SYNC_USER1 at minimum
cp .env.example .env # set SIDECAR_TOKEN, BETTER_AUTH_SECRET, SYNC_USER1 at minimum

# published image (fast)
docker compose -f docker-compose.yml -f docker-compose.standalone.yml up
Expand All @@ -34,8 +34,19 @@ docker compose --build -f docker-compose.yml -f docker-compose.standalone.yml -f

### Cloud mode

Full production-like stack. Users authenticate via Google OAuth; deck data stored in their
Google Drive. Requires all OAuth credentials in `.env`.
Full production-like stack. Users authenticate via Google OAuth (via Better Auth); deck data
stored in their Google Drive. Requires all OAuth credentials in `.env`.

Before running, add these URIs to your Google OAuth app in
[Google Cloud Console](https://console.cloud.google.com) → APIs & Services → Credentials:

```
{BETTER_AUTH_URL}/v1/auth/callback/google # sign-in callback
{FRONTEND_URL}/v1/me/storage/connect/google/callback # Google Drive callback
```

Set `TRUSTED_ORIGINS` in `.env` to your frontend URL(s) (comma-separated) so Better Auth accepts
requests from the web UI. Defaults to `FRONTEND_URL` if unset.

```bash
cp .env.example .env # fill in all credentials
Expand All @@ -56,10 +67,10 @@ of `../anki-cloud-sync`. First build takes ~2–3 min; subsequent starts are ins

Once the stack is running, two endpoints are available:

| URL | Purpose |
|-----|---------|
| URL | Purpose |
|--------------------------------------|--------------------------------------------------------------|
| `http://localhost:3000/openapi.json` | OpenAPI 3.1 spec — import into Postman via **Import → Link** |
| `http://localhost:3000/docs` | Scalar interactive UI |
| `http://localhost:3000/docs` | Scalar interactive UI |

All data endpoints (`/v1/decks/*`, `/v1/notes/*`, `/v1/cards/*`) require an API key:

Expand All @@ -69,7 +80,7 @@ Authorization: Bearer ak_<your-key>

Generate a key in the web UI under **Account → API Keys**, or via `POST /v1/me/api-keys`.

Account management endpoints (`/v1/me/*`) use the session cookie set by Google OAuth login.
Account management endpoints (`/v1/me/*`) use the session cookie set by [Better Auth](https://better-auth.com) after Google OAuth login. The auth handler is mounted at `/v1/auth/*`.

---

Expand All @@ -93,11 +104,11 @@ Run the setup script to install all required tools (skips anything already prese
./scripts/setup.zsh
```

| Tool | Purpose |
|-------------------------------------------------------------------|-----------------------------------------------------|
| [Bun](https://bun.sh) | TypeScript runtime for REST API, MCP server, web UI |
| Tool | Purpose |
|-------------------------------------------------------------------|------------------------------------------------------|
| [Bun](https://bun.sh) | TypeScript runtime for REST API, MCP server, web UI |
| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | Full stack via `docker compose` (install separately) |
| [Rust](https://rustup.rs) ≥ 1.80 + `protoc` | Only needed to build anki-cloud-sync from source |
| [Rust](https://rustup.rs) ≥ 1.80 + `protoc` | Only needed to build anki-cloud-sync from source |

### Installing dependencies

Expand Down Expand Up @@ -141,16 +152,16 @@ docs/ Architecture decisions (ADRs) + narrative docs
scripts/ Dev tooling (setup, SDK generation)

docker-compose.yml Base stack (api + anki-sync-server)
docker-compose.standalone.yml Standalone mode override (no DB/GDrive)
docker-compose.cloud.yml Cloud mode override (SQLite + GDrive OAuth)
docker-compose.standalone.yml Standalone mode override (no DB/Google Drive)
docker-compose.cloud.yml Cloud mode override (SQLite + Google Drive OAuth)
docker-compose.dev.yml Local build of anki-cloud-sync (any mode)
```

The sync server lives in a separate repository:
[github.com/danielpmichalski/anki-cloud-sync](https://github.com/danielpmichalski/anki-cloud-sync) —
see its README for all configuration options and environment variables.

Full self-hosting walkthrough (Google OAuth setup, GDrive, Anki Desktop, Claude Desktop): [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md)
Full self-hosting walkthrough (Google OAuth setup, Google Drive, Anki Desktop, Claude Desktop): [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md)

Full architecture and design decisions: [CLAUDE.md](CLAUDE.md)

Expand Down
11 changes: 6 additions & 5 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
- default GDrive directory should be /AnkiCloudSync
- extend REST API with note types fetching; possibly note type creation? maybe in future
- extend REST API with note tags fetching;
- implement comprehensive unit tests for api module
- we have e2e module and ./scripts/smoke-test.sh - not sure if we should keep both or combine them somehow
- allow users to configure GDrive directory, so it's not hardcoded to /AnkiSync
-
- make code more extensible following the Open-Closed principle; the below isn't extensible:
- if (provider !== "google") {
return c.json({error: "Provider not yet supported", code: "UNSUPPORTED_PROVIDER"}, 400);
}
- extend REST API with note tags fetching;
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"@anki-cloud/db": "workspace:*",
"@hono/zod-openapi": "^0.19.6",
"arctic": "^3.7.0",
"better-auth": "^1.0.0",
"drizzle-orm": "^0.43.1",
"hono": "^4.7.7",
"jose": "^6.2.2",
"zod": "^3.24.2"
},
"devDependencies": {
Expand Down
25 changes: 25 additions & 0 deletions api/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2026 Archont Soft Daniel Klimuntowski
// Licensed under the Elastic License 2.0 — see LICENSE in the repository root.
import {betterAuth} from "better-auth";
import {drizzleAdapter} from "better-auth/adapters/drizzle";
import {db} from "@anki-cloud/db";

const trustedOrigins = process.env.TRUSTED_ORIGINS
? process.env.TRUSTED_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean)
: [process.env.FRONTEND_URL ?? "http://localhost:5173"];

export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/v1/auth",
secret: process.env.BETTER_AUTH_SECRET!,
trustedOrigins,
database: drizzleAdapter(db, {
provider: "sqlite",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});
3 changes: 3 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2026 Archont Soft Daniel Klimuntowski
// Licensed under the Elastic License 2.0 — see LICENSE in the repository root.
import { OpenAPIHono } from "@hono/zod-openapi";
import { auth } from "@/auth";
import { authRouter } from "@/routes/auth";
import { storageRouter } from "@/routes/storage";
import { apiKeysRouter } from "@/routes/api-keys";
Expand Down Expand Up @@ -45,6 +46,7 @@ publicApi.get("/docs", (c) =>
const app = new OpenAPIHono<Env>();

app.get("/health", (c) => c.json({ status: "ok" }));
app.on(["POST", "GET"], "/v1/auth/*", (c) => auth.handler(c.req.raw));
app.route("/", publicApi);
app.route("/v1", authRouter);
app.route("/v1", storageRouter);
Expand All @@ -61,5 +63,6 @@ app.onError((err, c) => {

export default {
port: Number.parseInt(process.env.PORT ?? "3000"),
idleTimeout: 120,
fetch: app.fetch,
};
Loading
Loading