Files
MosisService/DEV_PORTAL_M06_API.md

716 lines
18 KiB
Markdown

# 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
```yaml
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
```yaml
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
```yaml
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
```yaml
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
```yaml
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
```yaml
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
```yaml
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
```go
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
```go
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
```go
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
```go
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
```go
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
```go
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
```json
{
"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
```json
{
"apps": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 45,
"total_pages": 3
}
}
```
---
## Webhooks (Future)
```yaml
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
```go
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
- [REST API Design Guidelines](https://github.com/microsoft/api-guidelines)
- [OpenAPI 3.0 Specification](https://swagger.io/specification/)
- [HTTP Status Codes](https://httpstatuses.com/)