From 4f010151323972f9bd65931adab49fc486002d0e Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 21 Feb 2026 15:38:37 -0700 Subject: [PATCH] feat: implement project access enforcement and management API - Fix no-op RequireProjectAccess middleware to enforce project_ids - Apply project access middleware to all project-scoped routes - Filter GET /projects by allowed project IDs for restricted keys - Add GET /me endpoint with key identity, scopes, and project access info - Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in) - Add GET/POST/DELETE /projects/{id}/access for project-centric access management - Auto-grant creating key access when using POST /project/create-and-build - Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation - Move newProvisionerWithDeps test helper from production code to test file Co-Authored-By: Claude Sonnet 4.6 --- cmd/rdev-api/config.go | 9 +- cmd/rdev-api/main.go | 21 +- deployments/k8s/base/rdev-api.yaml | 41 +- internal/adapter/memory/apikey_repository.go | 55 +- internal/adapter/notify/admin_client.go | 59 ++ internal/adapter/notify/provisioner.go | 287 ++++++-- internal/adapter/notify/provisioner_test.go | 511 +++++++++++++++ internal/adapter/notify/resend_client.go | 128 ++++ .../adapter/postgres/apikey_repository.go | 112 ++++ .../components/app-react/src/App.tsx.tmpl | 77 ++- .../src/pages/ForgotPasswordPage.tsx.tmpl | 110 ++++ .../app-react/src/pages/LoginPage.tsx.tmpl | 249 +++++-- .../app-react/src/pages/MediaPage.tsx.tmpl | 6 +- .../app-react/src/pages/RegisterPage.tsx.tmpl | 128 ++++ .../src/pages/ResetPasswordPage.tsx.tmpl | 135 ++++ .../app-react/src/pages/SessionsPage.tsx.tmpl | 178 +++++ .../src/pages/VerifyEmailPage.tsx.tmpl | 161 +++++ .../components/service/.env.example.tmpl | 10 +- .../service/cmd/server/main.go.tmpl | 161 ++++- .../{ => cmd/server}/migrations/.gitkeep | 0 .../server/migrations/001_create_users.sql | 79 +++ .../cmd/server/migrations/002_add_indexes.sql | 9 + .../migrations/003_create_media_objects.sql | 22 + .../internal/adapter/email/helpers.go.tmpl | 33 + .../internal/adapter/email/log.go.tmpl | 32 + .../internal/adapter/email/notify.go.tmpl | 57 ++ .../internal/adapter/memory/auth_code.go.tmpl | 76 +++ .../internal/adapter/memory/media.go.tmpl | 135 ++++ .../internal/adapter/memory/session.go.tmpl | 120 ++++ .../internal/adapter/memory/user.go.tmpl | 253 ++++++-- .../adapter/postgres/auth_code.go.tmpl | 120 ++++ .../internal/adapter/postgres/media.go.tmpl | 184 ++++++ .../internal/adapter/postgres/session.go.tmpl | 162 +++++ .../internal/adapter/postgres/user.go.tmpl | 260 ++++++++ .../internal/api/handlers/auth.go.tmpl | 294 +++++++-- .../internal/api/handlers/auth_flows.go.tmpl | 288 ++++++++ .../internal/api/handlers/generate.go.tmpl | 60 +- .../internal/api/handlers/media.go.tmpl | 291 +++++++-- .../service/internal/api/routes.go.tmpl | 61 +- .../service/internal/config/config.go.tmpl | 36 +- .../service/internal/domain/auth_code.go.tmpl | 32 + .../service/internal/domain/errors.go.tmpl | 15 + .../service/internal/domain/media.go.tmpl | 27 + .../service/internal/domain/session.go.tmpl | 25 + .../service/internal/domain/user.go.tmpl | 52 ++ .../service/internal/port/auth_code.go.tmpl | 24 + .../service/internal/port/email.go.tmpl | 11 + .../service/internal/port/media.go.tmpl | 38 ++ .../service/internal/port/session.go.tmpl | 33 + .../service/internal/port/user.go.tmpl | 52 +- .../service/internal/service/auth.go.tmpl | 613 ++++++++++++++++-- .../components/worker/cmd/worker/main.go.tmpl | 2 +- .../.claude/agents/database-architect.md | 15 + .../.claude/agents/security-architect.md | 57 +- .../templates/skeleton/.claude/guides/auth.md | 146 +++++ .../templates/skeleton/CLAUDE.md.tmpl | 4 + .../packages/auth/src/AuthProvider.tsx | 450 +++++++++++-- .../skeleton/packages/auth/src/index.ts | 10 +- .../skeleton/packages/auth/src/types.ts | 46 +- .../ui/src/components/MediaLibrary.tsx | 20 +- .../templates/skeleton/pkg/auth/jwt.go.tmpl | 97 ++- .../skeleton/pkg/auth/middleware.go.tmpl | 81 ++- .../skeleton/pkg/auth/password.go.tmpl | 71 ++ .../skeleton/pkg/auth/useragent.go.tmpl | 68 ++ .../skeleton/pkg/generation/handlers.go.tmpl | 4 +- .../templates/skeleton/pkg/go.mod.tmpl | 2 +- .../skeleton/pkg/middleware/ratelimit.go.tmpl | 132 ++++ .../skeleton/pkg/notify/client.go.tmpl | 184 ++++++ .../skeleton/pkg/notify/errors.go.tmpl | 69 ++ .../skeleton/pkg/notify/types.go.tmpl | 57 ++ .../skeleton/pkg/queue/memory.go.tmpl | 43 +- .../skeleton/pkg/queue/postgres.go.tmpl | 7 +- .../skeleton/pkg/queue/queue.go.tmpl | 7 + .../skeleton/pkg/storage/gcs.go.tmpl | 4 +- .../skeleton/pkg/storage/memory.go.tmpl | 13 +- internal/auth/middleware.go | 11 +- internal/auth/service.go | 11 + internal/domain/credential.go | 14 +- internal/domain/notify.go | 7 +- internal/handlers/builds.go | 8 +- internal/handlers/checkout.go | 2 + internal/handlers/create_and_build.go | 47 ++ internal/handlers/keys.go | 98 +++ internal/handlers/me.go | 99 +++ internal/handlers/project_access.go | 205 ++++++ internal/handlers/projects.go | 21 +- internal/handlers/sdlc.go | 2 + internal/handlers/sessions.go | 2 + internal/handlers/verify.go | 2 +- internal/port/apikey_repository.go | 17 + internal/port/notify_provisioner.go | 15 +- internal/port/port_test.go | 20 + internal/service/apikey_service.go | 10 + internal/service/apikey_service_test.go | 18 + internal/service/component_deploy.go | 4 + internal/service/project_infra_crud.go | 18 +- internal/service/project_service.go | 29 +- internal/service/project_service_test.go | 4 +- 98 files changed, 7649 insertions(+), 536 deletions(-) create mode 100644 internal/adapter/notify/provisioner_test.go create mode 100644 internal/adapter/notify/resend_client.go create mode 100644 internal/adapter/templates/templates/components/app-react/src/pages/ForgotPasswordPage.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/pages/RegisterPage.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/pages/ResetPasswordPage.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/pages/SessionsPage.tsx.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/src/pages/VerifyEmailPage.tsx.tmpl rename internal/adapter/templates/templates/components/service/{ => cmd/server}/migrations/.gitkeep (100%) create mode 100644 internal/adapter/templates/templates/components/service/cmd/server/migrations/001_create_users.sql create mode 100644 internal/adapter/templates/templates/components/service/cmd/server/migrations/002_add_indexes.sql create mode 100644 internal/adapter/templates/templates/components/service/cmd/server/migrations/003_create_media_objects.sql create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/email/log.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/memory/auth_code.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/memory/media.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/memory/session.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/postgres/auth_code.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/postgres/media.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/postgres/session.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/postgres/user.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/api/handlers/auth_flows.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/domain/auth_code.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/domain/media.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/domain/session.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/domain/user.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/port/auth_code.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/port/email.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/port/media.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/port/session.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/.claude/guides/auth.md create mode 100644 internal/adapter/templates/templates/skeleton/pkg/auth/password.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/auth/useragent.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/middleware/ratelimit.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/notify/client.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/notify/errors.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/notify/types.go.tmpl create mode 100644 internal/handlers/me.go create mode 100644 internal/handlers/project_access.go diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go index ec86221..976903d 100644 --- a/cmd/rdev-api/config.go +++ b/cmd/rdev-api/config.go @@ -88,11 +88,10 @@ type InfraConfig struct { GCSCredentialsPath string // Path to service account JSON (empty = ADC) GCSLocation string // Bucket location (default: "US") - // Notify provisioner (for project email delivery) + // Notify provisioner (for per-project email delivery) NotifyURL string // e.g., "https://notify.orchard9.ai" NotifyAdminKey string // notify_admin_... admin API key - NotifyHost string // shared host (e.g., "threesix.ai") - NotifyFrom string // from-address (e.g., "noreply@threesix.ai") + ResendAPIKey string // re_... Resend API key for per-project domain provisioning } func loadConfig() Config { @@ -155,6 +154,7 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config domain.CredKeyRegistryURL, domain.CredKeyNotifyURL, domain.CredKeyNotifyAdminKey, + domain.CredKeyResendAPIKey, }) if err != nil { logger.Warn("failed to load credentials from store, using env vars", "error", err) @@ -201,8 +201,7 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config // Notify provisioner (credential store with env fallback) NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")), NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")), - NotifyHost: envutil.GetEnv("NOTIFY_HOST", "threesix.ai"), - NotifyFrom: envutil.GetEnv("NOTIFY_FROM", "noreply@threesix.ai"), + ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")), } // Log which credentials were loaded from store vs env diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 0d16eba..1a6bef3 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -241,20 +241,20 @@ func main() { } defer closeProvisioner(storageProvisioner, "gcs", logger) - // Initialize notify provisioner (optional - for project email delivery) + // Initialize notify provisioner (optional - for per-project email delivery) var notifyProvisioner port.NotifyProvisioner if infraCfg.NotifyURL != "" && infraCfg.NotifyAdminKey != "" { np := notifyadapter.NewProvisioner(notifyadapter.Config{ - BaseURL: infraCfg.NotifyURL, - AdminKey: infraCfg.NotifyAdminKey, - Host: infraCfg.NotifyHost, - From: infraCfg.NotifyFrom, - }, logger) + BaseURL: infraCfg.NotifyURL, + AdminKey: infraCfg.NotifyAdminKey, + ResendAPIKey: infraCfg.ResendAPIKey, + BaseDomain: infraCfg.DefaultDomain, + }, dnsClient, logger) if err := np.TestConnection(context.Background()); err != nil { logger.Warn("notify provisioner connection test failed, disabling", "error", err) } else { notifyProvisioner = np - logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL, "host", infraCfg.NotifyHost) + logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL) } } @@ -453,6 +453,8 @@ func main() { // Initialize handlers projectsHandler := handlers.NewProjectsHandlerWithService(projectService) + meHandler := handlers.NewMeHandler(authService, projectService) + projectAccessHandler := handlers.NewProjectAccessHandler(authService) keysHandler := handlers.NewKeysHandler(authService) claudeConfigHandler := handlers.NewClaudeConfigHandlerWithService(projectService, projectRepo, k8sExecutor) auditHandler := handlers.NewAuditHandler(auditLogger) @@ -574,7 +576,8 @@ func main() { // Initialize worker pool handlers workersHandler := handlers.NewWorkersHandler(workerService).WithWorkService(workService).WithWorkQueue(workQueueRepo) buildsHandler := handlers.NewBuildsHandler(buildService) - createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService) + createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService). + WithAuthService(authService) sdlcHandler := handlers.NewSDLCHandler(sdlcService) sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator) @@ -660,6 +663,8 @@ func main() { // Register routes projectsHandler.Mount(app.Router()) + meHandler.Mount(app.Router()) + projectAccessHandler.Mount(app.Router()) keysHandler.Mount(app.Router()) claudeConfigHandler.Mount(app.Router()) auditHandler.Mount(app.Router()) diff --git a/deployments/k8s/base/rdev-api.yaml b/deployments/k8s/base/rdev-api.yaml index f91da38..fb753fd 100644 --- a/deployments/k8s/base/rdev-api.yaml +++ b/deployments/k8s/base/rdev-api.yaml @@ -146,6 +146,16 @@ spec: secretKeyRef: name: redis-credentials key: REDIS_PASSWORD + # Citadel logging integration (environment provisioning + audit shipping) + - name: CITADEL_URL + value: "http://citadel-community.citadel.svc.cluster.local" + - name: CITADEL_API_KEY + valueFrom: + secretKeyRef: + name: rdev-credentials + key: CITADEL_API_KEY + - name: CITADEL_PLATFORM_TENANT_ID + value: "bf874fbf-6150-4aa9-b7bc-db531791bde1" # OpenTelemetry - name: OTEL_EXPORTER_OTLP_ENDPOINT value: "otel-collector.observability.svc.cluster.local:4317" @@ -256,7 +266,7 @@ roleRef: name: rdev-api-deployer apiGroup: rbac.authorization.k8s.io --- -# Ingress for rdev-api +# Ingress for rdev-api (masq-ops subdomain, DNS-01 via Cloudflare) apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -283,3 +293,32 @@ spec: - hosts: - rdev.masq-ops.orchard9.ai secretName: rdev-api-tls +--- +# Ingress for rdev-api (orchard9.ai vanity domain, HTTP-01 via letsencrypt-prod-http01) +# orchard9.ai is on GoDaddy; Cloudflare token only covers threesix.ai +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rdev-api-orchard9 + namespace: rdev + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod-http01 + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + ingressClassName: traefik + rules: + - host: rdev.orchard9.ai + http: + paths: + - backend: + service: + name: rdev-api + port: + number: 8080 + path: / + pathType: Prefix + tls: + - hosts: + - rdev.orchard9.ai + secretName: rdev-orchard9-tls diff --git a/internal/adapter/memory/apikey_repository.go b/internal/adapter/memory/apikey_repository.go index 85dc90e..131e62c 100644 --- a/internal/adapter/memory/apikey_repository.go +++ b/internal/adapter/memory/apikey_repository.go @@ -11,10 +11,10 @@ import ( // APIKeyRepository is an in-memory implementation of port.APIKeyRepository. type APIKeyRepository struct { - keys map[domain.APIKeyID]*domain.APIKey + keys map[domain.APIKeyID]*domain.APIKey keysByHash map[string]domain.APIKeyID - nextID int - mu sync.RWMutex + nextID int + mu sync.RWMutex } // NewAPIKeyRepository creates a new in-memory API key repository. @@ -122,6 +122,55 @@ func (r *APIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyI return nil } +// Update applies a partial update to an API key. +func (r *APIKeyRepository) Update(ctx context.Context, id domain.APIKeyID, update port.APIKeyUpdate) error { + r.mu.Lock() + defer r.mu.Unlock() + + key, ok := r.keys[id] + if !ok || key.RevokedAt != nil { + return domain.ErrKeyNotFound + } + + if update.Name != nil { + key.Name = *update.Name + } + if update.Scopes != nil { + key.Scopes = update.Scopes + } + if update.ProjectIDs != nil { + key.ProjectIDs = *update.ProjectIDs + } + if update.AllowedIPs != nil { + key.AllowedIPs = *update.AllowedIPs + } + if update.ExpiresAt != nil { + key.ExpiresAt = *update.ExpiresAt + } + + return nil +} + +// ListByProjectID returns all active keys that have the given project ID in their project_ids. +func (r *APIKeyRepository) ListByProjectID(ctx context.Context, projectID domain.ProjectID) ([]*domain.APIKey, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*domain.APIKey + for _, key := range r.keys { + if key.RevokedAt != nil { + continue + } + for _, pid := range key.ProjectIDs { + if pid == projectID { + result = append(result, key) + break + } + } + } + return result, nil +} + // itoa converts an integer to a string. func itoa(i int) string { if i == 0 { diff --git a/internal/adapter/notify/admin_client.go b/internal/adapter/notify/admin_client.go index 87b5183..6c26308 100644 --- a/internal/adapter/notify/admin_client.go +++ b/internal/adapter/notify/admin_client.go @@ -12,6 +12,20 @@ import ( "time" ) +// notifyAdminAPI is the interface Provisioner uses to call the notify admin API. +// Extracted for testability. +type notifyAdminAPI interface { + createHost(ctx context.Context, hostSlug, strategy string) error + deleteHost(ctx context.Context, hostSlug string) error + createProvider(ctx context.Context, hostSlug, provider string, config map[string]string, priority, retryAttempts, retryBackoffMs int) error + createFromAddress(ctx context.Context, hostSlug, email, displayName string) error + createAccount(ctx context.Context, name string) (*accountResponse, error) + createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) + grantHostAccess(ctx context.Context, hostSlug, accountID string) error + deleteAccount(ctx context.Context, accountID string) error + listAccounts(ctx context.Context) ([]accountResponse, error) +} + // adminClient calls the notify admin API to manage accounts and keys. type adminClient struct { baseURL string @@ -87,6 +101,51 @@ func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) return &key, nil } +// createHost creates a new notify sending host with the given slug and sending strategy. +func (c *adminClient) createHost(ctx context.Context, hostSlug, strategy string) error { + payload := map[string]string{"host": hostSlug, "strategy": strategy} + _, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts", payload) + if err != nil { + return fmt.Errorf("create host %s: %w", hostSlug, err) + } + return nil +} + +// deleteHost removes a notify host by its slug. +func (c *adminClient) deleteHost(ctx context.Context, hostSlug string) error { + _, err := c.doRequest(ctx, http.MethodDelete, "/admin/hosts/"+hostSlug, nil) + if err != nil { + return fmt.Errorf("delete host %s: %w", hostSlug, err) + } + return nil +} + +// createProvider adds a sending provider to an existing host. +func (c *adminClient) createProvider(ctx context.Context, hostSlug, provider string, config map[string]string, priority, retryAttempts, retryBackoffMs int) error { + payload := map[string]any{ + "provider": provider, + "config": config, + "priority": priority, + "retry_attempts": retryAttempts, + "retry_backoff_ms": retryBackoffMs, + } + _, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/providers", payload) + if err != nil { + return fmt.Errorf("create provider %s on host %s: %w", provider, hostSlug, err) + } + return nil +} + +// createFromAddress registers a from-address on a host. +func (c *adminClient) createFromAddress(ctx context.Context, hostSlug, email, displayName string) error { + payload := map[string]string{"email": email, "display_name": displayName} + _, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/from-addresses", payload) + if err != nil { + return fmt.Errorf("create from-address %s on host %s: %w", email, hostSlug, err) + } + return nil +} + // grantHostAccess grants the given account access to send from the specified host slug. func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error { payload := map[string]string{"account_id": accountID} diff --git a/internal/adapter/notify/provisioner.go b/internal/adapter/notify/provisioner.go index 8e59e1e..12da017 100644 --- a/internal/adapter/notify/provisioner.go +++ b/internal/adapter/notify/provisioner.go @@ -7,110 +7,263 @@ import ( "time" "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" ) // Provisioner implements port.NotifyProvisioner using the notify admin API. -// Each project gets an isolated notify account and send key scoped to the -// shared sending host (e.g., "threesix.ai"). +// Each project gets an isolated sending host (mail.{slug}.{baseDomain}), +// a Resend domain with DKIM/SPF DNS records, and a dedicated send key. type Provisioner struct { - client *adminClient - host string // shared sending host slug (e.g., "threesix.ai") - from string // from-address (e.g., "noreply@threesix.ai") - logger *slog.Logger + client notifyAdminAPI + resend resendAPI // nil when ResendAPIKey not configured + resendAPIKey string // passed to createProvider; kept separate from resend for interface compatibility + dns port.DNSProvider // nil when Cloudflare not configured + baseDomain string // e.g., "threesix.ai" + logger *slog.Logger } // Config holds configuration for the notify provisioner. type Config struct { - BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai") - AdminKey string // Required: admin API key (notify_admin_...) - Host string // Shared host slug for all projects (e.g., "threesix.ai") - From string // Default from-address (e.g., "noreply@threesix.ai") + BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai") + AdminKey string // Required: admin API key (notify_admin_...) + ResendAPIKey string // Optional: Resend API key for per-project domain provisioning + BaseDomain string // Base domain for per-project hosts (default: "threesix.ai") } // NewProvisioner creates a new notify provisioner. -func NewProvisioner(cfg Config, logger *slog.Logger) *Provisioner { - host := cfg.Host - if host == "" { - host = "threesix.ai" +func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Provisioner { + baseDomain := cfg.BaseDomain + if baseDomain == "" { + baseDomain = "threesix.ai" } - from := cfg.From - if from == "" { - from = "noreply@threesix.ai" + p := &Provisioner{ + client: newAdminClient(cfg.BaseURL, cfg.AdminKey), + dns: dns, + baseDomain: baseDomain, + logger: logger, } - return &Provisioner{ - client: newAdminClient(cfg.BaseURL, cfg.AdminKey), - host: host, - from: from, - logger: logger, + if cfg.ResendAPIKey != "" { + p.resend = newResendClient(cfg.ResendAPIKey) + p.resendAPIKey = cfg.ResendAPIKey } + return p } -// CreateProjectNotify provisions a notify account and send key for the project. +// CreateProjectNotify provisions a per-project notify host, Resend domain, DNS records, +// and notify account with send key. +// // Steps: -// 1. Create account named "project-{projectID}" -// 2. Create send API key via POST /admin/api-keys -// 3. Grant account access to the shared host -func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { +// 1. Create notify host mail.{slug}.{baseDomain} +// 2. Add Resend provider to the host (skipped if ResendAPIKey not configured) +// 3. Register from-address noreply@mail.{slug}.{baseDomain} +// 4. Create notify account "project-{projectID}" +// 5. Create send key for the account +// 6. Grant the account access to the host (non-fatal) +// 7. Create Resend domain (non-fatal — skipped if ResendAPIKey not configured) +// 8. Add DNS records via Cloudflare (non-fatal — skipped if DNS not configured) +// 9. Fire-and-forget async domain verification +func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error) { + host := "mail." + slug + "." + p.baseDomain + from := "noreply@" + host accountName := "project-" + projectID - // 1. Create account + // 1. Create notify host + if err := p.client.createHost(ctx, host, "failover"); err != nil { + return nil, fmt.Errorf("notify: create host %s for project %s: %w", host, projectID, err) + } + + // 2. Add Resend provider to the host (only when Resend is configured) + if p.resend != nil { + if err := p.client.createProvider(ctx, host, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil { + p.bestEffortDeleteHost(ctx, host, projectID) + return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", host, projectID, err) + } + } + + // 3. Register from-address + if err := p.client.createFromAddress(ctx, host, from, slug); err != nil { + p.bestEffortDeleteHost(ctx, host, projectID) + return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", from, projectID, err) + } + + // 4. Create account acct, err := p.client.createAccount(ctx, accountName) if err != nil { + p.bestEffortDeleteHost(ctx, host, projectID) return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err) } - // 2. Create send key (plaintext key only returned here) + // 5. Create send key key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send") if err != nil { - // Best-effort cleanup - if delErr := p.client.deleteAccount(ctx, acct.ID); delErr != nil { - p.logger.Warn("failed to clean up notify account after key creation failure", - "account_id", acct.ID, - "project_id", projectID, - "error", delErr, - ) - } + p.bestEffortDeleteAccount(ctx, acct.ID, projectID) + p.bestEffortDeleteHost(ctx, host, projectID) return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err) } - // 3. Grant host access - if err := p.client.grantHostAccess(ctx, p.host, acct.ID); err != nil { + // 6. Grant host access (non-fatal — log warn and continue) + if err := p.client.grantHostAccess(ctx, host, acct.ID); err != nil { p.logger.Warn("failed to grant notify host access", - "host", p.host, + "host", host, "account_id", acct.ID, "project_id", projectID, "error", err, ) } + // 7. Create Resend domain (non-fatal — project still usable, email won't send until fixed) + var resendDomainID string + var dnsRecords []resendDNSRecord + if p.resend != nil { + var resendErr error + resendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, host, "us-east-1") + if resendErr != nil { + p.logger.Warn("failed to create resend domain — email delivery will not work until resolved", + "host", host, + "project_id", projectID, + "error", resendErr, + ) + } else { + p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID) + } + } + + // 8. Add DNS records for DKIM/SPF (non-fatal). + // Resend returns record names relative to the registered domain; build FQDNs for Cloudflare. + // Cloudflare's normalizeName handles FQDNs ending in the zone name correctly. + if p.dns != nil && len(dnsRecords) > 0 { + for _, rec := range dnsRecords { + fqdn := rec.Name + "." + host + dnsRec := domain.DNSRecord{ + Type: rec.Record, + Name: fqdn, + Content: rec.Value, + TTL: 1, + } + if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil { + p.logger.Warn("failed to upsert notify DNS record", + "name", fqdn, + "record", rec.Record, + "project_id", projectID, + "error", upsertErr, + ) + } + } + } + + // 9. Fire-and-forget async domain verification + if p.resend != nil && resendDomainID != "" { + go func() { + verifyCtx := context.WithoutCancel(ctx) + if err := p.resend.verifyDomain(verifyCtx, resendDomainID); err != nil { + p.logger.Warn("async resend domain verification failed", + "domain_id", resendDomainID, + "host", host, + "error", err, + ) + } + }() + } + + p.logger.Info("notify provisioned", + "project_id", projectID, + "host", host, + "resend_domain_id", resendDomainID, + ) + return &domain.NotifyCredentials{ - ProjectID: projectID, - AccountID: acct.ID, - APIKey: key.Key, - Host: p.host, - From: p.from, - CreatedAt: time.Now(), + ProjectID: projectID, + AccountID: acct.ID, + APIKey: key.Key, + Host: host, + From: from, + ResendDomainID: resendDomainID, + CreatedAt: time.Now(), }, nil } -// DeleteProjectNotify removes the notify account for the project. -func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID string) error { +// DeleteProjectNotify removes all notify resources for a project. +// Failures are logged as warnings — cleanup continues regardless. +func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error { + host := "mail." + slug + "." + p.baseDomain + + // 1. Delete notify account (cascades keys + host grants) acct, err := p.findAccountByProject(ctx, projectID) if err != nil { - return fmt.Errorf("notify: find account for project %s: %w", projectID, err) - } - if acct == nil { - return nil // Already deleted or never provisioned + p.logger.Warn("failed to find notify account during deletion", + "project_id", projectID, + "error", err, + ) + } else if acct != nil { + if err := p.client.deleteAccount(ctx, acct.ID); err != nil { + p.logger.Warn("failed to delete notify account", + "account_id", acct.ID, + "project_id", projectID, + "error", err, + ) + } } - if err := p.client.deleteAccount(ctx, acct.ID); err != nil { - return fmt.Errorf("notify: delete account %s for project %s: %w", acct.ID, projectID, err) + // 2. Delete notify host + if err := p.client.deleteHost(ctx, host); err != nil { + p.logger.Warn("failed to delete notify host", + "host", host, + "project_id", projectID, + "error", err, + ) } + + // 3. Delete Resend domain + if p.resend != nil && resendDomainID != "" { + if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil { + p.logger.Warn("failed to delete resend domain", + "domain_id", resendDomainID, + "project_id", projectID, + "error", err, + ) + } + } + + // 4. Delete Cloudflare DNS records for DKIM/SPF. + // Names follow Resend's standard format: + // DKIM: resend._domainkey.{host} + // SPF MX: send.{host} + // SPF TXT: send.{host} + // If Resend changes their record naming, manual cleanup may be needed. + if p.dns != nil { + dkimName := "resend._domainkey." + host + if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil { + p.logger.Warn("failed to delete DKIM DNS record", + "name", dkimName, + "project_id", projectID, + "error", err, + ) + } + spfSendName := "send." + host + if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil { + p.logger.Warn("failed to delete SPF MX DNS record", + "name", spfSendName, + "project_id", projectID, + "error", err, + ) + } + if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil { + p.logger.Warn("failed to delete SPF TXT DNS record", + "name", spfSendName, + "project_id", projectID, + "error", err, + ) + } + } + + p.logger.Info("notify resources deleted", "project_id", projectID, "host", host) return nil } // GetProjectNotify returns notify credentials for the project, or nil if not provisioned. -// Note: APIKey cannot be retrieved after creation — returns empty string. +// Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not +// recoverable after provisioning. Use this method solely to check whether provisioning +// has already occurred (non-nil return = already provisioned). func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { acct, err := p.findAccountByProject(ctx, projectID) if err != nil { @@ -123,8 +276,6 @@ func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (* return &domain.NotifyCredentials{ ProjectID: projectID, AccountID: acct.ID, - Host: p.host, - From: p.from, CreatedAt: acct.CreatedAt, }, nil } @@ -153,3 +304,25 @@ func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string } return nil, nil } + +// bestEffortDeleteHost deletes the notify host, logging on failure. +func (p *Provisioner) bestEffortDeleteHost(ctx context.Context, host, projectID string) { + if err := p.client.deleteHost(ctx, host); err != nil { + p.logger.Warn("failed to clean up notify host after provisioning failure", + "host", host, + "project_id", projectID, + "error", err, + ) + } +} + +// bestEffortDeleteAccount deletes the notify account, logging on failure. +func (p *Provisioner) bestEffortDeleteAccount(ctx context.Context, accountID, projectID string) { + if err := p.client.deleteAccount(ctx, accountID); err != nil { + p.logger.Warn("failed to clean up notify account after provisioning failure", + "account_id", accountID, + "project_id", projectID, + "error", err, + ) + } +} diff --git a/internal/adapter/notify/provisioner_test.go b/internal/adapter/notify/provisioner_test.go new file mode 100644 index 0000000..bda4201 --- /dev/null +++ b/internal/adapter/notify/provisioner_test.go @@ -0,0 +1,511 @@ +package notify + +import ( + "context" + "errors" + "log/slog" + "os" + "testing" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// --- mock implementations --- + +// mockAdminClient is a controllable implementation of notifyAdminAPI. +type mockAdminClient struct { + // accounts simulates the account registry + accounts []accountResponse + + // Configurable errors per operation + createHostErr error + deleteHostErr error + createProviderErr error + createFromAddressErr error + createAccountErr error + createSendKeyErr error + grantHostAccessErr error + deleteAccountErr error + listAccountsErr error + + // Call counters + createHostCalls int + deleteHostCalls int + createProviderCalls int + createFromAddressCalls int + createAccountCalls int + createSendKeyCalls int + grantHostAccessCalls int + deleteAccountCalls int +} + +func (m *mockAdminClient) createHost(_ context.Context, _, _ string) error { + m.createHostCalls++ + return m.createHostErr +} + +func (m *mockAdminClient) deleteHost(_ context.Context, _ string) error { + m.deleteHostCalls++ + return m.deleteHostErr +} + +func (m *mockAdminClient) createProvider(_ context.Context, _, _ string, _ map[string]string, _, _, _ int) error { + m.createProviderCalls++ + return m.createProviderErr +} + +func (m *mockAdminClient) createFromAddress(_ context.Context, _, _, _ string) error { + m.createFromAddressCalls++ + return m.createFromAddressErr +} + +func (m *mockAdminClient) createAccount(_ context.Context, name string) (*accountResponse, error) { + m.createAccountCalls++ + if m.createAccountErr != nil { + return nil, m.createAccountErr + } + acct := &accountResponse{ID: "acct-" + name, Name: name, CreatedAt: time.Now()} + m.accounts = append(m.accounts, *acct) + return acct, nil +} + +func (m *mockAdminClient) createSendKey(_ context.Context, accountID, name string) (*apiKeyResponse, error) { + m.createSendKeyCalls++ + if m.createSendKeyErr != nil { + return nil, m.createSendKeyErr + } + return &apiKeyResponse{ + ID: 1, + Key: "notify_send_test_key", + KeyType: "send", + AccountID: accountID, + Name: name, + }, nil +} + +func (m *mockAdminClient) grantHostAccess(_ context.Context, _, _ string) error { + m.grantHostAccessCalls++ + return m.grantHostAccessErr +} + +func (m *mockAdminClient) deleteAccount(_ context.Context, _ string) error { + m.deleteAccountCalls++ + return m.deleteAccountErr +} + +func (m *mockAdminClient) listAccounts(_ context.Context) ([]accountResponse, error) { + if m.listAccountsErr != nil { + return nil, m.listAccountsErr + } + return m.accounts, nil +} + +// mockResendClient is a controllable implementation of resendAPI. +type mockResendClient struct { + createDomainErr error + verifyDomainErr error + deleteDomainErr error + + createDomainCalls int + verifyDomainCalls int + deleteDomainCalls int + + domainID string + dnsRecords []resendDNSRecord +} + +func (m *mockResendClient) createDomain(_ context.Context, _, _ string) (string, []resendDNSRecord, error) { + m.createDomainCalls++ + if m.createDomainErr != nil { + return "", nil, m.createDomainErr + } + id := m.domainID + if id == "" { + id = "resend-domain-id-123" + } + return id, m.dnsRecords, nil +} + +func (m *mockResendClient) verifyDomain(_ context.Context, _ string) error { + m.verifyDomainCalls++ + return m.verifyDomainErr +} + +func (m *mockResendClient) deleteDomain(_ context.Context, _ string) error { + m.deleteDomainCalls++ + return m.deleteDomainErr +} + +// mockDNS is a controllable implementation of port.DNSProvider. +type mockDNS struct { + upsertErr error + deleteByNameErr error + upsertCalls []domain.DNSRecord + deleteByNameCalls []struct{ recordType, name string } +} + +func (m *mockDNS) UpsertRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) { + m.upsertCalls = append(m.upsertCalls, record) + if m.upsertErr != nil { + return nil, m.upsertErr + } + return &record, nil +} + +func (m *mockDNS) DeleteRecordByName(_ context.Context, recordType, name string) error { + m.deleteByNameCalls = append(m.deleteByNameCalls, struct{ recordType, name string }{recordType, name}) + return m.deleteByNameErr +} + +func (m *mockDNS) CreateRecord(_ context.Context, r domain.DNSRecord) (*domain.DNSRecord, error) { + return &r, nil +} +func (m *mockDNS) UpdateRecord(_ context.Context, _ string, r domain.DNSRecord) (*domain.DNSRecord, error) { + return &r, nil +} +func (m *mockDNS) DeleteRecord(_ context.Context, _ string) error { return nil } +func (m *mockDNS) GetRecord(_ context.Context, _ string) (*domain.DNSRecord, error) { return nil, nil } +func (m *mockDNS) ListRecords(_ context.Context, _ string) ([]*domain.DNSRecord, error) { + return nil, nil +} +func (m *mockDNS) FindRecord(_ context.Context, _, _ string) (*domain.DNSRecord, error) { + return nil, nil +} + +// --- helpers --- + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +// newProvisionerWithDeps creates a Provisioner with injected dependencies for testing. +func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain string, logger *slog.Logger) *Provisioner { + if baseDomain == "" { + baseDomain = "threesix.ai" + } + return &Provisioner{ + client: client, + resend: resend, + resendAPIKey: resendAPIKey, + dns: dns, + baseDomain: baseDomain, + logger: logger, + } +} + +func newTestProvisioner(admin *mockAdminClient, resend *mockResendClient, dns *mockDNS) *Provisioner { + var r resendAPI + if resend != nil { + r = resend + } + var d interface { + UpsertRecord(context.Context, domain.DNSRecord) (*domain.DNSRecord, error) + DeleteRecordByName(context.Context, string, string) error + CreateRecord(context.Context, domain.DNSRecord) (*domain.DNSRecord, error) + UpdateRecord(context.Context, string, domain.DNSRecord) (*domain.DNSRecord, error) + DeleteRecord(context.Context, string) error + GetRecord(context.Context, string) (*domain.DNSRecord, error) + ListRecords(context.Context, string) ([]*domain.DNSRecord, error) + FindRecord(context.Context, string, string) (*domain.DNSRecord, error) + } + if dns != nil { + d = dns + } + return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", testLogger()) +} + +// --- tests --- + +func TestCreateProjectNotify_Success(t *testing.T) { + admin := &mockAdminClient{} + resend := &mockResendClient{ + dnsRecords: []resendDNSRecord{ + {Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1; p=..."}, + {Record: "MX", Name: "send", Value: "feedback-smtp.us-east-1.amazonses.com", Priority: 10}, + }, + } + dns := &mockDNS{} + p := newTestProvisioner(admin, resend, dns) + + creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if creds.Host != "mail.happy-fox.test.example" { + t.Errorf("expected host mail.happy-fox.test.example, got %s", creds.Host) + } + if creds.From != "noreply@mail.happy-fox.test.example" { + t.Errorf("expected from noreply@mail.happy-fox.test.example, got %s", creds.From) + } + if creds.APIKey != "notify_send_test_key" { + t.Errorf("expected send key, got %s", creds.APIKey) + } + if creds.ResendDomainID != "resend-domain-id-123" { + t.Errorf("expected resend domain id, got %s", creds.ResendDomainID) + } + if creds.ProjectID != "proj-123" { + t.Errorf("expected project id proj-123, got %s", creds.ProjectID) + } + + // Verify all steps executed + if admin.createHostCalls != 1 { + t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls) + } + if admin.createProviderCalls != 1 { + t.Errorf("expected 1 createProvider call, got %d", admin.createProviderCalls) + } + if admin.createFromAddressCalls != 1 { + t.Errorf("expected 1 createFromAddress call, got %d", admin.createFromAddressCalls) + } + if admin.createAccountCalls != 1 { + t.Errorf("expected 1 createAccount call, got %d", admin.createAccountCalls) + } + if admin.createSendKeyCalls != 1 { + t.Errorf("expected 1 createSendKey call, got %d", admin.createSendKeyCalls) + } + if resend.createDomainCalls != 1 { + t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls) + } + + // Verify 2 DNS records were upserted + if len(dns.upsertCalls) != 2 { + t.Errorf("expected 2 DNS upserts, got %d", len(dns.upsertCalls)) + } +} + +func TestCreateProjectNotify_RollsBackOnProviderFailure(t *testing.T) { + admin := &mockAdminClient{ + createProviderErr: errors.New("provider setup failed"), + } + p := newTestProvisioner(admin, &mockResendClient{}, nil) + + _, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err == nil { + t.Fatal("expected error, got nil") + } + + if admin.deleteHostCalls != 1 { + t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls) + } +} + +func TestCreateProjectNotify_RollsBackOnFromAddressFailure(t *testing.T) { + admin := &mockAdminClient{ + createFromAddressErr: errors.New("from address failed"), + } + p := newTestProvisioner(admin, &mockResendClient{}, nil) + + _, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err == nil { + t.Fatal("expected error, got nil") + } + + if admin.deleteHostCalls != 1 { + t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls) + } + if admin.deleteAccountCalls != 0 { + t.Errorf("account not yet created, should not delete account") + } +} + +func TestCreateProjectNotify_RollsBackOnAccountFailure(t *testing.T) { + admin := &mockAdminClient{ + createAccountErr: errors.New("account creation failed"), + } + p := newTestProvisioner(admin, &mockResendClient{}, nil) + + _, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err == nil { + t.Fatal("expected error, got nil") + } + + if admin.deleteHostCalls != 1 { + t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls) + } +} + +func TestCreateProjectNotify_RollsBackOnSendKeyFailure(t *testing.T) { + admin := &mockAdminClient{ + createSendKeyErr: errors.New("send key creation failed"), + } + p := newTestProvisioner(admin, &mockResendClient{}, nil) + + _, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err == nil { + t.Fatal("expected error, got nil") + } + + if admin.deleteAccountCalls != 1 { + t.Errorf("expected account rollback, got %d deleteAccount calls", admin.deleteAccountCalls) + } + if admin.deleteHostCalls != 1 { + t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls) + } +} + +func TestCreateProjectNotify_ResendFailureIsNonFatal(t *testing.T) { + admin := &mockAdminClient{} + resend := &mockResendClient{ + createDomainErr: errors.New("resend API down"), + } + p := newTestProvisioner(admin, resend, nil) + + creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err != nil { + t.Fatalf("resend failure should be non-fatal, got error: %v", err) + } + if creds.ResendDomainID != "" { + t.Errorf("expected empty resend domain id on failure, got %s", creds.ResendDomainID) + } +} + +func TestCreateProjectNotify_WithoutResend_SkipsProviderAndDomain(t *testing.T) { + admin := &mockAdminClient{} + p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", testLogger()) + + creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err != nil { + t.Fatalf("expected no error without resend, got %v", err) + } + + if admin.createProviderCalls != 0 { + t.Errorf("createProvider should not be called without ResendAPIKey, got %d calls", admin.createProviderCalls) + } + if creds.ResendDomainID != "" { + t.Errorf("expected no resend domain id without ResendAPIKey, got %s", creds.ResendDomainID) + } +} + +func TestCreateProjectNotify_DNSFailureIsNonFatal(t *testing.T) { + admin := &mockAdminClient{} + resend := &mockResendClient{ + dnsRecords: []resendDNSRecord{{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1"}}, + } + dns := &mockDNS{upsertErr: errors.New("cloudflare down")} + p := newTestProvisioner(admin, resend, dns) + + creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox") + if err != nil { + t.Fatalf("DNS failure should be non-fatal, got error: %v", err) + } + // Project still usable; DNS will need manual fix + if creds.Host != "mail.happy-fox.test.example" { + t.Errorf("creds should still be returned on DNS failure, got host %s", creds.Host) + } +} + +func TestDeleteProjectNotify_Success(t *testing.T) { + admin := &mockAdminClient{ + accounts: []accountResponse{ + {ID: "acct-001", Name: "project-proj-123"}, + }, + } + resend := &mockResendClient{} + dns := &mockDNS{} + p := newTestProvisioner(admin, resend, dns) + + err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if admin.deleteAccountCalls != 1 { + t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls) + } + if admin.deleteHostCalls != 1 { + t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls) + } + if resend.deleteDomainCalls != 1 { + t.Errorf("expected 1 deleteDomain call, got %d", resend.deleteDomainCalls) + } + // 3 DNS records: DKIM TXT, SPF MX, SPF TXT + if len(dns.deleteByNameCalls) != 3 { + t.Errorf("expected 3 DNS deleteByName calls, got %d", len(dns.deleteByNameCalls)) + } +} + +func TestDeleteProjectNotify_NoResendDomainID_SkipsDomainDeletion(t *testing.T) { + admin := &mockAdminClient{ + accounts: []accountResponse{ + {ID: "acct-001", Name: "project-proj-123"}, + }, + } + resend := &mockResendClient{} + p := newTestProvisioner(admin, resend, nil) + + err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if resend.deleteDomainCalls != 0 { + t.Errorf("should skip domain deletion when resendDomainID is empty") + } +} + +func TestDeleteProjectNotify_AccountNotFound_ContinuesCleanup(t *testing.T) { + // Account doesn't exist (never provisioned or already deleted) + admin := &mockAdminClient{accounts: []accountResponse{}} + resend := &mockResendClient{} + p := newTestProvisioner(admin, resend, nil) + + err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123") + if err != nil { + t.Fatalf("expected no error when account not found, got %v", err) + } + // Should still attempt host and Resend domain deletion + if admin.deleteHostCalls != 1 { + t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls) + } + if resend.deleteDomainCalls != 1 { + t.Errorf("expected 1 deleteDomain call, got %d", resend.deleteDomainCalls) + } +} + +func TestGetProjectNotify_NotProvisioned(t *testing.T) { + admin := &mockAdminClient{} + p := newTestProvisioner(admin, nil, nil) + + creds, err := p.GetProjectNotify(context.Background(), "proj-123") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if creds != nil { + t.Errorf("expected nil for unprovisioned project, got %+v", creds) + } +} + +func TestGetProjectNotify_AlreadyProvisioned(t *testing.T) { + admin := &mockAdminClient{ + accounts: []accountResponse{ + {ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()}, + }, + } + p := newTestProvisioner(admin, nil, nil) + + creds, err := p.GetProjectNotify(context.Background(), "proj-123") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if creds == nil { + t.Fatal("expected non-nil credentials for provisioned project") + } + if creds.AccountID != "acct-001" { + t.Errorf("expected account id acct-001, got %s", creds.AccountID) + } +} + +func TestCreateProjectNotify_HostUsesBaseDomain(t *testing.T) { + admin := &mockAdminClient{} + p := newProvisionerWithDeps(admin, nil, "", nil, "staging.example.com", testLogger()) + + creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "some-slug") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if creds.Host != "mail.some-slug.staging.example.com" { + t.Errorf("expected host to use baseDomain, got %s", creds.Host) + } +} diff --git a/internal/adapter/notify/resend_client.go b/internal/adapter/notify/resend_client.go new file mode 100644 index 0000000..ce92d4b --- /dev/null +++ b/internal/adapter/notify/resend_client.go @@ -0,0 +1,128 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const resendBaseURL = "https://api.resend.com" + +// resendAPI is the interface Provisioner uses to call the Resend API. +// Extracted for testability. +type resendAPI interface { + createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error) + verifyDomain(ctx context.Context, domainID string) error + deleteDomain(ctx context.Context, domainID string) error +} + +// resendClient calls the Resend API for domain management. +type resendClient struct { + apiKey string + httpClient *http.Client +} + +// resendDNSRecord is a DNS record returned by Resend after domain creation. +// The "record" JSON field contains the DNS record type (e.g., "TXT", "MX"). +type resendDNSRecord struct { + Record string `json:"record"` // DNS record type: "TXT", "MX", "CNAME" + Name string `json:"name"` // relative name (e.g., "resend._domainkey") + Value string `json:"value"` // record content + Priority int `json:"priority,omitempty"` +} + +// resendCreateDomainResponse is the shape returned by POST /domains. +type resendCreateDomainResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Records []resendDNSRecord `json:"records"` +} + +func newResendClient(apiKey string) *resendClient { + return &resendClient{ + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// createDomain creates a new Resend domain and returns the domain ID and DNS records to set. +func (r *resendClient) createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error) { + payload := map[string]string{"name": name, "region": region} + respBody, err := r.doRequest(ctx, http.MethodPost, "/domains", payload) + if err != nil { + return "", nil, fmt.Errorf("create resend domain %s: %w", name, err) + } + + var resp resendCreateDomainResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return "", nil, fmt.Errorf("unmarshal resend domain response: %w", err) + } + return resp.ID, resp.Records, nil +} + +// verifyDomain triggers domain verification on Resend. +func (r *resendClient) verifyDomain(ctx context.Context, domainID string) error { + _, err := r.doRequest(ctx, http.MethodPost, "/domains/"+domainID+"/verify", nil) + if err != nil { + return fmt.Errorf("verify resend domain %s: %w", domainID, err) + } + return nil +} + +// deleteDomain removes a Resend domain by ID. +func (r *resendClient) deleteDomain(ctx context.Context, domainID string) error { + _, err := r.doRequest(ctx, http.MethodDelete, "/domains/"+domainID, nil) + if err != nil { + return fmt.Errorf("delete resend domain %s: %w", domainID, err) + } + return nil +} + +// doRequest executes an HTTP request against the Resend API. +func (r *resendClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) { + var reqBody io.Reader + if bodyData != nil { + jsonBody, err := json.Marshal(bodyData) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, resendBaseURL+path, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+r.apiKey) + if bodyData != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := r.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http do: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + + return nil, fmt.Errorf("resend API error (HTTP %d): %s", resp.StatusCode, string(respBody)) +} diff --git a/internal/adapter/postgres/apikey_repository.go b/internal/adapter/postgres/apikey_repository.go index 4ca6a4e..40b3eba 100644 --- a/internal/adapter/postgres/apikey_repository.go +++ b/internal/adapter/postgres/apikey_repository.go @@ -203,6 +203,118 @@ func (r *APIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyI return err } +// Update applies a partial update to an API key. +func (r *APIKeyRepository) Update(ctx context.Context, id domain.APIKeyID, update port.APIKeyUpdate) error { + // Build SET clauses dynamically based on non-nil fields + setClauses := []string{} + args := []any{} + argIdx := 1 + + if update.Name != nil { + setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) + args = append(args, *update.Name) + argIdx++ + } + if update.Scopes != nil { + setClauses = append(setClauses, fmt.Sprintf("scopes = $%d", argIdx)) + args = append(args, pq.Array(scopesToStrings(update.Scopes))) + argIdx++ + } + if update.ProjectIDs != nil { + setClauses = append(setClauses, fmt.Sprintf("project_ids = $%d", argIdx)) + args = append(args, pq.Array(projectIDsToStrings(*update.ProjectIDs))) + argIdx++ + } + if update.AllowedIPs != nil { + setClauses = append(setClauses, fmt.Sprintf("allowed_ips = $%d", argIdx)) + args = append(args, pq.Array(*update.AllowedIPs)) + argIdx++ + } + if update.ExpiresAt != nil { + setClauses = append(setClauses, fmt.Sprintf("expires_at = $%d", argIdx)) + args = append(args, *update.ExpiresAt) + argIdx++ + } + + if len(setClauses) == 0 { + return nil // nothing to update + } + + args = append(args, string(id)) + query := fmt.Sprintf("UPDATE api_keys SET %s WHERE id = $%d AND revoked_at IS NULL", + joinStrings(setClauses, ", "), argIdx) + + result, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("update key: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrKeyNotFound + } + return nil +} + +// ListByProjectID returns all active keys that have the given project ID in their project_ids. +func (r *APIKeyRepository) ListByProjectID(ctx context.Context, projectID domain.ProjectID) ([]*domain.APIKey, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, key_prefix, scopes, project_ids, allowed_ips, created_at, expires_at, last_used_at, revoked_at, created_by + FROM api_keys + WHERE $1 = ANY(project_ids) AND revoked_at IS NULL + ORDER BY created_at DESC + `, string(projectID)) + if err != nil { + return nil, fmt.Errorf("query keys by project: %w", err) + } + defer func() { _ = rows.Close() }() + + var keys []*domain.APIKey + for rows.Next() { + var ( + key domain.APIKey + id string + scopeStrings []string + projectIDs []string + ) + if err := rows.Scan( + &id, + &key.Name, + &key.KeyPrefix, + pq.Array(&scopeStrings), + pq.Array(&projectIDs), + pq.Array(&key.AllowedIPs), + &key.CreatedAt, + &key.ExpiresAt, + &key.LastUsedAt, + &key.RevokedAt, + &key.CreatedBy, + ); err != nil { + return nil, fmt.Errorf("scan key: %w", err) + } + key.ID = domain.APIKeyID(id) + key.Scopes = scopesFromStrings(scopeStrings) + key.ProjectIDs = projectIDsFromStrings(projectIDs) + keys = append(keys, &key) + } + + return keys, nil +} + +// joinStrings joins string slices with a separator (avoids importing strings in this file). +func joinStrings(ss []string, sep string) string { + result := "" + for i, s := range ss { + if i > 0 { + result += sep + } + result += s + } + return result +} + // Helper functions for scope conversion func scopesToStrings(scopes []domain.Scope) []string { ss := make([]string, len(scopes)) diff --git a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl index bcf1243..7408f45 100644 --- a/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl +++ b/internal/adapter/templates/templates/components/app-react/src/App.tsx.tmpl @@ -1,4 +1,5 @@ -import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { Routes, Route, useLocation, useNavigate, useSearchParams, Link } from 'react-router-dom'; import { AuthProvider, useAuth, ProtectedRoute } from '@{{PROJECT_NAME}}/auth'; import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout'; import { @@ -17,8 +18,14 @@ import { MessageSquare, Sparkles, Loader2, + AlertCircle, } from '@{{PROJECT_NAME}}/ui'; import { LoginPage } from './pages/LoginPage'; +import { RegisterPage } from './pages/RegisterPage'; +import { ForgotPasswordPage } from './pages/ForgotPasswordPage'; +import { ResetPasswordPage } from './pages/ResetPasswordPage'; +import { VerifyEmailPage } from './pages/VerifyEmailPage'; +import { SessionsPage } from './pages/SessionsPage'; import { ChatPage } from './pages/ChatPage'; import { GeneratePage } from './pages/GeneratePage'; import { MediaPage } from './pages/MediaPage'; @@ -41,6 +48,8 @@ const pageTitles: Record = { '/analytics': 'Analytics', '/users': 'Users', '/settings': 'Settings', + '/settings/sessions': 'Sessions', + '/settings/verify-email': 'Verify Email', }; function DashboardPage() { @@ -287,6 +296,64 @@ function LoadingScreen() { ); } +function MagicLinkCallbackPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginWithMagicLink } = useAuth(); + const [error, setError] = useState(null); + const [verifying, setVerifying] = useState(true); + + useEffect(() => { + const token = searchParams.get('token'); + const email = searchParams.get('email'); + + if (!token || !email) { + setError('Invalid magic link. Missing token or email.'); + setVerifying(false); + return; + } + + loginWithMagicLink({ email, token }) + .then(() => { + navigate('/', { replace: true }); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Magic link verification failed.'); + setVerifying(false); + }); + }, [searchParams, loginWithMagicLink, navigate]); + + return ( +
+ + + {verifying ? 'Verifying Magic Link' : 'Verification Failed'} + + {verifying + ? 'Please wait while we verify your magic link...' + : 'We could not verify your magic link.'} + + + + {verifying ? ( + + ) : ( + <> +
+ +

{error}

+
+ + + + + )} +
+
+
+ ); +} + function AppLayout() { const location = useLocation(); const navigate = useNavigate(); @@ -331,6 +398,8 @@ function AppLayout() { } /> } /> } /> + } /> + } /> ); @@ -343,6 +412,10 @@ function AppRoutes() { return ( } /> + } /> + } /> + } /> + } /> + ); diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/ForgotPasswordPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/ForgotPasswordPage.tsx.tmpl new file mode 100644 index 0000000..80e882e --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/pages/ForgotPasswordPage.tsx.tmpl @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + FormField, + Alert, + AlertDescription, + Loader2, +} from '@{{PROJECT_NAME}}/ui'; + +export function ForgotPasswordPage() { + const [isLoading, setIsLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + const apiPrefix = import.meta.env.VITE_API_URL || ''; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error?.message || body.message || 'Request failed'); + } + + setSent(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Reset your password + + {sent + ? 'Check your email for a reset link' + : "Enter your email and we'll send you a reset link"} + + + + {!sent ? ( +
+ + {error && ( + + {error} + + )} + + + + + + + + Back to sign in + + +
+ ) : ( + +

+ If an account exists with that email, you will receive a password reset link. +

+

+ In dev mode, check the server console for the reset token. +

+ + Back to sign in + +
+ )} +
+
+ ); +} diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl index f519b8a..cb91fba 100644 --- a/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl +++ b/internal/adapter/templates/templates/components/app-react/src/pages/LoginPage.tsx.tmpl @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useAuth } from '@{{PROJECT_NAME}}/auth'; import { Button, @@ -17,17 +17,22 @@ import { } from '@{{PROJECT_NAME}}/ui'; import { isApiClientError } from '@{{PROJECT_NAME}}/api-client'; +type LoginTab = 'password' | 'otp' | 'magic-link'; + export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); - const { login, isLoading } = useAuth(); + const { login, sendOTP, loginWithOTP, sendMagicLink, isLoading } = useAuth(); const { setErrors, clearErrors, getError } = useFormErrors(); const [generalError, setGeneralError] = useState(null); + const [activeTab, setActiveTab] = useState('password'); + const [otpSent, setOtpSent] = useState(false); + const [otpEmail, setOtpEmail] = useState(''); + const [magicLinkSent, setMagicLinkSent] = useState(false); - // Get the redirect path from location state, default to dashboard const from = (location.state as { from?: string })?.from || '/'; - const handleSubmit = async (e: React.FormEvent) => { + const handlePasswordLogin = async (e: React.FormEvent) => { e.preventDefault(); clearErrors(); setGeneralError(null); @@ -46,12 +51,70 @@ export function LoginPage() { } else { setGeneralError(error.message); } + } else if (error instanceof Error) { + setGeneralError(error.message); } else { - setGeneralError('An unexpected error occurred. Please try again.'); + setGeneralError('An unexpected error occurred.'); } } }; + const handleSendOTP = async (e: React.FormEvent) => { + e.preventDefault(); + clearErrors(); + setGeneralError(null); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + + try { + await sendOTP(email); + setOtpEmail(email); + setOtpSent(true); + } catch (error) { + setGeneralError(error instanceof Error ? error.message : 'Failed to send code'); + } + }; + + const handleVerifyOTP = async (e: React.FormEvent) => { + e.preventDefault(); + clearErrors(); + setGeneralError(null); + + const formData = new FormData(e.currentTarget); + const code = formData.get('code') as string; + + try { + await loginWithOTP({ email: otpEmail, code }); + navigate(from, { replace: true }); + } catch (error) { + setGeneralError(error instanceof Error ? error.message : 'Invalid code'); + } + }; + + const handleSendMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + clearErrors(); + setGeneralError(null); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + + try { + await sendMagicLink(email); + setMagicLinkSent(true); + } catch (error) { + setGeneralError(error instanceof Error ? error.message : 'Failed to send link'); + } + }; + + const tabClass = (tab: LoginTab) => + `flex-1 py-2 text-sm font-medium text-center rounded-md transition-colors ${ + activeTab === tab + ? 'bg-[var(--surface-100)] text-[var(--text-primary)] shadow-sm' + : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]' + }`; + return (
@@ -60,49 +123,147 @@ export function LoginPage() { Sign in to your {{PROJECT_NAME}} account -
- - {generalError && ( - - {generalError} - - )} + + {/* Tab switcher */} +
+ + + +
- + {generalError && ( + + {generalError} + + )} - -
+ {/* Password tab */} + {activeTab === 'password' && ( + + + +
+ + Forgot password? + +
+ + + )} - - + {/* OTP tab */} + {activeTab === 'otp' && !otpSent && ( +
+ + + + )} -

- Demo accounts: test@example.com / password123 -
- or admin@example.com / admin123 -

-
- + {activeTab === 'otp' && otpSent && ( +
+

+ A 6-digit code was sent to {otpEmail} +

+ + + + + )} + + {/* Magic Link tab */} + {activeTab === 'magic-link' && !magicLinkSent && ( +
+ + + + )} + + {activeTab === 'magic-link' && magicLinkSent && ( +
+

Check your email

+

+ We sent a sign-in link to your email. Click it to continue. +

+

+ In dev mode, check the server console for the link token. +

+
+ )} +
+ + +

+ Don't have an account?{' '} + + Sign up + +

+
); diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl index 93378fb..a44a88f 100644 --- a/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl +++ b/internal/adapter/templates/templates/components/app-react/src/pages/MediaPage.tsx.tmpl @@ -61,15 +61,15 @@ export function MediaPage() { fetchMedia(); }, [fetchMedia]); - const handleDelete = useCallback(async (path: string) => { + const handleDelete = useCallback(async (id: string) => { setDeleteError(null); try { - const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${path}`, { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${id}`, { method: 'DELETE', headers: { ...authHeaders }, }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); - setItems((prev) => prev.filter((item) => item.path !== path)); + setItems((prev) => prev.filter((item) => item.id !== id)); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Delete failed'); } diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/RegisterPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/RegisterPage.tsx.tmpl new file mode 100644 index 0000000..0b572cb --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/pages/RegisterPage.tsx.tmpl @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '@{{PROJECT_NAME}}/auth'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + FormField, + useFormErrors, + Alert, + AlertDescription, + Loader2, +} from '@{{PROJECT_NAME}}/ui'; + +export function RegisterPage() { + const navigate = useNavigate(); + const { register, isLoading } = useAuth(); + const { setErrors, clearErrors, getError } = useFormErrors(); + const [generalError, setGeneralError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearErrors(); + setGeneralError(null); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirmPassword') as string; + const name = formData.get('name') as string; + + if (password !== confirmPassword) { + setErrors({ confirmPassword: 'Passwords do not match' }); + return; + } + + try { + await register({ email, password, name }); + navigate('/', { replace: true }); + } catch (error) { + if (error instanceof Error) { + setGeneralError(error.message); + } else { + setGeneralError('An unexpected error occurred.'); + } + } + }; + + return ( +
+ + + Create an account + Get started with {{PROJECT_NAME}} + + +
+ + {generalError && ( + + {generalError} + + )} + + + + + + + + + + + + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+
+ ); +} diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/ResetPasswordPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/ResetPasswordPage.tsx.tmpl new file mode 100644 index 0000000..f4dd877 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/pages/ResetPasswordPage.tsx.tmpl @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + FormField, + useFormErrors, + Alert, + AlertDescription, + Loader2, +} from '@{{PROJECT_NAME}}/ui'; + +export function ResetPasswordPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { setErrors, clearErrors, getError } = useFormErrors(); + const [isLoading, setIsLoading] = useState(false); + const [generalError, setGeneralError] = useState(null); + + const token = searchParams.get('token') || ''; + const email = searchParams.get('email') || ''; + const apiPrefix = import.meta.env.VITE_API_URL || ''; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearErrors(); + setGeneralError(null); + + const formData = new FormData(e.currentTarget); + const newPassword = formData.get('newPassword') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (newPassword !== confirmPassword) { + setErrors({ confirmPassword: 'Passwords do not match' }); + return; + } + + setIsLoading(true); + + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, token, newPassword }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error?.message || body.message || 'Reset failed'); + } + + navigate('/login', { state: { message: 'Password reset successfully. Please sign in.' } }); + } catch (err) { + setGeneralError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + if (!token || !email) { + return ( +
+ + + Invalid reset link + This password reset link is missing required parameters. + + + + Request a new reset link + + + +
+ ); + } + + return ( +
+ + + Set new password + Enter your new password below + + +
+ + {generalError && ( + + {generalError} + + )} + + + + + + + + + + Back to sign in + + +
+
+
+ ); +} diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/SessionsPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/SessionsPage.tsx.tmpl new file mode 100644 index 0000000..e260c70 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/pages/SessionsPage.tsx.tmpl @@ -0,0 +1,178 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAuth, type Session } from '@{{PROJECT_NAME}}/auth'; +import { + Button, + Card, + CardContent, + Badge, + Alert, + AlertDescription, + Loader2, + Trash2, +} from '@{{PROJECT_NAME}}/ui'; + +export function SessionsPage() { + const { getToken } = useAuth(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [revokingId, setRevokingId] = useState(null); + + const apiPrefix = import.meta.env.VITE_API_URL || ''; + + const authHeaders = useCallback(() => { + const token = getToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }; + }, [getToken]); + + const loadSessions = useCallback(async () => { + setError(null); + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, { + headers: authHeaders(), + }); + if (!res.ok) throw new Error('Failed to load sessions'); + const data = await res.json(); + setSessions(data.data || data || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load sessions'); + } finally { + setIsLoading(false); + } + }, [apiPrefix, authHeaders]); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + const revokeSession = async (sessionId: string) => { + setRevokingId(sessionId); + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions/${sessionId}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (!res.ok) throw new Error('Failed to revoke session'); + setSessions(prev => prev.filter(s => s.id !== sessionId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke session'); + } finally { + setRevokingId(null); + } + }; + + const revokeAll = async () => { + setIsLoading(true); + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (!res.ok) throw new Error('Failed to revoke sessions'); + await loadSessions(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke sessions'); + setIsLoading(false); + } + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMs / 3600000); + const diffDay = Math.floor(diffMs / 86400000); + + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + return date.toLocaleDateString(); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Active Sessions

+

+ Manage your active login sessions across devices. +

+
+ {sessions.length > 1 && ( + + )} +
+ + {error && ( + + {error} + + )} + +
+ {sessions.length === 0 ? ( + + + No active sessions found. + + + ) : ( + sessions.map((session) => ( + + +
+
+ + {session.deviceLabel || 'Unknown device'} + + {session.isCurrent && ( + Current + )} +
+
+ + {session.ipAddress || 'Unknown IP'} + + + Last active: {formatDate(session.lastActiveAt)} + +
+
+ + {!session.isCurrent && ( + + )} +
+
+ )) + )} +
+
+ ); +} diff --git a/internal/adapter/templates/templates/components/app-react/src/pages/VerifyEmailPage.tsx.tmpl b/internal/adapter/templates/templates/components/app-react/src/pages/VerifyEmailPage.tsx.tmpl new file mode 100644 index 0000000..770e9f7 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/src/pages/VerifyEmailPage.tsx.tmpl @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { useAuth } from '@{{PROJECT_NAME}}/auth'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + FormField, + Alert, + AlertDescription, + Loader2, + Check, +} from '@{{PROJECT_NAME}}/ui'; + +export function VerifyEmailPage() { + const { user, getToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [codeSent, setCodeSent] = useState(false); + const [verified, setVerified] = useState(false); + const [error, setError] = useState(null); + + const apiPrefix = import.meta.env.VITE_API_URL || ''; + + const authHeaders = () => { + const token = getToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }; + }; + + const handleSendCode = async () => { + setError(null); + setIsLoading(true); + + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email/send`, { + method: 'POST', + headers: authHeaders(), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error?.message || body.message || 'Failed to send code'); + } + + setCodeSent(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + const formData = new FormData(e.currentTarget); + const code = formData.get('code') as string; + + try { + const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ code }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error?.message || body.message || 'Verification failed'); + } + + setVerified(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + if (verified) { + return ( +
+ + +
+ +
+ Email verified + Your email address has been verified successfully. +
+
+
+ ); + } + + return ( +
+ + + Verify your email + + {codeSent + ? `Enter the 6-digit code sent to ${user?.email}` + : 'Verify your email address to access all features'} + + + + + {error && ( + + {error} + + )} + + {!codeSent ? ( +
+

+ We'll send a verification code to {user?.email} +

+ +

+ In dev mode, check the server console for the code. +

+
+ ) : ( +
+ + + + + )} +
+
+
+ ); +} diff --git a/internal/adapter/templates/templates/components/service/.env.example.tmpl b/internal/adapter/templates/templates/components/service/.env.example.tmpl index 40a9052..67150e3 100644 --- a/internal/adapter/templates/templates/components/service/.env.example.tmpl +++ b/internal/adapter/templates/templates/components/service/.env.example.tmpl @@ -15,7 +15,15 @@ LOG_FORMAT=text # Auth (set AUTH_ENABLED=true to require JWT for protected routes) AUTH_ENABLED=false -JWT_SECRET=dev-secret-change-in-production +JWT_SECRET=dev-secret-change-in-production # Required — server refuses to start with empty secret +REGISTRATION_ENABLED=true + +# Email delivery (notify service) +# When NOTIFY_URL is empty, auth codes are logged to stdout (dev mode). +# NOTIFY_URL=https://notify.threesix.ai +# NOTIFY_API_KEY=notify_send_xxx +# NOTIFY_HOST=myapp.threesix.ai +# NOTIFY_FROM=noreply@myapp.threesix.ai # Database (if needed) # Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned). diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl index 3c0447b..141800a 100644 --- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -3,6 +3,7 @@ package main import ( "context" + "embed" "flag" "fmt" "os" @@ -18,17 +19,24 @@ import ( "{{GO_MODULE}}/pkg/mediagen" mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters" "{{GO_MODULE}}/pkg/generation" + "{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/realtime" "{{GO_MODULE}}/pkg/storage" "{{GO_MODULE}}/pkg/textgen" textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters" + emailadapter "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/email" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/postgres" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service" ) +//go:embed migrations/*.sql +var migrationsFS embed.FS + func main() { // Parse flags exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit") @@ -52,17 +60,18 @@ func main() { // Create logger logger := logging.Default() - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Create SSE hub for async event delivery (generation progress, chat, etc.) sseHub := realtime.NewSSEHub(logger.Logger) // Initialize storage backend (before queue, since standalone queue handlers use it). // GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory). - port := fmt.Sprintf("%d", {{PORT}}) + listenPort := fmt.Sprintf("%d", {{PORT}}) var mediaStore storage.Store if bucket := os.Getenv("GCS_BUCKET"); bucket != "" { - gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger) + gcsStore, err := storage.NewGCSStore(ctx, bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger) if err != nil { logger.Error("failed to create GCS store", "error", err) os.Exit(1) @@ -71,29 +80,97 @@ func main() { mediaStore = gcsStore logger.Info("storage initialized (GCS)", "bucket", bucket) } else { - memStore := storage.NewMemoryStore("http://localhost:" + port + "/storage") + memStore := storage.NewMemoryStore("http://localhost:" + listenPort + "/storage") mediaStore = memStore logger.Info("storage initialized (in-memory dev mode)") } - // Select queue backend based on DATABASE_URL availability. - // With DATABASE_URL: DB queue + separate worker process (production) - // Without DATABASE_URL: in-memory queue + in-process handlers (development) + // Select backend based on DATABASE_URL availability. + // With DATABASE_URL: Postgres repos + DB queue (production) + // Without DATABASE_URL: in-memory repos + in-process AI (development) + exampleRepo := memory.NewExampleRepository() + var userRepo port.UserRepository + var sessionRepo port.SessionRepository + var authCodeRepo port.AuthCodeRepository + var mediaRepo port.MediaRepository var jobQueue queue.Producer + var jobReader queue.JobReader + if cfg.Database.URL != "" { - jobQueue = setupDBQueue(ctx, cfg, sseHub, logger) + // Connect to database (shared pool for queue + auth repos). + dbPool, err := database.Connect(ctx, cfg.Database.URL, database.Options{ + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + }) + if err != nil { + logger.Error("failed to connect to database", "error", err) + os.Exit(1) + } + logger.Info("connected to database") + + // Verify the database connection is actually alive before proceeding. + if err := dbPool.DB.PingContext(ctx); err != nil { + logger.Error("database health check failed", "error", err) + os.Exit(1) + } + logger.Info("database health check passed") + + // Run auth migrations. + if err := database.RunMigrations(ctx, dbPool, migrationsFS, "migrations"); err != nil { + logger.Error("failed to run auth migrations", "error", err) + os.Exit(1) + } + logger.Info("auth migrations complete") + + // Postgres-backed repositories. + userRepo = postgres.NewUserRepository(dbPool.DB) + sessionRepo = postgres.NewSessionRepository(dbPool.DB) + authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB) + mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB) + + // DB-backed queue. + jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger) } else { logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)") - jobQueue = setupStandaloneQueue(ctx, mediaStore, sseHub, logger) + userRepo = memory.NewUserRepository() + sessionRepo = memory.NewSessionRepository() + authCodeRepo = memory.NewAuthCodeRepository() + mediaRepo = memory.NewMediaRepository() + jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, sseHub, logger) } - // Create adapters (repositories) - exampleRepo := memory.NewExampleRepository() - userRepo := memory.NewUserRepository() + // Validate required config. + if cfg.JWTSecret == "" { + logger.Error("JWT_SECRET must be set (even in development)") + os.Exit(1) + } + + // Create email sender — notify service in production (NOTIFY_URL set), log-only for dev. + var emailSender port.EmailSender + if cfg.NotifyURL != "" { + notifyClient, err := notify.NewClient(notify.Config{ + URL: cfg.NotifyURL, + APIKey: cfg.NotifyAPIKey, + Logger: logger.Logger, + }) + if err != nil { + logger.Error("failed to create notify client", "error", err) + os.Exit(1) + } + emailSender = emailadapter.NewNotifySender(notifyClient, cfg.NotifyHost, cfg.NotifyFrom, logger) + logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost) + } else { + emailSender = emailadapter.NewLogSender(logger) + logger.Info("email sender initialized (log-only dev mode)") + } // Create services (business logic) exampleService := service.NewExampleService(exampleRepo, logger) - authService := service.NewAuthService(userRepo, cfg.JWTSecret, logger) + authService := service.NewAuthService( + userRepo, sessionRepo, authCodeRepo, emailSender, + cfg.JWTSecret, cfg.RegistrationEnabled, logger, + ) // Create application application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}})) @@ -108,29 +185,22 @@ func main() { ExampleService: exampleService, AuthService: authService, Queue: jobQueue, + JobReader: jobReader, SSEHub: sseHub, Store: mediaStore, + MediaRepo: mediaRepo, }) + // Start background cleanup of expired sessions and auth codes. + go runCleanup(ctx, sessionRepo, authCodeRepo, logger) + // Start server application.Run() } -// setupDBQueue initializes the production queue backend with database + optional Redis. -func setupDBQueue(ctx context.Context, cfg *config.Config, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer { - pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{ - MaxOpenConns: cfg.Database.MaxOpenConns, - MaxIdleConns: cfg.Database.MaxIdleConns, - ConnMaxLifetime: cfg.Database.ConnMaxLifetime, - }) - if err != nil { - logger.Error("failed to connect to database", "error", err) - os.Exit(1) - } - // Note: pool is not deferred here since it's needed for the lifetime of the process. - // The OS reclaims resources on exit. - logger.Info("connected to database") - +// setupDBQueue initializes the production queue backend using the shared database pool + optional Redis. +// Returns both Producer (for enqueue) and JobReader (for status polling). +func setupDBQueue(ctx context.Context, cfg *config.Config, pool *database.Pool, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) { if err := queue.RunMigrations(ctx, pool); err != nil { logger.Error("failed to run queue migrations", "error", err) os.Exit(1) @@ -162,12 +232,13 @@ func setupDBQueue(ctx context.Context, cfg *config.Config, sseHub *realtime.SSEH logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered") } - return jobQueue + return jobQueue, jobQueue } // setupStandaloneQueue initializes an in-memory queue with in-process AI handlers. // This mode requires no database or Redis — everything runs in a single process. -func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) queue.Producer { +// Returns both Producer (for enqueue) and JobReader (for status polling). +func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) { memQueue := queue.NewMemoryQueue(logger.Logger) // LocalPublisher delivers events directly to the SSE hub (no Redis needed). @@ -187,7 +258,7 @@ func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *real memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger)) } - return memQueue + return memQueue, memQueue } // initMediagen creates a mediagen manager from available AI provider credentials. @@ -290,3 +361,31 @@ func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager { logger.Info("textgen manager initialized") return mgr } + +// runCleanup periodically removes expired sessions and auth codes. +// Runs every hour. Stops when ctx is cancelled. +func runCleanup(ctx context.Context, sessions port.SessionRepository, codes port.AuthCodeRepository, logger *logging.Logger) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sessCount, err := sessions.DeleteExpired(ctx) + if err != nil { + logger.Warn("failed to cleanup expired sessions", "error", err) + } else if sessCount > 0 { + logger.Info("cleaned up expired sessions", "count", sessCount) + } + + codeCount, err := codes.DeleteExpired(ctx) + if err != nil { + logger.Warn("failed to cleanup expired auth codes", "error", err) + } else if codeCount > 0 { + logger.Info("cleaned up expired auth codes", "count", codeCount) + } + } + } +} diff --git a/internal/adapter/templates/templates/components/service/migrations/.gitkeep b/internal/adapter/templates/templates/components/service/cmd/server/migrations/.gitkeep similarity index 100% rename from internal/adapter/templates/templates/components/service/migrations/.gitkeep rename to internal/adapter/templates/templates/components/service/cmd/server/migrations/.gitkeep diff --git a/internal/adapter/templates/templates/components/service/cmd/server/migrations/001_create_users.sql b/internal/adapter/templates/templates/components/service/cmd/server/migrations/001_create_users.sql new file mode 100644 index 0000000..13877c8 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/cmd/server/migrations/001_create_users.sql @@ -0,0 +1,79 @@ +-- 001_create_users.sql +-- Auth tables for user management, sessions, and authentication codes. +-- Compatible with both PostgreSQL (local dev) and CockroachDB (production). + +-- Core user identity. Email is the primary identifier for humans. +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + email_verified BOOL NOT NULL DEFAULT FALSE, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Password credentials. Separate table because OAuth-only users have no password. +CREATE TABLE IF NOT EXISTS user_passwords ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- OAuth provider connections (Google, GitHub, Apple, etc.). +CREATE TABLE IF NOT EXISTS oauth_connections ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + provider_email TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + refresh_token TEXT NOT NULL DEFAULT '', + token_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (provider, provider_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_oauth_connections_user_id ON oauth_connections (user_id); + +-- Verification codes for OTP login, magic links, password reset, and email verification. +CREATE TABLE IF NOT EXISTS auth_codes ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + code TEXT NOT NULL, + purpose TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + ip_address TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_auth_codes_email_purpose ON auth_codes (email, purpose, expires_at) + WHERE used_at IS NULL; + +-- Sessions track where and when users are logged in. +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + device_label TEXT NOT NULL DEFAULT '', + last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id) + WHERE revoked_at IS NULL; + +-- User roles. Separate table so users can have multiple roles. +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, role) +); diff --git a/internal/adapter/templates/templates/components/service/cmd/server/migrations/002_add_indexes.sql b/internal/adapter/templates/templates/components/service/cmd/server/migrations/002_add_indexes.sql new file mode 100644 index 0000000..1013dbd --- /dev/null +++ b/internal/adapter/templates/templates/components/service/cmd/server/migrations/002_add_indexes.sql @@ -0,0 +1,9 @@ +-- 002_add_indexes.sql +-- Additional indexes for query performance. +-- Compatible with both PostgreSQL (local dev) and CockroachDB (production). + +-- Speed up OTP/magic-link/reset token lookup in FindValid queries. +-- The existing partial index on (email, purpose, expires_at) doesn't cover the code column, +-- so queries filtering on code must scan all matching rows. +CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes (code) + WHERE used_at IS NULL; diff --git a/internal/adapter/templates/templates/components/service/cmd/server/migrations/003_create_media_objects.sql b/internal/adapter/templates/templates/components/service/cmd/server/migrations/003_create_media_objects.sql new file mode 100644 index 0000000..8e96b5c --- /dev/null +++ b/internal/adapter/templates/templates/components/service/cmd/server/migrations/003_create_media_objects.sql @@ -0,0 +1,22 @@ +-- 003_create_media_objects.sql +-- Media metadata table for tracking uploads, generation provenance, and soft deletes. +-- Compatible with both PostgreSQL (local dev) and CockroachDB (production). + +CREATE TABLE IF NOT EXISTS media_objects ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + generation_job_id TEXT NOT NULL DEFAULT '', + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_media_objects_user_id ON media_objects (user_id, created_at DESC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_media_objects_generation_job ON media_objects (generation_job_id) + WHERE generation_job_id != ''; diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl new file mode 100644 index 0000000..c0972b8 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl @@ -0,0 +1,33 @@ +package email + +import "fmt" + +func subjectForPurpose(purpose string) string { + switch purpose { + case "login_otp": + return "Your login code" + case "magic_link": + return "Your sign-in link" + case "password_reset": + return "Reset your password" + case "email_verify": + return "Verify your email" + default: + return "Your authentication code" + } +} + +func bodyForPurpose(purpose, code string) string { + switch purpose { + case "login_otp": + return fmt.Sprintf("Your login code is: %s\n\nThis code expires in 10 minutes.", code) + case "magic_link": + return fmt.Sprintf("Click this link to sign in:\n\n%s\n\nThis link expires in 15 minutes.", code) + case "password_reset": + return fmt.Sprintf("Use this code to reset your password:\n\n%s\n\nThis code expires in 1 hour.", code) + case "email_verify": + return fmt.Sprintf("Your verification code is: %s\n\nThis code expires in 24 hours.", code) + default: + return fmt.Sprintf("Your code is: %s", code) + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/log.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/log.go.tmpl new file mode 100644 index 0000000..8dceb35 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/email/log.go.tmpl @@ -0,0 +1,32 @@ +// Package email provides email sending adapters for authentication flows. +package email + +import ( + "context" + + "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.EmailSender = (*LogSender)(nil) + +// LogSender logs emails to the console instead of sending them. +// Useful for development and testing when no notify service is configured. +type LogSender struct { + logger *logging.Logger +} + +// NewLogSender creates a new log-based email sender. +func NewLogSender(logger *logging.Logger) *LogSender { + return &LogSender{logger: logger.WithComponent("EmailSender")} +} + +func (s *LogSender) SendAuthCode(_ context.Context, email, code, purpose string) error { + s.logger.Warn("DEV MODE — email not sent, code logged", + "to", email, + "purpose", purpose, + "code", code, + ) + return nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl new file mode 100644 index 0000000..d3d0f79 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl @@ -0,0 +1,57 @@ +package email + +import ( + "context" + "fmt" + + "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/pkg/notify" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.EmailSender = (*NotifySender)(nil) + +// NotifySender sends emails via the orchard9 notify service. +type NotifySender struct { + client *notify.Client + host string + from string + logger *logging.Logger +} + +// NewNotifySender creates a new notify-backed email sender. +func NewNotifySender(client *notify.Client, host, from string, logger *logging.Logger) *NotifySender { + return &NotifySender{ + client: client, + host: host, + from: from, + logger: logger.WithComponent("EmailSender"), + } +} + +func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error { + resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{ + To: toEmail, + From: s.from, + Content: notify.Content{ + Subject: subjectForPurpose(purpose), + Text: bodyForPurpose(purpose, code), + }, + Meta: notify.Meta{ + Host: s.host, + Category: "critical", + Tags: []string{"auth", purpose}, + }, + Options: notify.Options{ + IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code), + }, + }) + if err != nil { + s.logger.Error("failed to send email via notify", "to", toEmail, "purpose", purpose, "error", err) + return fmt.Errorf("send email: %w", err) + } + + s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID) + return nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/auth_code.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/auth_code.go.tmpl new file mode 100644 index 0000000..c9bc42e --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/auth_code.go.tmpl @@ -0,0 +1,76 @@ +package memory + +import ( + "context" + "sync" + "time" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil) + +// AuthCodeRepository is an in-memory auth code store for standalone development. +type AuthCodeRepository struct { + mu sync.RWMutex + codes map[string]*domain.AuthCode +} + +// NewAuthCodeRepository creates a new in-memory auth code repository. +func NewAuthCodeRepository() *AuthCodeRepository { + return &AuthCodeRepository{ + codes: make(map[string]*domain.AuthCode), + } +} + +func (r *AuthCodeRepository) Create(_ context.Context, code *domain.AuthCode) error { + r.mu.Lock() + defer r.mu.Unlock() + + cp := *code + r.codes[code.ID] = &cp + return nil +} + +func (r *AuthCodeRepository) FindValid(_ context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, c := range r.codes { + if c.Email == email && c.Code == code && c.Purpose == purpose && c.IsValid() { + cp := *c + return &cp, nil + } + } + return nil, domain.ErrInvalidAuthCode +} + +func (r *AuthCodeRepository) MarkUsed(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + c, ok := r.codes[id] + if !ok { + return domain.ErrInvalidAuthCode + } + now := time.Now() + c.UsedAt = &now + return nil +} + +func (r *AuthCodeRepository) DeleteExpired(_ context.Context) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + deleted := 0 + for id, c := range r.codes { + if now.After(c.ExpiresAt) { + delete(r.codes, id) + deleted++ + } + } + return deleted, nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/media.go.tmpl new file mode 100644 index 0000000..b4e2458 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/media.go.tmpl @@ -0,0 +1,135 @@ +package memory + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.MediaRepository = (*MediaRepository)(nil) + +// MediaRepository is an in-memory media metadata store for standalone development. +type MediaRepository struct { + mu sync.RWMutex + objects map[domain.MediaObjectID]*domain.MediaObject + byPath map[string]domain.MediaObjectID +} + +// NewMediaRepository creates a new in-memory media repository. +func NewMediaRepository() *MediaRepository { + return &MediaRepository{ + objects: make(map[domain.MediaObjectID]*domain.MediaObject), + byPath: make(map[string]domain.MediaObjectID), + } +} + +func (r *MediaRepository) copyObject(obj *domain.MediaObject) *domain.MediaObject { + cp := *obj + return &cp +} + +func (r *MediaRepository) Create(_ context.Context, obj *domain.MediaObject) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.objects[obj.ID] = r.copyObject(obj) + r.byPath[obj.Path] = obj.ID + return nil +} + +func (r *MediaRepository) Get(_ context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + obj, ok := r.objects[id] + if !ok || obj.DeletedAt != nil { + return nil, domain.ErrNotFound + } + return r.copyObject(obj), nil +} + +func (r *MediaRepository) ListByUser(_ context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var all []domain.MediaObject + for _, obj := range r.objects { + if obj.UserID != userID || obj.DeletedAt != nil { + continue + } + if opts.ContentTypePrefix != "" && !strings.HasPrefix(obj.ContentType, opts.ContentTypePrefix) { + continue + } + all = append(all, *r.copyObject(obj)) + } + + // Sort by created_at DESC + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + + total := len(all) + + // Apply pagination + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + offset := opts.Offset + if offset > len(all) { + offset = len(all) + } + end := offset + limit + if end > len(all) { + end = len(all) + } + + return all[offset:end], total, nil +} + +func (r *MediaRepository) SoftDelete(_ context.Context, id domain.MediaObjectID) error { + r.mu.Lock() + defer r.mu.Unlock() + + obj, ok := r.objects[id] + if !ok { + return domain.ErrNotFound + } + now := time.Now() + obj.DeletedAt = &now + return nil +} + +func (r *MediaRepository) HardDelete(_ context.Context, id domain.MediaObjectID) error { + r.mu.Lock() + defer r.mu.Unlock() + + obj, ok := r.objects[id] + if !ok { + return domain.ErrNotFound + } + delete(r.byPath, obj.Path) + delete(r.objects, id) + return nil +} + +func (r *MediaRepository) GetByPath(_ context.Context, path string) (*domain.MediaObject, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + id, ok := r.byPath[path] + if !ok { + return nil, domain.ErrNotFound + } + obj, ok := r.objects[id] + if !ok || obj.DeletedAt != nil { + return nil, domain.ErrNotFound + } + return r.copyObject(obj), nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/session.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/session.go.tmpl new file mode 100644 index 0000000..49cfdc7 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/session.go.tmpl @@ -0,0 +1,120 @@ +package memory + +import ( + "context" + "sync" + "time" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.SessionRepository = (*SessionRepository)(nil) + +// SessionRepository is an in-memory session store for standalone development. +type SessionRepository struct { + mu sync.RWMutex + sessions map[domain.SessionID]*domain.Session +} + +// NewSessionRepository creates a new in-memory session repository. +func NewSessionRepository() *SessionRepository { + return &SessionRepository{ + sessions: make(map[domain.SessionID]*domain.Session), + } +} + +func (r *SessionRepository) copySession(s *domain.Session) *domain.Session { + cp := *s + return &cp +} + +func (r *SessionRepository) Create(_ context.Context, session *domain.Session) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.sessions[session.ID] = r.copySession(session) + return nil +} + +func (r *SessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + s, ok := r.sessions[id] + if !ok { + return nil, domain.ErrSessionNotFound + } + return r.copySession(s), nil +} + +func (r *SessionRepository) ListByUser(_ context.Context, userID domain.UserID) ([]domain.Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + now := time.Now() + var result []domain.Session + for _, s := range r.sessions { + if s.UserID == userID && s.RevokedAt == nil && s.ExpiresAt.After(now) { + result = append(result, *r.copySession(s)) + } + } + return result, nil +} + +func (r *SessionRepository) UpdateLastActive(_ context.Context, id domain.SessionID) error { + r.mu.Lock() + defer r.mu.Unlock() + + s, ok := r.sessions[id] + if !ok { + return domain.ErrSessionNotFound + } + s.LastActiveAt = time.Now() + return nil +} + +func (r *SessionRepository) Revoke(_ context.Context, id domain.SessionID) error { + r.mu.Lock() + defer r.mu.Unlock() + + s, ok := r.sessions[id] + if !ok { + return domain.ErrSessionNotFound + } + now := time.Now() + s.RevokedAt = &now + return nil +} + +func (r *SessionRepository) RevokeAllForUser(_ context.Context, userID domain.UserID, exceptID *domain.SessionID) error { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + for _, s := range r.sessions { + if s.UserID == userID && s.RevokedAt == nil { + if exceptID != nil && s.ID == *exceptID { + continue + } + s.RevokedAt = &now + } + } + return nil +} + +func (r *SessionRepository) DeleteExpired(_ context.Context) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + deleted := 0 + for id, s := range r.sessions { + if now.After(s.ExpiresAt) { + delete(r.sessions, id) + deleted++ + } + } + return deleted, nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl index e742ee6..5f44651 100644 --- a/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/user.go.tmpl @@ -3,90 +3,233 @@ package memory import ( "context" "sync" + "time" "{{GO_MODULE}}/pkg/auth" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" ) -// userEntry stores a user with their password for demo purposes. -type userEntry struct { - user *auth.User - password string -} +// Compile-time interface check. +var _ port.UserRepository = (*UserRepository)(nil) -// UserRepository is an in-memory user store for demo/testing purposes. -// Pre-populated with demo users. +// UserRepository is an in-memory user store with bcrypt password hashing. +// Pre-populated with demo users for standalone development. type UserRepository struct { - mu sync.RWMutex - users map[string]*userEntry // keyed by email + mu sync.RWMutex + users map[domain.UserID]*domain.User + passwords map[domain.UserID]string // bcrypt hashes + roles map[domain.UserID][]string // role lists + byEmail map[string]domain.UserID // email → user ID index } -// NewUserRepository creates a new in-memory user repository with demo users. +// NewUserRepository creates a new in-memory user repository seeded with demo users. func NewUserRepository() *UserRepository { repo := &UserRepository{ - users: make(map[string]*userEntry), + users: make(map[domain.UserID]*domain.User), + passwords: make(map[domain.UserID]string), + roles: make(map[domain.UserID][]string), + byEmail: make(map[string]domain.UserID), } - // Add demo users - repo.users["test@example.com"] = &userEntry{ - user: &auth.User{ - ID: "usr_test_001", - Email: "test@example.com", - Roles: []string{"user"}, - Metadata: map[string]any{ - "name": "Test User", - }, - }, - password: "password123", - } - - repo.users["admin@example.com"] = &userEntry{ - user: &auth.User{ - ID: "usr_admin_001", - Email: "admin@example.com", - Roles: []string{"admin", "user"}, - Metadata: map[string]any{ - "name": "Admin User", - }, - }, - password: "admin123", - } + // Seed demo users with bcrypt-hashed passwords. + // Passwords meet complexity requirements (min 8 chars, uppercase, lowercase, digit). + repo.seedUser("usr_test_001", "test@example.com", "Test User", "Password123", []string{"user"}) + repo.seedUser("usr_admin_001", "admin@example.com", "Admin User", "Admin1234", []string{"admin", "user"}) return repo } -// FindByEmail returns a user by email address. -func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*auth.User, error) { - r.mu.RLock() - defer r.mu.RUnlock() +func (r *UserRepository) seedUser(id, email, name, password string, userRoles []string) { + uid := domain.UserID(id) + now := time.Now() - entry, ok := r.users[email] - if !ok { - return nil, nil + hash, err := auth.HashPassword(password) + if err != nil { + panic("failed to hash seed password: " + err.Error()) } - return entry.user, nil + + r.users[uid] = &domain.User{ + ID: uid, + Email: email, + EmailVerified: true, + Name: name, + Status: domain.UserStatusActive, + Roles: userRoles, + CreatedAt: now, + UpdatedAt: now, + } + r.passwords[uid] = hash + r.roles[uid] = userRoles + r.byEmail[email] = uid } -// FindByID returns a user by ID. -func (r *UserRepository) FindByID(ctx context.Context, id string) (*auth.User, error) { +func (r *UserRepository) copyUser(u *domain.User) *domain.User { + cp := *u + cp.Roles = make([]string, len(u.Roles)) + copy(cp.Roles, u.Roles) + return &cp +} + +func (r *UserRepository) Create(_ context.Context, user *domain.User) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.byEmail[user.Email]; exists { + return domain.ErrDuplicateEmail + } + + r.users[user.ID] = r.copyUser(user) + r.byEmail[user.Email] = user.ID + r.roles[user.ID] = make([]string, len(user.Roles)) + copy(r.roles[user.ID], user.Roles) + return nil +} + +func (r *UserRepository) Get(_ context.Context, id domain.UserID) (*domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() - for _, entry := range r.users { - if entry.user.ID == id { - return entry.user, nil + u, ok := r.users[id] + if !ok { + return nil, domain.ErrUserNotFound + } + return r.copyUser(u), nil +} + +func (r *UserRepository) GetByEmail(_ context.Context, email string) (*domain.User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + uid, ok := r.byEmail[email] + if !ok { + return nil, domain.ErrUserNotFound + } + return r.copyUser(r.users[uid]), nil +} + +func (r *UserRepository) Update(_ context.Context, user *domain.User) error { + r.mu.Lock() + defer r.mu.Unlock() + + existing, ok := r.users[user.ID] + if !ok { + return domain.ErrUserNotFound + } + + // If email changed, update the index. + if existing.Email != user.Email { + if _, taken := r.byEmail[user.Email]; taken { + return domain.ErrDuplicateEmail + } + delete(r.byEmail, existing.Email) + r.byEmail[user.Email] = user.ID + } + + user.UpdatedAt = time.Now() + r.users[user.ID] = r.copyUser(user) + return nil +} + +func (r *UserRepository) UpdateLastLogin(_ context.Context, id domain.UserID) error { + r.mu.Lock() + defer r.mu.Unlock() + + u, ok := r.users[id] + if !ok { + return domain.ErrUserNotFound + } + now := time.Now() + u.LastLoginAt = &now + return nil +} + +func (r *UserRepository) ExistsByEmail(_ context.Context, email string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + _, ok := r.byEmail[email] + return ok, nil +} + +func (r *UserRepository) SetPassword(_ context.Context, userID domain.UserID, hash string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.users[userID]; !ok { + return domain.ErrUserNotFound + } + r.passwords[userID] = hash + return nil +} + +func (r *UserRepository) GetPasswordHash(_ context.Context, userID domain.UserID) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + hash := r.passwords[userID] + return hash, nil +} + +func (r *UserRepository) HasPassword(_ context.Context, userID domain.UserID) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + _, ok := r.passwords[userID] + return ok, nil +} + +func (r *UserRepository) AddRole(_ context.Context, userID domain.UserID, role string) error { + r.mu.Lock() + defer r.mu.Unlock() + + u, ok := r.users[userID] + if !ok { + return domain.ErrUserNotFound + } + + for _, existing := range r.roles[userID] { + if existing == role { + return nil } } - return nil, nil + r.roles[userID] = append(r.roles[userID], role) + u.Roles = make([]string, len(r.roles[userID])) + copy(u.Roles, r.roles[userID]) + return nil } -// ValidatePassword checks if the password matches for a user. -func (r *UserRepository) ValidatePassword(ctx context.Context, user *auth.User, password string) bool { +func (r *UserRepository) RemoveRole(_ context.Context, userID domain.UserID, role string) error { + r.mu.Lock() + defer r.mu.Unlock() + + u, ok := r.users[userID] + if !ok { + return domain.ErrUserNotFound + } + + filtered := make([]string, 0, len(r.roles[userID])) + for _, existing := range r.roles[userID] { + if existing != role { + filtered = append(filtered, existing) + } + } + r.roles[userID] = filtered + u.Roles = make([]string, len(filtered)) + copy(u.Roles, filtered) + return nil +} + +func (r *UserRepository) GetRoles(_ context.Context, userID domain.UserID) ([]string, error) { r.mu.RLock() defer r.mu.RUnlock() - entry, ok := r.users[user.Email] - if !ok { - return false + if _, ok := r.users[userID]; !ok { + return nil, domain.ErrUserNotFound } - return entry.password == password + + roles := r.roles[userID] + result := make([]string, len(roles)) + copy(result, roles) + return result, nil } diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/postgres/auth_code.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/auth_code.go.tmpl new file mode 100644 index 0000000..ba1683a --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/auth_code.go.tmpl @@ -0,0 +1,120 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil) + +// authCodeRow maps to the auth_codes table. +type authCodeRow struct { + ID string `db:"id"` + UserID *string `db:"user_id"` + Email string `db:"email"` + Code string `db:"code"` + Purpose string `db:"purpose"` + ExpiresAt time.Time `db:"expires_at"` + UsedAt *time.Time `db:"used_at"` + IPAddress string `db:"ip_address"` + CreatedAt time.Time `db:"created_at"` +} + +func (r *authCodeRow) toDomain() *domain.AuthCode { + ac := &domain.AuthCode{ + ID: r.ID, + Email: r.Email, + Code: r.Code, + Purpose: domain.AuthCodePurpose(r.Purpose), + ExpiresAt: r.ExpiresAt, + UsedAt: r.UsedAt, + IPAddress: r.IPAddress, + CreatedAt: r.CreatedAt, + } + if r.UserID != nil { + uid := domain.UserID(*r.UserID) + ac.UserID = &uid + } + return ac +} + +// AuthCodeRepository implements port.AuthCodeRepository with PostgreSQL/CockroachDB. +type AuthCodeRepository struct { + db *sqlx.DB +} + +// NewAuthCodeRepository creates a new Postgres-backed auth code repository. +func NewAuthCodeRepository(db *sqlx.DB) *AuthCodeRepository { + return &AuthCodeRepository{db: db} +} + +func (r *AuthCodeRepository) Create(ctx context.Context, code *domain.AuthCode) error { + var userID *string + if code.UserID != nil { + s := string(*code.UserID) + userID = &s + } + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO auth_codes (id, user_id, email, code, purpose, expires_at, ip_address, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, code.ID, userID, code.Email, code.Code, string(code.Purpose), + code.ExpiresAt, code.IPAddress, code.CreatedAt) + if err != nil { + return fmt.Errorf("insert auth code: %w", err) + } + return nil +} + +func (r *AuthCodeRepository) FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) { + var row authCodeRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, user_id, email, code, purpose, expires_at, used_at, ip_address, created_at + FROM auth_codes + WHERE email = $1 AND code = $2 AND purpose = $3 + AND used_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + `, email, code, string(purpose)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrInvalidAuthCode + } + return nil, fmt.Errorf("find valid auth code: %w", err) + } + return row.toDomain(), nil +} + +func (r *AuthCodeRepository) MarkUsed(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE auth_codes SET used_at = NOW() WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("mark auth code used: %w", err) + } + return nil +} + +func (r *AuthCodeRepository) DeleteExpired(ctx context.Context) (int, error) { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM auth_codes WHERE expires_at < NOW() + `) + if err != nil { + return 0, fmt.Errorf("delete expired auth codes: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired rows affected: %w", err) + } + return int(rows), nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/postgres/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/media.go.tmpl new file mode 100644 index 0000000..a567eae --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/media.go.tmpl @@ -0,0 +1,184 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.MediaRepository = (*MediaObjectRepository)(nil) + +// mediaObjectRow maps to the media_objects table. +type mediaObjectRow struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Path string `db:"path"` + Filename string `db:"filename"` + ContentType string `db:"content_type"` + Size int64 `db:"size"` + GenerationJobID string `db:"generation_job_id"` + DeletedAt *time.Time `db:"deleted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *mediaObjectRow) toDomain() *domain.MediaObject { + return &domain.MediaObject{ + ID: domain.MediaObjectID(r.ID), + UserID: domain.UserID(r.UserID), + Path: r.Path, + Filename: r.Filename, + ContentType: r.ContentType, + Size: r.Size, + GenerationJobID: r.GenerationJobID, + DeletedAt: r.DeletedAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// MediaObjectRepository implements port.MediaRepository with PostgreSQL/CockroachDB. +type MediaObjectRepository struct { + db *sqlx.DB +} + +// NewMediaObjectRepository creates a new Postgres-backed media repository. +func NewMediaObjectRepository(db *sqlx.DB) *MediaObjectRepository { + return &MediaObjectRepository{db: db} +} + +func (r *MediaObjectRepository) Create(ctx context.Context, obj *domain.MediaObject) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO media_objects (id, user_id, path, filename, content_type, size, generation_job_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, string(obj.ID), string(obj.UserID), obj.Path, obj.Filename, obj.ContentType, + obj.Size, obj.GenerationJobID, obj.CreatedAt, obj.UpdatedAt) + if err != nil { + return fmt.Errorf("insert media object: %w", err) + } + return nil +} + +func (r *MediaObjectRepository) Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) { + var row mediaObjectRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at + FROM media_objects WHERE id = $1 AND deleted_at IS NULL + `, string(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("get media object: %w", err) + } + return row.toDomain(), nil +} + +func (r *MediaObjectRepository) ListByUser(ctx context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) { + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + + // Count total matching records + countQuery := `SELECT COUNT(*) FROM media_objects WHERE user_id = $1 AND deleted_at IS NULL` + args := []any{string(userID)} + argIdx := 2 + + if opts.ContentTypePrefix != "" { + countQuery += fmt.Sprintf(` AND content_type LIKE $%d`, argIdx) + args = append(args, opts.ContentTypePrefix+"%") + argIdx++ + } + + var total int + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, fmt.Errorf("count media objects: %w", err) + } + + // Fetch paginated results + query := ` + SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at + FROM media_objects + WHERE user_id = $1 AND deleted_at IS NULL` + + fetchArgs := []any{string(userID)} + fetchIdx := 2 + + if opts.ContentTypePrefix != "" { + query += fmt.Sprintf(` AND content_type LIKE $%d`, fetchIdx) + fetchArgs = append(fetchArgs, opts.ContentTypePrefix+"%") + fetchIdx++ + } + + query += ` ORDER BY created_at DESC` + query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, fetchIdx, fetchIdx+1) + fetchArgs = append(fetchArgs, limit, opts.Offset) + + var rows []mediaObjectRow + if err := r.db.SelectContext(ctx, &rows, query, fetchArgs...); err != nil { + return nil, 0, fmt.Errorf("list media objects: %w", err) + } + + objects := make([]domain.MediaObject, len(rows)) + for i := range rows { + objects[i] = *rows[i].toDomain() + } + return objects, total, nil +} + +func (r *MediaObjectRepository) SoftDelete(ctx context.Context, id domain.MediaObjectID) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE media_objects SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + `, string(id)) + if err != nil { + return fmt.Errorf("soft delete media object: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("soft delete rows affected: %w", err) + } + if rows == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *MediaObjectRepository) HardDelete(ctx context.Context, id domain.MediaObjectID) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM media_objects WHERE id = $1`, string(id)) + if err != nil { + return fmt.Errorf("hard delete media object: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("hard delete rows affected: %w", err) + } + if rows == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *MediaObjectRepository) GetByPath(ctx context.Context, path string) (*domain.MediaObject, error) { + var row mediaObjectRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at + FROM media_objects WHERE path = $1 AND deleted_at IS NULL + `, path) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("get media object by path: %w", err) + } + return row.toDomain(), nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/postgres/session.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/session.go.tmpl new file mode 100644 index 0000000..3c08dfa --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/session.go.tmpl @@ -0,0 +1,162 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.SessionRepository = (*SessionRepository)(nil) + +// sessionRow maps to the sessions table. +type sessionRow struct { + ID string `db:"id"` + UserID string `db:"user_id"` + IPAddress string `db:"ip_address"` + UserAgent string `db:"user_agent"` + DeviceLabel string `db:"device_label"` + LastActiveAt time.Time `db:"last_active_at"` + ExpiresAt time.Time `db:"expires_at"` + RevokedAt *time.Time `db:"revoked_at"` + CreatedAt time.Time `db:"created_at"` +} + +func (r *sessionRow) toDomain() *domain.Session { + return &domain.Session{ + ID: domain.SessionID(r.ID), + UserID: domain.UserID(r.UserID), + IPAddress: r.IPAddress, + UserAgent: r.UserAgent, + DeviceLabel: r.DeviceLabel, + LastActiveAt: r.LastActiveAt, + ExpiresAt: r.ExpiresAt, + RevokedAt: r.RevokedAt, + CreatedAt: r.CreatedAt, + } +} + +// SessionRepository implements port.SessionRepository with PostgreSQL/CockroachDB. +type SessionRepository struct { + db *sqlx.DB +} + +// NewSessionRepository creates a new Postgres-backed session repository. +func NewSessionRepository(db *sqlx.DB) *SessionRepository { + return &SessionRepository{db: db} +} + +func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO sessions (id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, string(session.ID), string(session.UserID), session.IPAddress, session.UserAgent, + session.DeviceLabel, session.LastActiveAt, session.ExpiresAt, session.CreatedAt) + if err != nil { + return fmt.Errorf("insert session: %w", err) + } + return nil +} + +func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) { + var row sessionRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at + FROM sessions WHERE id = $1 + `, string(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrSessionNotFound + } + return nil, fmt.Errorf("get session: %w", err) + } + return row.toDomain(), nil +} + +func (r *SessionRepository) ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error) { + var rows []sessionRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at + FROM sessions + WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_active_at DESC + `, string(userID)) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + + sessions := make([]domain.Session, len(rows)) + for i := range rows { + sessions[i] = *rows[i].toDomain() + } + return sessions, nil +} + +func (r *SessionRepository) UpdateLastActive(ctx context.Context, id domain.SessionID) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE sessions SET last_active_at = NOW() WHERE id = $1 + `, string(id)) + if err != nil { + return fmt.Errorf("update last active: %w", err) + } + return nil +} + +func (r *SessionRepository) Revoke(ctx context.Context, id domain.SessionID) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL + `, string(id)) + if err != nil { + return fmt.Errorf("revoke session: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("revoke session rows affected: %w", err) + } + if rows == 0 { + return domain.ErrSessionNotFound + } + + return nil +} + +func (r *SessionRepository) RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error { + if exceptID != nil { + _, err := r.db.ExecContext(ctx, ` + UPDATE sessions SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL AND id != $2 + `, string(userID), string(*exceptID)) + if err != nil { + return fmt.Errorf("revoke all sessions except: %w", err) + } + } else { + _, err := r.db.ExecContext(ctx, ` + UPDATE sessions SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + `, string(userID)) + if err != nil { + return fmt.Errorf("revoke all sessions: %w", err) + } + } + return nil +} + +func (r *SessionRepository) DeleteExpired(ctx context.Context) (int, error) { + result, err := r.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < NOW()`) + if err != nil { + return 0, fmt.Errorf("delete expired sessions: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired sessions rows: %w", err) + } + return int(rows), nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/postgres/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/user.go.tmpl new file mode 100644 index 0000000..ed9e5d4 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/postgres/user.go.tmpl @@ -0,0 +1,260 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" +) + +// Compile-time interface check. +var _ port.UserRepository = (*UserRepository)(nil) + +// userRow maps to the users table. +type userRow struct { + ID string `db:"id"` + Email string `db:"email"` + EmailVerified bool `db:"email_verified"` + Name string `db:"name"` + AvatarURL string `db:"avatar_url"` + Status string `db:"status"` + LastLoginAt *time.Time `db:"last_login_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *userRow) toDomain(roles []string) *domain.User { + return &domain.User{ + ID: domain.UserID(r.ID), + Email: r.Email, + EmailVerified: r.EmailVerified, + Name: r.Name, + AvatarURL: r.AvatarURL, + Status: domain.UserStatus(r.Status), + Roles: roles, + LastLoginAt: r.LastLoginAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// UserRepository implements port.UserRepository with PostgreSQL/CockroachDB. +type UserRepository struct { + db *sqlx.DB +} + +// NewUserRepository creates a new Postgres-backed user repository. +func NewUserRepository(db *sqlx.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(ctx context.Context, user *domain.User) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, email_verified, name, avatar_url, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, string(user.ID), user.Email, user.EmailVerified, user.Name, user.AvatarURL, + string(user.Status), user.CreatedAt, user.UpdatedAt) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrDuplicateEmail + } + return fmt.Errorf("insert user: %w", err) + } + + // Insert roles + for _, role := range user.Roles { + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO user_roles (user_id, role) VALUES ($1, $2) + ON CONFLICT (user_id, role) DO NOTHING + `, string(user.ID), role); err != nil { + return fmt.Errorf("insert role: %w", err) + } + } + + return nil +} + +func (r *UserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) { + var row userRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at + FROM users WHERE id = $1 + `, string(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, fmt.Errorf("get user: %w", err) + } + + roles, err := r.GetRoles(ctx, id) + if err != nil { + return nil, err + } + + return row.toDomain(roles), nil +} + +func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + var row userRow + err := r.db.GetContext(ctx, &row, ` + SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at + FROM users WHERE email = $1 + `, email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, fmt.Errorf("get user by email: %w", err) + } + + roles, err := r.GetRoles(ctx, domain.UserID(row.ID)) + if err != nil { + return nil, err + } + + return row.toDomain(roles), nil +} + +func (r *UserRepository) Update(ctx context.Context, user *domain.User) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE users + SET email = $2, email_verified = $3, name = $4, avatar_url = $5, + status = $6, updated_at = $7 + WHERE id = $1 + `, string(user.ID), user.Email, user.EmailVerified, user.Name, + user.AvatarURL, string(user.Status), time.Now()) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrDuplicateEmail + } + return fmt.Errorf("update user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update user rows affected: %w", err) + } + if rows == 0 { + return domain.ErrUserNotFound + } + + return nil +} + +func (r *UserRepository) UpdateLastLogin(ctx context.Context, id domain.UserID) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE users SET last_login_at = NOW() WHERE id = $1 + `, string(id)) + if err != nil { + return fmt.Errorf("update last login: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update last login rows affected: %w", err) + } + if rows == 0 { + return domain.ErrUserNotFound + } + + return nil +} + +func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { + var exists bool + err := r.db.GetContext(ctx, &exists, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email) + if err != nil { + return false, fmt.Errorf("exists by email: %w", err) + } + return exists, nil +} + +func (r *UserRepository) SetPassword(ctx context.Context, userID domain.UserID, hash string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO user_passwords (user_id, password_hash, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) DO UPDATE SET password_hash = $2, updated_at = NOW() + `, string(userID), hash) + if err != nil { + return fmt.Errorf("set password: %w", err) + } + return nil +} + +func (r *UserRepository) GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error) { + var hash string + err := r.db.GetContext(ctx, &hash, ` + SELECT password_hash FROM user_passwords WHERE user_id = $1 + `, string(userID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("get password hash: %w", err) + } + return hash, nil +} + +func (r *UserRepository) HasPassword(ctx context.Context, userID domain.UserID) (bool, error) { + var exists bool + err := r.db.GetContext(ctx, &exists, ` + SELECT EXISTS(SELECT 1 FROM user_passwords WHERE user_id = $1) + `, string(userID)) + if err != nil { + return false, fmt.Errorf("has password: %w", err) + } + return exists, nil +} + +func (r *UserRepository) AddRole(ctx context.Context, userID domain.UserID, role string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO user_roles (user_id, role) VALUES ($1, $2) + ON CONFLICT (user_id, role) DO NOTHING + `, string(userID), role) + if err != nil { + return fmt.Errorf("add role: %w", err) + } + return nil +} + +func (r *UserRepository) RemoveRole(ctx context.Context, userID domain.UserID, role string) error { + _, err := r.db.ExecContext(ctx, ` + DELETE FROM user_roles WHERE user_id = $1 AND role = $2 + `, string(userID), role) + if err != nil { + return fmt.Errorf("remove role: %w", err) + } + return nil +} + +func (r *UserRepository) GetRoles(ctx context.Context, userID domain.UserID) ([]string, error) { + var roles []string + err := r.db.SelectContext(ctx, &roles, ` + SELECT role FROM user_roles WHERE user_id = $1 ORDER BY role + `, string(userID)) + if err != nil { + return nil, fmt.Errorf("get roles: %w", err) + } + if roles == nil { + roles = []string{} + } + return roles, nil +} + +// isUniqueViolation checks if a database error is a unique constraint violation. +// Works with both PostgreSQL (23505) and CockroachDB. +func isUniqueViolation(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return pqErr.Code == "23505" + } + return false +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl index 2aeca32..9b91815 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth.go.tmpl @@ -2,13 +2,16 @@ package handlers import ( "errors" + "net" "net/http" + "strings" "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/auth" "{{GO_MODULE}}/pkg/httperror" "{{GO_MODULE}}/pkg/httpresponse" "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service" ) @@ -18,7 +21,7 @@ type Auth struct { logger *logging.Logger } -// NewAuth creates a new Auth handler with injected dependencies. +// NewAuth creates a new Auth handler. func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth { return &Auth{ svc: svc, @@ -26,13 +29,22 @@ func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth { } } -// LoginRequest is the request body for login. +// --- Request / Response types --- + +// LoginRequest is the request body for password login. type LoginRequest struct { Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required,min=1"` } -// LoginResponse is the response for successful login. +// RegisterRequest is the request body for registration. +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8"` + Name string `json:"name"` +} + +// LoginResponse is the response for successful login or registration. type LoginResponse struct { Token string `json:"token"` User UserResponse `json:"user"` @@ -40,29 +52,54 @@ type LoginResponse struct { // UserResponse is the user data returned in auth responses. type UserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name,omitempty"` - Roles []string `json:"roles,omitempty"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + EmailVerified bool `json:"emailVerified"` + Roles []string `json:"roles,omitempty"` } -// toUserResponse converts an auth.User to UserResponse. -func toUserResponse(u *auth.User) UserResponse { - name := "" - if u.Metadata != nil { - if n, ok := u.Metadata["name"].(string); ok { - name = n - } - } +// UpdateProfileRequest is the request body for updating the user profile. +type UpdateProfileRequest struct { + Name string `json:"name"` + AvatarURL string `json:"avatarUrl"` +} + +// ChangePasswordRequest is the request body for changing password. +type ChangePasswordRequest struct { + CurrentPassword string `json:"currentPassword" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +// RefreshRequest is the request body for refreshing an access token. +type RefreshRequest struct { + Token string `json:"token" validate:"required"` +} + +// toUserResponse converts a domain.User to UserResponse. +func toUserResponse(u *domain.User) UserResponse { return UserResponse{ - ID: u.ID, - Email: u.Email, - Name: name, - Roles: u.Roles, + ID: string(u.ID), + Email: u.Email, + Name: u.Name, + AvatarURL: u.AvatarURL, + EmailVerified: u.EmailVerified, + Roles: u.Roles, } } -// Login authenticates a user and returns a JWT token. +// toLoginResponse creates a LoginResponse from service output. +func toLoginResponse(out *service.LoginOutput) LoginResponse { + return LoginResponse{ + Token: out.Token, + User: toUserResponse(out.User), + } +} + +// --- Handlers --- + +// Login authenticates a user with email and password. // // POST /api/{service}/auth/login func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error { @@ -71,21 +108,30 @@ func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error { return err } - output, err := h.svc.Login(r.Context(), service.LoginInput{ - Email: req.Email, - Password: req.Password, - }) + output, err := h.svc.LoginWithPassword(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent()) if err != nil { - if errors.Is(err, service.ErrInvalidCredentials) { - return httperror.Unauthorized("invalid email or password") - } + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// Register creates a new user account. +// +// POST /api/{service}/auth/register +func (h *Auth) Register(w http.ResponseWriter, r *http.Request) error { + var req RegisterRequest + if err := app.BindAndValidate(r, &req); err != nil { return err } - httpresponse.OK(w, r, LoginResponse{ - Token: output.Token, - User: toUserResponse(output.User), - }) + output, err := h.svc.Register(r.Context(), req.Email, req.Password, req.Name, clientIP(r), r.UserAgent()) + if err != nil { + return mapAuthError(err) + } + + httpresponse.Created(w, r, toLoginResponse(output)) return nil } @@ -98,30 +144,188 @@ func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error { return httperror.Unauthorized("not authenticated") } - // Optionally refresh user data from repository freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID) if err != nil { - if errors.Is(err, service.ErrUserNotFound) { - return httperror.Unauthorized("user not found") - } - return err + return mapAuthError(err) } httpresponse.OK(w, r, toUserResponse(freshUser)) return nil } -// Logout handles user logout. -// This is a stateless operation since we use JWTs. +// UpdateMe updates the current user's profile. // -// POST /api/{service}/auth/logout -func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error { - // With JWT-based auth, logout is handled client-side by discarding the token. - // This endpoint exists for API completeness and could be extended to: - // - Add the token to a blacklist - // - Clear server-side sessions if using hybrid auth - // - Log the logout event +// PUT /api/{service}/auth/me +func (h *Auth) UpdateMe(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + var req UpdateProfileRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + updated, err := h.svc.UpdateProfile(r.Context(), user.ID, req.Name, req.AvatarURL) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toUserResponse(updated)) + return nil +} + +// ChangePassword changes the current user's password. +// +// POST /api/{service}/auth/change-password +func (h *Auth) ChangePassword(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + var req ChangePasswordRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.ChangePassword(r.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil { + return mapAuthError(err) + } httpresponse.NoContent(w) return nil } + +// Logout revokes the current session. +// +// POST /api/{service}/auth/logout +func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error { + user := auth.GetUser(r.Context()) + if user == nil { + httpresponse.NoContent(w) + return nil + } + + sessionID := "" + if user.Metadata != nil { + if sid, ok := user.Metadata["sid"].(string); ok { + sessionID = sid + } + } + + if err := h.svc.Logout(r.Context(), sessionID); err != nil { + h.logger.Warn("logout session revoke failed", "error", err) + } + + httpresponse.NoContent(w) + return nil +} + +// RefreshToken issues a new access token for an active session. +// +// POST /api/{service}/auth/refresh +func (h *Auth) RefreshToken(w http.ResponseWriter, r *http.Request) error { + // The caller sends their current (possibly near-expiry) token. + // We parse it to get user ID and session ID, then issue a new one. + user := auth.GetUser(r.Context()) + if user == nil { + return httperror.Unauthorized("not authenticated") + } + + sessionID := "" + if user.Metadata != nil { + if sid, ok := user.Metadata["sid"].(string); ok { + sessionID = sid + } + } + if sessionID == "" { + return httperror.Unauthorized("no session") + } + + output, err := h.svc.RefreshToken(r.Context(), sessionID, user.ID) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// --- Helpers --- + +// mapAuthError translates domain errors to HTTP errors. +func mapAuthError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidCredentials): + return httperror.Unauthorized("invalid email or password") + case errors.Is(err, domain.ErrUserNotFound): + return httperror.Unauthorized("invalid email or password") + case errors.Is(err, domain.ErrUserSuspended): + return httperror.Forbidden("account is suspended") + case errors.Is(err, domain.ErrDuplicateEmail): + return httperror.Conflict("email already registered") + case errors.Is(err, domain.ErrWeakPassword): + return httperror.BadRequest(err.Error()) + case errors.Is(err, domain.ErrRegistrationDisabled): + return httperror.Forbidden("registration is currently disabled") + case errors.Is(err, domain.ErrNameTooLong), errors.Is(err, domain.ErrEmailTooLong): + return httperror.BadRequest(err.Error()) + case errors.Is(err, domain.ErrInvalidAvatarURL): + return httperror.BadRequest("avatar URL must use http or https") + case errors.Is(err, domain.ErrSessionNotFound): + return httperror.NotFound("session not found") + case errors.Is(err, domain.ErrSessionRevoked): + return httperror.Unauthorized("session has been revoked") + case errors.Is(err, domain.ErrInvalidAuthCode): + return httperror.Unauthorized("invalid or expired code") + default: + return err + } +} + +// clientIP extracts the client IP from the request. +// It prefers RemoteAddr (set by the Go HTTP server from the TCP connection) and +// only uses X-Forwarded-For/X-Real-Ip when the direct connection is from a +// private/loopback address, indicating a trusted reverse proxy. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + // Only trust proxy headers when the connection is from a private network. + if isPrivateIP(host) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return ip + } + } + if xri := r.Header.Get("X-Real-Ip"); xri != "" { + return xri + } + } + + return host +} + +// isPrivateIP returns true if the address is loopback or RFC 1918 private. +func isPrivateIP(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() +} + +// sessionID extracts the session ID from the authenticated user's metadata. +func sessionID(user *auth.User) string { + if user == nil || user.Metadata == nil { + return "" + } + sid, _ := user.Metadata["sid"].(string) + return sid +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/auth_flows.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth_flows.go.tmpl new file mode 100644 index 0000000..d7688a3 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/auth_flows.go.tmpl @@ -0,0 +1,288 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "{{GO_MODULE}}/pkg/app" + "{{GO_MODULE}}/pkg/auth" + "{{GO_MODULE}}/pkg/httperror" + "{{GO_MODULE}}/pkg/httpresponse" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" +) + +// --- Request types for auth flows --- + +// EmailRequest is used by OTP send, magic link, and forgot password. +type EmailRequest struct { + Email string `json:"email" validate:"required,email"` +} + +// OTPVerifyRequest verifies a one-time password. +type OTPVerifyRequest struct { + Email string `json:"email" validate:"required,email"` + Code string `json:"code" validate:"required,len=6"` +} + +// MagicLinkVerifyRequest verifies a magic link token. +type MagicLinkVerifyRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` +} + +// ResetPasswordRequest sets a new password using a reset token. +type ResetPasswordRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +// VerifyEmailRequest verifies an email with a code. +type VerifyEmailRequest struct { + Code string `json:"code" validate:"required,len=6"` +} + +// SessionResponse is a single session in the list. +type SessionResponse struct { + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + DeviceLabel string `json:"deviceLabel"` + LastActiveAt string `json:"lastActiveAt"` + CreatedAt string `json:"createdAt"` + IsCurrent bool `json:"isCurrent"` +} + +// --- OTP handlers --- + +// SendOTP sends a one-time password to the user's email. +// +// POST /api/{service}/auth/otp/send +func (h *Auth) SendOTP(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.SendOTP(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a code has been sent"}) + return nil +} + +// VerifyOTP verifies a one-time password and returns a login token. +// +// POST /api/{service}/auth/otp/verify +func (h *Auth) VerifyOTP(w http.ResponseWriter, r *http.Request) error { + var req OTPVerifyRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + output, err := h.svc.VerifyOTP(r.Context(), req.Email, req.Code, clientIP(r), r.UserAgent()) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// --- Magic Link handlers --- + +// SendMagicLink sends a magic link to the user's email. +// +// POST /api/{service}/auth/magic-link +func (h *Auth) SendMagicLink(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.SendMagicLink(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a link has been sent"}) + return nil +} + +// VerifyMagicLink verifies a magic link token and returns a login token. +// +// POST /api/{service}/auth/magic-link/verify +func (h *Auth) VerifyMagicLink(w http.ResponseWriter, r *http.Request) error { + var req MagicLinkVerifyRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + output, err := h.svc.VerifyMagicLink(r.Context(), req.Email, req.Token, clientIP(r), r.UserAgent()) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// --- Forgot / Reset Password handlers --- + +// ForgotPassword sends a password reset token. +// +// POST /api/{service}/auth/forgot-password +func (h *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.ForgotPassword(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a reset link has been sent"}) + return nil +} + +// ResetPassword sets a new password using a reset token. +// +// POST /api/{service}/auth/reset-password +func (h *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) error { + var req ResetPasswordRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.ResetPassword(r.Context(), req.Email, req.Token, req.NewPassword); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Password has been reset. Please sign in."}) + return nil +} + +// --- Email Verification handlers --- + +// SendVerifyEmail sends a verification code to the current user's email. +// +// POST /api/{service}/auth/verify-email/send +func (h *Auth) SendVerifyEmail(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + if err := h.svc.SendVerifyEmail(r.Context(), user.ID); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Verification code sent"}) + return nil +} + +// VerifyEmail verifies the current user's email with a code. +// +// POST /api/{service}/auth/verify-email +func (h *Auth) VerifyEmail(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + var req VerifyEmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.VerifyEmail(r.Context(), user.ID, req.Code); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Email verified"}) + return nil +} + +// --- Session Management handlers --- + +// ListSessions returns all active sessions for the current user. +// +// GET /api/{service}/auth/sessions +func (h *Auth) ListSessions(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + currentSID := sessionID(user) + + sessions, err := h.svc.ListSessions(r.Context(), user.ID) + if err != nil { + return err + } + + result := make([]SessionResponse, 0, len(sessions)) + for _, s := range sessions { + result = append(result, SessionResponse{ + ID: string(s.ID), + IPAddress: s.IPAddress, + DeviceLabel: s.DeviceLabel, + LastActiveAt: s.LastActiveAt.Format("2006-01-02T15:04:05Z07:00"), + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + IsCurrent: string(s.ID) == currentSID, + }) + } + + httpresponse.OK(w, r, result) + return nil +} + +// RevokeSession revokes a specific session. +// +// DELETE /api/{service}/auth/sessions/{id} +func (h *Auth) RevokeSession(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + sid := chi.URLParam(r, "id") + if sid == "" { + return httperror.BadRequest("session id required") + } + + if err := h.svc.RevokeSession(r.Context(), user.ID, sid); err != nil { + if errors.Is(err, domain.ErrSessionNotFound) { + return httperror.NotFound("session not found") + } + return err + } + + httpresponse.NoContent(w) + return nil +} + +// RevokeAllSessions revokes all sessions except the current one. +// +// DELETE /api/{service}/auth/sessions +func (h *Auth) RevokeAllSessions(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + currentSID := sessionID(user) + var except *string + if currentSID != "" { + except = ¤tSID + } + + if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil { + return err + } + + httpresponse.NoContent(w) + return nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl index 351dd19..eb70d42 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/generate.go.tmpl @@ -10,23 +10,28 @@ import ( "{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/realtime" + + "github.com/go-chi/chi/v5" ) // Generate handles HTTP requests for AI generation endpoints. // All generation is async: validate request, enqueue job, return 202 with job ID. // The worker processes jobs and sends results via Redis → SSE. +// Job status can be polled via GET /generate/jobs/{id} as a fallback to SSE. type Generate struct { - queue queue.Producer - sseHub *realtime.SSEHub - logger *logging.Logger + queue queue.Producer + jobReader queue.JobReader + sseHub *realtime.SSEHub + logger *logging.Logger } // NewGenerate creates a new Generate handler with injected dependencies. -func NewGenerate(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Generate { +func NewGenerate(q queue.Producer, jr queue.JobReader, hub *realtime.SSEHub, logger *logging.Logger) *Generate { return &Generate{ - queue: q, - sseHub: hub, - logger: logger.WithComponent("GenerateHandler"), + queue: q, + jobReader: jr, + sseHub: hub, + logger: logger.WithComponent("GenerateHandler"), } } @@ -177,6 +182,47 @@ func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error { return nil } +// --------------------------------------------------------------------------- +// Job status (poll fallback for SSE) +// --------------------------------------------------------------------------- + +// GetJobStatus returns the current status of a generation job. +// This is a poll-based fallback for clients that can't use SSE. +func (h *Generate) GetJobStatus(w http.ResponseWriter, r *http.Request) error { + jobID := chi.URLParam(r, "id") + if jobID == "" { + return httperror.BadRequest("job ID is required") + } + + job, err := h.jobReader.GetJob(r.Context(), jobID) + if err != nil { + if err == queue.ErrJobNotFound { + return httperror.NotFound("job not found") + } + h.logger.Error("failed to get job status", "error", err, "job_id", jobID) + return httperror.Internal("failed to get job status") + } + + resp := map[string]any{ + "id": job.ID, + "type": job.Type, + "status": string(job.Status), + "createdAt": job.CreatedAt, + } + if job.StartedAt != nil { + resp["startedAt"] = job.StartedAt + } + if job.CompletedAt != nil { + resp["completedAt"] = job.CompletedAt + } + if job.Error != "" { + resp["error"] = job.Error + } + + httpresponse.OK(w, r, resp) + return nil +} + // --------------------------------------------------------------------------- // SSE Events endpoint // --------------------------------------------------------------------------- diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl index 9398f10..be42a51 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/media.go.tmpl @@ -1,9 +1,12 @@ package handlers import ( + "errors" "fmt" "net/http" + "path/filepath" "strings" + "time" "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/auth" @@ -11,20 +14,43 @@ import ( "{{GO_MODULE}}/pkg/httpresponse" "{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/storage" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) +// maxUploadSize is the maximum allowed file size for uploads (500MB). +const maxUploadSize = 500 << 20 + +// allowedMediaTypes is the allowlist of MIME types permitted for upload. +var allowedMediaTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "video/mp4": true, + "video/webm": true, + "video/quicktime": true, + "audio/mpeg": true, + "audio/wav": true, + "audio/ogg": true, + "audio/webm": true, + "application/pdf": true, +} + // Media handles media upload and library operations. type Media struct { store storage.Store + repo port.MediaRepository logger *logging.Logger } // NewMedia creates a new media handler. -func NewMedia(store storage.Store, logger *logging.Logger) *Media { - return &Media{store: store, logger: logger.WithComponent("MediaHandler")} +func NewMedia(store storage.Store, repo port.MediaRepository, logger *logging.Logger) *Media { + return &Media{store: store, repo: repo, logger: logger.WithComponent("MediaHandler")} } // Routes returns the media subrouter. @@ -33,31 +59,63 @@ func (h *Media) Routes() http.Handler { r.Post("/upload/init", app.Wrap(h.InitUpload)) r.Post("/upload/complete", app.Wrap(h.CompleteUpload)) r.Get("/", app.Wrap(h.List)) - r.Delete("/*", app.Wrap(h.Delete)) + r.Get("/{id}", app.Wrap(h.GetOne)) + r.Get("/{id}/url", app.Wrap(h.RefreshURL)) + r.Delete("/{id}", app.Wrap(h.Delete)) return r } +// sanitizeFilename removes path separators and dangerous characters from filenames. +func sanitizeFilename(name string) string { + // Remove any directory components + name = filepath.Base(name) + // Replace any remaining path separators (e.g., from URL encoding) + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "..", "_") + // Remove null bytes + name = strings.ReplaceAll(name, "\x00", "") + if name == "" || name == "." { + name = "unnamed" + } + return name +} + // initUploadRequest is the request body for POST /media/upload/init. type initUploadRequest struct { Filename string `json:"filename" validate:"required"` ContentType string `json:"contentType" validate:"required"` + Size int64 `json:"size"` } // InitUpload returns a presigned URL for direct client-to-storage upload. +// The metadata record is created in CompleteUpload after the file is actually stored. func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error { var req initUploadRequest if err := app.BindAndValidate(r, &req); err != nil { return err } - user := auth.GetUser(r.Context()) - userID := "anonymous" - if user != nil { - userID = user.ID + // Validate MIME type against allowlist + if !allowedMediaTypes[req.ContentType] { + return httperror.BadRequest("unsupported file type: " + req.ContentType) } + // Validate file size if provided + if req.Size > maxUploadSize { + return httperror.BadRequest(fmt.Sprintf("file too large: %d bytes (max %d)", req.Size, maxUploadSize)) + } + + user := auth.GetUser(r.Context()) + if user == nil { + return httperror.Unauthorized("authentication required") + } + + // Sanitize filename to prevent path traversal + safeName := sanitizeFilename(req.Filename) + // Build object path: media/{userID}/{uuid}/{filename} - objectPath := fmt.Sprintf("media/%s/%s/%s", userID, uuid.New().String(), req.Filename) + objectPath := fmt.Sprintf("media/%s/%s/%s", user.ID, uuid.New().String(), safeName) presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType) if err != nil { @@ -68,6 +126,7 @@ func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error { httpresponse.OK(w, r, map[string]any{ "uploadURL": presigned.URL, "objectPath": objectPath, + "filename": safeName, "headers": presigned.Headers, "method": presigned.Method, "expires": presigned.Expires, @@ -77,85 +136,237 @@ func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error { // completeUploadRequest is the request body for POST /media/upload/complete. type completeUploadRequest struct { - ObjectPath string `json:"objectPath" validate:"required"` + ObjectPath string `json:"objectPath" validate:"required"` + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Size int64 `json:"size"` } -// CompleteUpload confirms an upload is done and returns the final URL. +// CompleteUpload confirms an upload is done, creates the metadata record, and returns the final URL. func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error { var req completeUploadRequest if err := app.BindAndValidate(r, &req); err != nil { return err } + // Verify the object path belongs to the authenticated user + user := auth.GetUser(r.Context()) + if user == nil { + return httperror.Unauthorized("authentication required") + } + expectedPrefix := fmt.Sprintf("media/%s/", user.ID) + if !strings.HasPrefix(req.ObjectPath, expectedPrefix) { + return httperror.Forbidden("cannot complete upload for another user's media") + } + url, err := h.store.GetURL(r.Context(), req.ObjectPath) if err != nil { h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath) return httperror.Internal("failed to confirm upload") } + // Create the metadata record now that the file is in storage. + now := time.Now() + filename := sanitizeFilename(req.Filename) + if filename == "unnamed" { + // Extract filename from the object path (last segment) + parts := strings.Split(req.ObjectPath, "/") + if len(parts) > 0 { + filename = parts[len(parts)-1] + } + } + + mediaObj := &domain.MediaObject{ + ID: domain.MediaObjectID("med_" + uuid.New().String()), + UserID: domain.UserID(user.ID), + Path: req.ObjectPath, + Filename: filename, + ContentType: req.ContentType, + Size: req.Size, + CreatedAt: now, + UpdatedAt: now, + } + if err := h.repo.Create(r.Context(), mediaObj); err != nil { + h.logger.Error("failed to create media record", "error", err) + return httperror.Internal("failed to create upload record") + } + httpresponse.OK(w, r, map[string]any{ + "id": string(mediaObj.ID), "url": url, "path": req.ObjectPath, }) return nil } -// List returns the user's media objects. +// List returns the user's media objects with pagination. func (h *Media) List(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) - userID := "anonymous" - if user != nil { - userID = user.ID + if user == nil { + return httperror.Unauthorized("authentication required") } - prefix := fmt.Sprintf("media/%s/", userID) - - // Allow filtering by sub-prefix (e.g., ?prefix=images) - if subPrefix := r.URL.Query().Get("prefix"); subPrefix != "" { - prefix = fmt.Sprintf("media/%s/%s", userID, subPrefix) + opts := port.ListMediaOptions{ + ContentTypePrefix: r.URL.Query().Get("type"), + Limit: intQueryParam(r, "limit", 50), + Offset: intQueryParam(r, "offset", 0), } - objects, err := h.store.List(r.Context(), prefix) + objects, total, err := h.repo.ListByUser(r.Context(), domain.UserID(user.ID), opts) if err != nil { h.logger.Error("failed to list media", "error", err) return httperror.Internal("failed to list media") } - if objects == nil { - objects = []storage.MediaObject{} + // Enrich each object with a fresh signed URL + type mediaItem struct { + ID string `json:"id"` + Path string `json:"path"` + URL string `json:"url"` + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` + } + + items := make([]mediaItem, 0, len(objects)) + for _, obj := range objects { + url, urlErr := h.store.GetURL(r.Context(), obj.Path) + if urlErr != nil { + h.logger.Warn("failed to get URL for media object", "path", obj.Path, "error", urlErr) + continue + } + items = append(items, mediaItem{ + ID: string(obj.ID), + Path: obj.Path, + URL: url, + Filename: obj.Filename, + ContentType: obj.ContentType, + Size: obj.Size, + CreatedAt: obj.CreatedAt, + }) } httpresponse.OK(w, r, map[string]any{ - "items": objects, - "count": len(objects), + "items": items, + "total": total, + "count": len(items), }) return nil } -// Delete removes a media object. -// Users can only delete objects under their own media/{userID}/ prefix. -func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error { - // Extract path from URL (everything after /media/) - path := strings.TrimPrefix(r.URL.Path, "/") - if path == "" { - return httperror.BadRequest("path is required") +// GetOne returns a single media object with a fresh URL. +func (h *Media) GetOne(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + if id == "" { + return httperror.BadRequest("media ID is required") } - // Verify the path belongs to the authenticated user - user := auth.GetUser(r.Context()) - if user == nil { - return httperror.Unauthorized("authentication required") + obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return httperror.NotFound("media object not found") + } + return httperror.Internal("failed to get media object") } - expectedPrefix := fmt.Sprintf("media/%s/", user.ID) - if !strings.HasPrefix(path, expectedPrefix) { + + // Verify ownership + user := auth.GetUser(r.Context()) + if user == nil || domain.UserID(user.ID) != obj.UserID { + return httperror.Forbidden("access denied") + } + + url, err := h.store.GetURL(r.Context(), obj.Path) + if err != nil { + h.logger.Error("failed to get URL", "error", err, "path", obj.Path) + return httperror.Internal("failed to get media URL") + } + + httpresponse.OK(w, r, map[string]any{ + "id": string(obj.ID), + "path": obj.Path, + "url": url, + "filename": obj.Filename, + "contentType": obj.ContentType, + "size": obj.Size, + "createdAt": obj.CreatedAt, + }) + return nil +} + +// RefreshURL returns a fresh signed URL for a media object. +func (h *Media) RefreshURL(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + if id == "" { + return httperror.BadRequest("media ID is required") + } + + obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return httperror.NotFound("media object not found") + } + return httperror.Internal("failed to get media object") + } + + // Verify ownership + user := auth.GetUser(r.Context()) + if user == nil || domain.UserID(user.ID) != obj.UserID { + return httperror.Forbidden("access denied") + } + + url, err := h.store.GetURL(r.Context(), obj.Path) + if err != nil { + h.logger.Error("failed to refresh URL", "error", err, "path", obj.Path) + return httperror.Internal("failed to refresh media URL") + } + + httpresponse.OK(w, r, map[string]any{ + "id": string(obj.ID), + "url": url, + }) + return nil +} + +// Delete soft-deletes a media object. +func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error { + id := chi.URLParam(r, "id") + if id == "" { + return httperror.BadRequest("media ID is required") + } + + obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id)) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return httperror.NotFound("media object not found") + } + return httperror.Internal("failed to get media object") + } + + // Verify ownership + user := auth.GetUser(r.Context()) + if user == nil || domain.UserID(user.ID) != obj.UserID { return httperror.Forbidden("cannot delete another user's media") } - if err := h.store.Delete(r.Context(), path); err != nil { - h.logger.Error("failed to delete media", "error", err, "path", path) + if err := h.repo.SoftDelete(r.Context(), domain.MediaObjectID(id)); err != nil { + h.logger.Error("failed to delete media", "error", err, "id", id) return httperror.Internal("failed to delete media") } - httpresponse.OK(w, r, map[string]any{"deleted": path}) + httpresponse.OK(w, r, map[string]any{"deleted": id}) return nil } + +// intQueryParam parses an integer query parameter with a default value. +func intQueryParam(r *http.Request, key string, defaultVal int) int { + val := r.URL.Query().Get(key) + if val == "" { + return defaultVal + } + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err != nil || n < 0 { + return defaultVal + } + return n +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index 9f1db6e..531cdd7 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -2,13 +2,17 @@ package api import ( + "time" + "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/auth" + "{{GO_MODULE}}/pkg/middleware" "{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/realtime" "{{GO_MODULE}}/pkg/storage" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service" ) @@ -26,9 +30,9 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { healthHandler := handlers.NewHealth(logger) exampleHandler := handlers.NewExample(deps.ExampleService, logger) authHandler := handlers.NewAuth(deps.AuthService, logger) - generateHandler := handlers.NewGenerate(deps.Queue, deps.SSEHub, logger) + generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger) chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger) - mediaHandler := handlers.NewMedia(deps.Store, logger) + mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger) // Build and mount OpenAPI spec spec := NewServiceSpec() @@ -45,17 +49,56 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { r.Get("/health", healthHandler.Check) - // ----- Auth routes ----- - // Public auth routes - r.Post("/auth/login", app.Wrap(authHandler.Login)) - r.Post("/auth/logout", app.Wrap(authHandler.Logout)) + // ----- Public auth routes (rate-limited) ----- + // Auth attempts: 20/min per IP (login, register, verify, reset). + authAttemptLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 20, Window: time.Minute}) + // Code sends: 5/min per IP (prevents email bombing via OTP/magic-link/forgot-password). + codeSendLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 5, Window: time.Minute}) - // Protected auth routes + r.Group(func(r app.Router) { + r.Use(authAttemptLimit) + r.Post("/auth/login", app.Wrap(authHandler.Login)) + r.Post("/auth/register", app.Wrap(authHandler.Register)) + r.Post("/auth/otp/verify", app.Wrap(authHandler.VerifyOTP)) + r.Post("/auth/magic-link/verify", app.Wrap(authHandler.VerifyMagicLink)) + r.Post("/auth/reset-password", app.Wrap(authHandler.ResetPassword)) + }) + r.Group(func(r app.Router) { + r.Use(codeSendLimit) + r.Post("/auth/otp/send", app.Wrap(authHandler.SendOTP)) + r.Post("/auth/magic-link", app.Wrap(authHandler.SendMagicLink)) + r.Post("/auth/forgot-password", app.Wrap(authHandler.ForgotPassword)) + }) + + // Refresh accepts expired tokens (still validates signature). + // The service layer checks session validity to prevent abuse. + r.Group(func(r app.Router) { + r.Use(auth.Middleware(auth.MiddlewareConfig{ + Validator: jwtValidator, + AllowExpired: true, + })) + r.Post("/auth/refresh", app.Wrap(authHandler.RefreshToken)) + }) + + // Session checker for revocation enforcement. + sessionChecker := deps.AuthService.CheckSession + + // ----- Protected auth routes ----- r.Group(func(r app.Router) { r.Use(auth.Middleware(auth.MiddlewareConfig{ Validator: jwtValidator, })) + r.Use(auth.SessionCheck(sessionChecker)) + r.Get("/auth/me", app.Wrap(authHandler.Me)) + r.Put("/auth/me", app.Wrap(authHandler.UpdateMe)) + r.Post("/auth/change-password", app.Wrap(authHandler.ChangePassword)) + r.Post("/auth/logout", app.Wrap(authHandler.Logout)) + r.Post("/auth/verify-email/send", app.Wrap(authHandler.SendVerifyEmail)) + r.Post("/auth/verify-email", app.Wrap(authHandler.VerifyEmail)) + r.Get("/auth/sessions", app.Wrap(authHandler.ListSessions)) + r.Delete("/auth/sessions", app.Wrap(authHandler.RevokeAllSessions)) + r.Delete("/auth/sessions/{id}", app.Wrap(authHandler.RevokeSession)) }) // ----- SSE Events ----- @@ -87,6 +130,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { r.Use(auth.Middleware(auth.MiddlewareConfig{ Validator: jwtValidator, })) + r.Use(auth.SessionCheck(sessionChecker)) // Chat messaging r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage)) @@ -95,6 +139,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage)) r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo)) r.Post("/generate/text", app.Wrap(generateHandler.GenerateText)) + r.Get("/generate/jobs/{id}", app.Wrap(generateHandler.GetJobStatus)) // Media library (upload, list, delete) r.Mount("/media", mediaHandler.Routes()) @@ -107,6 +152,8 @@ type Dependencies struct { ExampleService *service.ExampleService AuthService *service.AuthService Queue queue.Producer + JobReader queue.JobReader SSEHub *realtime.SSEHub Store storage.Store + MediaRepo port.MediaRepository } diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl index f96dfe6..fc94e4e 100644 --- a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl @@ -16,23 +16,49 @@ type Config struct { Logging config.LoggingConfig // Auth - AuthEnabled bool - JWTSecret string + AuthEnabled bool + JWTSecret string + RegistrationEnabled bool // Redis for cross-process SSE event delivery RedisURL string + + // Notify service for email delivery (OTP, magic links, password reset, etc.) + // When NotifyURL is empty, emails are logged to stdout (dev mode). + NotifyURL string + NotifyAPIKey string + NotifyHost string + NotifyFrom string } // Load reads configuration from environment variables. func Load() *Config { + regEnabled := true + if v := os.Getenv("REGISTRATION_ENABLED"); v != "" { + regEnabled = strings.EqualFold(v, "true") + } + return &Config{ AppConfig: config.ReadAppConfig(), Server: config.ReadServerConfig(), Database: config.ReadDatabaseConfig(), Logging: config.ReadLoggingConfig(), - AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"), - JWTSecret: os.Getenv("JWT_SECRET"), - RedisURL: os.Getenv("REDIS_URL"), + AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"), + JWTSecret: os.Getenv("JWT_SECRET"), + RegistrationEnabled: regEnabled, + RedisURL: os.Getenv("REDIS_URL"), + + NotifyURL: os.Getenv("NOTIFY_URL"), + NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"), + NotifyHost: os.Getenv("NOTIFY_HOST"), + NotifyFrom: getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com"), } } + +func getEnvDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} diff --git a/internal/adapter/templates/templates/components/service/internal/domain/auth_code.go.tmpl b/internal/adapter/templates/templates/components/service/internal/domain/auth_code.go.tmpl new file mode 100644 index 0000000..12cdf6f --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/domain/auth_code.go.tmpl @@ -0,0 +1,32 @@ +package domain + +import "time" + +// AuthCodePurpose identifies what an auth code is used for. +type AuthCodePurpose string + +const ( + PurposeLoginOTP AuthCodePurpose = "login_otp" + PurposeMagicLink AuthCodePurpose = "magic_link" + PurposePasswordReset AuthCodePurpose = "password_reset" + PurposeEmailVerify AuthCodePurpose = "email_verify" +) + +// AuthCode is a single-use, time-limited code for authentication flows. +// Used by OTP login, magic links, password reset, and email verification. +type AuthCode struct { + ID string + UserID *UserID // Nullable for magic link signup + Email string + Code string + Purpose AuthCodePurpose + ExpiresAt time.Time + UsedAt *time.Time + IPAddress string + CreatedAt time.Time +} + +// IsValid returns true if the code has not been used and has not expired. +func (c *AuthCode) IsValid() bool { + return c.UsedAt == nil && time.Now().Before(c.ExpiresAt) +} diff --git a/internal/adapter/templates/templates/components/service/internal/domain/errors.go.tmpl b/internal/adapter/templates/templates/components/service/internal/domain/errors.go.tmpl index d4ffe10..a512fcc 100644 --- a/internal/adapter/templates/templates/components/service/internal/domain/errors.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/domain/errors.go.tmpl @@ -18,4 +18,19 @@ var ( // ErrInvalidExampleName indicates the example name is invalid. ErrInvalidExampleName = errors.New("invalid example name") + + // Auth errors + ErrUserNotFound = errors.New("user not found") + ErrDuplicateEmail = errors.New("email already registered") + ErrInvalidCredentials = errors.New("invalid email or password") + ErrSessionNotFound = errors.New("session not found") + ErrSessionRevoked = errors.New("session has been revoked") + ErrInvalidAuthCode = errors.New("invalid or expired code") + ErrExpiredAuthCode = errors.New("code has expired") + ErrWeakPassword = errors.New("password does not meet requirements") + ErrUserSuspended = errors.New("account is suspended") + ErrRegistrationDisabled = errors.New("registration is disabled") + ErrNameTooLong = errors.New("name exceeds maximum length") + ErrEmailTooLong = errors.New("email exceeds maximum length") + ErrInvalidAvatarURL = errors.New("avatar URL must use http or https") ) diff --git a/internal/adapter/templates/templates/components/service/internal/domain/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/domain/media.go.tmpl new file mode 100644 index 0000000..22a4ee4 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/domain/media.go.tmpl @@ -0,0 +1,27 @@ +package domain + +import "time" + +// MediaObjectID is a typed media object identifier with prefix "med_". +type MediaObjectID string + +// MediaObject tracks a stored media file with ownership and metadata. +// The actual file is stored in GCS (production) or MemoryStore (dev). +// This record enables querying, soft deletes, and provenance tracking. +type MediaObject struct { + ID MediaObjectID + UserID UserID + Path string // Storage path (e.g., "media/usr_123/uuid/photo.png") + Filename string // Original filename + ContentType string + Size int64 + GenerationJobID string // Non-empty if created by AI generation + DeletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// IsDeleted returns true if the media object has been soft-deleted. +func (m *MediaObject) IsDeleted() bool { + return m.DeletedAt != nil +} diff --git a/internal/adapter/templates/templates/components/service/internal/domain/session.go.tmpl b/internal/adapter/templates/templates/components/service/internal/domain/session.go.tmpl new file mode 100644 index 0000000..81e89ae --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/domain/session.go.tmpl @@ -0,0 +1,25 @@ +package domain + +import "time" + +// SessionID is a typed session identifier with prefix "ses_". +type SessionID string + +// Session tracks a user login with device and location information. +// The session ID is embedded in the JWT token for revocation support. +type Session struct { + ID SessionID + UserID UserID + IPAddress string + UserAgent string + DeviceLabel string + LastActiveAt time.Time + ExpiresAt time.Time + RevokedAt *time.Time + CreatedAt time.Time +} + +// IsActive returns true if the session has not been revoked and has not expired. +func (s *Session) IsActive() bool { + return s.RevokedAt == nil && time.Now().Before(s.ExpiresAt) +} diff --git a/internal/adapter/templates/templates/components/service/internal/domain/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/domain/user.go.tmpl new file mode 100644 index 0000000..2b24d21 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/domain/user.go.tmpl @@ -0,0 +1,52 @@ +package domain + +import "time" + +// UserID is a typed user identifier with prefix "usr_". +type UserID string + +// UserStatus represents the account state. +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusSuspended UserStatus = "suspended" + UserStatusDeactivated UserStatus = "deactivated" +) + +// User is the full domain model for a registered user. +// This is the database-backed identity, separate from auth.User which is the +// lightweight JWT-derived identity carried in request context. +type User struct { + ID UserID + Email string + EmailVerified bool + Name string + AvatarURL string + Status UserStatus + Roles []string + LastLoginAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// Validation constants for user fields. +const ( + MaxNameLen = 100 + MaxEmailLen = 254 // RFC 5321 +) + +// NewUser creates a new user with default values. +func NewUser(id UserID, email, name string) *User { + now := time.Now() + return &User{ + ID: id, + Email: email, + EmailVerified: false, + Name: name, + Status: UserStatusActive, + Roles: []string{"user"}, + CreatedAt: now, + UpdatedAt: now, + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/port/auth_code.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/auth_code.go.tmpl new file mode 100644 index 0000000..46c549e --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/port/auth_code.go.tmpl @@ -0,0 +1,24 @@ +package port + +import ( + "context" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" +) + +// AuthCodeRepository defines the interface for auth code persistence. +type AuthCodeRepository interface { + // Create persists a new auth code. + Create(ctx context.Context, code *domain.AuthCode) error + + // FindValid returns an unused, non-expired code matching the criteria. + // Returns domain.ErrInvalidAuthCode if no valid code exists. + FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) + + // MarkUsed sets the used_at timestamp on a code, making it single-use. + MarkUsed(ctx context.Context, id string) error + + // DeleteExpired removes codes that have passed their expiry time. + // Returns the number of codes deleted. + DeleteExpired(ctx context.Context) (int, error) +} diff --git a/internal/adapter/templates/templates/components/service/internal/port/email.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/email.go.tmpl new file mode 100644 index 0000000..9b54741 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/port/email.go.tmpl @@ -0,0 +1,11 @@ +package port + +import "context" + +// EmailSender sends emails for authentication flows (OTP, magic link, password reset, etc.). +type EmailSender interface { + // SendAuthCode sends an authentication code to the given email. + // purpose identifies the flow (e.g. "login_otp", "magic_link", "password_reset", "email_verify"). + // code is the token or OTP to include in the email. + SendAuthCode(ctx context.Context, email, code, purpose string) error +} diff --git a/internal/adapter/templates/templates/components/service/internal/port/media.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/media.go.tmpl new file mode 100644 index 0000000..1bb053c --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/port/media.go.tmpl @@ -0,0 +1,38 @@ +package port + +import ( + "context" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" +) + +// MediaRepository defines the interface for media metadata persistence. +type MediaRepository interface { + // Create persists a new media object record. + Create(ctx context.Context, obj *domain.MediaObject) error + + // Get returns a media object by ID. Returns domain.ErrNotFound if not found or soft-deleted. + Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) + + // ListByUser returns non-deleted media objects for a user, ordered by created_at DESC. + ListByUser(ctx context.Context, userID domain.UserID, opts ListMediaOptions) ([]domain.MediaObject, int, error) + + // SoftDelete marks a media object as deleted without removing it. + SoftDelete(ctx context.Context, id domain.MediaObjectID) error + + // HardDelete permanently removes a media object record. + HardDelete(ctx context.Context, id domain.MediaObjectID) error + + // GetByPath returns a media object by its storage path. Returns domain.ErrNotFound if not found. + GetByPath(ctx context.Context, path string) (*domain.MediaObject, error) +} + +// ListMediaOptions controls filtering and pagination for media queries. +type ListMediaOptions struct { + // ContentTypePrefix filters by MIME type prefix (e.g., "image/", "video/"). + ContentTypePrefix string + // Limit is the maximum number of results (0 = default 50). + Limit int + // Offset is the pagination offset. + Offset int +} diff --git a/internal/adapter/templates/templates/components/service/internal/port/session.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/session.go.tmpl new file mode 100644 index 0000000..6b38bee --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/port/session.go.tmpl @@ -0,0 +1,33 @@ +package port + +import ( + "context" + + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" +) + +// SessionRepository defines the interface for session persistence. +type SessionRepository interface { + // Create persists a new session record. + Create(ctx context.Context, session *domain.Session) error + + // Get returns a session by ID. Returns domain.ErrSessionNotFound if not found. + Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) + + // ListByUser returns all active (non-revoked) sessions for a user. + ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error) + + // UpdateLastActive updates the last_active_at timestamp for a session. + UpdateLastActive(ctx context.Context, id domain.SessionID) error + + // Revoke marks a session as revoked by setting revoked_at. + Revoke(ctx context.Context, id domain.SessionID) error + + // RevokeAllForUser revokes all sessions for a user. + // If exceptID is non-nil, that session is kept active. + RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error + + // DeleteExpired removes sessions that have passed their expiry time. + // Returns the number of sessions deleted. + DeleteExpired(ctx context.Context) (int, error) +} diff --git a/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl index 2b1fc68..c79a326 100644 --- a/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/port/user.go.tmpl @@ -3,21 +3,49 @@ package port import ( "context" - "{{GO_MODULE}}/pkg/auth" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" ) -// UserRepository defines the interface for user lookup operations. -// Used by AuthService for authentication. +// UserRepository defines the interface for user persistence. type UserRepository interface { - // FindByEmail returns a user by email address. - // Returns nil if not found (no error). - FindByEmail(ctx context.Context, email string) (*auth.User, error) + // Create persists a new user. + Create(ctx context.Context, user *domain.User) error - // FindByID returns a user by ID. - // Returns nil if not found (no error). - FindByID(ctx context.Context, id string) (*auth.User, error) + // Get returns a user by ID. Returns domain.ErrUserNotFound if not found. + Get(ctx context.Context, id domain.UserID) (*domain.User, error) - // ValidatePassword checks if the password matches for a user. - // Returns true if valid, false otherwise. - ValidatePassword(ctx context.Context, user *auth.User, password string) bool + // GetByEmail returns a user by email. Returns domain.ErrUserNotFound if not found. + GetByEmail(ctx context.Context, email string) (*domain.User, error) + + // Update persists changes to an existing user. + Update(ctx context.Context, user *domain.User) error + + // UpdateLastLogin sets the last_login_at timestamp. + UpdateLastLogin(ctx context.Context, id domain.UserID) error + + // ExistsByEmail returns true if a user with the given email exists. + ExistsByEmail(ctx context.Context, email string) (bool, error) + + // Password operations (separate from user CRUD because OAuth-only users have no password) + + // SetPassword stores a bcrypt hash for a user. Creates or replaces existing. + SetPassword(ctx context.Context, userID domain.UserID, hash string) error + + // GetPasswordHash returns the bcrypt hash for a user. + // Returns empty string and nil error if user has no password set. + GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error) + + // HasPassword returns true if the user has a password set. + HasPassword(ctx context.Context, userID domain.UserID) (bool, error) + + // Role operations + + // AddRole grants a role to a user. No-op if already granted. + AddRole(ctx context.Context, userID domain.UserID, role string) error + + // RemoveRole revokes a role from a user. No-op if not granted. + RemoveRole(ctx context.Context, userID domain.UserID, role string) error + + // GetRoles returns all roles for a user. + GetRoles(ctx context.Context, userID domain.UserID) ([]string, error) } diff --git a/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl index 7e42177..d7cd85f 100644 --- a/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl @@ -2,96 +2,603 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" "errors" + "fmt" + "math/big" + "net/url" + "strings" "time" "{{GO_MODULE}}/pkg/auth" "{{GO_MODULE}}/pkg/logging" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" ) -// Auth errors. -var ( - ErrInvalidCredentials = errors.New("invalid email or password") - ErrUserNotFound = errors.New("user not found") +const ( + // TokenLifetime is the access token duration (short-lived, requires refresh). + TokenLifetime = 15 * time.Minute + // SessionLifetime is how long a session stays valid before requiring re-login. + SessionLifetime = 30 * 24 * time.Hour // 30 days + // OTPExpiry is how long a one-time password is valid. + OTPExpiry = 10 * time.Minute + // MagicLinkExpiry is how long a magic link token is valid. + MagicLinkExpiry = 15 * time.Minute + // PasswordResetExpiry is how long a password reset token is valid. + PasswordResetExpiry = 1 * time.Hour + // EmailVerifyExpiry is how long an email verification code is valid. + EmailVerifyExpiry = 24 * time.Hour ) -// AuthService handles authentication logic. +// AuthService handles all authentication and identity flows. type AuthService struct { - userRepo port.UserRepository - jwtSecret []byte - issuer string - logger *logging.Logger + users port.UserRepository + sessions port.SessionRepository + codes port.AuthCodeRepository + email port.EmailSender + jwtSecret []byte + issuer string + registrationEnabled bool + logger *logging.Logger } // NewAuthService creates a new auth service. -func NewAuthService(userRepo port.UserRepository, jwtSecret string, logger *logging.Logger) *AuthService { +func NewAuthService( + users port.UserRepository, + sessions port.SessionRepository, + codes port.AuthCodeRepository, + email port.EmailSender, + jwtSecret string, + registrationEnabled bool, + logger *logging.Logger, +) *AuthService { return &AuthService{ - userRepo: userRepo, - jwtSecret: []byte(jwtSecret), - issuer: "{{PROJECT_NAME}}", - logger: logger.WithService("AuthService"), + users: users, + sessions: sessions, + codes: codes, + email: email, + jwtSecret: []byte(jwtSecret), + issuer: "{{PROJECT_NAME}}", + registrationEnabled: registrationEnabled, + logger: logger.WithService("AuthService"), } } -// LoginInput contains the data needed to log in. -type LoginInput struct { - Email string - Password string -} - -// LoginOutput contains the login result. +// LoginOutput is the result of a successful login or registration. type LoginOutput struct { Token string - User *auth.User + User *domain.User } -// Login authenticates a user and returns a JWT token. -func (s *AuthService) Login(ctx context.Context, input LoginInput) (*LoginOutput, error) { - // Find user by email - user, err := s.userRepo.FindByEmail(ctx, input.Email) +// Register creates a new user account with email and password. +func (s *AuthService) Register(ctx context.Context, email, password, name, ip, userAgent string) (*LoginOutput, error) { + if !s.registrationEnabled { + return nil, domain.ErrRegistrationDisabled + } + + if err := auth.ValidatePasswordStrength(password); err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) + } + + name = strings.TrimSpace(name) + if len(name) > domain.MaxNameLen { + return nil, domain.ErrNameTooLong + } + if len(email) > domain.MaxEmailLen { + return nil, domain.ErrEmailTooLong + } + + exists, err := s.users.ExistsByEmail(ctx, email) if err != nil { return nil, err } - if user == nil { - s.logger.Warn("login attempt for unknown email", "email", input.Email) - return nil, ErrInvalidCredentials + if exists { + return nil, domain.ErrDuplicateEmail } - // Validate password - if !s.userRepo.ValidatePassword(ctx, user, input.Password) { - s.logger.Warn("invalid password attempt", "email", input.Email) - return nil, ErrInvalidCredentials - } - - // Generate JWT token - token, err := auth.GenerateTokenWithIssuer( - s.jwtSecret, - user, - 24*time.Hour, // 24 hour expiration - s.issuer, - s.issuer, // audience = issuer for simplicity - ) + hash, err := auth.HashPassword(password) if err != nil { + return nil, fmt.Errorf("hashing password: %w", err) + } + + userID := domain.UserID("usr_" + generateID()) + user := domain.NewUser(userID, email, name) + + if err := s.users.Create(ctx, user); err != nil { + return nil, err + } + if err := s.users.SetPassword(ctx, userID, hash); err != nil { return nil, err } - s.logger.Info("user logged in", "user_id", user.ID, "email", user.Email) + s.logger.Info("user registered", "user_id", string(userID), "email", email) - return &LoginOutput{ - Token: token, - User: user, - }, nil + return s.createSession(ctx, user, ip, userAgent) } -// GetCurrentUser returns the user for the given ID. -func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*auth.User, error) { - user, err := s.userRepo.FindByID(ctx, userID) +// LoginWithPassword authenticates a user with email and password. +func (s *AuthService) LoginWithPassword(ctx context.Context, email, password, ip, userAgent string) (*LoginOutput, error) { + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + return nil, domain.ErrInvalidCredentials + } + return nil, err + } + + if user.Status == domain.UserStatusSuspended { + return nil, domain.ErrUserSuspended + } + + hash, err := s.users.GetPasswordHash(ctx, user.ID) if err != nil { return nil, err } - if user == nil { - return nil, ErrUserNotFound + if hash == "" || !auth.CheckPassword(password, hash) { + s.logger.Warn("invalid password attempt", "email", email) + return nil, domain.ErrInvalidCredentials + } + + _ = s.users.UpdateLastLogin(ctx, user.ID) + s.logger.Info("user logged in", "user_id", string(user.ID), "email", email) + + return s.createSession(ctx, user, ip, userAgent) +} + +// RefreshToken issues a new access token if the session is still active. +func (s *AuthService) RefreshToken(ctx context.Context, sessionID string, userID string) (*LoginOutput, error) { + sid := domain.SessionID(sessionID) + session, err := s.sessions.Get(ctx, sid) + if err != nil { + return nil, domain.ErrSessionNotFound + } + if !session.IsActive() { + return nil, domain.ErrSessionRevoked + } + + user, err := s.users.Get(ctx, domain.UserID(userID)) + if err != nil { + return nil, err + } + if user.Status == domain.UserStatusSuspended { + return nil, domain.ErrUserSuspended + } + + _ = s.sessions.UpdateLastActive(ctx, sid) + + token, err := s.generateToken(user, sessionID) + if err != nil { + return nil, err + } + + return &LoginOutput{Token: token, User: user}, nil +} + +// Logout revokes the current session. +func (s *AuthService) Logout(ctx context.Context, sessionID string) error { + if sessionID == "" { + return nil + } + return s.sessions.Revoke(ctx, domain.SessionID(sessionID)) +} + +// LogoutAll revokes all sessions for a user, optionally keeping one. +func (s *AuthService) LogoutAll(ctx context.Context, userID string, exceptSessionID *string) error { + var except *domain.SessionID + if exceptSessionID != nil { + sid := domain.SessionID(*exceptSessionID) + except = &sid + } + return s.sessions.RevokeAllForUser(ctx, domain.UserID(userID), except) +} + +// CheckSession returns whether a session is active (not revoked, not expired). +// Used as auth.SessionChecker for the SessionCheck middleware. +func (s *AuthService) CheckSession(ctx context.Context, sessionID string) (bool, error) { + session, err := s.sessions.Get(ctx, domain.SessionID(sessionID)) + if err != nil { + return false, nil + } + return session.IsActive(), nil +} + +// ListSessions returns all active sessions for a user. +func (s *AuthService) ListSessions(ctx context.Context, userID string) ([]domain.Session, error) { + return s.sessions.ListByUser(ctx, domain.UserID(userID)) +} + +// RevokeSession revokes a specific session for a user. +func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID string) error { + session, err := s.sessions.Get(ctx, domain.SessionID(sessionID)) + if err != nil { + return err + } + if session.UserID != domain.UserID(userID) { + return domain.ErrSessionNotFound + } + return s.sessions.Revoke(ctx, domain.SessionID(sessionID)) +} + +// GetCurrentUser returns the full user for the given ID. +func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*domain.User, error) { + return s.users.Get(ctx, domain.UserID(userID)) +} + +// UpdateProfile updates a user's name and avatar. +func (s *AuthService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*domain.User, error) { + user, err := s.users.Get(ctx, domain.UserID(userID)) + if err != nil { + return nil, err + } + + if name != "" { + name = strings.TrimSpace(name) + if len(name) > domain.MaxNameLen { + return nil, domain.ErrNameTooLong + } + user.Name = name + } + if avatarURL != "" { + if err := validateAvatarURL(avatarURL); err != nil { + return nil, err + } + user.AvatarURL = avatarURL + } + + if err := s.users.Update(ctx, user); err != nil { + return nil, err } return user, nil } + +// ChangePassword changes a user's password after verifying the current one. +func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error { + uid := domain.UserID(userID) + + hash, err := s.users.GetPasswordHash(ctx, uid) + if err != nil { + return err + } + if hash == "" || !auth.CheckPassword(currentPassword, hash) { + return domain.ErrInvalidCredentials + } + + if err := auth.ValidatePasswordStrength(newPassword); err != nil { + return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) + } + + newHash, err := auth.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + + return s.users.SetPassword(ctx, uid, newHash) +} + +// SendOTP generates and logs a one-time password for the given email. +// In production, this would send an email. In dev mode, the code is logged to stdout. +func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error { + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + // Don't reveal whether email exists + s.logger.Info("OTP requested for unknown email", "email", email) + return nil + } + return err + } + + code := generateOTP() + uid := user.ID + authCode := &domain.AuthCode{ + ID: "acd_" + generateID(), + UserID: &uid, + Email: email, + Code: code, + Purpose: domain.PurposeLoginOTP, + ExpiresAt: time.Now().Add(OTPExpiry), + IPAddress: ip, + CreatedAt: time.Now(), + } + + if err := s.codes.Create(ctx, authCode); err != nil { + return err + } + + s.logger.Info("auth code created", "purpose", "login_otp", "email", email, "code_id", authCode.ID) + if err := s.email.SendAuthCode(ctx, email, code, string(domain.PurposeLoginOTP)); err != nil { + s.logger.Error("failed to send OTP email", "email", email, "error", err) + } + return nil +} + +// VerifyOTP verifies a one-time password and returns a login token. +func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) { + authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP) + if err != nil { + return nil, domain.ErrInvalidAuthCode + } + + if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { + return nil, err + } + + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + return nil, err + } + + _ = s.users.UpdateLastLogin(ctx, user.ID) + s.logger.Info("user logged in via OTP", "user_id", string(user.ID), "email", email) + + return s.createSession(ctx, user, ip, userAgent) +} + +// SendMagicLink generates and logs a magic link token. +func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error { + // Magic links can work for existing users. + // Don't reveal whether email exists — but propagate infrastructure errors. + user, err := s.users.GetByEmail(ctx, email) + if err != nil && !errors.Is(err, domain.ErrUserNotFound) { + return err + } + + token := generateHexToken() + var uid *domain.UserID + if user != nil { + uid = &user.ID + } + + authCode := &domain.AuthCode{ + ID: "acd_" + generateID(), + UserID: uid, + Email: email, + Code: token, + Purpose: domain.PurposeMagicLink, + ExpiresAt: time.Now().Add(MagicLinkExpiry), + IPAddress: ip, + CreatedAt: time.Now(), + } + + if err := s.codes.Create(ctx, authCode); err != nil { + return err + } + + s.logger.Info("auth code created", "purpose", "magic_link", "email", email, "code_id", authCode.ID) + if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposeMagicLink)); err != nil { + s.logger.Error("failed to send magic link email", "email", email, "error", err) + } + return nil +} + +// VerifyMagicLink verifies a magic link token and returns a login token. +func (s *AuthService) VerifyMagicLink(ctx context.Context, email, token, ip, userAgent string) (*LoginOutput, error) { + authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposeMagicLink) + if err != nil { + return nil, domain.ErrInvalidAuthCode + } + + if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { + return nil, err + } + + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + return nil, err + } + + _ = s.users.UpdateLastLogin(ctx, user.ID) + s.logger.Info("user logged in via magic link", "user_id", string(user.ID), "email", email) + + return s.createSession(ctx, user, ip, userAgent) +} + +// ForgotPassword generates a password reset token. +func (s *AuthService) ForgotPassword(ctx context.Context, email, ip string) error { + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + // Don't reveal whether email exists + s.logger.Info("password reset requested for unknown email", "email", email) + return nil + } + return err + } + + token := generateHexToken() + uid := user.ID + authCode := &domain.AuthCode{ + ID: "acd_" + generateID(), + UserID: &uid, + Email: email, + Code: token, + Purpose: domain.PurposePasswordReset, + ExpiresAt: time.Now().Add(PasswordResetExpiry), + IPAddress: ip, + CreatedAt: time.Now(), + } + + if err := s.codes.Create(ctx, authCode); err != nil { + return err + } + + s.logger.Info("auth code created", "purpose", "password_reset", "email", email, "code_id", authCode.ID) + if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposePasswordReset)); err != nil { + s.logger.Error("failed to send password reset email", "email", email, "error", err) + } + return nil +} + +// ResetPassword sets a new password using a reset token and revokes all sessions. +func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error { + authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposePasswordReset) + if err != nil { + return domain.ErrInvalidAuthCode + } + + if err := auth.ValidatePasswordStrength(newPassword); err != nil { + return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err) + } + + user, err := s.users.GetByEmail(ctx, email) + if err != nil { + return err + } + + hash, err := auth.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + + if err := s.users.SetPassword(ctx, user.ID, hash); err != nil { + return err + } + if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { + return err + } + + // Revoke all sessions — user must re-login with new password. + _ = s.sessions.RevokeAllForUser(ctx, user.ID, nil) + s.logger.Info("password reset completed", "user_id", string(user.ID), "email", email) + + return nil +} + +// SendVerifyEmail generates an email verification code. +func (s *AuthService) SendVerifyEmail(ctx context.Context, userID string) error { + user, err := s.users.Get(ctx, domain.UserID(userID)) + if err != nil { + return err + } + if user.EmailVerified { + return nil + } + + code := generateOTP() + uid := user.ID + authCode := &domain.AuthCode{ + ID: "acd_" + generateID(), + UserID: &uid, + Email: user.Email, + Code: code, + Purpose: domain.PurposeEmailVerify, + ExpiresAt: time.Now().Add(EmailVerifyExpiry), + CreatedAt: time.Now(), + } + + if err := s.codes.Create(ctx, authCode); err != nil { + return err + } + + s.logger.Info("auth code created", "purpose", "email_verify", "email", user.Email, "code_id", authCode.ID) + if err := s.email.SendAuthCode(ctx, user.Email, code, string(domain.PurposeEmailVerify)); err != nil { + s.logger.Error("failed to send email verification", "email", user.Email, "error", err) + } + return nil +} + +// VerifyEmail marks the user's email as verified. +func (s *AuthService) VerifyEmail(ctx context.Context, userID, code string) error { + user, err := s.users.Get(ctx, domain.UserID(userID)) + if err != nil { + return err + } + + authCode, err := s.codes.FindValid(ctx, user.Email, code, domain.PurposeEmailVerify) + if err != nil { + return domain.ErrInvalidAuthCode + } + + if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil { + return err + } + + user.EmailVerified = true + if err := s.users.Update(ctx, user); err != nil { + return err + } + + s.logger.Info("email verified", "user_id", userID, "email", user.Email) + return nil +} + +// createSession creates a session record and generates a JWT. +func (s *AuthService) createSession(ctx context.Context, user *domain.User, ip, userAgent string) (*LoginOutput, error) { + sessionID := "ses_" + generateID() + now := time.Now() + + session := &domain.Session{ + ID: domain.SessionID(sessionID), + UserID: user.ID, + IPAddress: ip, + UserAgent: userAgent, + DeviceLabel: auth.ParseDeviceLabel(userAgent), + LastActiveAt: now, + ExpiresAt: now.Add(SessionLifetime), + CreatedAt: now, + } + + if err := s.sessions.Create(ctx, session); err != nil { + return nil, err + } + + token, err := s.generateToken(user, sessionID) + if err != nil { + return nil, err + } + + return &LoginOutput{Token: token, User: user}, nil +} + +// generateToken creates a JWT for the user with the given session ID. +func (s *AuthService) generateToken(user *domain.User, sessionID string) (string, error) { + authUser := &auth.User{ + ID: string(user.ID), + Email: user.Email, + Roles: user.Roles, + } + return auth.GenerateTokenWithSession( + s.jwtSecret, authUser, TokenLifetime, s.issuer, s.issuer, sessionID, + ) +} + +// generateID returns a random hex string suitable for entity IDs. +func generateID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// generateOTP returns a 6-digit numeric one-time password. +func generateOTP() string { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return fmt.Sprintf("%06d", n.Int64()) +} + +// validateAvatarURL checks that the URL uses http or https. +func validateAvatarURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return domain.ErrInvalidAvatarURL + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return domain.ErrInvalidAvatarURL + } + return nil +} + +// generateHexToken returns a 32-character hex token for magic links and resets. +func generateHexToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl index acaa962..498978b 100644 --- a/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl +++ b/internal/adapter/templates/templates/components/worker/cmd/worker/main.go.tmpl @@ -197,7 +197,7 @@ func main() { // GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted). var mediaStore storage.Store if bucket := os.Getenv("GCS_BUCKET"); bucket != "" { - gcsStore, err := storage.NewGCSStore(bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger) + gcsStore, err := storage.NewGCSStore(ctx, bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger) if err != nil { logger.Warn("failed to create GCS store, generated media will not be persisted", "error", err) } else { diff --git a/internal/adapter/templates/templates/skeleton/.claude/agents/database-architect.md b/internal/adapter/templates/templates/skeleton/.claude/agents/database-architect.md index abae22c..b0cce9d 100644 --- a/internal/adapter/templates/templates/skeleton/.claude/agents/database-architect.md +++ b/internal/adapter/templates/templates/skeleton/.claude/agents/database-architect.md @@ -41,6 +41,21 @@ You design database schemas and optimize queries for {{PROJECT_NAME}}. Every ser - Composite indexes: most selective column first - Name format: `idx_{table}_{columns}` +## Auth Tables (built-in) + +These tables are auto-created by `001_create_users.sql`: + +| Table | Purpose | Key Columns | +|-------|---------|-------------| +| `users` | Core identity | `id TEXT PK`, `email UNIQUE`, `email_verified`, `status` | +| `user_passwords` | Bcrypt hashes | `user_id TEXT PK FK`, `password_hash` | +| `sessions` | Login tracking | `user_id FK`, `ip_address`, `device_label`, `revoked_at` | +| `auth_codes` | OTP/magic/reset | `email`, `code`, `purpose`, `expires_at`, `used_at` | +| `user_roles` | Role assignments | `(user_id, role) PK` | +| `oauth_connections` | OAuth providers | `(provider, provider_user_id) UNIQUE` | + +Key indexes: `idx_auth_codes_email_purpose` (partial, WHERE used_at IS NULL), `idx_sessions_user_id` (partial, WHERE revoked_at IS NULL). + ## Migration Rules - NEVER modify committed migrations diff --git a/internal/adapter/templates/templates/skeleton/.claude/agents/security-architect.md b/internal/adapter/templates/templates/skeleton/.claude/agents/security-architect.md index 012b42b..7f9c20b 100644 --- a/internal/adapter/templates/templates/skeleton/.claude/agents/security-architect.md +++ b/internal/adapter/templates/templates/skeleton/.claude/agents/security-architect.md @@ -10,26 +10,45 @@ You enforce security best practices across {{PROJECT_NAME}}. Authentication is c ## Authentication -### JWT Pattern -- Tokens issued by auth service -- Other services validate tokens via middleware -- Short-lived access tokens + longer refresh tokens -- Never store tokens in localStorage (use httpOnly cookies) +### JWT Token Lifecycle +- **Access tokens:** 15 minutes, signed with `JWT_SECRET` +- **Session ID:** Embedded as `sid` claim for revocation support +- **Refresh:** POST `/auth/refresh` issues new token, same session +- **Revocation:** Revoking a session invalidates all tokens for that session -### Middleware +### Password Security +- **Hashing:** bcrypt cost 12 (`pkg/auth/password.go`) +- **Strength:** Min 8 chars, max 72 (bcrypt limit), requires uppercase + lowercase + digit +- **Storage:** Separate `user_passwords` table (OAuth-only users have no password row) + +### Auth Codes +- **OTP login:** 6-digit numeric, 10-minute expiry +- **Magic links:** 32-char hex token, 15-minute expiry +- **Password reset:** 32-char hex token, 1-hour expiry +- **Email verification:** 6-digit numeric, 24-hour expiry +- All codes are single-use (marked with `used_at` timestamp) +- In dev mode (`NOTIFY_URL` unset): codes logged to stdout +- In production: emails sent via the notify service, which handles provider routing, retries, and suppression + +### Session Management +- Sessions track IP address, user agent, device label +- 30-day session lifetime +- Revokable individually or all-at-once (except current) +- `SessionCheck` middleware (opt-in) validates session on every request + +### Middleware Stack ```go -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := extractToken(r) - claims, err := validateToken(token) - if err != nil { - httpresponse.Unauthorized(w, "invalid token") - return - } - ctx := context.WithValue(r.Context(), userKey, claims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} +// Auth middleware validates JWT and sets user in context +r.Use(auth.Middleware(auth.MiddlewareConfig{ + Validator: jwtValidator, +})) + +// Optional: enforce session revocation +r.Use(auth.SessionCheck(checker)) + +// Require specific roles +r.Use(auth.RequireRole("admin")) +``` ``` ## Input Validation @@ -54,7 +73,7 @@ func AuthMiddleware(next http.Handler) http.Handler { |------|-----------| | SQL Injection | Parameterized queries only | | XSS | Sanitize input, escape output | -| CSRF | CSRF tokens for state-changing requests | +| CSRF | Not applicable — all auth uses Bearer tokens in Authorization header, not cookies | | Auth Bypass | Middleware on every protected route | | Secret Exposure | .env in .gitignore, no hardcoding | | Mass Assignment | Explicit field mapping (no bind-all) | diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/auth.md b/internal/adapter/templates/templates/skeleton/.claude/guides/auth.md new file mode 100644 index 0000000..61c0118 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/auth.md @@ -0,0 +1,146 @@ +# Authentication & User Management + +Complete auth system with registration, login, sessions, and verification flows. + +## Architecture + +``` +Frontend (AuthProvider) → HTTP → Auth Handlers → AuthService → Repositories (Memory | Postgres) +``` + +- **pkg/auth/** — JWT validation, middleware, password hashing, session checking (shared) +- **service/internal/domain/** — User, Session, AuthCode domain models +- **service/internal/port/** — Repository interfaces (UserRepository, SessionRepository, AuthCodeRepository) +- **service/internal/adapter/memory/** — In-memory implementations for standalone dev +- **service/internal/adapter/postgres/** — PostgreSQL/CockroachDB implementations for production +- **service/internal/service/auth.go** — Business logic (AuthService) +- **service/internal/api/handlers/auth.go** — Core HTTP handlers (login, register, profile) +- **service/internal/api/handlers/auth_flows.go** — Flow handlers (OTP, magic link, sessions, reset) + +## Standalone Mode (No DATABASE_URL) + +When `DATABASE_URL` is not set, the service runs with in-memory adapters: +- Two demo users seeded: `test@example.com` / `Password123`, `admin@example.com` / `Admin1234` +- Auth codes (OTP, magic links, reset tokens) logged to stdout (no notify/email needed) +- Sessions stored in memory (lost on restart) +- No external dependencies required + +## Token Lifecycle + +- **Access token:** 15 minutes, JWT with embedded session ID (`sid` claim) +- **Refresh:** POST `/auth/refresh` with valid token returns new token (same session) +- **Session:** 30-day lifetime, tracked in sessions table +- **Revocation:** Revoking a session invalidates all tokens for that session + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `JWT_SECRET` | `""` | Secret for signing JWT tokens | +| `REGISTRATION_ENABLED` | `true` | Allow new user registration | +| `DATABASE_URL` | `""` | If set, use Postgres repos; otherwise in-memory | +| `NOTIFY_URL` | `""` | Notify service URL. If set, emails sent via notify; otherwise logged to stdout | +| `NOTIFY_API_KEY` | `""` | Per-project notify send key (`notify_send_xxx`) | +| `NOTIFY_HOST` | `""` | Sending domain (e.g. `myapp.threesix.ai`) | +| `NOTIFY_FROM` | `noreply@{project}.com` | Registered sender address | + +## Auth Flows + +### Password Login +``` +POST /auth/login { email, password } → { token, user } +``` + +### Registration +``` +POST /auth/register { email, password, name } → { token, user } +``` + +### OTP Login +``` +POST /auth/otp/send { email } → 200 (code logged to stdout in dev) +POST /auth/otp/verify { email, code } → { token, user } +``` + +### Magic Link +``` +POST /auth/magic-link { email } → 200 (token logged to stdout in dev) +POST /auth/magic-link/verify { email, token } → { token, user } +``` + +### Password Reset +``` +POST /auth/forgot-password { email } → 200 (token logged to stdout in dev) +POST /auth/reset-password { email, token, newPassword } → 200 +``` + +### Email Verification (requires auth) +``` +POST /auth/verify-email/send → 200 (code logged to stdout in dev) +POST /auth/verify-email { code } → 200 +``` + +### Session Management (requires auth) +``` +GET /auth/sessions → [{ id, deviceLabel, ipAddress, lastActiveAt, isCurrent }] +DELETE /auth/sessions/{id} → 204 +DELETE /auth/sessions → 204 (revoke all except current) +``` + +### Profile (requires auth) +``` +GET /auth/me → { user } +PUT /auth/me { name, avatarUrl } → { user } +POST /auth/change-password { currentPassword, newPassword } → 200 +POST /auth/logout → 204 +``` + +## Frontend Integration + +The `@{{PROJECT_NAME}}/auth` package provides `AuthProvider` and `useAuth()` hook: + +```tsx +// In App.tsx + + + + +// In components +const { user, login, register, logout, sendOTP, loginWithOTP } = useAuth(); +``` + +Auto-refresh schedules token renewal at 80% of token lifetime. + +## Adding Session Revocation Middleware + +To enforce session revocation on every request (opt-in): + +```go +import "{{GO_MODULE}}/pkg/auth" + +checker := func(ctx context.Context, sid string) (bool, error) { + session, err := sessionRepo.Get(ctx, domain.SessionID(sid)) + if err != nil { return false, nil } + return session.IsActive(), nil +} + +r.Use(auth.SessionCheck(checker)) +``` + +## Password Requirements + +- Minimum 8 characters, maximum 72 (bcrypt limit) +- Must contain uppercase, lowercase, and digit +- Hashed with bcrypt cost 12 + +## Database Tables + +When `DATABASE_URL` is set, these tables are auto-created: +- `users` — Core identity (email, name, status) +- `user_passwords` — Bcrypt hashes (separate for OAuth-only users) +- `sessions` — Login sessions with IP/device tracking +- `auth_codes` — OTP, magic link, reset, and verification codes +- `user_roles` — Many-to-many user roles +- `oauth_connections` — Schema placeholder for future OAuth provider links (table exists but no handlers/adapters yet) + +> **Note:** The `oauth_connections` table is created by the migration but has no corresponding handlers, service methods, or adapters. It's a schema placeholder — implementing OAuth requires building the full handler → service → adapter chain. See the composable monorepo templates guide for adding new auth providers. diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl index 6f79b36..7e60593 100644 --- a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl +++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl @@ -10,6 +10,7 @@ | **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) | | **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.md) | | **Frontend design system** | [frontend/design-system.md](.claude/guides/frontend/design-system.md) | +| **Auth & user management** | [auth.md](.claude/guides/auth.md) | | **Event channels** | [events.md](.claude/guides/events.md) | | **Media pipeline** | [media.md](.claude/guides/media.md) | | **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) | @@ -46,6 +47,9 @@ - **Media generation:** Same pattern - POST queues job, returns ID, results via SSE. Video takes 2-5 min; never block HTTP. Text generation streams `ai_chat_chunk` events token-by-token. - **Media storage:** Backend returns complete URLs. Never construct storage paths in frontend. Variants (thumbnail, optimized) auto-generated. - **No fake progress:** Never simulate progress with timers. Real progress comes from real events. +- **Auth tokens:** 15-minute access tokens with embedded session ID (`sid`). Refresh via POST `/auth/refresh`. Session revocation invalidates all tokens for that session. +- **Passwords:** Bcrypt cost 12, min 8 chars, max 72. Hashing lives in `pkg/auth/password.go`. Never store plaintext. +- **Auth codes:** OTP/magic link/reset codes are single-use and time-limited. In dev mode (`NOTIFY_URL` unset), codes are logged to stdout. In production, emails go through the notify service (`NOTIFY_URL`/`NOTIFY_API_KEY`/`NOTIFY_HOST`/`NOTIFY_FROM`). ## Architecture diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx index c8c8c55..c4fd718 100644 --- a/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/AuthProvider.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { createContext, useContext, useCallback, useMemo, useEffect, useState } from 'react'; -import type { User, AuthState, LoginCredentials } from './types'; +import { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react'; +import type { User, AuthState, LoginCredentials, RegisterCredentials, OTPVerifyCredentials, MagicLinkVerifyCredentials } from './types'; const TOKEN_STORAGE_KEY = 'auth_token'; const USER_STORAGE_KEY = 'auth_user'; @@ -9,18 +9,30 @@ const USER_STORAGE_KEY = 'auth_user'; * Authentication context value. */ export interface AuthContextValue extends AuthState { - /** Log in with credentials */ + /** Log in with email and password */ login: (credentials: LoginCredentials) => Promise; /** Log in with a token directly */ loginWithToken: (token: string, user?: User) => void; + /** Register a new account */ + register: (credentials: RegisterCredentials) => Promise; /** Log out the current user */ - logout: () => void; + logout: () => Promise; /** Get the current access token */ getToken: () => string | null; /** Check if user has a specific role */ hasRole: (role: string) => boolean; /** Check if user has a specific scope */ hasScope: (scope: string) => boolean; + /** Send an OTP code to an email */ + sendOTP: (email: string) => Promise; + /** Log in with an OTP code */ + loginWithOTP: (credentials: OTPVerifyCredentials) => Promise; + /** Send a magic link to an email */ + sendMagicLink: (email: string) => Promise; + /** Log in with a magic link token */ + loginWithMagicLink: (credentials: MagicLinkVerifyCredentials) => Promise; + /** Refresh the access token */ + refreshToken: () => Promise; } const AuthContext = createContext(null); @@ -30,10 +42,16 @@ const AuthContext = createContext(null); */ export interface AuthProviderProps { children: React.ReactNode; - /** API endpoint for login */ + /** API base URL for auth endpoints (e.g. "/api/my-service") */ + authBaseUrl?: string; + /** API endpoint for login (defaults to authBaseUrl + "/auth/login") */ loginUrl?: string; /** API endpoint for logout */ logoutUrl?: string; + /** API endpoint for registration */ + registerUrl?: string; + /** API endpoint for token refresh */ + refreshUrl?: string; /** Custom login handler */ onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>; /** Custom logout handler */ @@ -44,32 +62,28 @@ export interface AuthProviderProps { /** * AuthProvider manages authentication state and provides auth methods. - * - * @example - * // Basic usage - * - * - * - * - * @example - * // With custom handlers - * { - * const res = await myAuthService.login(creds); - * return { token: res.token, user: res.user }; - * }} - * > - * - * */ export function AuthProvider({ children, - loginUrl = '/api/auth/login', - logoutUrl = '/api/auth/logout', + authBaseUrl, + loginUrl, + logoutUrl, + registerUrl, + refreshUrl, onLogin, onLogout, storage = 'localStorage', }: AuthProviderProps) { + // Derive URLs from authBaseUrl if individual URLs not provided + const resolvedLoginUrl = loginUrl || (authBaseUrl ? `${authBaseUrl}/auth/login` : '/api/auth/login'); + const resolvedLogoutUrl = logoutUrl || (authBaseUrl ? `${authBaseUrl}/auth/logout` : '/api/auth/logout'); + const resolvedRegisterUrl = registerUrl || (authBaseUrl ? `${authBaseUrl}/auth/register` : '/api/auth/register'); + const resolvedRefreshUrl = refreshUrl || (authBaseUrl ? `${authBaseUrl}/auth/refresh` : '/api/auth/refresh'); + const resolvedOtpSendUrl = authBaseUrl ? `${authBaseUrl}/auth/otp/send` : '/api/auth/otp/send'; + const resolvedOtpVerifyUrl = authBaseUrl ? `${authBaseUrl}/auth/otp/verify` : '/api/auth/otp/verify'; + const resolvedMagicLinkUrl = authBaseUrl ? `${authBaseUrl}/auth/magic-link` : '/api/auth/magic-link'; + const resolvedMagicLinkVerifyUrl = authBaseUrl ? `${authBaseUrl}/auth/magic-link/verify` : '/api/auth/magic-link/verify'; + const [state, setState] = useState({ user: null, isLoading: true, @@ -77,12 +91,86 @@ export function AuthProvider({ error: null, }); + const refreshTimerRef = useRef | null>(null); + // Get storage implementation const getStorage = useCallback(() => { if (storage === 'none') return null; return storage === 'sessionStorage' ? sessionStorage : localStorage; }, [storage]); + // Store token and user + const persistAuth = useCallback((token: string, user: User) => { + const store = getStorage(); + if (store) { + store.setItem(TOKEN_STORAGE_KEY, token); + store.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + } + }, [getStorage]); + + // Clear stored auth + const clearAuth = useCallback(() => { + const store = getStorage(); + if (store) { + store.removeItem(TOKEN_STORAGE_KEY); + store.removeItem(USER_STORAGE_KEY); + } + }, [getStorage]); + + // Schedule token refresh (at 80% of token lifetime) + const scheduleRefresh = useCallback((token: string) => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + } + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const exp = payload.exp * 1000; + const iat = payload.iat * 1000; + const lifetime = exp - iat; + const refreshAt = iat + lifetime * 0.8; + const delay = refreshAt - Date.now(); + + if (delay > 0) { + refreshTimerRef.current = setTimeout(async () => { + try { + const store = getStorage(); + const currentToken = store?.getItem(TOKEN_STORAGE_KEY); + if (!currentToken) return; + + const response = await fetch(resolvedRefreshUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${currentToken}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + const newToken = data.data?.token || data.token; + const newUser = data.data?.user || data.user; + persistAuth(newToken, newUser); + setState(s => ({ ...s, user: newUser })); + scheduleRefresh(newToken); + } else if (response.status === 401) { + // Session revoked or token invalid — force logout. + clearAuth(); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + } + } catch { + // Refresh failed (network error) — clear auth to prevent silent expiry. + clearAuth(); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + } + }, delay); + } + } catch { + // Corrupted token in storage — clear it and force logout. + clearAuth(); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + } + }, [getStorage, persistAuth, clearAuth, resolvedRefreshUrl]); + // Initialize auth state from storage useEffect(() => { const store = getStorage(); @@ -97,14 +185,70 @@ export function AuthProvider({ if (token && userJson) { try { const user = JSON.parse(userJson) as User; - setState({ - user, - isLoading: false, - isAuthenticated: true, - error: null, - }); + + // Check if the stored token is already expired + let tokenExpired = false; + let deeplyExpired = false; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const exp = payload.exp * 1000; + tokenExpired = exp < Date.now(); + // Session is deeply expired if token expired over 30 days ago + deeplyExpired = exp + 30 * 24 * 60 * 60 * 1000 < Date.now(); + } catch { + // Corrupted token — treat as expired + tokenExpired = true; + deeplyExpired = true; + } + + if (deeplyExpired) { + // Session expired beyond recovery — clear auth + store.removeItem(TOKEN_STORAGE_KEY); + store.removeItem(USER_STORAGE_KEY); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + } else if (tokenExpired) { + // Token expired but session may still be valid — attempt refresh + setState((s) => ({ ...s, isLoading: true })); + fetch(resolvedRefreshUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }) + .then((response) => { + if (response.ok) { + return response.json().then((data) => { + const newToken = data.data?.token || data.token; + const newUser = data.data?.user || data.user; + store.setItem(TOKEN_STORAGE_KEY, newToken); + store.setItem(USER_STORAGE_KEY, JSON.stringify(newUser)); + setState({ user: newUser, isLoading: false, isAuthenticated: true, error: null }); + scheduleRefresh(newToken); + }); + } + // Refresh failed (401, etc.) — clear auth + store.removeItem(TOKEN_STORAGE_KEY); + store.removeItem(USER_STORAGE_KEY); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + }) + .catch(() => { + // Network error during refresh — clear auth + store.removeItem(TOKEN_STORAGE_KEY); + store.removeItem(USER_STORAGE_KEY); + setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); + }); + } else { + // Token is still valid — restore authenticated state + setState({ + user, + isLoading: false, + isAuthenticated: true, + error: null, + }); + scheduleRefresh(token); + } } catch { - // Invalid stored data, clear it store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); setState((s) => ({ ...s, isLoading: false })); @@ -112,7 +256,31 @@ export function AuthProvider({ } else { setState((s) => ({ ...s, isLoading: false })); } - }, [getStorage]); + }, [getStorage, scheduleRefresh, resolvedRefreshUrl]); + + // Cleanup timer + useEffect(() => { + return () => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + } + }; + }, []); + + // Helper to handle auth response (login, register, OTP verify, magic link verify) + const handleAuthResponse = useCallback( + (token: string, user: User) => { + persistAuth(token, user); + setState({ + user, + isLoading: false, + isAuthenticated: true, + error: null, + }); + scheduleRefresh(token); + }, + [persistAuth, scheduleRefresh] + ); // Login with credentials const login = useCallback( @@ -124,13 +292,11 @@ export function AuthProvider({ let user: User; if (onLogin) { - // Use custom login handler const result = await onLogin(credentials); token = result.token; user = result.user; } else { - // Use default API login - const response = await fetch(loginUrl, { + const response = await fetch(resolvedLoginUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), @@ -147,19 +313,7 @@ export function AuthProvider({ user = data.data?.user || data.user; } - // Store token and user - const store = getStorage(); - if (store) { - store.setItem(TOKEN_STORAGE_KEY, token); - store.setItem(USER_STORAGE_KEY, JSON.stringify(user)); - } - - setState({ - user, - isLoading: false, - isAuthenticated: true, - error: null, - }); + handleAuthResponse(token, user); } catch (error) { setState({ user: null, @@ -170,7 +324,7 @@ export function AuthProvider({ throw error; } }, - [loginUrl, onLogin, getStorage] + [resolvedLoginUrl, onLogin, handleAuthResponse] ); // Login with token directly @@ -190,25 +344,68 @@ export function AuthProvider({ isAuthenticated: true, error: null, }); + scheduleRefresh(token); }, - [getStorage] + [getStorage, scheduleRefresh] + ); + + // Register + const register = useCallback( + async (credentials: RegisterCredentials) => { + setState((s) => ({ ...s, isLoading: true, error: null })); + + try { + const response = await fetch(resolvedRegisterUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const errMsg = errBody.error?.message || errBody.message || 'Registration failed'; + throw new Error(errMsg); + } + + const data = await response.json(); + const token = data.data?.token || data.token; + const user = data.data?.user || data.user; + + handleAuthResponse(token, user); + } catch (error) { + setState({ + user: null, + isLoading: false, + isAuthenticated: false, + error: error instanceof Error ? error : new Error('Registration failed'), + }); + throw error; + } + }, + [resolvedRegisterUrl, handleAuthResponse] ); // Logout const logout = useCallback(async () => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + } + try { if (onLogout) { await onLogout(); - } else if (logoutUrl) { - await fetch(logoutUrl, { method: 'POST' }).catch(() => {}); + } else { + const store = getStorage(); + const token = store?.getItem(TOKEN_STORAGE_KEY); + if (token) { + await fetch(resolvedLogoutUrl, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + }).catch(() => {}); + } } } finally { - const store = getStorage(); - if (store) { - store.removeItem(TOKEN_STORAGE_KEY); - store.removeItem(USER_STORAGE_KEY); - } - + clearAuth(); setState({ user: null, isLoading: false, @@ -216,7 +413,134 @@ export function AuthProvider({ error: null, }); } - }, [logoutUrl, onLogout, getStorage]); + }, [resolvedLogoutUrl, onLogout, getStorage, clearAuth]); + + // Send OTP + const sendOTP = useCallback( + async (email: string) => { + const response = await fetch(resolvedOtpSendUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + throw new Error(errBody.error?.message || errBody.message || 'Failed to send code'); + } + }, + [resolvedOtpSendUrl] + ); + + // Login with OTP + const loginWithOTP = useCallback( + async (credentials: OTPVerifyCredentials) => { + setState((s) => ({ ...s, isLoading: true, error: null })); + + try { + const response = await fetch(resolvedOtpVerifyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + throw new Error(errBody.error?.message || errBody.message || 'Invalid code'); + } + + const data = await response.json(); + const token = data.data?.token || data.token; + const user = data.data?.user || data.user; + + handleAuthResponse(token, user); + } catch (error) { + setState((s) => ({ + ...s, + isLoading: false, + error: error instanceof Error ? error : new Error('OTP verification failed'), + })); + throw error; + } + }, + [resolvedOtpVerifyUrl, handleAuthResponse] + ); + + // Send magic link + const sendMagicLink = useCallback( + async (email: string) => { + const response = await fetch(resolvedMagicLinkUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + throw new Error(errBody.error?.message || errBody.message || 'Failed to send link'); + } + }, + [resolvedMagicLinkUrl] + ); + + // Login with magic link token + const loginWithMagicLink = useCallback( + async (credentials: MagicLinkVerifyCredentials) => { + setState((s) => ({ ...s, isLoading: true, error: null })); + + try { + const response = await fetch(resolvedMagicLinkVerifyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + throw new Error(errBody.error?.message || errBody.message || 'Invalid link'); + } + + const data = await response.json(); + const token = data.data?.token || data.token; + const user = data.data?.user || data.user; + + handleAuthResponse(token, user); + } catch (error) { + setState((s) => ({ + ...s, + isLoading: false, + error: error instanceof Error ? error : new Error('Magic link verification failed'), + })); + throw error; + } + }, + [resolvedMagicLinkVerifyUrl, handleAuthResponse] + ); + + // Refresh token + const refreshTokenFn = useCallback(async () => { + const store = getStorage(); + const currentToken = store?.getItem(TOKEN_STORAGE_KEY); + if (!currentToken) return; + + const response = await fetch(resolvedRefreshUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${currentToken}`, + }, + }); + + if (!response.ok) { + throw new Error('Token refresh failed'); + } + + const data = await response.json(); + const newToken = data.data?.token || data.token; + const newUser = data.data?.user || data.user; + + persistAuth(newToken, newUser); + setState(s => ({ ...s, user: newUser })); + scheduleRefresh(newToken); + }, [getStorage, resolvedRefreshUrl, persistAuth, scheduleRefresh]); // Get token const getToken = useCallback(() => { @@ -245,12 +569,18 @@ export function AuthProvider({ ...state, login, loginWithToken, + register, logout, getToken, hasRole, hasScope, + sendOTP, + loginWithOTP, + sendMagicLink, + loginWithMagicLink, + refreshToken: refreshTokenFn, }), - [state, login, loginWithToken, logout, getToken, hasRole, hasScope] + [state, login, loginWithToken, register, logout, getToken, hasRole, hasScope, sendOTP, loginWithOTP, sendMagicLink, loginWithMagicLink, refreshTokenFn] ); return {children}; diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts index a51948c..400c342 100644 --- a/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/index.ts @@ -1,3 +1,11 @@ export { AuthProvider, useAuth, type AuthContextValue } from './AuthProvider'; export { ProtectedRoute } from './ProtectedRoute'; -export type { User, AuthState, LoginCredentials } from './types'; +export type { + User, + AuthState, + LoginCredentials, + RegisterCredentials, + OTPVerifyCredentials, + MagicLinkVerifyCredentials, + Session, +} from './types'; diff --git a/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts index fde8653..ef78fe4 100644 --- a/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts +++ b/internal/adapter/templates/templates/skeleton/packages/auth/src/types.ts @@ -5,6 +5,8 @@ export interface User { id: string; email?: string; name?: string; + avatarUrl?: string; + emailVerified?: boolean; roles?: string[]; scopes?: string[]; metadata?: Record; @@ -32,12 +34,48 @@ export interface LoginCredentials { password: string; } +/** + * Registration credentials. + */ +export interface RegisterCredentials { + email: string; + password: string; + name?: string; +} + +/** + * OTP verification credentials. + */ +export interface OTPVerifyCredentials { + email: string; + code: string; +} + +/** + * Magic link verification credentials. + */ +export interface MagicLinkVerifyCredentials { + email: string; + token: string; +} + +/** + * A login session with device and location information. + */ +export interface Session { + id: string; + ipAddress: string; + deviceLabel: string; + lastActiveAt: string; + createdAt: string; + isCurrent: boolean; +} + /** * Token response from the authentication API. + * Matches the backend LoginResponse shape: { token, user }. */ export interface TokenResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; + token: string; + user: User; } diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx index be82155..4ae3c8f 100644 --- a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/MediaLibrary.tsx @@ -4,8 +4,10 @@ import { useState } from 'react'; import { Trash2, Image, Video, ExternalLink } from 'lucide-react'; export interface MediaItem { + id: string; path: string; url: string; + filename: string; contentType: string; size: number; createdAt: string; @@ -14,8 +16,8 @@ export interface MediaItem { export interface MediaLibraryProps { /** Media items to display */ items: MediaItem[]; - /** Called when a media item is deleted */ - onDelete?: (path: string) => void; + /** Called when a media item is deleted (by ID) */ + onDelete?: (id: string) => void; /** Called when a media item is selected */ onSelect?: (item: MediaItem) => void; /** Whether delete operations are in progress */ @@ -45,7 +47,7 @@ export function MediaLibrary({ isDeleting = false, emptyMessage = 'No media files yet. Upload or generate some!', }: MediaLibraryProps) { - const [selectedPath, setSelectedPath] = useState(null); + const [selectedId, setSelectedId] = useState(null); if (items.length === 0) { return ( @@ -60,15 +62,15 @@ export function MediaLibrary({
{items.map((item) => (
{ - setSelectedPath(item.path); + setSelectedId(item.id); onSelect?.(item); }} > @@ -77,7 +79,7 @@ export function MediaLibrary({ {isImage(item.contentType) ? ( {item.path.split('/').pop() @@ -100,7 +102,7 @@ export function MediaLibrary({ {/* Info bar */}

- {item.path.split('/').pop()} + {item.filename || item.path.split('/').pop()}

{formatSize(item.size)} @@ -121,7 +123,7 @@ export function MediaLibrary({ {onDelete && (