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
This commit is contained in:
2026-07-16 12:24:36 +08:00
parent 6604ecb026
commit a18f96912d
30 changed files with 1259 additions and 394 deletions
+40 -27
View File
@@ -10,15 +10,22 @@ import (
"github.com/dhao2001/mygo/internal/model"
)
// PasskeyParams contains the fields accepted when creating an app passkey.
type PasskeyParams struct {
ID string
UserID string
Label string
SecretHash string
}
// CredentialRepository provides access to alternative credential records.
type CredentialRepository interface {
Create(ctx context.Context, cred *model.Credential) error
FindByID(ctx context.Context, id string) (*model.Credential, error)
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
UpdateLastUsed(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
CreatePasskey(ctx context.Context, params PasskeyParams) error
FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error)
ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error)
FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error)
RecordPasskeyUsed(ctx context.Context, id string) error
RevokeOwnedPasskey(ctx context.Context, userID, id string) error
}
type credentialRepository struct {
@@ -30,7 +37,14 @@ func NewCredentialRepository(db *gorm.DB) CredentialRepository {
return &credentialRepository{db: db}
}
func (r *credentialRepository) Create(ctx context.Context, cred *model.Credential) error {
func (r *credentialRepository) CreatePasskey(ctx context.Context, params PasskeyParams) error {
cred := &model.Credential{
ID: params.ID,
UserID: params.UserID,
Type: model.CredentialTypeAppPasskey,
Label: params.Label,
SecretHash: params.SecretHash,
}
result := r.db.WithContext(ctx).Create(cred)
if result.Error != nil {
if isDuplicateKeyError(result.Error) {
@@ -41,9 +55,9 @@ func (r *credentialRepository) Create(ctx context.Context, cred *model.Credentia
return nil
}
func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.Credential, error) {
func (r *credentialRepository) FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error) {
var cred model.Credential
result := r.db.WithContext(ctx).First(&cred, "id = ?", id)
result := r.db.WithContext(ctx).First(&cred, "id = ? AND type = ?", id, model.CredentialTypeAppPasskey)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
@@ -53,27 +67,18 @@ func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.
return &cred, nil
}
func (r *credentialRepository) FindByUserID(ctx context.Context, userID string) ([]model.Credential, error) {
func (r *credentialRepository) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
var creds []model.Credential
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&creds)
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, model.CredentialTypeAppPasskey).Find(&creds)
if result.Error != nil {
return nil, result.Error
}
return creds, nil
}
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
var creds []model.Credential
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, credType).Find(&creds)
if result.Error != nil {
return nil, result.Error
}
return creds, nil
}
func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*model.Credential, error) {
func (r *credentialRepository) FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error) {
var cred model.Credential
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ?", hash)
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ? AND type = ?", hash, model.CredentialTypeAppPasskey)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
@@ -83,19 +88,27 @@ func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*mo
return &cred, nil
}
func (r *credentialRepository) UpdateLastUsed(ctx context.Context, id string) error {
func (r *credentialRepository) RecordPasskeyUsed(ctx context.Context, id string) error {
now := time.Now()
result := r.db.WithContext(ctx).Model(&model.Credential{}).Where("id = ?", id).Update("last_used_at", now)
result := r.db.WithContext(ctx).Model(&model.Credential{}).
Where("id = ? AND type = ?", id, model.CredentialTypeAppPasskey).
Update("last_used_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.ErrNotFound
}
return nil
}
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
func (r *credentialRepository) RevokeOwnedPasskey(ctx context.Context, userID, id string) error {
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ? AND user_id = ? AND type = ?", id, userID, model.CredentialTypeAppPasskey)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.ErrNotFound
}
return nil
}