Skip to content

Latest commit

 

History

History
407 lines (312 loc) · 18.3 KB

File metadata and controls

407 lines (312 loc) · 18.3 KB

API Guidelines

Conventions for the T'Day REST API served by the Ktor backend. Keep this file aligned with shared/, backend routes, mobile Retrofit/URLSession clients, and DATA_MODEL.md.

Base URL

All API routes live under /api/. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). Android and iOS clients target them at the user-configured server URL in Server Mode. Local Mode does not call the API.

Authentication

  • All routes require a valid JWE session unless listed as public.
  • Public routes: /api/auth/* (CSRF, register, login-challenge, credentials-key, callback), /api/mobile/probe, /.well-known/apple-app-site-association, /apple-app-site-association, /.well-known/assetlinks.json, /health.
  • Authentication is enforced by a Ktor pipeline intercept in Security.kt:
    1. Reads a JWE token from Authorization: Bearer header or session cookies.
    2. Decodes and validates claims (expiry, tokenVersion, role, approval status).
    3. Attaches AuthUser to the call attributes.
  • Route handlers use call.withAuth { } to require an authenticated, approved user.
  • Admin routes additionally require role == ADMIN and approvalStatus == APPROVED via requireAdminAccess().
  • App-layer request throttling runs before handlers and may return 429 on /api/**, /health, /api/mobile/probe, POST /api/todo/summary, POST /api/user/change-password, and /ws.

Request Format

Content Type

  • Request bodies use application/json.
  • Serialization is handled by Ktor's ContentNegotiation plugin with kotlinx.serialization.

Validation

  • Validate incoming request bodies using Konform validators from domain/Validations.kt with the validateOrFail() helper.
  • Return typed AppError variants via Either.Left for invalid input (maps to 400 Bad Request).
  • Validate string-backed enum fields before service calls. Invalid enum values such as priority, list color, and preference sort/group/direction must return 400, not a generic 500.
  • Never trust client input without validation.
either {
    val body = call.receive<TodoCreateRequest>()
    validateCreateTodo.validateOrFail(body).bind()
    todoService.create(user.id, body.title, ...).bind()
}

Query Parameters

  • Use for filtering, sorting, and pagination.
  • Access via call.request.queryParameters["key"].

Response Format

Success Responses

Return JSON with appropriate HTTP status:

// Single resource
{
  "id": "clx...",
  "title": "Buy groceries",
  "priority": "Medium"
}

// Collection
[
  { "id": "clx...", "title": "Buy groceries" },
  { "id": "cly...", "title": "Walk the dog" }
]

// Action confirmation
{
  "message": "Todo completed successfully"
}

Error Responses

Always return a JSON object with a message field. Validation errors may include field; throttled responses include reason and retryAfterSeconds:

{
  "code": 400,
  "message": "priority is invalid",
  "field": "priority"
}

Malformed JSON/request bodies return 400 with message: "Invalid request body". Error responses are produced by respondAppError() from the withAuth helper, or by StatusPages for malformed requests and unhandled exceptions. The legacy ApiException hierarchy is deprecated — new code should use Either<AppError, T> exclusively.

HTTP Status Codes

Success

Code Usage
200 OK Successful GET, PATCH, PUT, DELETE
201 Created Successful POST that creates a resource

Client Errors

Code Usage
400 Bad Request Invalid input, malformed JSON, failed validation
401 Unauthorized Missing or invalid session
403 Forbidden Authenticated but lacking permission (pending approval, non-admin)
404 Not Found Resource does not exist or does not belong to the user
409 Conflict Duplicate resource or version conflict
429 Too Many Requests Rate limit exceeded

Server Errors

Code Usage
500 Internal Server Error Unhandled exceptions (generic message returned to client)

HTTP Methods

Method Purpose Idempotent
GET Retrieve resources Yes
POST Create a new resource No
PATCH Partial update of an existing resource Yes
PUT Full replacement of an existing resource Yes
DELETE Remove a resource Yes
  • Prefer PATCH over PUT for updates.
  • DELETE should be idempotent — deleting a non-existent resource returns 200 or 404, never 500.

Route Handler Pattern

Route handlers delegate to services and use the withAuth helper for authentication:

route("/api/todo") {
    post {
        call.withAuth { user ->
            val request = call.receive<CreateTodoRequest>()
            todoService.create(user.id, request)
                .fold(
                    { error -> call.respondError(error) },
                    { todo -> call.respond(HttpStatusCode.Created, todo) }
                )
        }
    }

    get {
        call.withAuth { user ->
            val todos = todoService.listForUser(user.id, call.request.queryParameters)
            call.respond(HttpStatusCode.OK, todos)
        }
    }
}

Services return Either<AppError, T> (Arrow) for typed error handling. Routes fold the result into HTTP responses.

Shared route constants live in shared/src/commonMain/kotlin/com/ohmz/tday/shared/routes/ApiRoutes.kt. Add or update those constants with backend route changes so backend, Android, and iOS have one contract reference point.

Tenant Isolation

  • Every data query must filter by userID from the authenticated session.
  • Never return data belonging to other users.
  • Admin endpoints that access other users' data must be behind requireAdminAccess().

Existing API Surface

Health and Infrastructure

Method Path Purpose Auth
GET /health Health check ({ status: "ok" }) Public
WS /ws WebSocket for real-time domain events Required

Auth

Method Path Purpose Auth
GET /api/auth/csrf Fetch CSRF token Public
POST /api/auth/register User registration Public
POST /api/auth/login-challenge Password-proof challenge Public
GET /api/auth/credentials-key Public key for credential envelope encryption Public
POST /api/auth/callback/credentials Login (plain or encrypted envelope) Public
GET /api/auth/session Get current session; also returns apiKey: { scope, label, keyPreview } when an API key authenticated the request Required
POST /api/auth/logout Logout (invalidate session) Required

Todos

Method Path Purpose
GET /api/todo List todos (query: start/end, timeline, recurringFutureDays)
POST /api/todo Create a new todo
PATCH /api/todo Update a todo
DELETE /api/todo Delete a todo
PATCH /api/todo/complete Mark todo complete
PATCH /api/todo/uncomplete Mark todo incomplete
PATCH /api/todo/prioritize Change priority
PATCH /api/todo/reorder Reorder todos
PATCH /api/todo/instance Update a recurring instance
DELETE /api/todo/instance Delete a recurring instance
GET /api/todo/overdue List overdue todos
POST /api/todo/nlp Natural language date/title parsing
POST /api/todo/summary Task summary with optional AI and logic fallback

PATCH /api/todo/complete and /api/todo/uncomplete take { id, instanceDate? }, and instanceDate is what decides the scope:

rrule instanceDate What is written
null anything todos.completed, plus a CompletedTodo row with a null instanceDate. A one-off has no occurrences, so an instanceDate sent anyway is ignored rather than keyed into history.
set set A todo_instances row for that occurrence, plus a CompletedTodo row keyed by the same date. The series keeps running.
set null The series: todos.completed, plus a CompletedTodo row with a null instanceDate.

The last row is the one to know about. The request names a todo, not an occurrence, and todos.completed is the only thing the listing queries read — they never consult todo_instances — so it is also the only outcome that takes the task off a client's screen. A client that means one occurrence must send its instanceDate; see docs/design/bulk-selection.md §4.1, which requires exactly that of bulk complete. Uncomplete is the mirror in every case, so any completion reverses cleanly.

Older servers wrote that third row's history entry and then changed no state at all, leaving the task on screen with a phantom entry in Completed. Clients that already guard against that should keep doing so — a self-hosted server may predate the fix.

Floaters

Floaters are unscheduled Anytime tasks. They are not scheduled todos with a nullable due date.

Method Path Purpose
GET /api/floater List all active floaters
POST /api/floater Create a floater
PATCH /api/floater Update a floater
DELETE /api/floater Delete a floater
PATCH /api/floater/complete Complete a floater
PATCH /api/floater/uncomplete Restore a completed floater to active — recreates its list first if the list was deleted since completion (see docs/design/completed-floaters-durability.md)
PATCH /api/floater/prioritize Change floater priority
PATCH /api/floater/reorder Reorder floaters

Lists

Lists group scheduled tasks.

Method Path Purpose
GET /api/list List all lists
POST /api/list Create a list
PATCH /api/list Update a list
DELETE /api/list Delete a list
GET /api/list/{id} Get list with its todos
GET /api/list/{id}/members Get the list owner and members with roles
POST /api/list/{id}/members Add a member by username with role EDITOR or VIEWER (owner only)
PATCH /api/list/{id}/members Change a member's role (owner only)
DELETE /api/list/{id}/members Remove a member (owner only)
POST /api/list/{id}/leave Leave a shared list (non-owner members)

GET /api/list/{id} returns ListTodoDto rows, a narrower shape than the timeline's TodoResponse. It carries rrule and listID (added alongside bulk selection) so a client can tell a repeating task from a one-off on a list screen; it still has no instanceDate, because these rows are recurring templates, not occurrences. A client that must decide whether a row repeats has to treat a missing rrule as unknown rather than as "does not repeat" — see docs/design/bulk-selection.md §4.1.

Floater Lists

Floater lists group floaters.

Method Path Purpose
GET /api/floaterList List all floater lists
POST /api/floaterList Create a floater list
PATCH /api/floaterList Update a floater list
DELETE /api/floaterList Delete one or many floater lists
GET /api/floaterList/{id} Get floater list with its floaters
GET /api/floaterList/{id}/members Get the list owner and members with roles
POST /api/floaterList/{id}/members Add a member by username with role EDITOR or VIEWER (owner only)
PATCH /api/floaterList/{id}/members Change a member's role (owner only)
DELETE /api/floaterList/{id}/members Remove a member (owner only)
POST /api/floaterList/{id}/leave Leave a shared floater list (non-owner members)

User

Method Path Purpose
GET /api/user Get current user profile
GET /api/user/search?q= Typeahead for the share-member picker: substring match on username or display name (min 2 chars, APPROVED users, excludes self, max 10). The query is matched literally — % and _ are escaped, not treated as wildcards
PATCH /api/user Update user (encryption settings)
PATCH /api/user/profile Update profile
POST /api/user/change-password Change password
GET /api/user/security-questions Get the user's chosen question ids + whether they still need to be set (requireSecurityQuestions)
POST /api/user/security-questions Set/replace the user's security questions (exactly 3 distinct). When the account already has questions configured (requireSecurityQuestions = false), the body must include a valid currentPassword — otherwise 400 "current password is required" / "current password is incorrect". The first-time setup gate (requireSecurityQuestions = true) omits the password.

Admin

Method Path Purpose
GET /api/admin/settings Get app configuration and Summary availability
PATCH /api/admin/settings Update app configuration
GET /api/admin/users List all users
PATCH /api/admin/users/{id} Update user (approve, change role)
DELETE /api/admin/users/{id} Delete user and related data. 409 when a row still references the account (the constraint is named in the server log)

Preferences

Method Path Purpose
GET /api/preferences Get user preferences
PATCH /api/preferences Update preferences

Completed Todos

Method Path Purpose
GET /api/completedTodo List completed todos
DELETE /api/completedTodo Delete all completed todos, or delete one when an id body is supplied
PATCH /api/completedTodo Update a completed todo, or remove it when no update fields are supplied

Completed Floaters

Method Path Purpose
GET /api/completedFloater List completed floaters
DELETE /api/completedFloater Delete all or one completed floater
PATCH /api/completedFloater Update or remove a completed floater

Timezone

Method Path Purpose
GET /api/timezone Get/detect timezone using timezone, X-Timezone, or X-User-Timezone

App Settings

Method Path Purpose
GET /api/app-settings Get public app settings (Summary enabled)

Integrations

Method Path Purpose
GET /api/integration/context One-call grounding for external integrations: key scope, user + timezone, server time, capabilities.canWrite, and both list namespaces

MCP

Method Path Purpose Auth
POST /mcp Model Context Protocol endpoint (JSON-RPC 2.0, stateless Streamable HTTP) Required
GET / DELETE /mcp 405 Method Not Allowed — the server keeps no per-connection state Required

/mcp is mounted outside /api, next to /calendar/{token}.ics, so the connector URL a user pastes into an AI client is just <origin>/mcp. Two consequences are load-bearing:

  • It is the one path exempt from the API-key read-only method guard in Security.kt. MCP tunnels every message — including initialize and tools/list — through a POST, so a READ key would otherwise be rejected before routing. Scope is enforced per tool in McpToolDispatcher, which fails closed.
  • It gets no rate limiting from the /api/ prefix rule, so RateLimiting.kt adds an explicit mcp policy using the same window and budget as api_global.

See MCP.md.

Mobile

Method Path Purpose Auth
GET /api/mobile/probe Server discovery, compatibility/version metadata, optional encrypted probe payload Public

GET /api/mobile/probe returns the public probe contract:

  • service: "tday", probe: "ok", version: "1", and serverTime.
  • appVersion with the backend's T'Day release version; mobile App Version screens use this to display the server version even when encrypted compatibility metadata is unavailable.
  • encryptedCompatibility when mobile compatibility enforcement is configured; Android and iOS decrypt it to enforce app/server version compatibility.

When exact compatibility is enabled, Android and iOS send X-Tday-Client and X-Tday-App-Version on API requests. The backend leaves /api/mobile/probe, web requests, and requests without mobile client headers unaffected, but rejects mismatched mobile API requests with:

  • 426 Upgrade Required + reason: "app_update_required" when the app is older than the server-compatible version.
  • 409 Conflict + reason: "server_update_required" when the app is newer than the server-compatible version.

App Association Files

Method Path Purpose Auth
GET /.well-known/apple-app-site-association iOS webcredentials/deep-link association Public
GET /apple-app-site-association iOS association fallback path Public
GET /.well-known/assetlinks.json Android app links association Public

Cache Behavior

  • The web API client sends private API requests with cache: "no-store" so browser fetch caching does not serve stale authenticated data.
  • Routes that return compatibility or discovery metadata, such as /api/mobile/probe, should set explicit Cache-Control: no-store response headers.
  • Security headers (CSP, HSTS, etc.) are applied by the Ktor SecurityHeaders plugin.
  • Static SPA assets are served from the filesystem when STATIC_FILES_DIR is set.

Adding a New Endpoint

  1. Add or update shared request/response models in shared/ when the endpoint is consumed outside the backend.
  2. Add a route function in routes/<domain>.kt (or create a new file for a new domain).
  3. Use call.withAuth { } for authenticated routes.
  4. Validate input using Konform validators or shared model validation.
  5. Delegate to a service in services/.
  6. Filter data by userID for tenant isolation.
  7. Use appropriate HTTP status codes.
  8. Update Android Retrofit and iOS URLSession clients when mobile consumes it.
  9. Update local cache/sync models if the route changes mobile persisted data.
  10. Add tests in tday-backend/src/test/kotlin/ if the endpoint involves security or complex logic.
  11. Update this document with the new route.