Files
MosisService/DEV_PORTAL_M06_API.md

18 KiB

Milestone 6: App Store Backend API

Status: Decided Goal: REST API for app submission, review, and distribution.

Decision

Go + Chi router with JSON REST API:

Framework:   Chi (lightweight, idiomatic)
Validation:  go-playground/validator/v10
OpenAPI:     ogen (generated from spec)
Middleware:  Custom auth, rate limiting, logging
Database:    SQLite via repository pattern

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                     mosis-portal container                       │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │                     Chi Router                              │ │
│  │  /v1/auth/*     /v1/apps/*     /v1/store/*     /v1/keys/*  │ │
│  └──────────────────────┬─────────────────────────────────────┘ │
│                         │                                        │
│  ┌──────────┬───────────┼───────────┬──────────┐               │
│  │ Handlers │ Services  │  Repos    │ SQLite   │               │
│  └──────────┴───────────┴───────────┴──────────┘               │
│                                                                  │
│  /volume1/mosis/data/portal.db                                  │
│  /volume1/mosis/packages/                                       │
└─────────────────────────────────────────────────────────────────┘

Overview

The backend API serves the developer portal, CLI tools, and device-side app management. It handles authentication, app lifecycle, file storage, and telemetry ingestion.


API Design Principles

  1. RESTful - Standard HTTP methods and status codes
  2. JSON - Request and response bodies in JSON
  3. Versioned - /v1/ prefix for breaking changes
  4. Consistent - Same patterns across all endpoints
  5. Documented - OpenAPI specification

Base URL

Production: https://api.mosis.dev/v1
Staging:    https://api.staging.mosis.dev/v1
Local:      http://localhost:8080/v1

Authentication

Headers

Authorization: Bearer <jwt_token>
# or
X-API-Key: mk_live_xxxxxxxx

Scopes

Scope Description
apps:read Read app metadata
apps:write Create/update apps
versions:upload Upload new versions
versions:publish Publish versions
telemetry:read Read analytics
keys:manage Manage API keys

Endpoints

Authentication

POST /v1/auth/oauth/github:
  summary: Start GitHub OAuth flow
  response: { redirect_url: string }

GET /v1/auth/oauth/github/callback:
  summary: GitHub OAuth callback
  query:
    code: string
    state: string
  response: { access_token, refresh_token, user }

POST /v1/auth/oauth/google:
  summary: Start Google OAuth flow
  response: { redirect_url: string }

GET /v1/auth/oauth/google/callback:
  summary: Google OAuth callback

POST /v1/auth/refresh:
  summary: Refresh access token
  body: { refresh_token: string }
  response: { access_token, refresh_token }

POST /v1/auth/logout:
  summary: Invalidate tokens
  auth: required

GET /v1/auth/me:
  summary: Get current user
  auth: required
  response: Developer

Apps

GET /v1/apps:
  summary: List developer's apps
  auth: required
  query:
    status: draft | published | suspended
    page: number
    limit: number
  response: { apps: App[], total: number }

POST /v1/apps:
  summary: Create new app
  auth: required
  body:
    package_id: string  # com.developer.appname
    name: string
    description?: string
    category?: string
  response: App

GET /v1/apps/:id:
  summary: Get app details
  auth: required
  response: App

PATCH /v1/apps/:id:
  summary: Update app metadata
  auth: required
  body:
    name?: string
    description?: string
    category?: string
    tags?: string[]
  response: App

DELETE /v1/apps/:id:
  summary: Delete app (if no published versions)
  auth: required
  response: { success: true }

App Versions

GET /v1/apps/:id/versions:
  summary: List app versions
  auth: required
  query:
    status: draft | review | approved | published | rejected
    page: number
    limit: number
  response: { versions: AppVersion[], total: number }

POST /v1/apps/:id/versions:
  summary: Create new version (get upload URL)
  auth: required
  body:
    version_name: string    # 1.0.0
    version_code: number    # 1
    release_notes?: string
  response:
    version: AppVersion
    upload_url: string      # Presigned S3 URL
    upload_expires: string  # ISO timestamp

PUT /v1/apps/:id/versions/:vid/upload-complete:
  summary: Mark upload as complete, trigger validation
  auth: required
  response: AppVersion

GET /v1/apps/:id/versions/:vid:
  summary: Get version details
  auth: required
  response: AppVersion

POST /v1/apps/:id/versions/:vid/submit:
  summary: Submit version for review
  auth: required
  response: AppVersion  # status: review

POST /v1/apps/:id/versions/:vid/publish:
  summary: Publish approved version
  auth: required
  response: AppVersion  # status: published

DELETE /v1/apps/:id/versions/:vid:
  summary: Delete draft version
  auth: required
  response: { success: true }

Public App Store

GET /v1/store/apps:
  summary: Browse/search published apps
  auth: none
  query:
    q: string           # Search query
    category: string
    sort: popular | recent | name
    page: number
    limit: number
  response: { apps: PublicApp[], total: number }

GET /v1/store/apps/:package_id:
  summary: Get app store listing
  auth: none
  response: PublicApp

GET /v1/store/apps/:package_id/download:
  summary: Get download URL for latest version
  auth: none (or device token)
  response:
    download_url: string
    version: string
    size: number
    signature: string

GET /v1/store/apps/:package_id/versions/:version_code/download:
  summary: Get download URL for specific version
  auth: none
  response: { download_url, version, size, signature }

API Keys

GET /v1/keys:
  summary: List API keys
  auth: required
  response: { keys: APIKey[] }

POST /v1/keys:
  summary: Create API key
  auth: required
  body:
    name: string
    permissions: string[]
    expires_at?: string
  response:
    key: APIKey
    secret: string  # Only shown once!

DELETE /v1/keys/:id:
  summary: Revoke API key
  auth: required
  response: { success: true }

Signing Keys

GET /v1/signing-keys:
  summary: List signing keys
  auth: required
  response: { keys: SigningKey[] }

POST /v1/signing-keys:
  summary: Register signing key
  auth: required
  body:
    name: string
    public_key: string  # PEM format
  response: SigningKey

DELETE /v1/signing-keys/:id:
  summary: Revoke signing key
  auth: required
  response: { success: true }

Telemetry

POST /v1/telemetry/events:
  summary: Submit telemetry events (batch)
  auth: device token or API key
  body:
    events:
      - app_id: string
        event_type: string
        event_data: object
        timestamp: string
  response: { received: number }

POST /v1/telemetry/crash:
  summary: Submit crash report
  auth: device token or API key
  body:
    app_id: string
    app_version: string
    crash_type: string
    message: string
    stack_trace: string
    context: object
    timestamp: string
  response: { id: string }

GET /v1/apps/:id/analytics:
  summary: Get app analytics
  auth: required
  query:
    start_date: string
    end_date: string
    metrics: downloads | active_users | crashes
  response:
    data:
      - date: string
        downloads: number
        active_users: number
        crashes: number

GET /v1/apps/:id/crashes:
  summary: Get crash reports
  auth: required
  query:
    version?: string
    page: number
    limit: number
  response: { crashes: CrashReport[], total: number }

Data Models (Go)

Developer

type Developer struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    AvatarURL *string   `json:"avatar_url,omitempty"`
    Verified  bool      `json:"verified"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

App

type AppStatus string

const (
    AppStatusDraft     AppStatus = "draft"
    AppStatusPublished AppStatus = "published"
    AppStatusSuspended AppStatus = "suspended"
)

type App struct {
    ID            string      `json:"id"`
    PackageID     string      `json:"package_id"`
    Name          string      `json:"name"`
    Description   *string     `json:"description,omitempty"`
    Category      *string     `json:"category,omitempty"`
    Tags          []string    `json:"tags"`
    Status        AppStatus   `json:"status"`
    IconURL       *string     `json:"icon_url,omitempty"`
    LatestVersion *AppVersion `json:"latest_version,omitempty"`
    CreatedAt     time.Time   `json:"created_at"`
    UpdatedAt     time.Time   `json:"updated_at"`
}

AppVersion

type VersionStatus string

const (
    VersionStatusDraft      VersionStatus = "draft"
    VersionStatusUploading  VersionStatus = "uploading"
    VersionStatusValidating VersionStatus = "validating"
    VersionStatusReview     VersionStatus = "review"
    VersionStatusApproved   VersionStatus = "approved"
    VersionStatusPublished  VersionStatus = "published"
    VersionStatusRejected   VersionStatus = "rejected"
)

type AppVersion struct {
    ID              string        `json:"id"`
    AppID           string        `json:"app_id"`
    VersionName     string        `json:"version_name"`
    VersionCode     int           `json:"version_code"`
    PackageURL      *string       `json:"package_url,omitempty"`
    PackageSize     *int64        `json:"package_size,omitempty"`
    Signature       *string       `json:"signature,omitempty"`
    Permissions     []string      `json:"permissions"`
    MinMosisVersion *string       `json:"min_mosis_version,omitempty"`
    ReleaseNotes    *string       `json:"release_notes,omitempty"`
    Status          VersionStatus `json:"status"`
    ReviewNotes     *string       `json:"review_notes,omitempty"`
    PublishedAt     *time.Time    `json:"published_at,omitempty"`
    CreatedAt       time.Time     `json:"created_at"`
}

PublicApp

type PublicApp struct {
    PackageID     string    `json:"package_id"`
    Name          string    `json:"name"`
    Description   *string   `json:"description,omitempty"`
    Category      *string   `json:"category,omitempty"`
    Tags          []string  `json:"tags"`
    IconURL       *string   `json:"icon_url,omitempty"`
    AuthorName    string    `json:"author_name"`
    LatestVersion string    `json:"latest_version"`
    DownloadCount int64     `json:"download_count"`
    Rating        *float64  `json:"rating,omitempty"`
    CreatedAt     time.Time `json:"created_at"`
    UpdatedAt     time.Time `json:"updated_at"`
}

APIKey

type APIKey struct {
    ID          string     `json:"id"`
    Name        string     `json:"name"`
    KeyPrefix   string     `json:"key_prefix"` // "mk_live_abc..."
    Permissions []string   `json:"permissions"`
    LastUsedAt  *time.Time `json:"last_used_at,omitempty"`
    ExpiresAt   *time.Time `json:"expires_at,omitempty"`
    CreatedAt   time.Time  `json:"created_at"`
}

CrashReport

type CrashReport struct {
    ID          string                 `json:"id"`
    AppID       string                 `json:"app_id"`
    AppVersion  string                 `json:"app_version"`
    CrashType   string                 `json:"crash_type"`
    Message     string                 `json:"message"`
    StackTrace  string                 `json:"stack_trace"`
    Context     map[string]interface{} `json:"context"`
    Occurrences int                    `json:"occurrences"`
    FirstSeen   time.Time              `json:"first_seen"`
    LastSeen    time.Time              `json:"last_seen"`
}

Error Handling

Error Response Format

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid package_id format",
    "details": {
      "field": "package_id",
      "constraint": "Must match pattern: ^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)+$"
    }
  }
}

Error Codes

Code HTTP Status Description
UNAUTHORIZED 401 Missing or invalid auth
FORBIDDEN 403 Insufficient permissions
NOT_FOUND 404 Resource not found
VALIDATION_ERROR 400 Invalid request body
CONFLICT 409 Resource already exists
RATE_LIMITED 429 Too many requests
INTERNAL_ERROR 500 Server error

Rate Limiting

Headers

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1704067200

Limits

Endpoint Category Limit Window
Auth 10 1 minute
Read 1000 1 hour
Write 100 1 hour
Upload 10 1 hour
Telemetry 10000 1 hour

Pagination

Request

GET /v1/apps?page=2&limit=20

Response

{
  "apps": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 45,
    "total_pages": 3
  }
}

Webhooks (Future)

POST /v1/webhooks:
  summary: Register webhook
  body:
    url: string
    events: string[]  # version.published, crash.new
    secret: string

Webhook Payload:
  headers:
    X-Mosis-Signature: sha256=xxx
  body:
    event: string
    data: object
    timestamp: string

OpenAPI Specification

Full OpenAPI 3.0 spec will be generated and hosted at:

  • https://api.mosis.dev/v1/openapi.json
  • https://api.mosis.dev/v1/docs (Swagger UI)

Implementation Structure (Go)

cmd/
└── portal/
    └── main.go              # Entry point, wire dependencies

internal/
├── api/
│   ├── router.go            # Chi router setup
│   ├── middleware/
│   │   ├── auth.go          # JWT/API key validation
│   │   ├── ratelimit.go     # Token bucket rate limiter
│   │   ├── logging.go       # Request/response logging
│   │   └── recovery.go      # Panic recovery
│   └── handlers/
│       ├── auth.go          # OAuth2 + token endpoints
│       ├── apps.go          # App CRUD
│       ├── versions.go      # Version upload/publish
│       ├── store.go         # Public store API
│       ├── keys.go          # API/signing keys
│       └── telemetry.go     # Event ingestion
├── service/
│   ├── app.go               # Business logic
│   ├── version.go
│   ├── auth.go
│   └── storage.go           # File storage operations
├── repository/
│   ├── sqlite/              # SQLite implementations
│   │   ├── app.go
│   │   ├── version.go
│   │   ├── developer.go
│   │   └── migrations/      # SQL migrations
│   └── interfaces.go        # Repository interfaces
└── domain/
    ├── app.go               # Domain types
    ├── version.go
    └── developer.go

pkg/
├── validator/               # Custom validation rules
└── signing/                 # Ed25519 operations

Chi Router Setup

func NewRouter(
    authHandler *handlers.AuthHandler,
    appHandler *handlers.AppHandler,
    storeHandler *handlers.StoreHandler,
) chi.Router {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    // API v1
    r.Route("/v1", func(r chi.Router) {
        // Public routes
        r.Group(func(r chi.Router) {
            r.Post("/auth/oauth/github", authHandler.GitHubOAuth)
            r.Get("/auth/oauth/github/callback", authHandler.GitHubCallback)
            r.Post("/auth/refresh", authHandler.Refresh)
            r.Get("/store/apps", storeHandler.ListApps)
            r.Get("/store/apps/{packageID}", storeHandler.GetApp)
        })

        // Protected routes
        r.Group(func(r chi.Router) {
            r.Use(middleware.RequireAuth)
            r.Get("/auth/me", authHandler.Me)
            r.Route("/apps", func(r chi.Router) {
                r.Get("/", appHandler.List)
                r.Post("/", appHandler.Create)
                r.Route("/{appID}", func(r chi.Router) {
                    r.Get("/", appHandler.Get)
                    r.Patch("/", appHandler.Update)
                    r.Delete("/", appHandler.Delete)
                    r.Route("/versions", func(r chi.Router) {
                        r.Get("/", appHandler.ListVersions)
                        r.Post("/", appHandler.CreateVersion)
                    })
                })
            })
        })
    })

    return r
}

Deliverables

  • OpenAPI specification
  • Authentication middleware
  • Rate limiting middleware
  • Auth endpoints
  • Apps CRUD endpoints
  • Versions endpoints with upload flow
  • Store public endpoints
  • API keys management
  • Signing keys management
  • Telemetry ingestion
  • Error handling
  • Request validation
  • Integration tests

Open Questions

  1. GraphQL alongside REST? → REST only for simplicity
  2. WebSocket for real-time review status? → Consider for v1.1
  3. Batch operations for bulk updates? → Not needed for MVP
  4. API versioning strategy (URL vs header)? → URL prefix (/v1/)

References