docs: reorder & testing
This commit is contained in:
@@ -71,7 +71,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
@@ -155,8 +155,7 @@ func (p *recordingPush) snapshot() []recordedPush {
|
||||
}
|
||||
|
||||
// stubGeo implements auth.GeoService with no real lookups. The country
|
||||
// it returns is configurable per call via CountryForIP; LanguageForIP
|
||||
// returns "" so the auth flow exercises the "en" fallback path.
|
||||
// it returns is configurable per call via countryByIP.
|
||||
type stubGeo struct {
|
||||
countryByIP map[string]string
|
||||
}
|
||||
@@ -169,8 +168,6 @@ func (g *stubGeo) LookupCountry(sourceIP string) string {
|
||||
return g.countryByIP[sourceIP]
|
||||
}
|
||||
|
||||
func (g *stubGeo) LanguageForIP(_ string) string { return "" }
|
||||
|
||||
func (g *stubGeo) SetDeclaredCountryAtRegistration(_ context.Context, _ uuid.UUID, _ string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -279,7 +276,10 @@ func TestAuthEndToEnd(t *testing.T) {
|
||||
t.Fatalf("GetSession user_id = %s, want %s", got.UserID, session.UserID)
|
||||
}
|
||||
|
||||
revoked, err := svc.RevokeSession(ctx, session.DeviceSessionID)
|
||||
revoked, err := svc.RevokeSession(ctx, session.DeviceSessionID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: session.UserID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeSession: %v", err)
|
||||
}
|
||||
@@ -294,7 +294,10 @@ func TestAuthEndToEnd(t *testing.T) {
|
||||
t.Fatalf("GetSession after revoke = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
|
||||
again, err := svc.RevokeSession(ctx, session.DeviceSessionID)
|
||||
again, err := svc.RevokeSession(ctx, session.DeviceSessionID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: session.UserID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent RevokeSession: %v", err)
|
||||
}
|
||||
@@ -330,6 +333,49 @@ func TestSendEmailCodePermanentlyBlocked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmEmailCodePermanentlyBlockedAfterSend covers the case where
|
||||
// an admin applies permanent_block in the window between send and
|
||||
// confirm. The send-time guard let the challenge through because the
|
||||
// account was unblocked at that moment; the confirm-time guard must
|
||||
// catch the late block and reject the registration.
|
||||
func TestConfirmEmailCodePermanentlyBlockedAfterSend(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
svc, mailer, _, _ := buildService(t, db)
|
||||
ctx := context.Background()
|
||||
|
||||
const email = "blockedlater@example.test"
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO backend.accounts (
|
||||
user_id, email, user_name, preferred_language, time_zone
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
`, uuid.New(), email, "Player-XXBLATER", "en", "UTC"); err != nil {
|
||||
t.Fatalf("seed account: %v", err)
|
||||
}
|
||||
|
||||
id, err := svc.SendEmailCode(ctx, email, "en", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmailCode: %v", err)
|
||||
}
|
||||
_, code, _ := mailer.snapshot()
|
||||
|
||||
if _, err := db.Exec(`
|
||||
UPDATE backend.accounts SET permanent_block = true WHERE email = $1
|
||||
`, email); err != nil {
|
||||
t.Fatalf("apply permanent_block: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||
ChallengeID: id,
|
||||
Code: code,
|
||||
ClientPublicKey: randomKey(t),
|
||||
TimeZone: "UTC",
|
||||
})
|
||||
if !errors.Is(err, auth.ErrEmailPermanentlyBlocked) {
|
||||
t.Fatalf("ConfirmEmailCode after block = %v, want ErrEmailPermanentlyBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailCodeThrottleReusesChallenge(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
svc, mailer, _, _ := buildService(t, db)
|
||||
@@ -468,7 +514,10 @@ func TestRevokeAllForUser(t *testing.T) {
|
||||
deviceSessionIDs = append(deviceSessionIDs, sess.DeviceSessionID)
|
||||
}
|
||||
|
||||
revoked, err := svc.RevokeAllForUser(ctx, userID)
|
||||
revoked, err := svc.RevokeAllForUser(ctx, userID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: userID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeAllForUser: %v", err)
|
||||
}
|
||||
@@ -485,7 +534,10 @@ func TestRevokeAllForUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// Idempotent: revoking again returns an empty slice.
|
||||
again, err := svc.RevokeAllForUser(ctx, userID)
|
||||
again, err := svc.RevokeAllForUser(ctx, userID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: userID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent RevokeAllForUser: %v", err)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,29 @@ func (c *Cache) Remove(deviceSessionID uuid.UUID) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListByUser returns a freshly-allocated snapshot of every cached
|
||||
// session belonging to userID. The user-surface "list my sessions"
|
||||
// handler consumes this. An empty slice is returned for an unknown
|
||||
// userID.
|
||||
func (c *Cache) ListByUser(userID uuid.UUID) []Session {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
set, ok := c.byUser[userID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]Session, 0, len(set))
|
||||
for id := range set {
|
||||
if sess, ok := c.byID[id]; ok {
|
||||
out = append(out, sess)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RemoveByUser evicts every cached entry belonging to userID and returns
|
||||
// the device_session_ids it removed. The returned slice is safe for the
|
||||
// caller to hold past the call — it is freshly allocated.
|
||||
|
||||
@@ -28,10 +28,11 @@ import (
|
||||
//
|
||||
// locale (request body, BCP 47) takes precedence over acceptLanguage
|
||||
// (the standard HTTP header forwarded by gateway) when both are
|
||||
// supplied. The captured value is persisted on the challenge row as
|
||||
// `preferred_language`, replayed at confirm-email-code, and used only
|
||||
// for newly-registered accounts; existing accounts keep their stored
|
||||
// language.
|
||||
// supplied. When neither is supplied SendEmailCode falls back to the
|
||||
// platform default ("en"). The resolved value is persisted on the
|
||||
// challenge row as `preferred_language` and used by confirm-email-code
|
||||
// only for newly-registered accounts; existing accounts keep their
|
||||
// stored language.
|
||||
func (s *Service) SendEmailCode(
|
||||
ctx context.Context,
|
||||
email, locale, acceptLanguage, sourceIP string,
|
||||
@@ -50,6 +51,9 @@ func (s *Service) SendEmailCode(
|
||||
}
|
||||
|
||||
captured := pickCapturedLocale(locale, acceptLanguage)
|
||||
if captured == "" {
|
||||
captured = defaultLanguage
|
||||
}
|
||||
|
||||
now := s.deps.Now()
|
||||
windowStart := now.Add(-s.deps.Config.ChallengeThrottle.Window)
|
||||
@@ -178,11 +182,23 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
||||
return Session{}, err
|
||||
}
|
||||
|
||||
// Re-check permanent_block after verifying the code. SendEmailCode
|
||||
// guards against fresh challenges for already-blocked addresses;
|
||||
// this guard catches the case where an admin applied
|
||||
// permanent_block in the window between send and confirm.
|
||||
permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, loaded.Email)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("auth: check permanent block at confirm: %w", err)
|
||||
}
|
||||
if permanent {
|
||||
return Session{}, ErrEmailPermanentlyBlocked
|
||||
}
|
||||
|
||||
preferredLang := loaded.PreferredLanguage
|
||||
if preferredLang == "" {
|
||||
preferredLang = s.deps.Geo.LanguageForIP(in.SourceIP)
|
||||
}
|
||||
if preferredLang == "" {
|
||||
// Defensive fallback: SendEmailCode now always persists a
|
||||
// non-empty preferred_language, but a row written by an older
|
||||
// build could still be empty.
|
||||
preferredLang = defaultLanguage
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ type UserEnsurer interface {
|
||||
}
|
||||
|
||||
// GeoService provides the geo helpers auth needs at confirm-email-code:
|
||||
// a country lookup for the `preferred_language` fallback and a
|
||||
// post-commit write of `accounts.declared_country`. Both methods are
|
||||
// best-effort — auth never blocks the registration flow on geo failures.
|
||||
// a country lookup that backfills `accounts.declared_country` for newly
|
||||
// registered accounts and a post-commit write of the same column. Both
|
||||
// methods are best-effort — auth never blocks the registration flow on
|
||||
// geo failures.
|
||||
type GeoService interface {
|
||||
LookupCountry(sourceIP string) string
|
||||
LanguageForIP(sourceIP string) string
|
||||
SetDeclaredCountryAtRegistration(ctx context.Context, userID uuid.UUID, sourceIP string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,48 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ActorKind enumerates the principals that can drive a session revoke.
|
||||
// The values are persisted into `session_revocations.actor_kind` and
|
||||
// must stay aligned with `user.SessionRevokeActor*` constants and any
|
||||
// admin/operator tooling that joins on the audit table.
|
||||
type ActorKind string
|
||||
|
||||
const (
|
||||
// ActorKindUserSelf indicates the session's owner initiated the
|
||||
// revoke (logout self / logout-all-self through the user surface).
|
||||
ActorKindUserSelf ActorKind = "user_self"
|
||||
|
||||
// ActorKindAdminSanction indicates an admin-applied sanction (most
|
||||
// notably permanent_block) caused the revoke.
|
||||
ActorKindAdminSanction ActorKind = "admin_sanction"
|
||||
|
||||
// ActorKindSoftDeleteUser indicates the session's owner triggered
|
||||
// account soft-delete on themselves.
|
||||
ActorKindSoftDeleteUser ActorKind = "soft_delete_user"
|
||||
|
||||
// ActorKindSoftDeleteAdmin indicates an admin soft-deleted the
|
||||
// account and the cascade revoked the sessions.
|
||||
ActorKindSoftDeleteAdmin ActorKind = "soft_delete_admin"
|
||||
)
|
||||
|
||||
// RevokeContext records the audit metadata persisted alongside every
|
||||
// session revoke. ActorID is the stable identifier of the principal (a
|
||||
// user UUID for self-driven flows, an admin username for admin-driven
|
||||
// flows). Reason is a free-form note kept verbatim.
|
||||
type RevokeContext struct {
|
||||
ActorKind ActorKind
|
||||
ActorID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// GetSession returns the active session keyed by deviceSessionID. The
|
||||
// lookup is cache-only: the cache is the write-through projection of
|
||||
// `device_sessions WHERE status='active'`, so a miss means the session
|
||||
// is either revoked or absent. Either way the gateway sees
|
||||
// ErrSessionNotFound and treats the calling client as unauthenticated.
|
||||
func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Session, error) {
|
||||
// lookup hits the cache; on a miss the session is either revoked or
|
||||
// absent. After a hit the call refreshes `last_seen_at` against
|
||||
// Postgres so admin observers see when each cached session was last
|
||||
// resolved by gateway. The refresh runs after the cache read and
|
||||
// updates the cached row in-place; failures are logged but never block
|
||||
// the lookup.
|
||||
func (s *Service) GetSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
|
||||
if deviceSessionID == uuid.Nil {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
@@ -21,31 +57,73 @@ func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Sess
|
||||
if !ok {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
return sess, nil
|
||||
now := s.deps.Now()
|
||||
if updated, err := s.deps.Store.TouchSessionLastSeen(ctx, deviceSessionID, now); err == nil {
|
||||
s.deps.Cache.Add(updated)
|
||||
return updated, nil
|
||||
} else if errors.Is(err, ErrSessionNotFound) {
|
||||
// The row vanished between Cache.Get and the touch — treat as
|
||||
// revoked from the caller's perspective.
|
||||
s.deps.Cache.Remove(deviceSessionID)
|
||||
return Session{}, ErrSessionNotFound
|
||||
} else {
|
||||
s.deps.Logger.Warn("auth: touch last_seen_at failed",
|
||||
zap.String("device_session_id", deviceSessionID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return sess, nil
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeSession marks deviceSessionID revoked, evicts it from the cache,
|
||||
// and emits a session_invalidation push event. The call is idempotent:
|
||||
// a second revoke on an already-revoked session returns the existing
|
||||
// row with status='revoked' (HTTP 200), not ErrSessionNotFound. An
|
||||
// ListActiveByUser returns the cached active sessions for userID. The
|
||||
// user-surface "list my sessions" handler consumes this. The slice is
|
||||
// safe for the caller to retain — it is freshly allocated.
|
||||
func (s *Service) ListActiveByUser(_ context.Context, userID uuid.UUID) []Session {
|
||||
if userID == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return s.deps.Cache.ListByUser(userID)
|
||||
}
|
||||
|
||||
// LookupSessionInCache returns the cached session for deviceSessionID
|
||||
// without touching last_seen_at. The user-surface revoke handler
|
||||
// consumes this to verify ownership before issuing a revoke. A miss
|
||||
// means the session is either revoked or absent — handlers must treat
|
||||
// the two cases identically so a caller cannot probe whether a foreign
|
||||
// device_session_id exists.
|
||||
func (s *Service) LookupSessionInCache(deviceSessionID uuid.UUID) (Session, bool) {
|
||||
if deviceSessionID == uuid.Nil {
|
||||
return Session{}, false
|
||||
}
|
||||
return s.deps.Cache.Get(deviceSessionID)
|
||||
}
|
||||
|
||||
// RevokeSession marks deviceSessionID revoked atomically with an
|
||||
// audit row in `session_revocations`, evicts it from the cache, and
|
||||
// emits a session_invalidation push event. The call is idempotent: a
|
||||
// second revoke on an already-revoked session returns the existing
|
||||
// row with status='revoked' (HTTP 200) and writes no audit row. An
|
||||
// unknown device_session_id yields ErrSessionNotFound.
|
||||
//
|
||||
// Cache eviction and the push emission run after the database UPDATE
|
||||
// commits so a failed UPDATE leaves both cache and gateway view intact.
|
||||
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
|
||||
// commits so a failed UPDATE leaves both cache and gateway view
|
||||
// intact.
|
||||
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext) (Session, error) {
|
||||
if deviceSessionID == uuid.Nil {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID)
|
||||
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID, rc, s.deps.Now())
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if ok {
|
||||
s.deps.Cache.Remove(deviceSessionID)
|
||||
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, "auth.revoke_session")
|
||||
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, string(rc.ActorKind))
|
||||
s.deps.Logger.Info("auth session revoked",
|
||||
zap.String("device_session_id", deviceSessionID.String()),
|
||||
zap.String("user_id", revoked.UserID.String()),
|
||||
zap.String("actor_kind", string(rc.ActorKind)),
|
||||
zap.String("actor_id", rc.ActorID),
|
||||
)
|
||||
return revoked, nil
|
||||
}
|
||||
@@ -63,27 +141,30 @@ func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// RevokeAllForUser marks every active session for userID revoked,
|
||||
// evicts each from the cache, and emits one session_invalidation push
|
||||
// event per revoked row. Returns the list of revoked sessions in the
|
||||
// order Postgres returned them. An empty result is a successful
|
||||
// idempotent call (handler reports revoked_count=0).
|
||||
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) {
|
||||
// RevokeAllForUser marks every active session for userID revoked
|
||||
// atomically with one audit row per revoked session, evicts each from
|
||||
// the cache, and emits one session_invalidation push event per
|
||||
// revoked row. Returns the list of revoked sessions in the order
|
||||
// Postgres returned them. An empty result is a successful idempotent
|
||||
// call (handler reports revoked_count=0).
|
||||
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext) ([]Session, error) {
|
||||
if userID == uuid.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID)
|
||||
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID, rc, s.deps.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sess := range revoked {
|
||||
s.deps.Cache.Remove(sess.DeviceSessionID)
|
||||
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, "auth.revoke_all_for_user")
|
||||
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, string(rc.ActorKind))
|
||||
}
|
||||
if len(revoked) > 0 {
|
||||
s.deps.Logger.Info("auth sessions revoked (bulk)",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Int("count", len(revoked)),
|
||||
zap.String("actor_kind", string(rc.ActorKind)),
|
||||
zap.String("actor_id", rc.ActorID),
|
||||
)
|
||||
}
|
||||
return revoked, nil
|
||||
|
||||
+128
-29
@@ -332,15 +332,14 @@ func (s *Store) LoadSession(ctx context.Context, deviceSessionID uuid.UUID) (Ses
|
||||
return modelToSession(row), nil
|
||||
}
|
||||
|
||||
// RevokeSession transitions an active row to status='revoked' and
|
||||
// returns the row as it stands after the update. The boolean reports
|
||||
// whether the UPDATE actually changed a row — false means the row was
|
||||
// already revoked or did not exist; the auth Service then falls back to
|
||||
// LoadSession for idempotent-revoke responses.
|
||||
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, bool, error) {
|
||||
// TouchSessionLastSeen sets `last_seen_at` to at on the row keyed by
|
||||
// deviceSessionID. The UPDATE is gated by `status='active'` so a
|
||||
// revoked or absent row reports ErrSessionNotFound. Returns the post-
|
||||
// update row so the cache can be refreshed without a second read.
|
||||
func (s *Store) TouchSessionLastSeen(ctx context.Context, deviceSessionID uuid.UUID, at time.Time) (Session, error) {
|
||||
stmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
|
||||
UPDATE(table.DeviceSessions.LastSeenAt).
|
||||
SET(postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
@@ -350,39 +349,139 @@ func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (S
|
||||
var row model.DeviceSessions
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Session{}, false, nil
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
return Session{}, fmt.Errorf("auth store: touch last_seen %s: %w", deviceSessionID, err)
|
||||
}
|
||||
return modelToSession(row), nil
|
||||
}
|
||||
|
||||
// RevokeSession transitions an active row to status='revoked' and
|
||||
// inserts the matching audit row into session_revocations atomically
|
||||
// inside one transaction. The boolean reports whether the UPDATE
|
||||
// actually changed a row — false means the row was already revoked or
|
||||
// did not exist, in which case no audit row is written and the auth
|
||||
// Service falls back to LoadSession for the idempotent-revoke
|
||||
// response.
|
||||
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext, at time.Time) (Session, bool, error) {
|
||||
var (
|
||||
revoked Session
|
||||
ok bool
|
||||
)
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
updateStmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
|
||||
var row model.DeviceSessions
|
||||
if err := updateStmt.QueryContext(ctx, tx, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
revoked = modelToSession(row)
|
||||
ok = true
|
||||
return insertRevocationTx(ctx, tx, deviceSessionID, revoked.UserID, rc, at)
|
||||
})
|
||||
if err != nil {
|
||||
return Session{}, false, fmt.Errorf("auth store: revoke session %s: %w", deviceSessionID, err)
|
||||
}
|
||||
return modelToSession(row), true, nil
|
||||
return revoked, ok, nil
|
||||
}
|
||||
|
||||
// RevokeAllForUser transitions every active row for userID to
|
||||
// status='revoked' and returns the rows as they stand after the update.
|
||||
// An empty slice with a nil error is returned when the user owned no
|
||||
// active sessions; the caller must treat that as a successful idempotent
|
||||
// revoke (the API surface returns revoked_count=0 in that case).
|
||||
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) {
|
||||
stmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
|
||||
WHERE(
|
||||
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
// status='revoked', writes one session_revocations row per revoked
|
||||
// session, and returns the rows as they stand after the update. The
|
||||
// UPDATE and the audit inserts run inside one transaction. An empty
|
||||
// slice with a nil error is returned when the user owned no active
|
||||
// sessions; the caller treats that as a successful idempotent revoke
|
||||
// (the API surface returns revoked_count=0).
|
||||
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext, at time.Time) ([]Session, error) {
|
||||
var out []Session
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
updateStmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
|
||||
var rows []model.DeviceSessions
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
var rows []model.DeviceSessions
|
||||
if err := updateStmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return err
|
||||
}
|
||||
out = make([]Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
sess := modelToSession(row)
|
||||
out = append(out, sess)
|
||||
if err := insertRevocationTx(ctx, tx, sess.DeviceSessionID, sess.UserID, rc, at); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth store: revoke all for user %s: %w", userID, err)
|
||||
}
|
||||
out := make([]Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToSession(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// insertRevocationTx writes a single audit row inside an existing
|
||||
// transaction. Callers are expected to mint a fresh revocation_id per
|
||||
// row; collisions are not retried because revocation_id is a uuid.New
|
||||
// in the only call sites.
|
||||
func insertRevocationTx(ctx context.Context, tx *sql.Tx, deviceSessionID, userID uuid.UUID, rc RevokeContext, at time.Time) error {
|
||||
actorUserID, actorUsername, err := revokeContextToColumns(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt := table.SessionRevocations.INSERT(
|
||||
table.SessionRevocations.RevocationID,
|
||||
table.SessionRevocations.DeviceSessionID,
|
||||
table.SessionRevocations.UserID,
|
||||
table.SessionRevocations.ActorKind,
|
||||
table.SessionRevocations.ActorUserID,
|
||||
table.SessionRevocations.ActorUsername,
|
||||
table.SessionRevocations.Reason,
|
||||
table.SessionRevocations.RevokedAt,
|
||||
).VALUES(uuid.New(), deviceSessionID, userID, string(rc.ActorKind), actorUserID, actorUsername, rc.Reason, at)
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert session_revocations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeContextToColumns splits RevokeContext.ActorID into the
|
||||
// (actor_user_id, actor_username) pair persisted by session_revocations.
|
||||
// User-driven kinds parse ActorID as a UUID; admin-driven kinds keep it
|
||||
// as the operator username. Empty ActorID lands as NULL/NULL.
|
||||
func revokeContextToColumns(rc RevokeContext) (any, any, error) {
|
||||
if rc.ActorID == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
switch rc.ActorKind {
|
||||
case ActorKindUserSelf, ActorKindSoftDeleteUser:
|
||||
uid, err := uuid.Parse(rc.ActorID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("auth store: actor_id %q is not a uuid: %w", rc.ActorID, err)
|
||||
}
|
||||
return uid, nil, nil
|
||||
case ActorKindAdminSanction, ActorKindSoftDeleteAdmin:
|
||||
return nil, rc.ActorID, nil
|
||||
default:
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// modelToChallenge projects a generated model row into the public
|
||||
// Challenge struct. Pointer fields are copied so callers cannot mutate
|
||||
// the underlying scan buffer.
|
||||
|
||||
@@ -65,7 +65,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scoped
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package geo
|
||||
|
||||
import "strings"
|
||||
|
||||
// countryToLanguage maps an uppercase ISO 3166-1 alpha-2 country code to
|
||||
// an ISO 639-1 lowercase language code. The set is intentionally minimal
|
||||
// — covering the top-traffic Galaxy locales — and is consulted as a
|
||||
// fallback when neither the request body nor the Accept-Language header
|
||||
// supplied a locale at send-email-code. Unknown countries map to the
|
||||
// empty string so the auth flow can default to "en".
|
||||
//
|
||||
// The mapping is intentionally hard-coded rather than derived from the
|
||||
// GeoLite2 database: countries with multiple official languages collapse
|
||||
// to the single most common UI locale to keep the registration path
|
||||
// deterministic. The implementation may revise this table without changing the
|
||||
// surface auth depends on.
|
||||
var countryToLanguage = map[string]string{
|
||||
// English-default territories and the platform fallback.
|
||||
"US": "en", "GB": "en", "AU": "en", "NZ": "en", "IE": "en", "CA": "en",
|
||||
// Western Europe.
|
||||
"DE": "de", "AT": "de", "CH": "de",
|
||||
"FR": "fr", "BE": "fr", "LU": "fr",
|
||||
"ES": "es", "MX": "es", "AR": "es", "CL": "es", "CO": "es",
|
||||
"IT": "it",
|
||||
"PT": "pt", "BR": "pt",
|
||||
"NL": "nl",
|
||||
// Central / Eastern Europe.
|
||||
"PL": "pl",
|
||||
"RU": "ru", "BY": "ru", "KZ": "ru",
|
||||
"UA": "uk",
|
||||
"CZ": "cs",
|
||||
"SK": "sk",
|
||||
"HU": "hu",
|
||||
"RO": "ro",
|
||||
"BG": "bg",
|
||||
// Northern Europe.
|
||||
"SE": "sv",
|
||||
"NO": "no",
|
||||
"DK": "da",
|
||||
"FI": "fi",
|
||||
// Asia.
|
||||
"JP": "ja",
|
||||
"KR": "ko",
|
||||
"CN": "zh", "TW": "zh", "HK": "zh", "SG": "zh",
|
||||
"VN": "vi",
|
||||
"TH": "th",
|
||||
"ID": "id",
|
||||
"IN": "en",
|
||||
"IL": "he",
|
||||
"TR": "tr",
|
||||
// Middle East and North Africa.
|
||||
"SA": "ar", "AE": "ar", "EG": "ar",
|
||||
}
|
||||
|
||||
// languageForCountry returns the ISO 639-1 language code mapped to
|
||||
// country, or "" when no mapping is known. country is normalised to
|
||||
// uppercase before lookup.
|
||||
func languageForCountry(country string) string {
|
||||
if country == "" {
|
||||
return ""
|
||||
}
|
||||
return countryToLanguage[strings.ToUpper(strings.TrimSpace(country))]
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
// registration time and by the user-surface middleware on every
|
||||
// authenticated request.
|
||||
//
|
||||
// The implementation shipped `LookupCountry`, `LanguageForIP` and
|
||||
// The implementation shipped `LookupCountry` and
|
||||
// `SetDeclaredCountryAtRegistration`. The implementation added the
|
||||
// `OnUserDeleted` cascade leg. The implementation layers `IncrementCounterAsync`
|
||||
// and `ListUserCounters` on top of the same Service plus the
|
||||
// background-goroutine machinery (cancellable context and WaitGroup)
|
||||
// needed to drain pending counter upserts on shutdown.
|
||||
// `OnUserDeleted` cascade leg. The implementation layers
|
||||
// `IncrementCounterAsync` and `ListUserCounters` on top of the same
|
||||
// Service plus the background-goroutine machinery (cancellable context
|
||||
// and WaitGroup) needed to drain pending counter upserts on shutdown.
|
||||
package geo
|
||||
|
||||
import (
|
||||
|
||||
@@ -8,22 +8,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestLanguageForCountry(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"DE": "de",
|
||||
"de": "de", // case-insensitive input
|
||||
"RU": "ru",
|
||||
"BR": "pt",
|
||||
"": "",
|
||||
"ZZ": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := languageForCountry(input); got != want {
|
||||
t.Errorf("languageForCountry(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCountryNilSafety(t *testing.T) {
|
||||
var s *Service
|
||||
if got := s.LookupCountry("8.8.8.8"); got != "" {
|
||||
@@ -31,13 +15,6 @@ func TestLookupCountryNilSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageForIPNilSafety(t *testing.T) {
|
||||
var s *Service
|
||||
if got := s.LanguageForIP("8.8.8.8"); got != "" {
|
||||
t.Errorf("nil Service LanguageForIP = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLoggerNilSafety(t *testing.T) {
|
||||
var s *Service
|
||||
s.SetLogger(zap.NewNop())
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package geo
|
||||
|
||||
// LanguageForIP returns an ISO 639-1 language code derived from
|
||||
// sourceIP. The function looks up the country via LookupCountry and then
|
||||
// consults the static country->language table. Returns "" when the
|
||||
// country lookup fails or no language mapping exists for the country.
|
||||
//
|
||||
// Auth uses LanguageForIP as a fallback after the client-supplied locale
|
||||
// (request body or Accept-Language header). The empty string signals
|
||||
// "fall through to the platform default 'en'".
|
||||
func (s *Service) LanguageForIP(sourceIP string) string {
|
||||
country := s.LookupCountry(sourceIP)
|
||||
return languageForCountry(country)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = testOpTimeout
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/user"
|
||||
"galaxy/backend/push"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -13,9 +14,17 @@ import (
|
||||
|
||||
// PushPublisher is the publisher contract notification uses to emit a
|
||||
// `client_event` push frame to gateway. The real implementation lives
|
||||
// in `backend/internal/push` ; NewNoopPushPublisher satisfies
|
||||
// in `backend/push` (`*push.Service`); NewNoopPushPublisher satisfies
|
||||
// the interface for tests that do not exercise push behaviour.
|
||||
//
|
||||
// `event` is a typed `push.Event`: the publisher invokes Marshal on
|
||||
// the event at publish time, so producers stay decoupled from the
|
||||
// wire encoding. Every catalog kind has a FlatBuffers schema in
|
||||
// `pkg/schema/fbs/notification.fbs` and is built by
|
||||
// `buildClientPushEvent`; an unknown kind falls back to
|
||||
// `push.JSONEvent` so a misconfigured producer keeps the pipeline
|
||||
// flowing.
|
||||
//
|
||||
// Implementations must be concurrency-safe. The deviceSessionID pointer
|
||||
// narrows the event to a single device session when non-nil; nil means
|
||||
// fan out to every active session of userID. eventID, requestID and
|
||||
@@ -23,7 +32,7 @@ import (
|
||||
// into the signed client envelope; empty strings are forwarded
|
||||
// unchanged.
|
||||
type PushPublisher interface {
|
||||
PublishClientEvent(ctx context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error
|
||||
PublishClientEvent(ctx context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error
|
||||
}
|
||||
|
||||
// Mailer is the email surface notification uses for outbound mail. The
|
||||
@@ -76,11 +85,14 @@ type noopPushPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (p *noopPushPublisher) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
|
||||
func (p *noopPushPublisher) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event push.Event, eventID, requestID, traceID string) error {
|
||||
kind := ""
|
||||
if event != nil {
|
||||
kind = event.Kind()
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Int("payload_keys", len(payload)),
|
||||
}
|
||||
if deviceSessionID != nil {
|
||||
fields = append(fields, zap.String("device_session_id", deviceSessionID.String()))
|
||||
|
||||
@@ -121,7 +121,11 @@ func (s *Service) performDispatch(ctx context.Context, claim ClaimedRoute) error
|
||||
eventID := claim.Route.RouteID.String()
|
||||
requestID := claim.Notification.IdempotencyKey
|
||||
traceID := traceIDFromContext(ctx)
|
||||
return s.deps.Push.PublishClientEvent(ctx, *claim.Route.UserID, claim.Route.DeviceSessionID, claim.Notification.Kind, claim.Notification.Payload, eventID, requestID, traceID)
|
||||
event, err := buildClientPushEvent(claim.Notification.Kind, claim.Notification.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build push event %q: %w", claim.Notification.Kind, err)
|
||||
}
|
||||
return s.deps.Push.PublishClientEvent(ctx, *claim.Route.UserID, claim.Route.DeviceSessionID, event, eventID, requestID, traceID)
|
||||
case ChannelEmail:
|
||||
entry, ok := LookupCatalog(claim.Notification.Kind)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"galaxy/backend/push"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// preMarshaledEvent adapts a pre-encoded FlatBuffers payload to the
|
||||
// push.Event interface. The factory below pre-encodes the payload at
|
||||
// construction time so the kind-specific build error surfaces inside
|
||||
// the dispatcher (where it can drive retry / dead-letter logic) rather
|
||||
// than inside push.Service.PublishClientEvent.
|
||||
type preMarshaledEvent struct {
|
||||
kind string
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (e preMarshaledEvent) Kind() string { return e.kind }
|
||||
func (e preMarshaledEvent) Marshal() ([]byte, error) { return e.payload, nil }
|
||||
|
||||
// buildClientPushEvent maps a catalog kind together with the producer
|
||||
// payload map onto a typed push.Event. Every catalog kind has a
|
||||
// FlatBuffers schema in `pkg/schema/fbs/notification.fbs`; an unknown
|
||||
// kind falls back to push.JSONEvent so a misconfigured producer keeps
|
||||
// the pipeline flowing while the catalog catches up.
|
||||
func buildClientPushEvent(kind string, payload map[string]any) (push.Event, error) {
|
||||
switch kind {
|
||||
case KindLobbyInviteReceived:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inviter, err := mapUUID(payload, "inviter_user_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyInviteReceivedEventToPayload(&transcoder.LobbyInviteReceivedEvent{
|
||||
GameID: gameID,
|
||||
InviterUserID: inviter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyInviteRevoked:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyInviteRevokedEventToPayload(&transcoder.LobbyInviteRevokedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationSubmitted:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appID, err := mapUUID(payload, "application_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{
|
||||
GameID: gameID,
|
||||
ApplicationID: appID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationApproved:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationApprovedEventToPayload(&transcoder.LobbyApplicationApprovedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyApplicationRejected:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyApplicationRejectedEventToPayload(&transcoder.LobbyApplicationRejectedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyMembershipRemoved:
|
||||
bytes, err := transcoder.LobbyMembershipRemovedEventToPayload(&transcoder.LobbyMembershipRemovedEvent{
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyMembershipBlocked:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyMembershipBlockedEventToPayload(&transcoder.LobbyMembershipBlockedEvent{
|
||||
GameID: gameID,
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNameRegistered:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNameRegisteredEventToPayload(&transcoder.LobbyRaceNameRegisteredEvent{RaceName: raceName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNamePending:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNamePendingEventToPayload(&transcoder.LobbyRaceNamePendingEvent{
|
||||
RaceName: raceName,
|
||||
ExpiresAt: mapStringOpt(payload, "expires_at"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindLobbyRaceNameExpired:
|
||||
raceName, err := mapString(payload, "race_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.LobbyRaceNameExpiredEventToPayload(&transcoder.LobbyRaceNameExpiredEvent{RaceName: raceName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeImagePullFailed:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeImagePullFailedEventToPayload(&transcoder.RuntimeImagePullFailedEvent{
|
||||
GameID: gameID,
|
||||
ImageRef: mapStringOpt(payload, "image_ref"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeContainerStartFailed:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeContainerStartFailedEventToPayload(&transcoder.RuntimeContainerStartFailedEvent{GameID: gameID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
|
||||
case KindRuntimeStartConfigInvalid:
|
||||
gameID, err := mapUUID(payload, "game_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := transcoder.RuntimeStartConfigInvalidEventToPayload(&transcoder.RuntimeStartConfigInvalidEvent{
|
||||
GameID: gameID,
|
||||
Reason: mapStringOpt(payload, "reason"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preMarshaledEvent{kind: kind, payload: bytes}, nil
|
||||
}
|
||||
|
||||
return push.JSONEvent{EventKind: kind, Payload: payload}, nil
|
||||
}
|
||||
|
||||
// mapUUID extracts a required UUID-shaped field from the producer
|
||||
// payload. Producers stringify uuid values before assembling Intent
|
||||
// payloads, so the JSON-roundtripped form is `string`.
|
||||
func mapUUID(payload map[string]any, key string) (uuid.UUID, error) {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s is missing", key)
|
||||
}
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s must be a string, got %T", key, raw)
|
||||
}
|
||||
parsed, err := uuid.Parse(str)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("notification payload: %s is not a uuid: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// mapString extracts a required string field from the producer payload.
|
||||
func mapString(payload map[string]any, key string) (string, error) {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("notification payload: %s is missing", key)
|
||||
}
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("notification payload: %s must be a string, got %T", key, raw)
|
||||
}
|
||||
if str == "" {
|
||||
return "", fmt.Errorf("notification payload: %s is empty", key)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// mapStringOpt returns the string value for key, or "" when the key is
|
||||
// missing or carries a non-string value.
|
||||
func mapStringOpt(payload map[string]any, key string) string {
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
str, _ := raw.(string)
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/push"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||
// returns a typed FB event (preMarshaledEvent) and that an unknown kind
|
||||
// falls through to the JSON safety net.
|
||||
func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
applicationID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
inviterID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
payload map[string]any
|
||||
}{
|
||||
{"invite received", KindLobbyInviteReceived, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"inviter_user_id": inviterID.String(),
|
||||
}},
|
||||
{"invite revoked", KindLobbyInviteRevoked, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
}},
|
||||
{"application submitted", KindLobbyApplicationSubmitted, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"application_id": applicationID.String(),
|
||||
}},
|
||||
{"application approved", KindLobbyApplicationApproved, map[string]any{"game_id": gameID.String()}},
|
||||
{"application rejected", KindLobbyApplicationRejected, map[string]any{"game_id": gameID.String()}},
|
||||
{"membership removed", KindLobbyMembershipRemoved, map[string]any{"reason": "deleted"}},
|
||||
{"membership blocked", KindLobbyMembershipBlocked, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"reason": "permanent_blocked",
|
||||
}},
|
||||
{"race name registered", KindLobbyRaceNameRegistered, map[string]any{"race_name": "Skylancer"}},
|
||||
{"race name pending", KindLobbyRaceNamePending, map[string]any{
|
||||
"race_name": "Skylancer",
|
||||
"expires_at": "2026-05-06T12:00:00Z",
|
||||
}},
|
||||
{"race name expired", KindLobbyRaceNameExpired, map[string]any{"race_name": "Skylancer"}},
|
||||
{"runtime image pull failed", KindRuntimeImagePullFailed, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"image_ref": "gcr.io/example:1.0.0",
|
||||
}},
|
||||
{"runtime container start failed", KindRuntimeContainerStartFailed, map[string]any{"game_id": gameID.String()}},
|
||||
{"runtime start config invalid", KindRuntimeStartConfigInvalid, map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"reason": "missing engine version",
|
||||
}},
|
||||
}
|
||||
|
||||
seenKinds := map[string]bool{}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
event, err := buildClientPushEvent(tt.kind, tt.payload)
|
||||
if err != nil {
|
||||
t.Fatalf("build %s: %v", tt.kind, err)
|
||||
}
|
||||
if event.Kind() != tt.kind {
|
||||
t.Fatalf("Kind() = %q, want %q", event.Kind(), tt.kind)
|
||||
}
|
||||
bytes, err := event.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
t.Fatalf("Marshal returned empty bytes")
|
||||
}
|
||||
if _, isJSON := event.(push.JSONEvent); isJSON {
|
||||
t.Fatalf("expected typed FB event for %s, got JSONEvent", tt.kind)
|
||||
}
|
||||
})
|
||||
seenKinds[tt.kind] = true
|
||||
}
|
||||
for _, kind := range SupportedKinds() {
|
||||
if !seenKinds[kind] {
|
||||
t.Errorf("catalog kind %q is not covered by this test", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientPushEventUnknownKindFallsBackToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event, err := buildClientPushEvent("unknown.kind", map[string]any{"x": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := event.(push.JSONEvent); !ok {
|
||||
t.Fatalf("expected JSONEvent fallback, got %T", event)
|
||||
}
|
||||
if event.Kind() != "unknown.kind" {
|
||||
t.Fatalf("Kind() = %q", event.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientPushEventRejectsBrokenPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
payload map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required uuid",
|
||||
kind: KindLobbyApplicationSubmitted,
|
||||
payload: map[string]any{"game_id": uuid.NewString()},
|
||||
want: "application_id is missing",
|
||||
},
|
||||
{
|
||||
name: "non-uuid string",
|
||||
kind: KindLobbyInviteRevoked,
|
||||
payload: map[string]any{"game_id": "not-a-uuid"},
|
||||
want: "is not a uuid",
|
||||
},
|
||||
{
|
||||
name: "uuid not a string",
|
||||
kind: KindLobbyInviteRevoked,
|
||||
payload: map[string]any{"game_id": 42},
|
||||
want: "must be a string",
|
||||
},
|
||||
{
|
||||
name: "missing required string",
|
||||
kind: KindLobbyRaceNameRegistered,
|
||||
payload: map[string]any{},
|
||||
want: "race_name is missing",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := buildClientPushEvent(tt.kind, tt.payload)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"galaxy/backend/internal/notification"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
"galaxy/backend/internal/user"
|
||||
"galaxy/backend/push"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -69,7 +70,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scoped
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
@@ -149,9 +150,17 @@ type recordedPushEvent struct {
|
||||
TraceID string
|
||||
}
|
||||
|
||||
func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
|
||||
func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, event push.Event, eventID, requestID, traceID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
kind := ""
|
||||
var payload map[string]any
|
||||
if event != nil {
|
||||
kind = event.Kind()
|
||||
if jsonEvent, ok := event.(push.JSONEvent); ok {
|
||||
payload = jsonEvent.Payload
|
||||
}
|
||||
}
|
||||
r.calls = append(r.calls, recordedPushEvent{
|
||||
UserID: userID,
|
||||
Kind: kind,
|
||||
|
||||
@@ -13,17 +13,18 @@ import (
|
||||
)
|
||||
|
||||
type Accounts struct {
|
||||
UserID uuid.UUID `sql:"primary_key"`
|
||||
Email string
|
||||
UserName string
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
DeclaredCountry *string
|
||||
PermanentBlock bool
|
||||
DeletedActorType *string
|
||||
DeletedActorID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
UserID uuid.UUID `sql:"primary_key"`
|
||||
Email string
|
||||
UserName string
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
DeclaredCountry *string
|
||||
PermanentBlock bool
|
||||
DeletedActorType *string
|
||||
DeletedActorUserID *uuid.UUID
|
||||
DeletedActorUsername *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type EntitlementRecords struct {
|
||||
RecordID uuid.UUID `sql:"primary_key"`
|
||||
UserID uuid.UUID
|
||||
Tier string
|
||||
IsPaid bool
|
||||
Source string
|
||||
ActorType string
|
||||
ActorID *string
|
||||
ReasonCode string
|
||||
StartsAt time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedAt time.Time
|
||||
RecordID uuid.UUID `sql:"primary_key"`
|
||||
UserID uuid.UUID
|
||||
Tier string
|
||||
IsPaid bool
|
||||
Source string
|
||||
ActorType string
|
||||
ActorUserID *uuid.UUID
|
||||
ActorUsername *string
|
||||
ReasonCode string
|
||||
StartsAt time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ type EntitlementSnapshots struct {
|
||||
IsPaid bool
|
||||
Source string
|
||||
ActorType string
|
||||
ActorID *string
|
||||
ActorUserID *uuid.UUID
|
||||
ActorUsername *string
|
||||
ReasonCode string
|
||||
StartsAt time.Time
|
||||
EndsAt *time.Time
|
||||
|
||||
@@ -19,11 +19,13 @@ type LimitRecords struct {
|
||||
Value int32
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID *string
|
||||
ActorUserID *uuid.UUID
|
||||
ActorUsername *string
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
RemovedAt *time.Time
|
||||
RemovedByType *string
|
||||
RemovedByID *string
|
||||
RemovedByUserID *uuid.UUID
|
||||
RemovedByUsername *string
|
||||
RemovedReasonCode *string
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ type SanctionRecords struct {
|
||||
Scope string
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID *string
|
||||
ActorUserID *uuid.UUID
|
||||
ActorUsername *string
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
RemovedAt *time.Time
|
||||
RemovedByType *string
|
||||
RemovedByID *string
|
||||
RemovedByUserID *uuid.UUID
|
||||
RemovedByUsername *string
|
||||
RemovedReasonCode *string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SessionRevocations struct {
|
||||
RevocationID uuid.UUID `sql:"primary_key"`
|
||||
DeviceSessionID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
ActorKind string
|
||||
ActorUserID *uuid.UUID
|
||||
ActorUsername *string
|
||||
Reason string
|
||||
RevokedAt time.Time
|
||||
}
|
||||
@@ -17,19 +17,20 @@ type accountsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
UserID postgres.ColumnString
|
||||
Email postgres.ColumnString
|
||||
UserName postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
DeclaredCountry postgres.ColumnString
|
||||
PermanentBlock postgres.ColumnBool
|
||||
DeletedActorType postgres.ColumnString
|
||||
DeletedActorID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
DeletedAt postgres.ColumnTimestampz
|
||||
UserID postgres.ColumnString
|
||||
Email postgres.ColumnString
|
||||
UserName postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
DeclaredCountry postgres.ColumnString
|
||||
PermanentBlock postgres.ColumnBool
|
||||
DeletedActorType postgres.ColumnString
|
||||
DeletedActorUserID postgres.ColumnString
|
||||
DeletedActorUsername postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
DeletedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -71,41 +72,43 @@ func newAccountsTable(schemaName, tableName, alias string) *AccountsTable {
|
||||
|
||||
func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
var (
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
EmailColumn = postgres.StringColumn("email")
|
||||
UserNameColumn = postgres.StringColumn("user_name")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
DeclaredCountryColumn = postgres.StringColumn("declared_country")
|
||||
PermanentBlockColumn = postgres.BoolColumn("permanent_block")
|
||||
DeletedActorTypeColumn = postgres.StringColumn("deleted_actor_type")
|
||||
DeletedActorIDColumn = postgres.StringColumn("deleted_actor_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
|
||||
allColumns = postgres.ColumnList{UserIDColumn, EmailColumn, UserNameColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, DeclaredCountryColumn, PermanentBlockColumn, DeletedActorTypeColumn, DeletedActorIDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{EmailColumn, UserNameColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, DeclaredCountryColumn, PermanentBlockColumn, DeletedActorTypeColumn, DeletedActorIDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PermanentBlockColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
EmailColumn = postgres.StringColumn("email")
|
||||
UserNameColumn = postgres.StringColumn("user_name")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
DeclaredCountryColumn = postgres.StringColumn("declared_country")
|
||||
PermanentBlockColumn = postgres.BoolColumn("permanent_block")
|
||||
DeletedActorTypeColumn = postgres.StringColumn("deleted_actor_type")
|
||||
DeletedActorUserIDColumn = postgres.StringColumn("deleted_actor_user_id")
|
||||
DeletedActorUsernameColumn = postgres.StringColumn("deleted_actor_username")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
|
||||
allColumns = postgres.ColumnList{UserIDColumn, EmailColumn, UserNameColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, DeclaredCountryColumn, PermanentBlockColumn, DeletedActorTypeColumn, DeletedActorUserIDColumn, DeletedActorUsernameColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{EmailColumn, UserNameColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, DeclaredCountryColumn, PermanentBlockColumn, DeletedActorTypeColumn, DeletedActorUserIDColumn, DeletedActorUsernameColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PermanentBlockColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
UserID: UserIDColumn,
|
||||
Email: EmailColumn,
|
||||
UserName: UserNameColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
DeclaredCountry: DeclaredCountryColumn,
|
||||
PermanentBlock: PermanentBlockColumn,
|
||||
DeletedActorType: DeletedActorTypeColumn,
|
||||
DeletedActorID: DeletedActorIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
DeletedAt: DeletedAtColumn,
|
||||
UserID: UserIDColumn,
|
||||
Email: EmailColumn,
|
||||
UserName: UserNameColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
DeclaredCountry: DeclaredCountryColumn,
|
||||
PermanentBlock: PermanentBlockColumn,
|
||||
DeletedActorType: DeletedActorTypeColumn,
|
||||
DeletedActorUserID: DeletedActorUserIDColumn,
|
||||
DeletedActorUsername: DeletedActorUsernameColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
DeletedAt: DeletedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -17,17 +17,18 @@ type entitlementRecordsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
RecordID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
Tier postgres.ColumnString
|
||||
IsPaid postgres.ColumnBool
|
||||
Source postgres.ColumnString
|
||||
ActorType postgres.ColumnString
|
||||
ActorID postgres.ColumnString
|
||||
ReasonCode postgres.ColumnString
|
||||
StartsAt postgres.ColumnTimestampz
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
RecordID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
Tier postgres.ColumnString
|
||||
IsPaid postgres.ColumnBool
|
||||
Source postgres.ColumnString
|
||||
ActorType postgres.ColumnString
|
||||
ActorUserID postgres.ColumnString
|
||||
ActorUsername postgres.ColumnString
|
||||
ReasonCode postgres.ColumnString
|
||||
StartsAt postgres.ColumnTimestampz
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -69,37 +70,39 @@ func newEntitlementRecordsTable(schemaName, tableName, alias string) *Entitlemen
|
||||
|
||||
func newEntitlementRecordsTableImpl(schemaName, tableName, alias string) entitlementRecordsTable {
|
||||
var (
|
||||
RecordIDColumn = postgres.StringColumn("record_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
TierColumn = postgres.StringColumn("tier")
|
||||
IsPaidColumn = postgres.BoolColumn("is_paid")
|
||||
SourceColumn = postgres.StringColumn("source")
|
||||
ActorTypeColumn = postgres.StringColumn("actor_type")
|
||||
ActorIDColumn = postgres.StringColumn("actor_id")
|
||||
ReasonCodeColumn = postgres.StringColumn("reason_code")
|
||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorIDColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorIDColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ReasonCodeColumn, StartsAtColumn, CreatedAtColumn}
|
||||
RecordIDColumn = postgres.StringColumn("record_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
TierColumn = postgres.StringColumn("tier")
|
||||
IsPaidColumn = postgres.BoolColumn("is_paid")
|
||||
SourceColumn = postgres.StringColumn("source")
|
||||
ActorTypeColumn = postgres.StringColumn("actor_type")
|
||||
ActorUserIDColumn = postgres.StringColumn("actor_user_id")
|
||||
ActorUsernameColumn = postgres.StringColumn("actor_username")
|
||||
ReasonCodeColumn = postgres.StringColumn("reason_code")
|
||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ReasonCodeColumn, StartsAtColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return entitlementRecordsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
RecordID: RecordIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
Tier: TierColumn,
|
||||
IsPaid: IsPaidColumn,
|
||||
Source: SourceColumn,
|
||||
ActorType: ActorTypeColumn,
|
||||
ActorID: ActorIDColumn,
|
||||
ReasonCode: ReasonCodeColumn,
|
||||
StartsAt: StartsAtColumn,
|
||||
EndsAt: EndsAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
RecordID: RecordIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
Tier: TierColumn,
|
||||
IsPaid: IsPaidColumn,
|
||||
Source: SourceColumn,
|
||||
ActorType: ActorTypeColumn,
|
||||
ActorUserID: ActorUserIDColumn,
|
||||
ActorUsername: ActorUsernameColumn,
|
||||
ReasonCode: ReasonCodeColumn,
|
||||
StartsAt: StartsAtColumn,
|
||||
EndsAt: EndsAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -22,7 +22,8 @@ type entitlementSnapshotsTable struct {
|
||||
IsPaid postgres.ColumnBool
|
||||
Source postgres.ColumnString
|
||||
ActorType postgres.ColumnString
|
||||
ActorID postgres.ColumnString
|
||||
ActorUserID postgres.ColumnString
|
||||
ActorUsername postgres.ColumnString
|
||||
ReasonCode postgres.ColumnString
|
||||
StartsAt postgres.ColumnTimestampz
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
@@ -74,14 +75,15 @@ func newEntitlementSnapshotsTableImpl(schemaName, tableName, alias string) entit
|
||||
IsPaidColumn = postgres.BoolColumn("is_paid")
|
||||
SourceColumn = postgres.StringColumn("source")
|
||||
ActorTypeColumn = postgres.StringColumn("actor_type")
|
||||
ActorIDColumn = postgres.StringColumn("actor_id")
|
||||
ActorUserIDColumn = postgres.StringColumn("actor_user_id")
|
||||
ActorUsernameColumn = postgres.StringColumn("actor_username")
|
||||
ReasonCodeColumn = postgres.StringColumn("reason_code")
|
||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
MaxRegisteredRaceNamesColumn = postgres.IntegerColumn("max_registered_race_names")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorIDColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, MaxRegisteredRaceNamesColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorIDColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, MaxRegisteredRaceNamesColumn, UpdatedAtColumn}
|
||||
allColumns = postgres.ColumnList{UserIDColumn, TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, MaxRegisteredRaceNamesColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{TierColumn, IsPaidColumn, SourceColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonCodeColumn, StartsAtColumn, EndsAtColumn, MaxRegisteredRaceNamesColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ReasonCodeColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
@@ -94,7 +96,8 @@ func newEntitlementSnapshotsTableImpl(schemaName, tableName, alias string) entit
|
||||
IsPaid: IsPaidColumn,
|
||||
Source: SourceColumn,
|
||||
ActorType: ActorTypeColumn,
|
||||
ActorID: ActorIDColumn,
|
||||
ActorUserID: ActorUserIDColumn,
|
||||
ActorUsername: ActorUsernameColumn,
|
||||
ReasonCode: ReasonCodeColumn,
|
||||
StartsAt: StartsAtColumn,
|
||||
EndsAt: EndsAtColumn,
|
||||
|
||||
@@ -23,12 +23,14 @@ type limitRecordsTable struct {
|
||||
Value postgres.ColumnInteger
|
||||
ReasonCode postgres.ColumnString
|
||||
ActorType postgres.ColumnString
|
||||
ActorID postgres.ColumnString
|
||||
ActorUserID postgres.ColumnString
|
||||
ActorUsername postgres.ColumnString
|
||||
AppliedAt postgres.ColumnTimestampz
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
RemovedAt postgres.ColumnTimestampz
|
||||
RemovedByType postgres.ColumnString
|
||||
RemovedByID postgres.ColumnString
|
||||
RemovedByUserID postgres.ColumnString
|
||||
RemovedByUsername postgres.ColumnString
|
||||
RemovedReasonCode postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
@@ -77,15 +79,17 @@ func newLimitRecordsTableImpl(schemaName, tableName, alias string) limitRecordsT
|
||||
ValueColumn = postgres.IntegerColumn("value")
|
||||
ReasonCodeColumn = postgres.StringColumn("reason_code")
|
||||
ActorTypeColumn = postgres.StringColumn("actor_type")
|
||||
ActorIDColumn = postgres.StringColumn("actor_id")
|
||||
ActorUserIDColumn = postgres.StringColumn("actor_user_id")
|
||||
ActorUsernameColumn = postgres.StringColumn("actor_username")
|
||||
AppliedAtColumn = postgres.TimestampzColumn("applied_at")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
RemovedAtColumn = postgres.TimestampzColumn("removed_at")
|
||||
RemovedByTypeColumn = postgres.StringColumn("removed_by_type")
|
||||
RemovedByIDColumn = postgres.StringColumn("removed_by_id")
|
||||
RemovedByUserIDColumn = postgres.StringColumn("removed_by_user_id")
|
||||
RemovedByUsernameColumn = postgres.StringColumn("removed_by_username")
|
||||
RemovedReasonCodeColumn = postgres.StringColumn("removed_reason_code")
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, LimitCodeColumn, ValueColumn, ReasonCodeColumn, ActorTypeColumn, ActorIDColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByIDColumn, RemovedReasonCodeColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, LimitCodeColumn, ValueColumn, ReasonCodeColumn, ActorTypeColumn, ActorIDColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByIDColumn, RemovedReasonCodeColumn}
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, LimitCodeColumn, ValueColumn, ReasonCodeColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByUserIDColumn, RemovedByUsernameColumn, RemovedReasonCodeColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, LimitCodeColumn, ValueColumn, ReasonCodeColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByUserIDColumn, RemovedByUsernameColumn, RemovedReasonCodeColumn}
|
||||
defaultColumns = postgres.ColumnList{AppliedAtColumn}
|
||||
)
|
||||
|
||||
@@ -99,12 +103,14 @@ func newLimitRecordsTableImpl(schemaName, tableName, alias string) limitRecordsT
|
||||
Value: ValueColumn,
|
||||
ReasonCode: ReasonCodeColumn,
|
||||
ActorType: ActorTypeColumn,
|
||||
ActorID: ActorIDColumn,
|
||||
ActorUserID: ActorUserIDColumn,
|
||||
ActorUsername: ActorUsernameColumn,
|
||||
AppliedAt: AppliedAtColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
RemovedAt: RemovedAtColumn,
|
||||
RemovedByType: RemovedByTypeColumn,
|
||||
RemovedByID: RemovedByIDColumn,
|
||||
RemovedByUserID: RemovedByUserIDColumn,
|
||||
RemovedByUsername: RemovedByUsernameColumn,
|
||||
RemovedReasonCode: RemovedReasonCodeColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
|
||||
@@ -23,12 +23,14 @@ type sanctionRecordsTable struct {
|
||||
Scope postgres.ColumnString
|
||||
ReasonCode postgres.ColumnString
|
||||
ActorType postgres.ColumnString
|
||||
ActorID postgres.ColumnString
|
||||
ActorUserID postgres.ColumnString
|
||||
ActorUsername postgres.ColumnString
|
||||
AppliedAt postgres.ColumnTimestampz
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
RemovedAt postgres.ColumnTimestampz
|
||||
RemovedByType postgres.ColumnString
|
||||
RemovedByID postgres.ColumnString
|
||||
RemovedByUserID postgres.ColumnString
|
||||
RemovedByUsername postgres.ColumnString
|
||||
RemovedReasonCode postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
@@ -77,15 +79,17 @@ func newSanctionRecordsTableImpl(schemaName, tableName, alias string) sanctionRe
|
||||
ScopeColumn = postgres.StringColumn("scope")
|
||||
ReasonCodeColumn = postgres.StringColumn("reason_code")
|
||||
ActorTypeColumn = postgres.StringColumn("actor_type")
|
||||
ActorIDColumn = postgres.StringColumn("actor_id")
|
||||
ActorUserIDColumn = postgres.StringColumn("actor_user_id")
|
||||
ActorUsernameColumn = postgres.StringColumn("actor_username")
|
||||
AppliedAtColumn = postgres.TimestampzColumn("applied_at")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
RemovedAtColumn = postgres.TimestampzColumn("removed_at")
|
||||
RemovedByTypeColumn = postgres.StringColumn("removed_by_type")
|
||||
RemovedByIDColumn = postgres.StringColumn("removed_by_id")
|
||||
RemovedByUserIDColumn = postgres.StringColumn("removed_by_user_id")
|
||||
RemovedByUsernameColumn = postgres.StringColumn("removed_by_username")
|
||||
RemovedReasonCodeColumn = postgres.StringColumn("removed_reason_code")
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, SanctionCodeColumn, ScopeColumn, ReasonCodeColumn, ActorTypeColumn, ActorIDColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByIDColumn, RemovedReasonCodeColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, SanctionCodeColumn, ScopeColumn, ReasonCodeColumn, ActorTypeColumn, ActorIDColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByIDColumn, RemovedReasonCodeColumn}
|
||||
allColumns = postgres.ColumnList{RecordIDColumn, UserIDColumn, SanctionCodeColumn, ScopeColumn, ReasonCodeColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByUserIDColumn, RemovedByUsernameColumn, RemovedReasonCodeColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, SanctionCodeColumn, ScopeColumn, ReasonCodeColumn, ActorTypeColumn, ActorUserIDColumn, ActorUsernameColumn, AppliedAtColumn, ExpiresAtColumn, RemovedAtColumn, RemovedByTypeColumn, RemovedByUserIDColumn, RemovedByUsernameColumn, RemovedReasonCodeColumn}
|
||||
defaultColumns = postgres.ColumnList{AppliedAtColumn}
|
||||
)
|
||||
|
||||
@@ -99,12 +103,14 @@ func newSanctionRecordsTableImpl(schemaName, tableName, alias string) sanctionRe
|
||||
Scope: ScopeColumn,
|
||||
ReasonCode: ReasonCodeColumn,
|
||||
ActorType: ActorTypeColumn,
|
||||
ActorID: ActorIDColumn,
|
||||
ActorUserID: ActorUserIDColumn,
|
||||
ActorUsername: ActorUsernameColumn,
|
||||
AppliedAt: AppliedAtColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
RemovedAt: RemovedAtColumn,
|
||||
RemovedByType: RemovedByTypeColumn,
|
||||
RemovedByID: RemovedByIDColumn,
|
||||
RemovedByUserID: RemovedByUserIDColumn,
|
||||
RemovedByUsername: RemovedByUsernameColumn,
|
||||
RemovedReasonCode: RemovedReasonCodeColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var SessionRevocations = newSessionRevocationsTable("backend", "session_revocations", "")
|
||||
|
||||
type sessionRevocationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
RevocationID postgres.ColumnString
|
||||
DeviceSessionID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
ActorKind postgres.ColumnString
|
||||
ActorUserID postgres.ColumnString
|
||||
ActorUsername postgres.ColumnString
|
||||
Reason postgres.ColumnString
|
||||
RevokedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type SessionRevocationsTable struct {
|
||||
sessionRevocationsTable
|
||||
|
||||
EXCLUDED sessionRevocationsTable
|
||||
}
|
||||
|
||||
// AS creates new SessionRevocationsTable with assigned alias
|
||||
func (a SessionRevocationsTable) AS(alias string) *SessionRevocationsTable {
|
||||
return newSessionRevocationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new SessionRevocationsTable with assigned schema name
|
||||
func (a SessionRevocationsTable) FromSchema(schemaName string) *SessionRevocationsTable {
|
||||
return newSessionRevocationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new SessionRevocationsTable with assigned table prefix
|
||||
func (a SessionRevocationsTable) WithPrefix(prefix string) *SessionRevocationsTable {
|
||||
return newSessionRevocationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new SessionRevocationsTable with assigned table suffix
|
||||
func (a SessionRevocationsTable) WithSuffix(suffix string) *SessionRevocationsTable {
|
||||
return newSessionRevocationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newSessionRevocationsTable(schemaName, tableName, alias string) *SessionRevocationsTable {
|
||||
return &SessionRevocationsTable{
|
||||
sessionRevocationsTable: newSessionRevocationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newSessionRevocationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newSessionRevocationsTableImpl(schemaName, tableName, alias string) sessionRevocationsTable {
|
||||
var (
|
||||
RevocationIDColumn = postgres.StringColumn("revocation_id")
|
||||
DeviceSessionIDColumn = postgres.StringColumn("device_session_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
ActorKindColumn = postgres.StringColumn("actor_kind")
|
||||
ActorUserIDColumn = postgres.StringColumn("actor_user_id")
|
||||
ActorUsernameColumn = postgres.StringColumn("actor_username")
|
||||
ReasonColumn = postgres.StringColumn("reason")
|
||||
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
|
||||
allColumns = postgres.ColumnList{RevocationIDColumn, DeviceSessionIDColumn, UserIDColumn, ActorKindColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonColumn, RevokedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{DeviceSessionIDColumn, UserIDColumn, ActorKindColumn, ActorUserIDColumn, ActorUsernameColumn, ReasonColumn, RevokedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ReasonColumn, RevokedAtColumn}
|
||||
)
|
||||
|
||||
return sessionRevocationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
RevocationID: RevocationIDColumn,
|
||||
DeviceSessionID: DeviceSessionIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
ActorKind: ActorKindColumn,
|
||||
ActorUserID: ActorUserIDColumn,
|
||||
ActorUsername: ActorUsernameColumn,
|
||||
Reason: ReasonColumn,
|
||||
RevokedAt: RevokedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,6 @@ func UseSchema(schema string) {
|
||||
RuntimeRecords = RuntimeRecords.FromSchema(schema)
|
||||
SanctionActive = SanctionActive.FromSchema(schema)
|
||||
SanctionRecords = SanctionRecords.FromSchema(schema)
|
||||
SessionRevocations = SessionRevocations.FromSchema(schema)
|
||||
UserCountryCounters = UserCountryCounters.FromSchema(schema)
|
||||
}
|
||||
|
||||
@@ -31,13 +31,14 @@ CREATE INDEX device_sessions_user_idx ON device_sessions (user_id);
|
||||
CREATE INDEX device_sessions_status_idx ON device_sessions (status);
|
||||
|
||||
CREATE TABLE auth_challenges (
|
||||
challenge_id uuid PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
code_hash bytea NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz
|
||||
challenge_id uuid PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
code_hash bytea NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
preferred_language text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX auth_challenges_email_idx ON auth_challenges (email);
|
||||
@@ -48,6 +49,30 @@ CREATE TABLE blocked_emails (
|
||||
blocked_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- session_revocations is the durable audit trail of every device-session
|
||||
-- revocation. Each revoke writes one row carrying the actor kind, actor
|
||||
-- id, and free-form reason. The table is append-only; reading it is the
|
||||
-- only way to answer "who and why revoked this session". The
|
||||
-- device_session_id column is not a foreign key because device_sessions
|
||||
-- rows survive after revoke (status='revoked'), and dropping a session
|
||||
-- through a future cleanup must not implicitly drop its audit history.
|
||||
CREATE TABLE session_revocations (
|
||||
revocation_id uuid PRIMARY KEY,
|
||||
device_session_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
actor_kind text NOT NULL,
|
||||
actor_user_id uuid,
|
||||
actor_username text,
|
||||
reason text NOT NULL DEFAULT '',
|
||||
revoked_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT session_revocations_actor_chk
|
||||
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX session_revocations_user_idx ON session_revocations (user_id, revoked_at DESC);
|
||||
CREATE INDEX session_revocations_device_idx ON session_revocations (device_session_id, revoked_at DESC);
|
||||
CREATE INDEX session_revocations_actor_kind_idx ON session_revocations (actor_kind, revoked_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- User domain
|
||||
-- =====================================================================
|
||||
@@ -64,14 +89,17 @@ CREATE TABLE accounts (
|
||||
preferred_language text NOT NULL,
|
||||
time_zone text NOT NULL,
|
||||
declared_country text,
|
||||
permanent_block boolean NOT NULL DEFAULT false,
|
||||
deleted_actor_type text,
|
||||
deleted_actor_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
permanent_block boolean NOT NULL DEFAULT false,
|
||||
deleted_actor_type text,
|
||||
deleted_actor_user_id uuid,
|
||||
deleted_actor_username text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT accounts_email_unique UNIQUE (email),
|
||||
CONSTRAINT accounts_user_name_unique UNIQUE (user_name)
|
||||
CONSTRAINT accounts_user_name_unique UNIQUE (user_name),
|
||||
CONSTRAINT accounts_deleted_actor_chk
|
||||
CHECK (deleted_actor_user_id IS NULL OR deleted_actor_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX accounts_listing_idx
|
||||
@@ -88,19 +116,22 @@ CREATE INDEX accounts_declared_country_idx
|
||||
-- shape used by sanction_records/limit_records: the *_active rollup carries
|
||||
-- only the binding, the records table is the durable audit log.
|
||||
CREATE TABLE entitlement_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
tier text NOT NULL,
|
||||
is_paid boolean NOT NULL,
|
||||
source text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
reason_code text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL DEFAULT now(),
|
||||
ends_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
tier text NOT NULL,
|
||||
is_paid boolean NOT NULL,
|
||||
source text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_user_id uuid,
|
||||
actor_username text,
|
||||
reason_code text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL DEFAULT now(),
|
||||
ends_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT entitlement_records_tier_chk
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent'))
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent')),
|
||||
CONSTRAINT entitlement_records_actor_chk
|
||||
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX entitlement_records_user_idx
|
||||
@@ -117,32 +148,41 @@ CREATE TABLE entitlement_snapshots (
|
||||
is_paid boolean NOT NULL,
|
||||
source text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
actor_user_id uuid,
|
||||
actor_username text,
|
||||
reason_code text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz,
|
||||
max_registered_race_names integer NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT entitlement_snapshots_tier_chk
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent'))
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent')),
|
||||
CONSTRAINT entitlement_snapshots_actor_chk
|
||||
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE sanction_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
sanction_code text NOT NULL,
|
||||
scope text NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_id text,
|
||||
removed_reason_code text,
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
sanction_code text NOT NULL,
|
||||
scope text NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_user_id uuid,
|
||||
actor_username text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_user_id uuid,
|
||||
removed_by_username text,
|
||||
removed_reason_code text,
|
||||
CONSTRAINT sanction_records_code_chk
|
||||
CHECK (sanction_code IN ('permanent_block'))
|
||||
CHECK (sanction_code IN ('permanent_block')),
|
||||
CONSTRAINT sanction_records_actor_chk
|
||||
CHECK (actor_user_id IS NULL OR actor_username IS NULL),
|
||||
CONSTRAINT sanction_records_removed_by_chk
|
||||
CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX sanction_records_user_idx
|
||||
@@ -161,19 +201,25 @@ CREATE TABLE sanction_active (
|
||||
CREATE INDEX sanction_active_code_idx ON sanction_active (sanction_code);
|
||||
|
||||
CREATE TABLE limit_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
limit_code text NOT NULL,
|
||||
value integer NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_id text,
|
||||
removed_reason_code text
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
limit_code text NOT NULL,
|
||||
value integer NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_user_id uuid,
|
||||
actor_username text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_user_id uuid,
|
||||
removed_by_username text,
|
||||
removed_reason_code text,
|
||||
CONSTRAINT limit_records_actor_chk
|
||||
CHECK (actor_user_id IS NULL OR actor_username IS NULL),
|
||||
CONSTRAINT limit_records_removed_by_chk
|
||||
CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX limit_records_user_idx
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Persist the locale captured at send-email-code so it can be replayed at
|
||||
-- confirm-email-code when the auth flow needs `preferred_language` to seed
|
||||
-- a freshly-created `accounts` row. Existing rows default to '' and are
|
||||
-- treated by the auth service as "no captured locale", in which case the
|
||||
-- service falls back to the geoip-derived language and finally to "en".
|
||||
|
||||
ALTER TABLE backend.auth_challenges
|
||||
ADD COLUMN preferred_language text NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE backend.auth_challenges
|
||||
DROP COLUMN preferred_language;
|
||||
@@ -0,0 +1,26 @@
|
||||
# Backend migrations
|
||||
|
||||
Goose migrations embedded into the backend binary by `embed.go`. Applied
|
||||
at startup before any listener opens (see `internal/postgres`).
|
||||
|
||||
## Pre-production single-file rule
|
||||
|
||||
**While the platform is not yet in production, every schema change goes
|
||||
into the existing `00001_init.sql` file** rather than a new
|
||||
`00002_*`-prefixed file. The intent is to keep the schema in one
|
||||
canonical place so reviewers and developers do not have to reconstruct
|
||||
the latest shape from a chain of incremental migrations.
|
||||
|
||||
Operationally this means that pulling a branch with schema changes
|
||||
requires a fresh database — the only consumer today is local development
|
||||
and integration tests, both of which spin up disposable Postgres
|
||||
instances.
|
||||
|
||||
> **Remove this rule before the first production deployment.** From
|
||||
> that point on every schema change must be a new migration file with a
|
||||
> monotonically increasing prefix, and `00001_init.sql` becomes
|
||||
> immutable history.
|
||||
|
||||
If you need to make a change, edit `00001_init.sql` directly. Down
|
||||
migrations should still be kept in sync (they live at the bottom of the
|
||||
file — currently a single `DROP SCHEMA backend CASCADE`).
|
||||
@@ -34,6 +34,7 @@ var expectedBackendTables = []string{
|
||||
"auth_challenges",
|
||||
"blocked_emails",
|
||||
"device_sessions",
|
||||
"session_revocations",
|
||||
// User domain.
|
||||
"accounts",
|
||||
"entitlement_records",
|
||||
@@ -110,7 +111,7 @@ func TestMigrationsApplyToFreshSchema(t *testing.T) {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = migrationsTestOpTimeout
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// NoObservabilityOptions returns the pgshared options that pin a fresh
|
||||
// `*sql.DB` to no-op tracer and meter providers. Tests that bring up a
|
||||
// real Postgres testcontainer use it so the otelsql instrumentation
|
||||
// never falls back to the global tracer/meter — leaving an OTLP
|
||||
// endpoint accidentally configured in the developer environment cannot
|
||||
// stall the test on a background exporter handshake. Production code
|
||||
// passes the runtime's real providers through galaxy/postgres directly
|
||||
// and does not touch this helper.
|
||||
func NoObservabilityOptions() []pgshared.Option {
|
||||
return []pgshared.Option{
|
||||
pgshared.WithTracerProvider(tracenoop.NewTracerProvider()),
|
||||
pgshared.WithMeterProvider(metricnoop.NewMeterProvider()),
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ import (
|
||||
)
|
||||
|
||||
// InternalSessionsHandlers groups the gateway-only session handlers
|
||||
// under `/api/v1/internal/sessions/*`. The current implementation ships real
|
||||
// implementations; nil *auth.Service falls back to the Stage-3
|
||||
// placeholder so the contract test continues to validate the OpenAPI
|
||||
// envelope without booting a database.
|
||||
// under `/api/v1/internal/sessions/*`. The internal surface only
|
||||
// carries the per-request session lookup gateway needs to verify
|
||||
// signed envelopes; revocation is driven through the user surface
|
||||
// (self-driven) or through admin operations that call auth in-process,
|
||||
// not through this listener. nil *auth.Service falls back to the
|
||||
// Stage-3 placeholder so the contract test continues to validate the
|
||||
// OpenAPI envelope without booting a database.
|
||||
type InternalSessionsHandlers struct {
|
||||
svc *auth.Service
|
||||
svc *auth.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -62,58 +65,3 @@ func (h *InternalSessionsHandlers) Get() gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/internal/sessions/{device_session_id}/revoke.
|
||||
func (h *InternalSessionsHandlers) Revoke() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevoke")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
sess, err := h.svc.RevokeSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrSessionNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("internal sessions revoke failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeAllForUser handles POST /api/v1/internal/sessions/users/{user_id}/revoke-all.
|
||||
func (h *InternalSessionsHandlers) RevokeAllForUser() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevokeAllForUser")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
revoked, err := h.svc.RevokeAllForUser(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("internal sessions revoke-all failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID.String(),
|
||||
"revoked_count": len(revoked),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code is incorrect")
|
||||
case errors.Is(err, auth.ErrTooManyAttempts):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "too many attempts")
|
||||
case errors.Is(err, auth.ErrEmailPermanentlyBlocked):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed")
|
||||
default:
|
||||
h.logger.Error("confirm-email-code failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
|
||||
@@ -116,15 +116,20 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
return
|
||||
}
|
||||
// Orders payload uses an updatedAt + commands shape; we don't
|
||||
// rewrite it here because the engine derives the actor from
|
||||
// the route, not the order body. We pass the body through
|
||||
// verbatim (per ARCHITECTURE.md §9: backend is the only
|
||||
// caller, so rewriting is unnecessary). Unused mapping is
|
||||
// kept in the lookup so 404 returns when no mapping exists.
|
||||
_ = mapping
|
||||
// Engine binds the order body into `gamerest.Command{Actor,
|
||||
// Commands}` and rejects an empty actor with `notblank`, so
|
||||
// backend rebinds the actor from the runtime player mapping
|
||||
// before forwarding — the same rule as for the command
|
||||
// handler. Per ARCHITECTURE.md §9 backend is the only caller
|
||||
// of the engine, so the body never carries a client-supplied
|
||||
// actor.
|
||||
_ = order.Order{}
|
||||
resp, err := h.engine.PutOrders(ctx, endpoint, body)
|
||||
payload, err := rebindActor(body, mapping.RaceName)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
|
||||
return
|
||||
}
|
||||
resp, err := h.engine.PutOrders(ctx, endpoint, payload)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/auth"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserSessionsHandlers groups the user-facing session handlers under
|
||||
// `/api/v1/user/sessions/*`. Authenticated callers can list their own
|
||||
// active device sessions, revoke a specific one (logout from one
|
||||
// device), or revoke all sessions at once (logout everywhere). Every
|
||||
// mutation lands an audit row in `session_revocations` through the
|
||||
// auth service. nil *auth.Service falls back to the standard 501
|
||||
// placeholder.
|
||||
type UserSessionsHandlers struct {
|
||||
svc *auth.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserSessionsHandlers constructs the handler set. svc may be nil
|
||||
// — in that case every handler returns 501 not_implemented.
|
||||
func NewUserSessionsHandlers(svc *auth.Service, logger *zap.Logger) *UserSessionsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserSessionsHandlers{svc: svc, logger: logger.Named("http.user.sessions")}
|
||||
}
|
||||
|
||||
type userSessionsListResponse struct {
|
||||
Items []deviceSessionPayload `json:"items"`
|
||||
}
|
||||
|
||||
type userSessionsRevocationSummary struct {
|
||||
UserID string `json:"user_id"`
|
||||
RevokedCount int `json:"revoked_count"`
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/user/sessions.
|
||||
func (h *UserSessionsHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userSessionsList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
callerID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
sessions := h.svc.ListActiveByUser(c.Request.Context(), callerID)
|
||||
items := make([]deviceSessionPayload, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
items = append(items, deviceSessionToWire(s))
|
||||
}
|
||||
c.JSON(http.StatusOK, userSessionsListResponse{Items: items})
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/user/sessions/{device_session_id}/revoke.
|
||||
// The target session must belong to the caller; otherwise the handler
|
||||
// returns 404 (using the same shape as a missing session) so callers
|
||||
// cannot probe foreign device_session_ids.
|
||||
func (h *UserSessionsHandlers) Revoke() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userSessionsRevoke")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
callerID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
// Ownership check via the cache — if the target session is not
|
||||
// active and owned by the caller, surface a 404 in both
|
||||
// branches so foreign sessions are not probeable.
|
||||
cached, ok := h.svc.LookupSessionInCache(deviceSessionID)
|
||||
if !ok || cached.UserID != callerID {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
sess, err := h.svc.RevokeSession(ctx, deviceSessionID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: callerID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrSessionNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("user sessions revoke failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeAll handles POST /api/v1/user/sessions/revoke-all.
|
||||
func (h *UserSessionsHandlers) RevokeAll() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userSessionsRevokeAll")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
callerID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
revoked, err := h.svc.RevokeAllForUser(ctx, callerID, auth.RevokeContext{
|
||||
ActorKind: auth.ActorKindUserSelf,
|
||||
ActorID: callerID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("user sessions revoke-all failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, userSessionsRevocationSummary{
|
||||
UserID: callerID.String(),
|
||||
RevokedCount: len(revoked),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type RouterDependencies struct {
|
||||
UserLobbyMy *UserLobbyMyHandlers
|
||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||
UserGames *UserGamesHandlers
|
||||
UserSessions *UserSessionsHandlers
|
||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||
AdminUsers *AdminUsersHandlers
|
||||
AdminGames *AdminGamesHandlers
|
||||
@@ -162,6 +163,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.UserGames == nil {
|
||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserSessions == nil {
|
||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminAdminAccounts == nil {
|
||||
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -258,6 +262,11 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
||||
userSessions.POST("/:device_session_id/revoke", deps.UserSessions.Revoke())
|
||||
}
|
||||
|
||||
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
@@ -323,9 +332,7 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupInternal))
|
||||
|
||||
sessions := group.Group("/sessions")
|
||||
sessions.POST("/users/:user_id/revoke-all", deps.InternalSessions.RevokeAllForUser())
|
||||
sessions.GET("/:device_session_id", deps.InternalSessions.Get())
|
||||
sessions.POST("/:device_session_id/revoke", deps.InternalSessions.Revoke())
|
||||
|
||||
users := group.Group("/users")
|
||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||
|
||||
@@ -12,19 +12,35 @@ import (
|
||||
|
||||
// ActorRef identifies the principal that produced an audit-bearing
|
||||
// mutation. The wire shape mirrors the OpenAPI ActorRef schema. Type is
|
||||
// a free-form string ("user", "admin", "system" in MVP); ID is opaque
|
||||
// (a user UUID, an admin username, or empty for system).
|
||||
// one of "user", "admin", "system" in MVP. ID carries a user UUID for
|
||||
// Type=="user", an admin username for Type=="admin", and is empty for
|
||||
// Type=="system".
|
||||
type ActorRef struct {
|
||||
Type string
|
||||
ID string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Validate rejects empty actor types. Admin handlers always populate
|
||||
// Type; user-side mutations supply Type internally.
|
||||
// Validate rejects empty actor types and enforces the per-type shape
|
||||
// of ID: a user actor requires a UUID id, a system actor must have an
|
||||
// empty id. Other types pass through with no further check.
|
||||
func (a ActorRef) Validate() error {
|
||||
if strings.TrimSpace(a.Type) == "" {
|
||||
t := strings.TrimSpace(a.Type)
|
||||
if t == "" {
|
||||
return ErrInvalidActor
|
||||
}
|
||||
switch t {
|
||||
case "user":
|
||||
if strings.TrimSpace(a.ID) == "" {
|
||||
return fmt.Errorf("%w: user actor requires id", ErrInvalidActor)
|
||||
}
|
||||
if _, err := uuid.Parse(a.ID); err != nil {
|
||||
return fmt.Errorf("%w: user actor id must be a uuid: %v", ErrInvalidActor, err)
|
||||
}
|
||||
case "system":
|
||||
if strings.TrimSpace(a.ID) != "" {
|
||||
return fmt.Errorf("%w: system actor must have an empty id", ErrInvalidActor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,34 @@ type GeoCascade interface {
|
||||
// canonical implementation wraps `*auth.Service.RevokeAllForUser`. The
|
||||
// adapter lives in `cmd/backend/main.go` so `auth` does not export an
|
||||
// extra method shape.
|
||||
//
|
||||
// The actor argument carries audit context: who initiated the revoke
|
||||
// and why. The auth side persists it into `session_revocations`; user
|
||||
// callers populate it with a fixed kind matching the trigger.
|
||||
type SessionRevoker interface {
|
||||
RevokeAllForUser(ctx context.Context, userID uuid.UUID) error
|
||||
RevokeAllForUser(ctx context.Context, userID uuid.UUID, actor SessionRevokeActor) error
|
||||
}
|
||||
|
||||
// SessionRevokeActor describes the principal behind a session revoke.
|
||||
// Kind is a closed vocabulary mirrored by `auth.ActorKind`; ID is the
|
||||
// stable identifier of the principal (a user UUID for self-driven
|
||||
// flows, an admin username for admin-driven flows). Reason is a
|
||||
// free-form note recorded in the audit row.
|
||||
type SessionRevokeActor struct {
|
||||
Kind string
|
||||
ID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Closed Kind vocabulary. Mirror constants live in
|
||||
// `auth.ActorKind*`; the values must stay in sync because the auth
|
||||
// adapter forwards them verbatim.
|
||||
const (
|
||||
SessionRevokeActorSoftDeleteUser = "soft_delete_user"
|
||||
SessionRevokeActorSoftDeleteAdmin = "soft_delete_admin"
|
||||
SessionRevokeActorAdminSanction = "admin_sanction"
|
||||
)
|
||||
|
||||
// NewNoopLobbyCascade returns a LobbyCascade that logs every invocation
|
||||
// at info level and returns nil. The canonical lobby is wired in `cmd/backend/main.go`.
|
||||
// implementation; until then the no-op keeps the cascade orchestration
|
||||
|
||||
@@ -59,14 +59,13 @@ func (s *Service) ApplyLimit(ctx context.Context, input ApplyLimitInput) (Accoun
|
||||
}
|
||||
|
||||
if err := s.deps.Store.ApplyLimitTx(ctx, limitInsert{
|
||||
UserID: input.UserID,
|
||||
LimitCode: input.LimitCode,
|
||||
Value: input.Value,
|
||||
UserID: input.UserID,
|
||||
LimitCode: input.LimitCode,
|
||||
Value: input.Value,
|
||||
ReasonCode: input.ReasonCode,
|
||||
ActorType: input.Actor.Type,
|
||||
ActorID: input.Actor.ID,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
Actor: input.Actor,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
return Account{}, err
|
||||
|
||||
@@ -77,14 +77,13 @@ func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (
|
||||
|
||||
flipPermanent := input.SanctionCode == SanctionCodePermanentBlock
|
||||
if err := s.deps.Store.ApplySanctionTx(ctx, sanctionInsert{
|
||||
UserID: input.UserID,
|
||||
SanctionCode: input.SanctionCode,
|
||||
Scope: input.Scope,
|
||||
ReasonCode: input.ReasonCode,
|
||||
ActorType: input.Actor.Type,
|
||||
ActorID: input.Actor.ID,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: input.UserID,
|
||||
SanctionCode: input.SanctionCode,
|
||||
Scope: input.Scope,
|
||||
ReasonCode: input.ReasonCode,
|
||||
Actor: input.Actor,
|
||||
AppliedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
FlipPermanent: flipPermanent,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
@@ -94,7 +93,7 @@ func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (
|
||||
}
|
||||
|
||||
if flipPermanent {
|
||||
if err := s.cascadePermanentBlock(ctx, input.UserID); err != nil {
|
||||
if err := s.cascadePermanentBlock(ctx, input.UserID, input.Actor, input.ReasonCode); err != nil {
|
||||
s.deps.Logger.Warn("permanent-block cascade returned error",
|
||||
zap.String("user_id", input.UserID.String()),
|
||||
zap.Error(err),
|
||||
@@ -117,10 +116,15 @@ func validateSanctionCode(code string) error {
|
||||
// lobby on-user-blocked hook. Both calls are best-effort — they run
|
||||
// after the database commit and only join errors for the caller to
|
||||
// log.
|
||||
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID) error {
|
||||
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID, actor ActorRef, reasonCode string) error {
|
||||
var joined error
|
||||
if s.deps.SessionRevoker != nil {
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
|
||||
revokeActor := SessionRevokeActor{
|
||||
Kind: SessionRevokeActorAdminSanction,
|
||||
ID: actor.ID,
|
||||
Reason: SanctionCodePermanentBlock + ":" + reasonCode,
|
||||
}
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil {
|
||||
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +45,26 @@ func (s *Service) SoftDelete(ctx context.Context, userID uuid.UUID, actor ActorR
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("actor_type", actor.Type),
|
||||
)
|
||||
return s.runSoftDeleteCascade(ctx, userID)
|
||||
return s.runSoftDeleteCascade(ctx, userID, actor)
|
||||
}
|
||||
|
||||
// runSoftDeleteCascade fans the soft-delete signal out to dependent
|
||||
// modules in the documented order: auth → lobby → notification → geo.
|
||||
// Each call's error is joined; the loop continues even after a
|
||||
// failure so the remaining modules still get notified.
|
||||
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID) error {
|
||||
func (s *Service) runSoftDeleteCascade(ctx context.Context, userID uuid.UUID, actor ActorRef) error {
|
||||
var joined error
|
||||
if s.deps.SessionRevoker != nil {
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID); err != nil {
|
||||
kind := SessionRevokeActorSoftDeleteAdmin
|
||||
if actor.Type == "user" {
|
||||
kind = SessionRevokeActorSoftDeleteUser
|
||||
}
|
||||
revokeActor := SessionRevokeActor{
|
||||
Kind: kind,
|
||||
ID: actor.ID,
|
||||
Reason: "soft delete",
|
||||
}
|
||||
if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil {
|
||||
joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +119,17 @@ func equalStrings(a, b []string) bool {
|
||||
// orderTracker spies on a single call kind and pushes its name into
|
||||
// the ordered slice when invoked. It satisfies user.SessionRevoker.
|
||||
type orderTracker struct {
|
||||
name string
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
appendTo func(string)
|
||||
name string
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
lastActor user.SessionRevokeActor
|
||||
appendTo func(string)
|
||||
}
|
||||
|
||||
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
|
||||
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
|
||||
r.calls++
|
||||
r.lastUser = userID
|
||||
r.lastActor = actor
|
||||
if r.appendTo != nil && r.name != "" {
|
||||
r.appendTo(r.name)
|
||||
}
|
||||
|
||||
+108
-49
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
@@ -72,8 +73,7 @@ type sanctionInsert struct {
|
||||
SanctionCode string
|
||||
Scope string
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
Actor ActorRef
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
FlipPermanent bool
|
||||
@@ -85,8 +85,7 @@ type limitInsert struct {
|
||||
LimitCode string
|
||||
Value int32
|
||||
ReasonCode string
|
||||
ActorType string
|
||||
ActorID string
|
||||
Actor ActorRef
|
||||
AppliedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
@@ -113,7 +112,8 @@ func accountColumns() postgres.ColumnList {
|
||||
func snapshotColumns() postgres.ColumnList {
|
||||
s := table.EntitlementSnapshots
|
||||
return postgres.ColumnList{
|
||||
s.UserID, s.Tier, s.IsPaid, s.Source, s.ActorType, s.ActorID,
|
||||
s.UserID, s.Tier, s.IsPaid, s.Source,
|
||||
s.ActorType, s.ActorUserID, s.ActorUsername,
|
||||
s.ReasonCode, s.StartsAt, s.EndsAt, s.MaxRegisteredRaceNames, s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]Ac
|
||||
r := table.SanctionRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.SanctionCode, r.Scope, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.ActorType, r.ActorUserID, r.ActorUsername,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
@@ -292,7 +292,7 @@ func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]Ac
|
||||
SanctionCode: row.SanctionCode,
|
||||
Scope: row.Scope,
|
||||
ReasonCode: row.ReasonCode,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
Actor: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
||||
AppliedAt: row.AppliedAt,
|
||||
}
|
||||
if row.ExpiresAt != nil {
|
||||
@@ -311,7 +311,7 @@ func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]Activ
|
||||
r := table.LimitRecords
|
||||
stmt := postgres.SELECT(
|
||||
r.LimitCode, a.Value, r.ReasonCode,
|
||||
r.ActorType, r.ActorID,
|
||||
r.ActorType, r.ActorUserID, r.ActorUsername,
|
||||
r.AppliedAt, r.ExpiresAt,
|
||||
).
|
||||
FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))).
|
||||
@@ -331,7 +331,7 @@ func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]Activ
|
||||
LimitCode: row.LimitRecords.LimitCode,
|
||||
Value: row.LimitActive.Value,
|
||||
ReasonCode: row.LimitRecords.ReasonCode,
|
||||
Actor: ActorRef{Type: row.LimitRecords.ActorType, ID: derefString(row.LimitRecords.ActorID)},
|
||||
Actor: actorFromColumns(row.LimitRecords.ActorType, row.LimitRecords.ActorUserID, row.LimitRecords.ActorUsername),
|
||||
AppliedAt: row.LimitRecords.AppliedAt,
|
||||
}
|
||||
if row.LimitRecords.ExpiresAt != nil {
|
||||
@@ -395,9 +395,12 @@ func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot
|
||||
if err := s.assertAccountLive(ctx, snap.UserID); err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
||||
if err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
}
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
@@ -409,20 +412,21 @@ func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot
|
||||
table.EntitlementRecords.IsPaid,
|
||||
table.EntitlementRecords.Source,
|
||||
table.EntitlementRecords.ActorType,
|
||||
table.EntitlementRecords.ActorID,
|
||||
table.EntitlementRecords.ActorUserID,
|
||||
table.EntitlementRecords.ActorUsername,
|
||||
table.EntitlementRecords.ReasonCode,
|
||||
table.EntitlementRecords.StartsAt,
|
||||
table.EntitlementRecords.EndsAt,
|
||||
table.EntitlementRecords.CreatedAt,
|
||||
).VALUES(
|
||||
recordID, snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorID, snap.ReasonCode,
|
||||
snap.Actor.Type, actorUserID, actorUsername, snap.ReasonCode,
|
||||
snap.StartsAt, endsAt, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert entitlement record: %w", err)
|
||||
}
|
||||
return upsertSnapshotTx(ctx, tx, snap)
|
||||
return upsertSnapshotTx(ctx, tx, snap, actorUserID, actorUsername)
|
||||
})
|
||||
if err != nil {
|
||||
return EntitlementSnapshot{}, err
|
||||
@@ -437,9 +441,12 @@ func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
@@ -451,12 +458,13 @@ func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error
|
||||
table.SanctionRecords.Scope,
|
||||
table.SanctionRecords.ReasonCode,
|
||||
table.SanctionRecords.ActorType,
|
||||
table.SanctionRecords.ActorID,
|
||||
table.SanctionRecords.ActorUserID,
|
||||
table.SanctionRecords.ActorUsername,
|
||||
table.SanctionRecords.AppliedAt,
|
||||
table.SanctionRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
input.Actor.Type, actorUserID, actorUsername, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert sanction record: %w", err)
|
||||
@@ -498,9 +506,12 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
if err := s.assertAccountLive(ctx, input.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(input.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
recordID := uuid.New()
|
||||
actorID := nullableString(input.ActorID)
|
||||
var expiresAt any
|
||||
if input.ExpiresAt != nil {
|
||||
expiresAt = *input.ExpiresAt
|
||||
@@ -512,12 +523,13 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
table.LimitRecords.Value,
|
||||
table.LimitRecords.ReasonCode,
|
||||
table.LimitRecords.ActorType,
|
||||
table.LimitRecords.ActorID,
|
||||
table.LimitRecords.ActorUserID,
|
||||
table.LimitRecords.ActorUsername,
|
||||
table.LimitRecords.AppliedAt,
|
||||
table.LimitRecords.ExpiresAt,
|
||||
).VALUES(
|
||||
recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode,
|
||||
input.ActorType, actorID, input.AppliedAt, expiresAt,
|
||||
input.Actor.Type, actorUserID, actorUsername, input.AppliedAt, expiresAt,
|
||||
)
|
||||
if _, err := recordStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert limit record: %w", err)
|
||||
@@ -547,12 +559,16 @@ func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error {
|
||||
// successful idempotent operation.
|
||||
func (s *Store) SoftDeleteAccount(ctx context.Context, userID uuid.UUID, actor ActorRef, now time.Time) (bool, error) {
|
||||
a := table.Accounts
|
||||
actorIDExpr := nullableStringExpr(actor.ID)
|
||||
actorUserIDExpr, actorUsernameExpr, err := actorToColumnExprs(actor)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stmt := a.UPDATE().
|
||||
SET(
|
||||
a.DeletedAt.SET(postgres.TimestampzT(now)),
|
||||
a.DeletedActorType.SET(postgres.String(actor.Type)),
|
||||
a.DeletedActorID.SET(actorIDExpr),
|
||||
a.DeletedActorUserID.SET(actorUserIDExpr),
|
||||
a.DeletedActorUsername.SET(actorUsernameExpr),
|
||||
a.UpdatedAt.SET(postgres.TimestampzT(now)),
|
||||
).
|
||||
WHERE(
|
||||
@@ -593,18 +609,23 @@ func (s *Store) assertAccountLive(ctx context.Context, userID uuid.UUID) error {
|
||||
}
|
||||
|
||||
func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
actorUserID, actorUsername, err := actorToColumnArgs(snap.Actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source,
|
||||
es.ActorType, es.ActorUserID, es.ActorUsername,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorUserID, actorUsername,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
@@ -613,19 +634,20 @@ func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error {
|
||||
func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot, actorUserID, actorUsername any) error {
|
||||
es := table.EntitlementSnapshots
|
||||
actorID := nullableString(snap.Actor.ID)
|
||||
var endsAt any
|
||||
if snap.EndsAt != nil {
|
||||
endsAt = *snap.EndsAt
|
||||
}
|
||||
stmt := es.INSERT(
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID,
|
||||
es.UserID, es.Tier, es.IsPaid, es.Source,
|
||||
es.ActorType, es.ActorUserID, es.ActorUsername,
|
||||
es.ReasonCode, es.StartsAt, es.EndsAt,
|
||||
es.MaxRegisteredRaceNames, es.UpdatedAt,
|
||||
).VALUES(
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID,
|
||||
snap.UserID, snap.Tier, snap.IsPaid, snap.Source,
|
||||
snap.Actor.Type, actorUserID, actorUsername,
|
||||
snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt,
|
||||
).
|
||||
ON_CONFLICT(es.UserID).
|
||||
@@ -634,7 +656,8 @@ func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot)
|
||||
es.IsPaid.SET(es.EXCLUDED.IsPaid),
|
||||
es.Source.SET(es.EXCLUDED.Source),
|
||||
es.ActorType.SET(es.EXCLUDED.ActorType),
|
||||
es.ActorID.SET(es.EXCLUDED.ActorID),
|
||||
es.ActorUserID.SET(es.EXCLUDED.ActorUserID),
|
||||
es.ActorUsername.SET(es.EXCLUDED.ActorUsername),
|
||||
es.ReasonCode.SET(es.EXCLUDED.ReasonCode),
|
||||
es.StartsAt.SET(es.EXCLUDED.StartsAt),
|
||||
es.EndsAt.SET(es.EXCLUDED.EndsAt),
|
||||
@@ -680,7 +703,7 @@ func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
|
||||
Tier: row.Tier,
|
||||
IsPaid: row.IsPaid,
|
||||
Source: row.Source,
|
||||
Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)},
|
||||
Actor: actorFromColumns(row.ActorType, row.ActorUserID, row.ActorUsername),
|
||||
ReasonCode: row.ReasonCode,
|
||||
StartsAt: row.StartsAt,
|
||||
MaxRegisteredRaceNames: row.MaxRegisteredRaceNames,
|
||||
@@ -693,31 +716,67 @@ func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot {
|
||||
return out
|
||||
}
|
||||
|
||||
// nullableString converts a Go string to the `any` form expected by jet
|
||||
// VALUES: an empty string becomes nil so the column receives NULL.
|
||||
func nullableString(v string) any {
|
||||
if v == "" {
|
||||
return nil
|
||||
// actorToColumnArgs converts an ActorRef into the (actor_user_id,
|
||||
// actor_username) values for jet INSERT VALUES. A nil-typed `any` lands
|
||||
// as SQL NULL through the database/sql driver. Type=="user" parses ID
|
||||
// as a UUID; Type=="admin" stores ID verbatim as the username;
|
||||
// everything else (system, unknown) writes both columns as NULL. An
|
||||
// empty ID is allowed for "user" so synthetic system events that label
|
||||
// themselves as "user" do not fail.
|
||||
func actorToColumnArgs(actor ActorRef) (any, any, error) {
|
||||
switch strings.TrimSpace(actor.Type) {
|
||||
case "user":
|
||||
id := strings.TrimSpace(actor.ID)
|
||||
if id == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user store: actor id %q is not a uuid: %w", actor.ID, err)
|
||||
}
|
||||
return uid, nil, nil
|
||||
case "admin":
|
||||
if strings.TrimSpace(actor.ID) == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, actor.ID, nil
|
||||
default:
|
||||
return nil, nil, nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// nullableStringExpr returns a typed jet expression: the empty string
|
||||
// produces NULL, otherwise a String literal. Used by UPDATE SET paths
|
||||
// where jet's SET wants a typed Expression rather than `any`.
|
||||
func nullableStringExpr(v string) postgres.StringExpression {
|
||||
if v == "" {
|
||||
return postgres.StringExp(postgres.NULL)
|
||||
// actorToColumnExprs is the typed-expression analogue of
|
||||
// actorToColumnArgs for the UPDATE SET sites. jet's generated bindings
|
||||
// type uuid columns as ColumnString (the dialect emits an explicit
|
||||
// CAST), so both returned expressions are StringExpression.
|
||||
func actorToColumnExprs(actor ActorRef) (postgres.StringExpression, postgres.StringExpression, error) {
|
||||
uidArg, nameArg, err := actorToColumnArgs(actor)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return postgres.String(v)
|
||||
uidExpr := postgres.StringExp(postgres.NULL)
|
||||
if uid, ok := uidArg.(uuid.UUID); ok {
|
||||
uidExpr = postgres.UUID(uid)
|
||||
}
|
||||
nameExpr := postgres.StringExp(postgres.NULL)
|
||||
if name, ok := nameArg.(string); ok {
|
||||
nameExpr = postgres.String(name)
|
||||
}
|
||||
return uidExpr, nameExpr, nil
|
||||
}
|
||||
|
||||
// derefString returns the empty string when p is nil, otherwise *p.
|
||||
func derefString(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
// actorFromColumns reconstructs an ActorRef from the (actor_type,
|
||||
// actor_user_id, actor_username) triple read from an audit row. The
|
||||
// non-nil column wins; both nil yields an empty ID.
|
||||
func actorFromColumns(actorType string, userID *uuid.UUID, username *string) ActorRef {
|
||||
out := ActorRef{Type: actorType}
|
||||
switch {
|
||||
case userID != nil:
|
||||
out.ID = userID.String()
|
||||
case username != nil:
|
||||
out.ID = *username
|
||||
}
|
||||
return *p
|
||||
return out
|
||||
}
|
||||
|
||||
// rowsAffectedOrNotFound returns ErrAccountNotFound when the UPDATE
|
||||
|
||||
@@ -68,7 +68,7 @@ func startPostgres(t *testing.T) *sql.DB {
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = testOpTimeout
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
@@ -508,13 +508,15 @@ func TestListAccountsExcludesSoftDeleted(t *testing.T) {
|
||||
// recordingRevoker is a SessionRevoker spy that captures every call
|
||||
// for assertion. It is shared across tests in this package.
|
||||
type recordingRevoker struct {
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
calls int
|
||||
lastUser uuid.UUID
|
||||
lastActor user.SessionRevokeActor
|
||||
}
|
||||
|
||||
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
|
||||
func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
|
||||
r.calls++
|
||||
r.lastUser = userID
|
||||
r.lastActor = actor
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user