Files
mygo/docs/server/decisions.md
T
ld a18f96912d feat(repository): refactor query/mutation ports with capability-safe
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
2026-07-16 12:24:36 +08:00

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 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 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.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.

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 return 404 Not Found while remaining idempotent in effect.
  • Unauthenticated requests remain 401 Unauthorized, non-empty directory deletion remains 409 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.