runer-api

module
v0.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 10, 2026 License: AGPL-3.0

README

Runer API

A RESTful notes API built in Go. It implements authentication, note sync, trash/restore, and subscription-based quota enforcement.


Stack

Layer Library
HTTP Echo v5
ORM GORM + pgx (PostgreSQL)
Auth tokens JWT (golang-jwt/jwt v5)
Config Viper
Logging zerolog
Email Resend (falls back to console in dev)

Architecture

The project follows a clean layered architecture. Every domain (auth, notes, users, subscription) is structured identically:

Handler  →  Service  →  Repository  →  Database
  • Handler — parses the HTTP request, calls the service, maps errors to HTTP status codes.
  • Service — contains all business logic; depends only on interfaces (repository, email sender, etc.), not concrete types.
  • Repository — owns all database queries via GORM.

Dependency injection is constructor-based. All cross-layer dependencies are expressed as Go interfaces, which means services and handlers can be unit-tested without a real database or email server.

Package layout
cmd/
  server/main.go          — entry point: load config, connect DB, start server

internal/
  api/                    — shared DTOs (ErrorResponse, MessageResponse)
  auth/                   — magic link auth, JWT issuance, refresh tokens
  config/                 — Viper config loading, DB connection + auto-migrate
  email/                  — email sender interface, Resend sender, console sender (dev)
  middleware/             — request logger, JWT auth middleware, rate limiter
  notes/                  — note CRUD, sync (upsert, trash, restore, purge, tombstones)
  subscription/           — subscription query handler (plan + quota)
  users/                  — user model and repository
  utils/                  — JWT manager, SHA-256 token hashing
  validator/              — Echo validator setup
  routes.go               — central route registration (wires all dependencies)

Authentication

Passwordless magic-link flow:

  1. RegisterPOST /api/v1/auth/register — creates a user and sends a magic link.
  2. Request magic linkPOST /api/v1/auth/magic-link — sends a new magic link to a registered email.
  3. VerifyPOST /api/v1/auth/verify — exchanges the magic link token for an access token + refresh token.
  4. Verify redirectGET /api/v1/auth/verify-redirect — deep-link handler for email clients that auto-follow links; redirects to runer://auth/verify?token=<token>.
  5. RefreshPOST /api/v1/auth/refresh — issues a new access token using the refresh token.
  6. LogoutPOST /api/v1/auth/logout (auth required) — revokes the refresh token.
Token security design
  • Magic link tokens and refresh tokens are 32-byte cryptographically random values (crypto/rand), base64url-encoded.
  • Only the SHA-256 hash of the raw token is stored in the database. A database breach does not yield usable tokens.
  • The magic link token is single-useMarkMagicLinkTokenAsUsed uses RowsAffected == 0 as a concurrency guard so two simultaneous requests with the same token cannot both succeed.
  • Access tokens and refresh tokens carry a type claim; a refresh token cannot be used as an access token and vice versa.
  • The POST /api/v1/auth/magic-link endpoint always returns HTTP 200, regardless of whether the email is registered, to prevent user enumeration.

Notes API

All note endpoints are protected (Authorization: Bearer <access_token>).

Method Path Description
GET /api/v1/notes Fetch all notes (supports delta sync)
GET /api/v1/notes/:note_id Fetch a single note
PUT /api/v1/notes/:note_id Create or update a note (upsert)
DELETE /api/v1/notes/:note_id Trash a note (soft-delete)
POST /api/v1/notes/:note_id/restore Restore a trashed note
DELETE /api/v1/notes/:note_id/purge Permanently delete a note (tombstone)
Note model

Notes store an opaque encrypted_payload (binary). The server never sees plaintext content — encryption and decryption happens on the client.

Delta sync

GET /api/v1/notes accepts a since query parameter (ISO 8601 timestamp). When provided, only notes updated after that timestamp are returned. Trashed notes are included in sync responses (with trashed_at populated) so all devices learn about trash and restore events.

Trash / restore / purge
  • DELETE /notes/:note_id — soft-delete (trash). Sets trashed_at = now and bumps updated_at. The note remains in sync responses so clients can update their local state.
  • POST /notes/:note_id/restore — clears trashed_at and bumps updated_at.
  • DELETE /notes/:note_id/purge — permanent hard-delete. Writes a NoteTombstone record and removes the note.
