interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
12 KiB
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
sessionstable and arepository.SessionRepositoryinterface. - UUID dependency:
github.com/google/uuidto 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.WebAppis the place to add future runtime services, storage, and app metadata incrementally; repository implementations stay inside composition setup.- 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.Durationin 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.Renameand falls back to copy/delete on cross-deviceEXDEV; 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_idinstead of embedding log references in the message. - Admin and auth middleware behavior is testable through service contracts rather than database access.
2026-07-15: Conceal File Resource Existence
Context: Returning permission-denied errors for cross-user file access allowed authenticated callers to distinguish another user's resource from a missing resource. Idempotent success for deleting missing files also conflicted with a consistent not-found concealment policy.
Decisions:
| Decision | Guidance |
|---|---|
| Conceal ownership | File and parent-directory ownership failures return the same not-found kind and safe message as missing or deleted resources. |
| Strict delete result | Deleting an active owned file returns success once; missing, deleted, and cross-user targets return not found. |
| Atomic soft delete | Repository deletes update only active rows and return model.ErrNotFound when no row changes. |
Consequences:
- Authenticated file operations cannot use status codes or messages to determine whether another user's resource exists.
- A successful first delete returns
204 No Content; repeated deletes return404 Not Foundwhile remaining idempotent in effect. - Unauthenticated requests remain
401 Unauthorized, non-empty directory deletion remains409 Conflict, and permission-denied behavior outside the file boundary remains unchanged.
2026-07-16: Command-Scoped Persistence Mutations
Context: Generic Repository.Update(*Entity), full-entity GORM Save, and broad delete methods exposed fields and lifecycle transitions that individual services were not authorized to perform. They also allowed a stale file entity to revive a soft-deleted row, left file parent checks vulnerable to concurrent deletion, and allowed two refresh requests to consume the same session.
Decisions:
| Decision | Guidance |
|---|---|
| Command-scoped writes | Every mutation has a use-case-specific method and parameter type. Mutable domain entities do not cross a service-to-repository write boundary. |
| Capability interfaces | Auth and admin services accept only the query and command capabilities they require. Wider user and session interfaces exist only for construction and repository-focused consumers. |
| Explicit mutation predicates | Updates name concrete columns and include ownership, credential type, and/or active-state predicates appropriate to the command. A zero-row guarded mutation maps to a domain not-found result. |
| Transactional file hierarchy | Child creation, movement, and directory deletion revalidate hierarchy state in the same transaction. PostgreSQL locks relevant rows in sorted ID order; SQLite DSNs force _txlock=immediate. |
| Atomic refresh consumption | Refresh rotates through one transaction that locks, returns, and deletes the session. A concurrent replay cannot issue a second token pair. Logout remains an idempotent revoke-by-hash command. |
| Test-only privilege setup | Tests elevate users through internal/testutil; no production role mutation is added merely to prepare fixtures. |
Consequences:
- Protected user, file, session, and credential fields are absent from ordinary mutation inputs, reducing illegal write paths at compile time.
- Deleted file rows cannot be revived by stale metadata updates, and successful hierarchy races cannot leave active orphan children.
- PostgreSQL relies on row-level locking while SQLite serializes these hierarchy and session write transactions from their first database operation. This favors correctness over concurrent SQLite writers.
- Adding a future profile, password, role, restore, or other mutation requires a named command and an explicit capability rather than extending a universal update method.
- REST routes, payloads, status codes, database schema, and dependency set remain unchanged.