package pgstore import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "textmachine/platform/internal/auth" ) // sessions.go is the domain face of the session statements: the SQL and the Scan are generated from // `queries/sessions.sql`, and what stays here is what a generator cannot know — the sentinel errors // callers switch on, and the domain types they speak in. // Lookup resolves a presented token. Expiry and revocation are clauses of the QUERY, not checks a // caller could forget: a row that comes back is live by construction. func (s *Store) Lookup(ctx context.Context, digest []byte, now time.Time) (auth.Session, error) { row, err := s.q.LookupSession(ctx, LookupSessionParams{TokenSha256: digest, Now: now}) if errors.Is(err, pgx.ErrNoRows) { return auth.Session{}, auth.ErrNoSession } if err != nil { return auth.Session{}, fmt.Errorf("pgstore: lookup session: %w", err) } // Field by NAME. The three used to be filled by position from the SELECT list, and generating the // Scan is what stops those two from disagreeing. // // ⚠ It does NOT make the mapping below safe, and saying so is the point: the projection is still // hand-written, and a transposed pair of the two deadlines here plans and scans perfectly while // stopping the idle window from ever sliding. Measured twice — the swap survived the whole // eighteen-package battery both before this conversion and after it. What closes it is // TestTheTwoSessionDeadlinesAreNotInterchangeable, not the generator. return auth.Session{ UserID: row.UserID, IdleExpiresAt: row.IdleExpiresAt, AbsoluteExpiresAt: row.AbsoluteExpiresAt, }, nil } // Touch slides the idle window. It never moves the absolute expiry — that is the point of having // two clocks — and it is called only in the window's second half, so reads stay reads. // // Its WHERE matches Lookup's, idle clause included (PD-4): reachable only after a successful // Lookup today, but a query that can resurrect an idle-expired session is not one to leave lying // around for the next caller. func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL time.Duration) error { // Deadlines are computed in Go and travel as timestamps: one clock, one place, and no interval // encoding to reason about. if err := s.q.TouchSession(ctx, TouchSessionParams{ TokenSha256: digest, Now: now, IdleDeadline: now.Add(idleTTL), }); err != nil { return fmt.Errorf("pgstore: touch session: %w", err) } return nil } // StillLive re-asks about a session that was already authenticated, for a response that is still // open. It is the query behind auth.SessionLife, and the event stream is its only caller. // // ⚠ THE IDLE CLAUSE IS DELIBERATELY ABSENT, and this is the one predicate over this table where that // is true. The idle window slides on a REQUEST (auth.Authenticator.Require), and the event stream is // ONE request that lives for hours — so a stream can never slide its own window, and asking the idle // question here would end the stream of a user who is sitting and watching it. What is asked instead // is the pair of facts a stream cannot influence by existing: was the session revoked, and has its // ABSOLUTE ceiling passed. // // ⚠ A row that is GONE answers ErrNoSession like a revoked one, and that is not a hole in the // paragraph above: SweepSessions deletes revoked rows too, so "no row" has to mean dead — otherwise // a revoked session would keep its stream for as long as it took the sweep to run, which is the // defect this query exists to remove. The cost is named: a session that idled out and was then swept // also ends its stream, up to an hour late. That is not the idle window ending a stream, it is the // row's absence, and by then every other request of that caller is a 401 anyway. func (s *Store) StillLive(ctx context.Context, digest []byte, now time.Time) error { _, err := s.q.SessionStillLive(ctx, SessionStillLiveParams{TokenSha256: digest, Now: now}) if errors.Is(err, pgx.ErrNoRows) { return auth.ErrNoSession } if err != nil { return fmt.Errorf("pgstore: re-check session: %w", err) } return nil } // CreateSession stores the digest; the plaintext never reaches this package. func (s *Store) CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error { if err := s.q.CreateSession(ctx, CreateSessionParams{ TokenSha256: digest, UserID: userID, Now: now, IdleExpiresAt: now.Add(idleTTL), AbsoluteExpiresAt: now.Add(maxAge), }); err != nil { return fmt.Errorf("pgstore: create session: %w", err) } return nil } // RevokeSession ends one session immediately. func (s *Store) RevokeSession(ctx context.Context, digest []byte, now time.Time) error { if err := s.q.RevokeSession(ctx, RevokeSessionParams{TokenSha256: digest, Now: now}); err != nil { return fmt.Errorf("pgstore: revoke session: %w", err) } return nil } // SweepSessions deletes rows nothing can authenticate with again: past either expiry, or revoked. // A revoked row is the one a compromised account most wants gone, and it used to sit until its // absolute expiry ninety days later. The audit lives in the login journal, not here. func (s *Store) SweepSessions(ctx context.Context, now time.Time) (int64, error) { n, err := s.q.SweepSessions(ctx, now) if err != nil { return 0, fmt.Errorf("pgstore: sweep sessions: %w", err) } return n, nil }