Tombstone pattern (offline-first purges)

When a note is permanently deleted via purge, a NoteTombstone record is written with the note_id, user_id, and deleted_at timestamp. On the next sync, clients receive the tombstone list so purges propagate correctly to all devices — including those that were offline.

A background purge job (not yet wired) will remove tombstones older than 30 days.

Conflict detection

PUT /api/v1/notes/:note_id accepts an updated_at field from the client. If the server's copy has a newer updated_at, the upsert is rejected with 409 Conflict. This is an optimistic-locking approach suitable for an offline-first sync model.


Subscription API

GET /api/v1/subscription (auth required) — returns the user's current plan, note count, and note limit.

{
  "plan": "free",
  "note_count": 12,
  "note_limit": 50
}

Free plan users are hard-capped at FREE_NOTE_LIMIT active notes (default 50). Pro plan users have note_limit: null (unlimited). Quota is enforced on PUT /notes/:note_id for new notes.


Health check

GET /health — unauthenticated liveness probe.

{ "status": "ok" }

Configuration

Copy .env.example to .env and fill in the values:

PORT=:8080
ENV=development

JWT_SECRET=<generate with: openssl rand -hex 32>
JWT_TOKEN_DURATION=1h
JWT_REFRESH_TOKEN_DURATION=168h
MAGIC_LINK_TOKEN_DURATION=1h

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/runer_notes
DATABASE_LOG_LEVEL=warn
DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100
DATABASE_CONN_MAX_LIFETIME=1h

APP_BASE_URL=http://localhost:8080
FREE_NOTE_LIMIT=50

# Email — if RESEND_API_KEY is unset the server logs magic links to stdout (dev mode)
RESEND_API_KEY=
EMAIL_FROM=noreply@example.com

All values can also be provided as environment variables — Viper reads from both. Config defaults are set before .env is loaded, so missing keys fall back to safe defaults.


Running locally

Prerequisites: Go 1.23+, PostgreSQL

# Install dependencies
go mod download

# Copy and edit config
cp .env.example .env

# Run
go run ./cmd/server

The server auto-migrates all tables on startup (GORM AutoMigrate).

Building

go build -o runer-api ./cmd/server
./runer-api

For a minimal production binary:

CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o runer-api ./cmd/server

Key design decisions

Decision Rationale
Interface-based DI Every service depends on interfaces, not concrete types. This keeps layers decoupled and makes unit testing straightforward without a live database.
Opaque refresh tokens Refresh tokens are random bytes, not JWTs. This means they can be revoked by hash lookup and a compromised token cannot be decoded to extract claims.
Encrypted payload The server stores bytea blobs. Client-side encryption means a database breach exposes no plaintext note content.
Trash before purge DELETE only trashes (soft-delete). A separate purge endpoint does the hard delete. This gives users a recoverable state and makes offline-first conflict resolution predictable.
Tombstone on purge only Tombstones are only written on hard-delete (purge). Trashed notes propagate via trashed_at on the note itself, keeping the tombstone table small.
Composite index (user_id, updated_at) Directly supports the delta-sync query: WHERE user_id = ? AND updated_at > ?.
Centralized route registration internal/routes.go is the single place to audit the full API surface and dependency wiring.
Console email fallback If RESEND_API_KEY is not set, magic links are printed to stdout. No additional config needed for local development.

Known gaps / roadmap

  • Background job to purge tombstones older than 30 days
  • JWT Audience claim and refresh token rotation
  • Production JSON logger (currently always uses ConsoleWriter)

Directories

Path Synopsis
cmd
server command
analytics
Package analytics provides a thin, non-blocking wrapper around PostHog for capturing product analytics events.
Package analytics provides a thin, non-blocking wrapper around PostHog for capturing product analytics events.
api
logging
Package logging provides the single initialisation point for the application logger.
Package logging provides the single initialisation point for the application logger.
webhook
Package webhook implements unauthenticated inbound webhook handlers.
Package webhook implements unauthenticated inbound webhook handlers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL