Files
pad/internal/models/activity.go
T
xarmian b0eeef16ce feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no
endpoint consumes it until Wave 3).

- Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table,
  cloning the password_resets shape (id/user_id FK/token_hash/expires_at/
  used_at/created_at + token_hash + user_id indexes), per-dialect created_at
  default.
- Store email_verification.go: 256-bit crypto/rand token, padver_ prefix,
  SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume.
  Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint
  (resend burns the old link), consume side-effect sets users.email_verified_at
  (RFC3339-with-Z, same format Wave 1's migration used) in one transaction —
  no password reset, no session mint.
- Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours".
- Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC
  — self-registers on Server.bg, context-cancellable via stop channel, started
  only from cmd/pad/main.go so unit tests don't leak goroutines) calling the
  four previously-unwired CleanExpired* methods (email verifications, password
  resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications.
- Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin.

Gates: make check + make test-pg green (store + migration on both dialects).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 01:19:41 -04:00

125 lines
5.1 KiB
Go

package models
import "time"
// Item-level actions (existing)
var ValidActions = []string{
"created", "updated", "archived", "restored", "moved", "read", "searched",
}
// Audit action constants for auth/admin events
const (
ActionLogin = "login"
ActionLoginFailed = "login_failed"
ActionLogout = "logout"
ActionBootstrap = "bootstrap"
ActionRegister = "register"
ActionPasswordChanged = "password_changed"
ActionPasswordReset = "password_reset"
ActionTokenCreated = "token_created"
ActionTokenRevoked = "token_revoked"
ActionTokenRotated = "token_rotated"
ActionTOTPEnabled = "totp_enabled"
ActionTOTPDisabled = "totp_disabled"
ActionMemberInvited = "member_invited"
ActionMemberRemoved = "member_removed"
ActionRoleChanged = "role_changed"
ActionSettingsChanged = "settings_changed"
ActionOAuthLogin = "oauth_login"
ActionOAuthLoginFailed = "oauth_login_failed"
ActionPlanChanged = "plan_changed"
// ActionPlanOverridesChanged is logged when an admin updates a
// user's plan_overrides JSON via the admin user-detail page.
// Surfaces per-user storage / workspace / API-token quota
// overrides in the audit feed so operators can correlate a
// mysteriously-allowed upload with the override that enabled it.
ActionPlanOverridesChanged = "plan_overrides_changed"
ActionPasswordResetByAdmin = "password_reset_by_admin"
ActionUserDisabled = "user_disabled"
ActionUserEnabled = "user_enabled"
ActionAccountDeleted = "account_deleted"
// ActionEmailVerified is logged when a user confirms their email address
// via a verification link (POST /auth/verify-email). PLAN-1933 / TASK-1936.
ActionEmailVerified = "email_verified"
// ActionEmailVerifiedByAdmin is logged when an admin force-verifies a
// user's email from the admin console (DR-7). PLAN-1933 / TASK-1936.
ActionEmailVerifiedByAdmin = "email_verified_by_admin"
// ActionSessionIPChanged is logged when a session presents a different
// client IP than the one recorded at creation. We don't strict-check IP
// by default (that breaks legitimate geo shifts — VPN toggle, mobile
// roaming) but surface the change to the audit log for detection. In
// deployments configured with PAD_IP_CHANGE_ENFORCE=strict the middleware
// additionally rejects the request.
ActionSessionIPChanged = "session_ip_changed"
// ActionStripeEventUnmarked is logged when /admin/stripe-event-unmark
// rolls back a row from stripe_processed_events (TASK-736). The
// endpoint intentionally reopens Stripe retry windows, so a persisted
// audit trail is required — a compromised cloud_secret could otherwise
// spam unmarks invisible to the admin /audit-log UI.
ActionStripeEventUnmarked = "stripe_event_unmarked"
// ActionPaymentFailedEmailSent is logged when the sidecar triggers the
// /admin/payment-failed endpoint and pad dispatches a failed-payment
// notification to the user. Audit trail exists so operators can prove
// a customer was notified before a dunning-related plan change.
ActionPaymentFailedEmailSent = "payment_failed_email_sent"
)
type Activity struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
DocumentID string `json:"document_id,omitempty"`
Action string `json:"action"`
Actor string `json:"actor"`
Source string `json:"source"`
Metadata string `json:"metadata,omitempty"` // JSON
UserID string `json:"user_id,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Enrichment fields — populated by handlers, not stored in DB
ItemTitle string `json:"item_title,omitempty"`
ItemSlug string `json:"item_slug,omitempty"`
ItemRef string `json:"item_ref,omitempty"` // e.g. "BUG-1748" — computed from the referenced item
CollectionSlug string `json:"collection_slug,omitempty"`
ActorName string `json:"actor_name,omitempty"`
}
type ActivityListParams struct {
Action string
Actor string
Source string
Limit int
Offset int
}
// AuditLogParams are query parameters for the audit log endpoint.
type AuditLogParams struct {
Action string
Actor string
WorkspaceID string
Days int
Limit int
Offset int
}
// TimelineEntry represents a single entry in the unified item timeline.
// It wraps one of: a comment, an activity, or a version.
type TimelineEntry struct {
ID string `json:"id"`
Kind string `json:"kind"` // "comment", "activity", "version"
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"`
ActorName string `json:"actor_name,omitempty"`
Source string `json:"source"`
Comment *Comment `json:"comment,omitempty"`
Activity *Activity `json:"activity,omitempty"`
Version *Version `json:"version,omitempty"`
}
// TimelineResponse is the paginated response from the timeline endpoint.
type TimelineResponse struct {
Entries []TimelineEntry `json:"entries"`
HasMore bool `json:"has_more"`
}