docs: reorder & testing

This commit is contained in:
Ilia Denisov
2026-05-07 00:58:53 +03:00
committed by GitHub
parent f446c6a2ac
commit 604fe40bcf
148 changed files with 9150 additions and 2757 deletions
+21 -15
View File
@@ -2,8 +2,8 @@
# Build context is the workspace root (galaxy/), not the backend/
# subdirectory, because the backend module pulls galaxy/{cronutil,error,
# geoip,model,postgres,util} through the go.work replace directives.
# Build with:
# geoip,model,postgres,schema,transcoder,util} through the go.work
# replace directives. Build with:
#
# docker build -t galaxy/backend:integration -f backend/Dockerfile .
@@ -11,13 +11,15 @@ FROM golang:1.26.2-alpine AS builder
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/
COPY pkg/model/ ./pkg/model/
COPY pkg/postgres/ ./pkg/postgres/
COPY pkg/util/ ./pkg/util/
COPY backend/ ./backend/
COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/
COPY pkg/model/ ./pkg/model/
COPY pkg/postgres/ ./pkg/postgres/
COPY pkg/schema/ ./pkg/schema/
COPY pkg/transcoder/ ./pkg/transcoder/
COPY pkg/util/ ./pkg/util/
COPY backend/ ./backend/
# Synthesise a minimal go.work tailored to the backend binary so the
# repository-level workspace (which lists every module) does not need
@@ -32,16 +34,20 @@ use (
./pkg/geoip
./pkg/model
./pkg/postgres
./pkg/schema
./pkg/transcoder
./pkg/util
)
replace (
galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip
galaxy/model v0.0.0 => ./pkg/model
galaxy/postgres v0.0.0 => ./pkg/postgres
galaxy/util v0.0.0 => ./pkg/util
galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip
galaxy/model v0.0.0 => ./pkg/model
galaxy/postgres v0.0.0 => ./pkg/postgres
galaxy/schema v0.0.0 => ./pkg/schema
galaxy/transcoder v0.0.0 => ./pkg/transcoder
galaxy/util v0.0.0 => ./pkg/util
)
EOF
+1 -1
View File
@@ -10,7 +10,7 @@ It should NOT be threated as source of truth for service functionality.
This plan is the technical specification for implementing the
consolidated Galaxy `backend` service. It is read together with
`../ARCHITECTURE.md` (architecture and security model) and
`../docs/ARCHITECTURE.md` (architecture and security model) and
`README.md` (module layout, configuration, operations).
After reading those two documents and this plan, an implementing
+23 -9
View File
@@ -3,7 +3,7 @@
`backend` is the consolidated business service of the Galaxy platform. It
owns identity, sessions, lobby, game runtime, mail, notifications, geo
signals, and administration. It is reachable only from `gateway` over
the trusted network. See `../ARCHITECTURE.md` for the platform-level
the trusted network. See `../docs/ARCHITECTURE.md` for the platform-level
context, security model, and decision rationale.
## 1. Purpose
@@ -205,12 +205,21 @@ message PushEvent {
- `ClientEvent` carries an opaque payload addressed to a `(user_id [,
device_session_id])`. Gateway signs and forwards it to active client
subscriptions. The frame also carries `event_id`, `request_id`, and
`trace_id` correlation strings populated by backend producers
(notification dispatcher fills `event_id` from `route_id`,
`request_id` from the originating intent's `idempotency_key`, and
`trace_id` from the active span); gateway re-emits the values inside
the signed client envelope without re-interpreting them.
subscriptions. Producers do not pass raw bytes to `push.Service`;
instead they pass a typed `push.Event` (`Kind() string`,
`Marshal() ([]byte, error)`) and `push.Service` invokes Marshal at
publish time. Every notification catalog kind (§10) has a 1:1
FlatBuffers schema in `pkg/schema/fbs/notification.fbs`; the
notification dispatcher routes `(kind, payload)` to a typed event
through `notification.buildClientPushEvent`, so client decoders can
rely on a stable wire shape per kind. `push.JSONEvent` remains as a
safety net for kinds that arrive without a catalog schema. The frame
also carries `event_id`, `request_id`, and `trace_id` correlation
strings populated by backend producers (notification dispatcher
fills `event_id` from `route_id`, `request_id` from the originating
intent's `idempotency_key`, and `trace_id` from the active span);
gateway re-emits the values inside the signed client envelope
without re-interpreting them.
- `SessionInvalidation` instructs gateway to close active subscriptions
and reject in-flight requests for the affected sessions.
- `cursor` is a monotonically increasing string. Gateway stores the last
@@ -275,7 +284,12 @@ Lifecycle:
and either marks `sent` or schedules `next_attempt_at` with
exponential backoff and jitter.
3. After `BACKEND_MAIL_MAX_ATTEMPTS` the delivery moves to
`mail_dead_letters`. An admin notification intent is emitted.
`mail_dead_letters` and the worker writes an operator log line.
The `mail.dead_lettered` notification kind is reserved in the
catalog (see §10) but has no producer wired up yet, so no admin
email or push event is emitted today; admin observability for
dead letters relies on the log line and the
`/api/v1/admin/mail/dead-letters` listing.
4. Operators can resend a `pending`, `retrying`, or `dead_lettered`
delivery via `POST /api/v1/admin/mail/{delivery_id}/resend`. Resend
on a `sent` delivery returns `409 Conflict` so operators cannot
@@ -469,4 +483,4 @@ Primary references:
- [`PLAN.md`](PLAN.md) — historical staged build-up of the service.
- [`openapi.yaml`](openapi.yaml) — REST contract.
- [`../ARCHITECTURE.md`](../ARCHITECTURE.md) — workspace-level architecture.
- [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) — workspace-level architecture.
+8 -2
View File
@@ -278,6 +278,7 @@ func run(ctx context.Context) (err error) {
publicAuthHandlers := backendserver.NewPublicAuthHandlers(authSvc, logger)
internalSessionsHandlers := backendserver.NewInternalSessionsHandlers(authSvc, logger)
userSessionsHandlers := backendserver.NewUserSessionsHandlers(authSvc, logger)
userAccountHandlers := backendserver.NewUserAccountHandlers(userSvc, logger)
adminUsersHandlers := backendserver.NewAdminUsersHandlers(userSvc, logger)
adminAdminAccountsHandlers := backendserver.NewAdminAdminAccountsHandlers(adminSvc, logger)
@@ -309,6 +310,7 @@ func run(ctx context.Context) (err error) {
GeoCounter: geoSvc,
PublicAuth: publicAuthHandlers,
InternalSessions: internalSessionsHandlers,
UserSessions: userSessionsHandlers,
UserAccount: userAccountHandlers,
AdminUsers: adminUsersHandlers,
AdminAdminAccounts: adminAdminAccountsHandlers,
@@ -370,11 +372,15 @@ type authSessionRevoker struct {
svc *auth.Service
}
func (r *authSessionRevoker) RevokeAllForUser(ctx context.Context, userID uuid.UUID) error {
func (r *authSessionRevoker) RevokeAllForUser(ctx context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
if r == nil || r.svc == nil {
return nil
}
_, err := r.svc.RevokeAllForUser(ctx, userID)
_, err := r.svc.RevokeAllForUser(ctx, userID, auth.RevokeContext{
ActorKind: auth.ActorKind(actor.Kind),
ActorID: actor.ID,
Reason: actor.Reason,
})
return err
}
+1 -1
View File
@@ -18,5 +18,5 @@ Primary references:
- [`../openapi.yaml`](../openapi.yaml) — REST contract.
- [`../PLAN.md`](../PLAN.md) — historical staged build-up; kept for
archaeology, not as a source of truth.
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) — workspace-level
- [`../../docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) — workspace-level
architecture.
+24 -6
View File
@@ -2,7 +2,7 @@
This document collects the multi-step interactions inside `backend`
that span domain modules. Each section assumes the reader is familiar
with `../README.md` and `../../ARCHITECTURE.md`.
with `../README.md` and `../../docs/ARCHITECTURE.md`.
## Registration (send + confirm)
@@ -39,11 +39,29 @@ sequenceDiagram
Gateway-->>Client: 200 {device_session_id}
```
Re-confirming the same `challenge_id` returns the existing session and
clears the throttle window (the throttle reuses the latest un-consumed
challenge rather than dropping the request). `accounts.user_name` is
synthesised once and never overwritten on subsequent sign-ins; the same
account always lands the same handle.
A `challenge_id` is single-use: confirm consumes the row in the same
transaction that inserts the device session, so a second confirm-email-code
on the same id returns `400 invalid_request` (`auth.ErrChallengeNotFound`)
together with unknown and expired ids. The opaque error code is
deliberate — the API never differentiates "consumed", "expired", and
"never existed" so an attacker cannot mine challenge_id state.
Throttle reuses the latest un-consumed challenge rather than dropping
the request: send-email-code returns the existing `challenge_id` to a
caller hitting the throttle, leaving the wire shape identical to a
fresh issue.
`accounts.permanent_block` is checked twice on the registration path:
once in send-email-code (no fresh challenge for an already-blocked
address) and once in confirm-email-code after the verification code has
matched (catches the case where an admin applied the block in the
window between the two calls). Both paths surface
`auth.ErrEmailPermanentlyBlocked` and the handler maps it to `400
invalid_request` with message `email is not allowed`.
`accounts.user_name` is synthesised once at first sign-in and never
overwritten on subsequent sign-ins; the same account always lands the
same handle.
## Authenticated request lifecycle
+1 -1
View File
@@ -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)
}
+61 -9
View File
@@ -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)
}
+23
View File
@@ -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.
+23 -7
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+103 -22
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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)
}
-63
View File
@@ -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))]
}
+5 -5
View File
@@ -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 (
-23
View File
@@ -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())
-14
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+16 -4
View File
@@ -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()))
+5 -1
View File
@@ -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 {
+247
View File
@@ -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)
}
})
}
}
+11 -2
View File
@@ -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`).
+2 -1
View File
@@ -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)
}
+23
View File
@@ -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()),
}
}
+1 -1
View File
@@ -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))...,
+13 -8
View File
@@ -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),
})
}
}
+9 -2
View File
@@ -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())
+22 -6
View File
@@ -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
}
+25 -1
View File
@@ -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
+6 -7
View File
@@ -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
+15 -11
View File
@@ -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))
}
}
+12 -3
View File
@@ -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))
}
}
+7 -5
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -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
}
+89 -42
View File
@@ -1062,6 +1062,86 @@ paths:
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/sessions:
get:
tags: [User]
operationId: userSessionsList
summary: List the caller's active device sessions
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
responses:
"200":
description: Caller's active device sessions.
content:
application/json:
schema:
$ref: "#/components/schemas/UserSessionList"
"400":
$ref: "#/components/responses/InvalidRequestError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/sessions/revoke-all:
post:
tags: [User]
operationId: userSessionsRevokeAll
summary: Revoke every device session belonging to the caller
description: |
Logout from every device. Subsequent authenticated requests on
any of the caller's sessions are rejected. Each revocation is
recorded in `session_revocations` with `actor_kind=user_self`.
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
responses:
"200":
description: Caller's sessions revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/DeviceSessionRevocationSummary"
"400":
$ref: "#/components/responses/InvalidRequestError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/sessions/{device_session_id}/revoke:
post:
tags: [User]
operationId: userSessionsRevoke
summary: Revoke one of the caller's device sessions
description: |
Logout from a single device. The target `device_session_id`
must belong to the caller; otherwise the endpoint returns
`404 not_found` (the same shape as a missing session) so the
endpoint cannot be used to probe foreign session ids. The
revocation is recorded in `session_revocations` with
`actor_kind=user_self`.
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
- $ref: "#/components/parameters/DeviceSessionID"
responses:
"200":
description: Device session revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/DeviceSession"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/admin-accounts:
get:
tags: [Admin]
@@ -2013,48 +2093,6 @@ paths:
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/internal/sessions/{device_session_id}/revoke:
post:
tags: [Internal]
operationId: internalSessionsRevoke
summary: Revoke a device session (gateway-only)
security: []
parameters:
- $ref: "#/components/parameters/DeviceSessionID"
responses:
"200":
description: Session revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/DeviceSession"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/internal/sessions/users/{user_id}/revoke-all:
post:
tags: [Internal]
operationId: internalSessionsRevokeAllForUser
summary: Revoke every device session belonging to a user
security: []
parameters:
- $ref: "#/components/parameters/UserID"
responses:
"200":
description: Sessions revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/DeviceSessionRevocationSummary"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/internal/users/{user_id}/account-internal:
get:
tags: [Internal]
@@ -3456,6 +3494,15 @@ components:
format: uuid
revoked_count:
type: integer
UserSessionList:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/DeviceSession"
responses:
NotImplementedError:
description: Endpoint is documented but not implemented yet.
+54
View File
@@ -0,0 +1,54 @@
package push
import "encoding/json"
// Event is the typed contract for client events emitted onto the gRPC
// push stream. Implementations carry their own serialiser; push.Service
// invokes Marshal at publish time to obtain the bytes that go into
// `pushv1.ClientEvent.Payload`.
//
// Notification dispatcher builds a typed FlatBuffers Event for every
// catalog kind through `notification.buildClientPushEvent`, backed by
// the per-kind helpers in `pkg/transcoder/notification.go`. JSONEvent
// (below) remains the safety net for kinds that arrive without a
// catalog schema.
type Event interface {
// Kind returns the catalog kind of this event (`backend/README.md`
// §10). Empty kind is rejected at publish time.
Kind() string
// Marshal returns the bytes that travel inside
// `pushv1.ClientEvent.Payload`. Implementations are expected to use
// FlatBuffers (preferred) or any deterministic encoding the client
// can decode; the push transport treats the result as opaque
// payload bytes.
Marshal() ([]byte, error)
}
// JSONEvent is the safety-net Event implementation for kinds that
// arrive without a catalog FlatBuffers schema. It serialises Payload
// via encoding/json so a misconfigured producer cannot silently drop
// events while a new kind is being added.
//
// New kinds must ship with a typed FlatBuffers schema in
// `pkg/schema/fbs/notification.fbs` and a matching case in
// `notification.buildClientPushEvent`; JSONEvent is not a canonical
// shape, only a fallback.
type JSONEvent struct {
// EventKind is the catalog kind returned by Kind().
EventKind string
// Payload is the JSON-serialisable map written by the producer.
Payload map[string]any
}
// Kind returns EventKind verbatim.
func (e JSONEvent) Kind() string { return e.EventKind }
// Marshal returns Payload encoded as JSON. The result is treated as
// opaque bytes by the push transport.
func (e JSONEvent) Marshal() ([]byte, error) {
return json.Marshal(e.Payload)
}
var _ Event = JSONEvent{}
+7 -7
View File
@@ -33,7 +33,7 @@ func TestPublishClientEventStampsCursorAndPayload(t *testing.T) {
userID := uuid.New()
devID := uuid.New()
payload := map[string]any{"game_id": "g1", "n": 7.0}
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, &devID, "lobby.invite.received", payload, "route-1", "req-1", "trace-1"))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, &devID, JSONEvent{EventKind: "lobby.invite.received", Payload: payload},"route-1", "req-1", "trace-1"))
events, stale := svc.ring.since(0, time.Now())
require.False(t, stale)
@@ -63,7 +63,7 @@ func TestPublishClientEventOmitsDeviceSessionWhenNil(t *testing.T) {
t.Cleanup(svc.Close)
userID := uuid.New()
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "x", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "x"},"", "", ""))
events, _ := svc.ring.since(0, time.Now())
require.Len(t, events, 1)
@@ -76,8 +76,8 @@ func TestPublishClientEventRequiresUserAndKind(t *testing.T) {
svc := newTestService(t)
t.Cleanup(svc.Close)
require.Error(t, svc.PublishClientEvent(context.Background(), uuid.Nil, nil, "k", nil, "", "", ""))
require.Error(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, " ", nil, "", "", ""))
require.Error(t, svc.PublishClientEvent(context.Background(), uuid.Nil, nil, JSONEvent{EventKind: "k"},"", "", ""))
require.Error(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, JSONEvent{EventKind: " "},"", "", ""))
}
func TestPublishSessionInvalidationStampsCursor(t *testing.T) {
@@ -123,7 +123,7 @@ func TestPublishCursorMonotonic(t *testing.T) {
userID := uuid.New()
for range 5 {
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "k"},"", "", ""))
}
events, _ := svc.ring.since(0, time.Now())
require.Len(t, events, 5)
@@ -137,7 +137,7 @@ func TestPublishOnClosedServiceIsNoop(t *testing.T) {
svc := newTestService(t)
svc.Close()
require.NoError(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, JSONEvent{EventKind: "k"},"", "", ""))
events, _ := svc.ring.since(0, time.Now())
assert.Empty(t, events)
}
@@ -150,7 +150,7 @@ var (
)
type pushClientEventPublisher 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 Event, eventID, requestID, traceID string) error
}
type pushSessionInvalidationEmitter interface {
+18 -12
View File
@@ -19,7 +19,6 @@ package push
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -131,23 +130,30 @@ func (s *Service) Close() {
}
}
// PublishClientEvent enqueues a ClientEvent for delivery. payload is
// marshalled to JSON; deviceSessionID is optional. eventID, requestID
// and traceID are correlation identifiers that gateway forwards
// verbatim into the signed client envelope (typically the producing
// route id, the originating client request id, and the trace id of the
// span that produced the event); empty strings are forwarded
// unchanged. The method satisfies notification.PushPublisher.
func (s *Service) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
// PublishClientEvent enqueues a ClientEvent for delivery. The typed
// `event` carries both the catalog kind and the payload bytes;
// push.Service invokes event.Marshal() at publish time so producers
// stay decoupled from the wire encoding. deviceSessionID is optional.
// eventID, requestID and traceID are correlation identifiers that
// gateway forwards verbatim into the signed client envelope (typically
// the producing route id, the originating client request id, and the
// trace id of the span that produced the event); empty strings are
// forwarded unchanged. The method satisfies
// notification.PushPublisher.
func (s *Service) PublishClientEvent(_ context.Context, userID uuid.UUID, deviceSessionID *uuid.UUID, event Event, eventID, requestID, traceID string) error {
if event == nil {
return errors.New("push.PublishClientEvent: event is required")
}
if userID == uuid.Nil {
return errors.New("push.PublishClientEvent: userID is required")
}
kind := event.Kind()
if strings.TrimSpace(kind) == "" {
return errors.New("push.PublishClientEvent: kind is required")
return errors.New("push.PublishClientEvent: event kind is required")
}
encoded, err := json.Marshal(payload)
encoded, err := event.Marshal()
if err != nil {
return fmt.Errorf("push.PublishClientEvent: marshal payload: %w", err)
return fmt.Errorf("push.PublishClientEvent: marshal event: %w", err)
}
ev := &pushv1.PushEvent{
Kind: &pushv1.PushEvent_ClientEvent{
+5 -5
View File
@@ -87,7 +87,7 @@ func TestSubscribePushDeliversLiveEvents(t *testing.T) {
require.Eventually(t, func() bool { return svc.SubscriberCount() == 1 }, time.Second, 5*time.Millisecond)
userID := uuid.New()
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "k"},"", "", ""))
ev, err := recvOne(t, stream, time.Second)
require.NoError(t, err)
@@ -104,7 +104,7 @@ func TestSubscribePushReplaysPastEventsOnReconnect(t *testing.T) {
userID := uuid.New()
for range 3 {
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "k"},"", "", ""))
}
client, cleanup := startBufconnServer(t, svc)
@@ -129,7 +129,7 @@ func TestSubscribePushSkipsReplayWhenCursorStale(t *testing.T) {
userID := uuid.New()
for range 4 {
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "k"},"", "", ""))
}
// Ring capacity 2 means cursors 1 and 2 are evicted.
@@ -141,7 +141,7 @@ func TestSubscribePushSkipsReplayWhenCursorStale(t *testing.T) {
require.Eventually(t, func() bool { return svc.SubscriberCount() == 1 }, time.Second, 5*time.Millisecond)
// Stale cursor → no replay; live publish must arrive.
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), userID, nil, JSONEvent{EventKind: "k"},"", "", ""))
ev, err := recvOne(t, stream, time.Second)
require.NoError(t, err)
assert.Equal(t, formatCursor(5), ev.Cursor)
@@ -173,7 +173,7 @@ func TestSubscribePushReplacesExistingClientID(t *testing.T) {
require.Eventually(t, func() bool { return svc.SubscriberCount() == 1 }, time.Second, 5*time.Millisecond)
// Live publish reaches the replacement.
require.NoError(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, "k", nil, "", "", ""))
require.NoError(t, svc.PublishClientEvent(context.Background(), uuid.New(), nil, JSONEvent{EventKind: "k"},"", "", ""))
ev, err := recvOne(t, stream2, time.Second)
require.NoError(t, err)
assert.NotEmpty(t, ev.Cursor)