63ede5c237
- refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes.
109 lines
8.6 KiB
Markdown
109 lines
8.6 KiB
Markdown
# Technical Decisions
|
|
|
|
## 2026-04-25: v0 Tech Stack & Architecture
|
|
|
|
**Context**: Project skeleton was created with only cobra CLI. We needed a concrete tech stack and package layout to begin implementation.
|
|
|
|
**Decisions**:
|
|
|
|
| Area | Choice | Rationale |
|
|
|------|--------|-----------|
|
|
| HTTP framework | Gin | Most widely adopted Go web framework, mature middleware ecosystem |
|
|
| ORM | GORM | SQLite-first dev, PostgreSQL option later; GORM abstracts dialect differences |
|
|
| Config management | Viper | YAML + env vars + CLI flags three-way merge, built for cobra integration |
|
|
| Database | SQLite (v0) → PostgreSQL (future) | SQLite zero setup for dev; repo interface isolates the switch |
|
|
| File storage | Local disk (v0) → S3 (future) | Backend interface (`internal/storage`) hides implementation |
|
|
| File identity | UUID | Distributed-friendly, no coordination needed; cost is negligible for file metadata |
|
|
| Token strategy | JWT, refresh token stored in DB | Enables server-side revocation (admin kick, logout-all-devices) |
|
|
| Pagination | OFFSET/LIMIT | Simple, sufficient for v0; migrate to cursor-based if needed |
|
|
| API response format | Direct JSON success bodies + unified error body | HTTP status codes carry request outcome; error body carries human-readable details |
|
|
|
|
**Architecture**: Four-layer model — Handler (Gin) → Service (business logic) → Repository (GORM data access) + Storage (file I/O). Each layer depends only on interfaces of the layer below.
|
|
|
|
**Consequences**:
|
|
- Handler layer has no business logic; Service layer is reusable across REST API, WebDAV, and future Nextcloud API.
|
|
- Repository interfaces keep DB swappable; future PostgreSQL implementation only needs a new package.
|
|
- Refresh token in DB adds a `sessions` table and a `repository.SessionRepository` interface.
|
|
- UUID dependency: `github.com/google/uuid` to be added.
|
|
- Gin middleware chain: default logger/recovery → cors → auth (route-group-scoped).
|
|
|
|
## 2026-04-27: Web API Foundation
|
|
|
|
**Context**: The project needed the first HTTP slice that can validate Gin wiring and provide a stable shape for future auth, file, and admin APIs.
|
|
|
|
**Decisions**:
|
|
|
|
| Area | Choice | Guidance |
|
|
|------|--------|----------|
|
|
| API versioning | All REST routes under `/api/v1` | Keep future REST handlers under the versioned group. |
|
|
| Initial public endpoint | `GET /api/v1/version` | Returns build metadata only; health/readiness endpoints need a separate security review. |
|
|
| Success responses | Direct JSON resource bodies | Use HTTP status codes as the request outcome signal. |
|
|
| Error responses | `{"error":{"message":"..."}}` | Add machine-readable error codes only when clients need stable branching behavior. |
|
|
| App composition | `internal/app.WebApp` | `cmd/serve.go` creates the app from config and build metadata, then passes it to router setup. |
|
|
| Router setup | `internal/server.NewRouter(*app.WebApp)` | Public routes (`routes_public.go`) and protected routes (`routes_protected.go`) split by auth boundary; `WebApp` serves as the unified dependency container. |
|
|
| Server lifecycle | `RunWithGracefulShutdown` | Preserve graceful shutdown while keeping command startup linear. |
|
|
| Default middleware | `gin.Default()` | Use default logger/recovery for the skeleton; add CORS/auth explicitly when their policies exist. |
|
|
|
|
**Consequences**:
|
|
- Version is build metadata from `internal/app/version.go`, not a config-file field.
|
|
- `app.WebApp` is the place to add future services, repositories, storage, and app metadata incrementally.
|
|
- Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design.
|
|
|
|
## 2026-04-29: Auth Refinements
|
|
|
|
**Context**: Auth layer had three structural weaknesses — handler duplication, indistinguishable token types, and fragile config duration parsing.
|
|
|
|
**Decisions**:
|
|
|
|
| Decision | Guidance |
|
|
|----------|----------|
|
|
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. |
|
|
| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. |
|
|
| Default JWT secret hardening | The development placeholder `jwt.secret` is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret. |
|
|
| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. |
|
|
|
|
**Consequences**:
|
|
- Handlers are independently extensible (caching, rate limiting scoped per handler).
|
|
- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
|
|
- The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart.
|
|
- New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
|
|
|
|
## 2026-07-05: Staged File Uploads
|
|
|
|
**Context**: Multipart uploads previously used `ParseMultipartForm`, which parses the request before service-level size checks and may spill oversized requests to temporary disk. The file service also wrote directly to the long-term storage path and then attempted compensating deletes on upload failure.
|
|
|
|
**Decisions**:
|
|
|
|
| Decision | Guidance |
|
|
|----------|----------|
|
|
| Stream multipart requests | Handlers use `Request.MultipartReader()` instead of `ParseMultipartForm`/`FormFile`, so uploads are streamed into the service. |
|
|
| Optional upload limits | `storage.max_upload_size = 0` means unlimited. Positive values enable both HTTP body limiting and service-level file content limiting. |
|
|
| Staging before promotion | Storage backends write upload bytes to a staging path first, then promote the object to the long-term data path only after validation succeeds. |
|
|
| Promote before DB create | The service promotes the object before creating the active file record, preventing visible DB rows from pointing at missing objects. If DB creation fails after promotion, the service best-effort deletes the promoted object. |
|
|
| Upload parent location | Multipart upload `parent_id` is passed as a query parameter, keeping the multipart body focused on the file stream. |
|
|
| Service-layer upload errors | HTTP transport errors are converted at the handler boundary; the file service only handles domain errors such as oversized uploads. |
|
|
|
|
**Consequences**:
|
|
- Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age.
|
|
- Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row.
|
|
- A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently.
|
|
|
|
## 2026-07-05: Protocol-Neutral Service Boundary
|
|
|
|
**Context**: The service and model layers carried HTTP status codes and JSON response tags, and some middleware and handlers accessed repositories directly. That made the business layer harder to reuse for future WebDAV and Nextcloud-compatible APIs.
|
|
|
|
**Decisions**:
|
|
|
|
| Decision | Guidance |
|
|
|----------|----------|
|
|
| Protocol-neutral errors | `model.AppError` uses an error `Kind`, safe message, and optional cause. REST status codes are mapped in `internal/api` only. |
|
|
| API log references | REST error responses may include `error.log_id` for server-recorded errors: all 5xx responses and 4xx responses with internal causes. |
|
|
| Service boundaries | Handlers and middleware depend on services, not repositories. `AuthService` authenticates access tokens into a principal; `AdminService` owns admin user operations. |
|
|
| DTO ownership | Service and model structs are internal/domain data. HTTP handlers assemble response DTOs with JSON tags. |
|
|
| Architecture enforcement | Package-level tests reject HTTP imports in model/service and repository imports in handlers/middleware. Targeted model tests verify defensive JSON redaction for sensitive fields. |
|
|
|
|
**Consequences**:
|
|
- REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage.
|
|
- Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message.
|
|
- Admin and auth middleware behavior is testable through service contracts rather than database access.
|