Help us shape the future of Go! Take the 2026 Go Developer Survey

session

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 20 Imported by: 0

README

Arkoren Session

arkoren.dev/core/http/session is a package in the arkoren.dev/core module. It owns session state and the HTTP middleware (session.Middleware, session.FromContext). Handlers read the session with request.Session and request.MustSession from arkoren.dev/core/http/request.

Sessions are stored as JSON records in a *cache.Cache, so every cache driver works as a session store.

Given an existing store (*cache.Cache from arkoren.dev/core/service/cache) and r router, keep sessions for one hour. Import time for the duration:

r.Use(session.Middleware(store, func(cookies *session.Builder) {
	cookies.TTL(time.Hour)
	cookies.SameSite("strict")
}))

The default middleware needs no context-selection settings. For a custom context slot, allocate one session.NewHandle() and share it through session.Builder.Handle, auth.SessionBuilder.Handle, and request.WithSessionHandle. Copies select the same slot; separate constructor calls select different slots. The zero handle selects the default slot. Handles do not isolate cookies, cache records, or authorization; independent sessions also need appropriate cookie and storage settings. See the auth reference for coordinated middleware, identity, and handler wiring.

The default cookie is __Host-arkoren.session (plain arkoren.session with Builder.AllowInsecure, a domain, or a non-/ path); upgrading from the old default logs existing sessions out once.

Use a shared auth.NewSessionIdentity(loadUser) for authentication: its checked Login and Logout methods rotate automatically while retaining unrelated data. Call Session.Regenerate for other privilege changes to prevent session fixation. Call Session.Destroy to remove all data and retire the old ID. Persistence occurs before response headers, not when these methods return; monitor Builder.ErrorHandler or persistence events for storage failures. Loaded-session persistence uses the cache driver's atomic CompareAndPut, so a pending save cannot overwrite successful retirement, even after its lease expires or when it proposes a regenerated ID. Upgrade all writers and drain older requests before relying on this guarantee; stored keys and JSON do not change.

New copies only the outer input map; values passed to New or Put remain borrowed. A runtime jsontext.Value must match its field's Go type and cannot impersonate persisted JSON. After a real save and reload, typed decoding uses the existing JSON representation, with no new field tags or storage wrappers. Existing nil interface values round-trip; they are not missing fields.

MaxLifetime caps initial expiry, renewal, and application Extend calls at the original creation deadline. Reaching that deadline discards a later save and cookie. A negative maximum still disables the cap. Best-effort lease release has a separate ten-second context deadline; backends must honor that context. Zero selects default durations. Negative idle TTL or PersistenceTimeout fails middleware construction; negative maximum lifetime and renewal windows remain valid disabled settings.

Session events report registered route patterns, not expanded URL paths; Path is empty without a matched pattern. Keep route literals free of secrets. The configured error handler still receives the original storage error for explicit application handling; events do not carry that error or its text.

The cache driver serializes session data but does not encrypt it; do not store secrets unless the values or backend are protected.

Cookie-authenticated state-changing routes also need CSRF protection.

Use the memory cache driver in tests.

Documentation

Overview

Package session provides cache-backed HTTP session state and middleware for Arkoren applications.

Use Middleware to load sessions from cookies, attach them to requests, and persist records in any cache driver, such as arkoren.dev/driver/redis/rediscache. Construction uses Builder callbacks, run directly once in order on a fresh draft. Nil entries panic; expanded nil/empty slices supply none. Cookie and persistence settings detach at publication; cache and event services stay shared. A retained draft cannot change published middleware. Configure a shared Handle with Builder.Handle, auth.SessionBuilder.Handle, and request.WithSessionHandle; a later zero restores default lookup. Handles are comparable opaque selectors, not cookie or storage isolation. Use NewContextFor and FromContextFor for explicit bridges; raw context keys do not populate them.

Declare immutable Field values with NewField to bind literal storage names to Go types. Empty and zero fields are invalid; operations reject them before mutation. In-memory reads require matching Go types. Persisted values remain ordinary JSON, decoded into the requested type without an intermediate floating-point representation. Independent declarations with the same name still share storage; field types are not persisted as a schema or type tag. Existing nil interface fields round-trip as nil for interface definitions; missing fields still return ErrKeyNotFound, and mismatched concrete types still return ErrInvalidValueType without a JSON conversion. jsontext.Value supplied through New or Put is ordinary runtime data, not proof of persisted provenance. Only the private persistence decoder enables cross-type JSON reads. The outer constructor copy remains shallow; raw slices and other mutable values are borrowed, including values exposed through All.

theme := session.NewField[string]("theme")
if err := current.Put(theme, "dark"); err != nil {
	return err
}
value, err := current.Get(theme)
if err != nil {
	return err
}
_ = value

Call Session.Regenerate after login or privilege changes to prevent session fixation. Regeneration preserves session data; use Session.Destroy to log out and discard it. Before the final response headers, Middleware persists these changes and retires the old ID with a tombstone, not physical deletion. Persistence is skipped for 5xx responses. Observe Builder.ErrorHandler or PersistFailed: a failed retirement write can leave the old ID valid. Loaded-session saves atomically match their original record before writing either the same ID or a regenerated ID. A missing, changed, or retired guard discards the save and cookie, even if the coordination lease expired. Existing keys, JSON, and bounded tombstone retention are unchanged; no retirement ledger is required. Drain old, unguarded writers during upgrades. Retirement does not revoke different IDs committed earlier or undo application handler effects. The absolute maximum caps initial and renewed expiry and is checked before persistence. Exact expiry is expired, never a zero-TTL persistent record. Detached best-effort lease cleanup retains trace values but has its own ten-second deadline; backend context cooperation remains necessary. Negative idle TTL or PersistenceTimeout is rejected during middleware construction after callbacks. Zero still restores defaults; negative maximum lifetime and expiration-delta settings retain their disabled meanings.

Browsers enforce the HostPrefix and SecurePrefix name prefixes. When no name is set, Middleware uses "__Host-arkoren.session" if the cookie is Secure, has Path=/ and no Domain, and "arkoren.session" otherwise (insecure development, a domain, or another path). An explicit name is never altered; put the prefix in it yourself. Middleware panics at construction when the attributes contradict a prefix in the name.

HostPrefix means sibling subdomains cannot overwrite the cookie. To pick the name yourself:

session.Middleware(store, func(cookies *session.Builder) {
	cookies.Name(session.HostPrefix + "arkoren.session")
})

Upgrading from a version whose default was "arkoren.session" logs existing sessions out once, because browsers keep sending the old cookie name.

To share sessions across subdomains, use SecurePrefix with a domain:

session.Middleware(store,
	func(cookies *session.Builder) {
		cookies.Name(session.SecurePrefix + "arkoren.session")
	},

	func(sessionPolicy *session.Builder) {
		sessionPolicy.Domain("example.com")
	})

Prefixed cookies need HTTPS; the default name drops the prefix when Builder.AllowInsecure is set, so local development needs no special casing:

session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.AllowInsecure(app.Environment() == application.Development)
})

Events

With Builder.Events, Middleware publishes Expired ("session.expired") when it replaces an expired session, with its creation and expiry times and never its ID or data. It publishes Destroyed ("session.destroyed") with the request method and path once a session ended with Session.Destroy has been retired in the store. It publishes PersistFailed ("session.persist.failed") when saving, verifying, or retiring a session record fails, with the failed operation, request method, and path, and never the error text; Builder.ErrorHandler still receives the error. Destroyed and PersistFailed carry registered route patterns, never expanded URL paths; their Path is empty when no request pattern is available. Route literals are application-owned and must not contain secrets.

Index

Examples

Constants

View Source
const (
	// PersistSave is writing the session record.
	// It includes the atomic guarded write for a loaded session.
	//
	// Example: failed := payload.Operation == session.PersistSave
	PersistSave = "save"

	// PersistVerify is checking that the session's original record is still
	// live before saving over it.
	//
	// Example: failed := payload.Operation == session.PersistVerify
	PersistVerify = "verify"

	// PersistRetire is writing the tombstone that retires a destroyed or
	// regenerated session's old ID.
	//
	// Example: failed := payload.Operation == session.PersistRetire
	PersistRetire = "retire"
)

Persistence operations reported by PersistFailedEvent.Operation.

View Source
const (
	// HostPrefix makes browsers accept the cookie only when it is Secure, has
	// Path=/ and no Domain (RFC 6265bis), so sibling subdomains cannot set it.
	// [Middleware] panics when the configuration cannot satisfy these rules.
	//
	// 	session.Middleware(store, func(cookies *session.Builder) {
	// 	cookies.Name(session.HostPrefix+"arkoren.session")
	// })
	HostPrefix = "__Host-"

	// SecurePrefix makes browsers accept the cookie only when it is Secure. It
	// still allows Domain, so sessions can be shared across subdomains.
	//
	// 	session.Middleware(store, func(cookies *session.Builder) {
	// 	cookies.Name(session.SecurePrefix+"arkoren.session")
	// }, func(sessionPolicy *session.Builder) {
	// 	sessionPolicy.Domain("example.com")
	// })
	SecurePrefix = "__Secure-"

	// DefaultCookie is the base session cookie name. When no name is set,
	// [Middleware] uses it with the [HostPrefix] prefix, or bare when the
	// configuration cannot satisfy that prefix (see [Builder.Name]).
	//
	// Example: name := session.HostPrefix + session.DefaultCookie
	DefaultCookie = "arkoren.session"

	// DefaultTTL is the default total session lifetime.
	// It is an idle lifetime; a positive maximum can cap the resulting expiry.
	//
	// Example: _ = session.Middleware(store, func(cookies *session.Builder) {
	// 	cookies.TTL(session.DefaultTTL)
	// })
	DefaultTTL = 2 * time.Hour

	// DefaultMaxLifetime is the default absolute maximum session age.
	// It remains anchored at original creation across regeneration and renewal.
	//
	// Example: _ = session.Middleware(store, func(cookies *session.Builder) {
	// 	cookies.MaxLifetime(session.DefaultMaxLifetime)
	// })
	DefaultMaxLifetime = 24 * time.Hour

	// DefaultExpirationDelta is the default remaining-time threshold that triggers
	// automatic session extension.
	//
	// Example: _ = session.Middleware(store, func(cookies *session.Builder) {
	// 	cookies.ExpirationDelta(session.DefaultExpirationDelta)
	// })
	DefaultExpirationDelta = 15 * time.Minute
)

Variables

View Source
var Destroyed = event.NewTopic[DestroyedEvent]("session.destroyed")

Destroyed is published when Middleware has retired a session the handler ended with Session.Destroy: after the old ID is tombstoned and before the cookie is expired on the response. A failed tombstone publishes PersistFailed instead, because the old ID may still be usable. Sessions that merely expire publish Expired, and regeneration publishes nothing. The event is published with a context that is not canceled with the request, because persistence happens as the response header is written.

Example:

stop, err := bus.Listen(session.Destroyed, func(_ context.Context, payload session.DestroyedEvent) error {
	_ = payload.Path // Registered pattern, not expanded parameters.
	return nil
})
if err != nil {
	return err
}
defer stop()
View Source
var ErrInvalidField = errors.New("session invalid field")

ErrInvalidField identifies an empty or zero session field or namespace, or a descendant of an invalid namespace. Operations reject these declarations before reading or changing session state.

Example:

if errors.Is(session.NewField[int]("").Err(), session.ErrInvalidField) {
	// Supply a nonempty application-owned field name.
}
View Source
var ErrInvalidValueType = errors.New("session invalid value type")

ErrInvalidValueType indicates that a session value cannot be decoded into its requested type. It does not expose the field name, value, or decoder diagnostic text.

Example:

current := session.New(time.Now().Add(time.Hour), map[string]any{"count": "three"})
_, err := current.Get(session.NewField[int]("count"))
_ = errors.Is(err, session.ErrInvalidValueType)
View Source
var ErrKeyNotFound = errors.New("session key not found")

ErrKeyNotFound indicates that a session has no value for a key. An existing nil interface is a value, not a missing field.

Example:

current := session.New(time.Now().Add(time.Hour), nil)
_, err := current.Get(session.NewField[string]("optional"))
_ = errors.Is(err, session.ErrKeyNotFound)
View Source
var ErrNoSession = problem.New(func(details *problem.Builder) {
	details.Type("tag:arkoren.dev,2026-09:core:http/session:no-session")
	details.Title("Session not found")
	details.Detail("Unable to find the session in the request")
	details.Status(500)
})

ErrNoSession is the problem returned when attempting to retrieve a session that does not exist in the request context.

Example:

failure := session.ErrNoSession.Derive(func(details *problem.Builder) {
	details.Detail("The request cannot be completed.")
})
_ = failure.Status()
View Source
var Expired = event.NewTopic[ExpiredEvent]("session.expired")

Expired is published when Middleware loads a session past its expiry or Builder.MaxLifetime and replaces it with a fresh one. It fires on the first request after the expiry, not at the expiry itself. Sessions the application ends with Session.Destroy publish Destroyed instead.

Example:

stop, err := bus.Listen(session.Expired, func(_ context.Context, payload session.ExpiredEvent) error {
	_ = payload.ExpiresAt
	return nil
})
if err != nil {
	return err
}
defer stop()
View Source
var PersistFailed = event.NewTopic[PersistFailedEvent]("session.persist.failed")

PersistFailed is published when Middleware cannot persist session changes after the handler ran, so the client keeps its previous session state. It fires in addition to the Builder.ErrorHandler callback, which still receives the error itself. The event is published with a context that is not canceled with the request, because persistence happens as the response header is written.

Example:

stop, err := bus.Listen(session.PersistFailed, func(_ context.Context, payload session.PersistFailedEvent) error {
	_ = payload.Operation // No raw storage error is attached.
	return nil
})
if err != nil {
	return err
}
defer stop()

Functions

func Middleware

func Middleware(store *cache.Cache, configure ...BuilderFunc) router.Middleware

Middleware returns session middleware configured with the given driver and configuration.

Changes are saved just before the response status is written, and never for 5xx responses, so a failing handler does not persist partial state. Concurrent requests of one client normally save their own copy in lease order (last-write-wins), but a retired session cannot be revived: destroying a session, or replacing its ID by regeneration (logout, login), overwrites the old record with a tombstone that lives for Builder.MaxLifetime. A tombstoned ID loads as a fresh session, and a request that loaded the session before it was retired re-reads its record before saving and drops the save, without a cookie, when the record is gone or tombstoned.

A loaded session's save atomically compares the exact re-read original record and writes the current ID using cache.Driver.CompareAndPut. This also guards regenerated IDs: a tombstone or missing original rejects the write without issuing a cookie. A changed guard drops the save as well. Per-ID ten-second cache leases reduce conflicts but do not provide the correctness guarantee; expiry cannot let a pending save bypass retirement. Only changed loaded sessions and retirement take a lease. Acquisition or storage failures within Builder.PersistenceTimeout are persistence errors. Retiring an old ID does not revoke a different ID committed before retirement. Upgrade all writers and drain older requests before relying on this fencing; unguarded writes or restoring an old live record bypass it. Keys and JSON representations are unchanged. Initial and renewed expiry never exceed the original absolute deadline; a handler that reaches it cannot publish another session record or cookie. Lease cleanup can use an additional ten seconds independently of persistence cancellation, provided the backend honors its cleanup context. Changes are not saved on hijacked connections such as WebSocket upgrades.

Sessions are stored as JSON records in the configured namespace, joined with the session ID, so any cache driver works as the session store. A missing record starts a fresh session; any other cache error fails the request. Construction panics for negative TTL or PersistenceTimeout after callbacks and default resolution. Negative MaxLifetime and ExpirationDelta retain their documented disabled meanings; zero values still select defaults.

Example:

routes.Use(session.Middleware(store, func(cookies *session.Builder) {
	cookies.Namespace(cache.NewNamespace[jsontext.Value]("app.sessions"))
}))

func NewContext

func NewContext(ctx context.Context, value *Session) context.Context

NewContext stores the default session. Nil hides an earlier default value and is reported as absent by FromContext; no session is created here.

Example:

ctx := session.NewContext(context.Background(), current)
_, _ = session.FromContext(ctx)

func NewContextFor

func NewContextFor(ctx context.Context, handle Handle, value *Session) context.Context

NewContextFor stores value under handle without changing other selections. Zero selects default; nil hides an earlier value for this handle. A raw context.WithValue call keyed by handle does not populate this bridge.

Example:

ctx := session.NewContextFor(context.Background(), admin, current)
_ = ctx

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder configures Middleware before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.TTL(time.Hour)
}))

func (*Builder) AllowInsecure

func (sessionPolicy *Builder) AllowInsecure(allow bool) *Builder

AllowInsecure explicitly permits cookies over HTTP. This is intended only for local development; production deployments should leave it false.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.AllowInsecure(false)
}))

func (*Builder) Domain

func (sessionPolicy *Builder) Domain(domain string) *Builder

Domain restricts the cookie to the given domain.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Domain("example.com")
}))

func (*Builder) ErrorHandler

func (sessionPolicy *Builder) ErrorHandler(handler func(error)) *Builder

ErrorHandler is an optional callback invoked when internal session operations fail. When nil, errors are silently discarded.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(cookies *session.Builder) {
	cookies.ErrorHandler(nil)
}))

func (*Builder) Events

func (sessionPolicy *Builder) Events(bus *event.Bus) *Builder

Events receives Expired, Destroyed, and PersistFailed. Nil, the default, publishes nothing.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Events(event.New())
}))

func (*Builder) ExpirationDelta

func (sessionPolicy *Builder) ExpirationDelta(delta time.Duration) *Builder

ExpirationDelta is the remaining-time threshold for idle renewal by TTL, still capped by MaxLifetime. Zero selects the default fifteen minutes; negative disables near-expiry renewal, including the minimum time.Duration.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.ExpirationDelta(time.Second)
}))

func (*Builder) Handle

func (sessionPolicy *Builder) Handle(handle Handle) *Builder

Handle selects the request bridge populated by this middleware. Zero selects the default read by FromContext. This does not change cookie names, storage namespaces, or lifetime; configure these separately for independent stores.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Handle(applicationSession)
}))

func (*Builder) MaxLifetime

func (sessionPolicy *Builder) MaxLifetime(lifetime time.Duration) *Builder

MaxLifetime caps initial expiry, renewal, and application-selected expiry at this duration after original creation. A zero value uses 24 hours; negative disables the cap. An expired absolute deadline discards later saves and cookies rather than granting a fresh lifetime to the same authenticated state.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.MaxLifetime(time.Second)
}))

func (*Builder) Name

func (sessionPolicy *Builder) Name(name string) *Builder

Name is the cookie name sent to the client. Empty selects "__Host-arkoren.session" when the cookie is Secure, has Path=/ and no Domain, and "arkoren.session" otherwise. An explicit name is never altered.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Name("session")
}))

func (*Builder) Namespace

func (sessionPolicy *Builder) Namespace(space cache.Namespace[jsontext.Value]) *Builder

Namespace selects the namespace of raw JSON session records. Its separator joins the session ID to the name; the default produces "arkoren:session:ID". A zero or invalid namespace causes Middleware to panic after all callbacks. Session idle and maximum lifetimes, not the namespace TTL, govern security: each record and tombstone write overrides TTL with its runtime lifetime.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Namespace(cache.NewNamespace[jsontext.Value]("app.sessions"))
}))

func (*Builder) Partitioned

func (sessionPolicy *Builder) Partitioned(partitioned bool) *Builder

Partitioned enables the CHIPS partitioned cookie attribute.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Partitioned(false)
}))

func (*Builder) Path

func (sessionPolicy *Builder) Path(path string) *Builder

Path restricts the cookie to the given URL path prefix.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.Path("/")
}))

func (*Builder) PersistenceTimeout

func (sessionPolicy *Builder) PersistenceTimeout(timeout time.Duration) *Builder

PersistenceTimeout bounds primary persistence before response headers are written. Zero defaults to five seconds. Lease release ignores request cancellation but has a separate ten-second best-effort cleanup deadline; release failure falls back to lease expiry. These bounds require the cache driver to honor its context and do not bound application error/event callbacks. A negative final timeout makes Middleware panic after construction callbacks; use a positive duration rather than an immediately canceled persistence policy.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.PersistenceTimeout(time.Second)
}))

func (*Builder) SameSite

func (sessionPolicy *Builder) SameSite(sameSite string) *Builder

SameSite controls cross-site cookie behavior. Supported values are "lax", "strict", "none", and "default".

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.SameSite("lax")
}))

func (*Builder) TTL

func (sessionPolicy *Builder) TTL(ttl time.Duration) *Builder

TTL selects the idle lifetime from creation or renewal. A positive MaxLifetime caps the resulting expiry at the original absolute deadline. Zero selects the default two hours; a negative final TTL makes Middleware panic after all construction callbacks, rather than publishing invalid policy.

The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.

Example:

r := router.New()
r.Use(session.Middleware(store, func(sessionPolicy *session.Builder) {
	sessionPolicy.TTL(time.Second)
}))

type BuilderFunc

type BuilderFunc = func(*Builder)

BuilderFunc configures a Builder. Example:

var policy session.BuilderFunc = func(cookies *session.Builder) {
	cookies.TTL(time.Hour)
}
_ = session.Middleware(store, policy)

type DestroyedEvent

type DestroyedEvent struct {
	// Method is the request method.
	//
	// Example: payload := session.DestroyedEvent{Method: "POST"}
	Method string

	// Path is the matched route pattern with its method prefix removed.
	// Parameters are not expanded; no pattern means empty, never URL.Path.
	// A host prefix on a host-specific pattern is retained.
	//
	// Example: payload := session.DestroyedEvent{Path: "/sessions/{id}/logout"}
	Path string
}

DestroyedEvent is the payload of Destroyed. It never carries the session ID or data, and it does not say who the session belonged to. Its path is registered route identity, not an expanded URL containing a visitor's path parameters. Do not put secrets in registered route literals.

Example:

payload := session.DestroyedEvent{Method: "POST", Path: "/sessions/{id}/logout"}
_ = payload.Path

type ExpiredEvent

type ExpiredEvent struct {
	// CreatedAt is when the expired session was created.
	// It is the original anchor, including after earlier renewals.
	//
	// Example: payload := session.ExpiredEvent{CreatedAt: createdAt}
	CreatedAt time.Time

	// ExpiresAt is when the expired session was due to expire.
	// It is the stored or capped expiry, not the time the event is delivered.
	//
	// Example: payload := session.ExpiredEvent{ExpiresAt: expiresAt}
	ExpiresAt time.Time
}

ExpiredEvent is the payload of Expired. It carries no session ID or data. Its timestamps describe the rejected lifetime, not the visitor's identity.

Example: payload := session.ExpiredEvent{CreatedAt: createdAt, ExpiresAt: expiresAt}

type Field

type Field[T any] struct {
	// contains filtered or unexported fields
}

Field binds a literal storage name to its Go value type. It is immutable and safe to share. Its zero value is invalid; construct fields with NewField. Independently declared fields with the same name share persisted JSON, so applications must keep their types consistent across deployments.

Example:

theme := session.NewField[string]("theme")
if err := current.Put(theme, "dark"); err != nil {
	return err
}

func NewField

func NewField[T any](name string) Field[T]

NewField declares an immutable field with a literal, nonempty name. It does not register a schema or inspect T. Empty names produce an invalid field whose Field.Err and all session operations return ErrInvalidField.

Example:

userID := session.NewField[int]("user_id")
id, err := current.Get(userID)
if err != nil {
	return err
}
_ = id

func (Field[T]) Err

func (field Field[T]) Err() error

Err returns ErrInvalidField for an empty name, zero value, or invalid namespace ancestor, and nil for a valid declaration. The error never includes the field name or value type.

Example:

if err := session.NewField[int]("user_id").Err(); err != nil {
	return err
}

func (Field[T]) Format

func (field Field[T]) Format(state fmt.State, _ rune)

Format redacts the identity for every fmt verb, including detailed Go formatting. Use Field.Name explicitly when the identity is needed.

Example:

text := fmt.Sprintf("%+v", session.NewField[int]("user_id"))
_ = text // "session.Field([redacted])"

func (Field[T]) LogValue

func (field Field[T]) LogValue() slog.Value

LogValue redacts the field identity in structured logs, independently of the formatting path used by fmt.

Example:

logger.Info("session field", "field", session.NewField[int]("user_id"))

func (Field[T]) Name

func (field Field[T]) Name() string

Name returns the exact persisted member name, including an empty name for an invalid declaration. Ordinary formatting redacts this identity instead.

Example:

name := session.NewField[string]("theme").Name() // "theme"
_ = name

type Handle

type Handle struct {
	// contains filtered or unexported fields
}

Handle selects a request session independently of cookies and storage. Zero selects the default; copies select the same bridge. It does not isolate storage, cookies, or authenticated users (which remain keyed by user type).

Example:

admin := session.NewHandle()
ctx := session.NewContextFor(context.Background(), admin, current)
_, _ = session.FromContextFor(ctx, admin)

func NewHandle

func NewHandle() Handle

NewHandle creates a unique comparable selector without I/O. Share its value between middleware, identity definitions and request lookup.

Example:

admin := session.NewHandle()
r.Use(session.Middleware(store, func(cookies *session.Builder) {
	cookies.Handle(admin)
}))

type Namespace

type Namespace[T any] struct {
	// contains filtered or unexported fields
}

Namespace is an immutable literal prefix for fields of T. It composes JSON member names, not cache record names: Builder.Namespace selects cache records separately. Namespaces do not isolate storage, register schemas, or own TTL; session middleware owns lifetime. The zero value is invalid.

Example:

preferences := session.NewNamespace[string]("preferences")
theme := preferences.Field("theme") // preferences:theme
if err := current.Put(theme, "dark"); err != nil {
	return err
}

func NewNamespace

func NewNamespace[T any](prefix string, configure ...NamespaceBuilderFunc) Namespace[T]

NewNamespace declares a nonempty literal prefix with default separator ":". Empty prefixes retain ErrInvalidField, including in descendants. Callbacks follow NamespaceBuilderFunc's rules; they cannot repair invalid prefixes. Prefer NewField when the exact standalone member name is already known.

Example:

preferences := session.NewNamespace[string]("preferences")
if err := preferences.Err(); err != nil {
	return err
}
_ = preferences.Field("theme")

func (Namespace[T]) Derive

func (space Namespace[T]) Derive(configure ...NamespaceBuilderFunc) Namespace[T]

Derive publishes an independent namespace after ordered edits on a fresh draft. It preserves the prefix, inherited errors, and separator without reapplying defaults. Callbacks follow NamespaceBuilderFunc's rules.

Example:

base := session.NewNamespace[string]("preferences")
local := base.Derive(func(fields *session.NamespaceBuilder) {
	fields.Separator("/")
})
_ = local.Field("theme") // preferences/theme

func (Namespace[T]) Err

func (space Namespace[T]) Err() error

Err returns ErrInvalidField for zero, empty, or invalid descendant declarations. Errors never disclose names or types. A nonempty Name alone does not establish validity: an invalid ancestor remains invalid.

Example:

if err := session.NewNamespace[int]("cart").Err(); err != nil {
	return err
}

func (Namespace[T]) Field

func (space Namespace[T]) Field(name string) Field[T]

Field joins a nonempty literal member name and binds it to T. Empty names or invalid ancestors produce ErrInvalidField at use, even when the composed name is nonempty. No lifetime or schema metadata is added to persisted JSON.

Example:

theme := session.NewNamespace[string]("preferences").Field("theme")
if err := current.Put(theme, "dark"); err != nil {
	return err
}

func (Namespace[T]) Format

func (space Namespace[T]) Format(state fmt.State, _ rune)

Format redacts namespace identities for every fmt verb, including detailed Go formatting. Use Namespace.Name explicitly when the identity is needed.

Example:

text := fmt.Sprintf("%+v", session.NewNamespace[int]("cart"))
_ = text // "session.Namespace([redacted])"

func (Namespace[T]) LogValue

func (space Namespace[T]) LogValue() slog.Value

LogValue redacts namespace identities in structured logs independently of fmt formatting. It does not expose the prefix, separator, or value type.

Example:

logger.Info("session fields", "namespace", session.NewNamespace[int]("cart"))

func (Namespace[T]) Name

func (space Namespace[T]) Name() string

Name returns the exact composed prefix, including invalid declarations. Formatting redacts this identity; use Name only for deliberate inspection.

Example:

name := session.NewNamespace[int]("cart").Name() // "cart"
_ = name

func (Namespace[T]) Separator

func (space Namespace[T]) Separator() string

Separator returns the literal join string for future fields and namespaces. An empty result means concatenation; Derive does not reapply the default.

Example:

separator := session.NewNamespace[int]("cart").Separator() // ":"
_ = separator

func (Namespace[T]) Subnamespace

func (space Namespace[T]) Subnamespace(name string, configure ...NamespaceBuilderFunc) Namespace[T]

Subnamespace joins a literal child using the parent's separator before applying child callbacks. A child separator affects only future joins. Empty children and invalid ancestors retain ErrInvalidField through later edits.

Example:

base := session.NewNamespace[string]("preferences")
child := base.Subnamespace("display", func(fields *session.NamespaceBuilder) {
	fields.Separator("/")
})
_ = child.Field("theme") // preferences:display/theme

type NamespaceBuilder

type NamespaceBuilder struct {
	// contains filtered or unexported fields
}

NamespaceBuilder configures one namespace draft. Methods return the same draft for chaining. Publication copies its settings, so retaining and changing the draft cannot change a published namespace or its fields.

Example:

preferences := session.NewNamespace[string]("preferences", func(fields *session.NamespaceBuilder) {
	fields.Separator("/")
})
_ = preferences.Field("theme") // preferences/theme

func (*NamespaceBuilder) Separator

func (fields *NamespaceBuilder) Separator(separator string) *NamespaceBuilder

Separator replaces the join string for future children. Empty means literal concatenation, not the default; no trimming, escaping, or normalization occurs.

Example:

preferences := session.NewNamespace[string]("preferences:", func(fields *session.NamespaceBuilder) {
	fields.Separator("")
})
_ = preferences.Field("theme") // preferences:theme

type NamespaceBuilderFunc

type NamespaceBuilderFunc = func(*NamespaceBuilder)

NamespaceBuilderFunc edits a fresh draft synchronously. Callbacks run exactly once in order, including invalid declarations. Nil entries and application panics propagate; expanded nil or empty slices omit edits.

Example:

var policy session.NamespaceBuilderFunc = func(fields *session.NamespaceBuilder) {
	fields.Separator("/")
}
_ = session.NewNamespace[string]("preferences", policy)

type PersistFailedEvent

type PersistFailedEvent struct {
	// Operation is the storage step that failed: [PersistSave],
	// [PersistVerify], or [PersistRetire].
	//
	// Example: writing := payload.Operation == session.PersistSave
	Operation string

	// Method is the request method.
	//
	// Example: payload := session.PersistFailedEvent{Method: "POST"}
	Method string

	// Path is the matched route pattern with its method prefix removed.
	// Parameters are not expanded; no pattern means empty, never URL.Path.
	// A host prefix on a host-specific pattern is retained.
	//
	// Example: payload := session.PersistFailedEvent{Path: "/accounts/{id}/cart"}
	Path string
}

PersistFailedEvent is the payload of PersistFailed. It never carries the session ID or data, the cookie, or the storage error, whose text may name the backend or the session key. The route pattern omits expanded parameters and the query; applications must still avoid secrets in registered route literals.

Example:

payload := session.PersistFailedEvent{Operation: session.PersistSave, Path: "/accounts/{id}/cart"}
_ = payload.Operation

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session represents a user session with data storage and lifecycle management capabilities. It provides methods to store, retrieve, and manage session data, as well as control session expiration and regeneration for security purposes. Access is protected by a mutex to ensure thread-safe operations. Construct with New or obtain one from middleware, rather than relying on a zero Session to supply an ID or writable map. The mutex does not recursively protect application-owned values retained in fields.

Example:

current := session.New(time.Now().Add(time.Hour), nil)
field := session.NewField[int]("cart")
if err := current.Put(field, 42); err != nil {
	return err
}
value, err := current.Get(field)
if err != nil {
	return err
}
_ = value

func FromContext

func FromContext(ctx context.Context) (*Session, bool)

FromContext reads the default session without creating one. A typed nil pointer is absent; custom handle selections do not affect this lookup.

Example:

current, ok := session.FromContext(ctx)
if !ok {
	return session.ErrNoSession
}
_ = current

func FromContextFor

func FromContextFor(ctx context.Context, handle Handle) (*Session, bool)

FromContextFor reads exactly handle once, without fallback or mutation. Zero reads default; a missing value or typed nil pointer returns nil, false.

Example:

current, ok := session.FromContextFor(ctx, admin)
if !ok {
	return session.ErrNoSession
}
_ = current

func New

func New(expiresAt time.Time, storage map[string]any) *Session

New creates a session expiring at expiresAt with the given initial data and a random ID. It is marked as changed so the first save persists it. Nil storage creates an empty, writable session.

New copies the outer storage map. Do not mutate that map during construction; after New returns, caller map edits and session writes are independent. Values are retained without recursive copying, so nested maps, slices, and pointers may remain shared. Callers must synchronize access to shared mutable values: the session mutex protects its fields and outer map, not those values. Mutations through shared values do not mark the session as changed; use Session.Put to replace a value and record a change.

Example:

theme := session.NewField[string]("theme")
initial := map[string]any{"theme": "light"}
current := session.New(time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC), initial)
initial["theme"] = "dark"
value, err := current.Get(theme)
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(value) // light
Example

ExampleNew demonstrates that later edits to the input map do not change the session. Initial values remain available through typed field definitions.

package main

import (
	"fmt"
	"time"

	"arkoren.dev/core/http/session"
)

func main() {
	theme := session.NewField[string]("theme")
	initial := map[string]any{"theme": "light"}
	current := session.New(time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC), initial)
	initial["theme"] = "dark"

	value, err := current.Get(theme)

	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(value)
}
Output:
light

func (*Session) All

func (session *Session) All() map[string]any

All returns a shallow copy of the outer session map for inspection or persistence. Unmodified persisted fields appear as jsontext.Value; runtime values retain their exact Go types. Values, including raw JSON slices, remain borrowed: do not mutate them concurrently with the session. Use Get for typed decoding of persisted fields. Copying this map into New does not retain private persisted provenance; it becomes ordinary runtime input.

Example:

current := session.New(time.Now().Add(time.Hour), map[string]any{"theme": "dark"})
snapshot := current.All()
delete(snapshot, "theme") // The session's outer map is unchanged.
value, err := current.Get(session.NewField[string]("theme"))
if err != nil {
	return err
}
_ = value // "dark"

func (*Session) Clear

func (session *Session) Clear()

Clear removes all data from the session while maintaining the session itself. This operation marks the session as changed. It neither rotates nor retires the ID; use Destroy to end the session.

Example:

current.Clear()
_ = current.HasChanged() // true

func (*Session) CreatedAt

func (session *Session) CreatedAt() time.Time

CreatedAt returns the absolute time the session was first created. Regeneration and renewal preserve this anchor for maximum-lifetime checks.

Example: createdAt := current.CreatedAt()

func (*Session) Delete

func (session *Session) Delete[T any](field Field[T]) error

Delete removes a field and marks the session as changed, even if absent. Invalid fields return ErrInvalidField without changing any state. The field type selects the declaration but deletion does not inspect the stored value.

Example:

if err := current.Delete(session.NewField[string]("theme")); err != nil {
	return err
}

func (*Session) Destroy

func (session *Session) Destroy()

Destroy marks the session as ended; later changes are discarded. It performs no storage I/O itself. Call it before the first final response headers to log out. Unless the response is 5xx, Middleware replaces the original ID's record with a tombstone and expires the cookie instead of saving the session. The tombstone prevents stale requests from reviving the retired ID; the record is not physically deleted. If retirement fails, the cookie still expires but the old ID can remain valid. Observe Builder.ErrorHandler or PersistFailed for that failure.

Example:

current.Destroy() // Before the final response headers.
_ = current.Destroyed() // Storage retirement still belongs to middleware.

func (*Session) Destroyed

func (session *Session) Destroyed() bool

Destroyed reports whether Session.Destroy was called. It is a local lifecycle flag, not confirmation of durable retirement.

Example: ended := current.Destroyed()

func (*Session) ExpiresAt

func (session *Session) ExpiresAt() time.Time

ExpiresAt returns the time at which the session will expire. Middleware may cap an application-selected expiry before saving it.

Example: expiresAt := current.ExpiresAt()

func (*Session) ExpiresSoon

func (session *Session) ExpiresSoon(delta time.Duration) bool

ExpiresSoon reports whether expiry is strictly after now and strictly before now+delta. Equality at either boundary returns false; HasExpired handles the expiry boundary. Nonpositive deltas disable the warning, including the minimum time.Duration, without overflowing through negation.

Example:

current := session.New(time.Now().Add(time.Hour), nil)
_ = current.ExpiresSoon(-time.Second) // false: warnings disabled.

func (*Session) Extend

func (session *Session) Extend(expiresAt time.Time)

Extend requests a new expiration time and marks the session changed. It performs no persistence itself. Middleware caps this requested expiry at the original creation time plus its positive MaxLifetime before saving; callers cannot use Extend to renew the absolute lifetime or revive a retired ID.

Example:

current.Extend(time.Now().Add(time.Hour))
_ = current.ExpiresAt() // Requested expiry, before middleware applies its cap.

func (*Session) Get

func (session *Session) Get[T any](field Field[T]) (res T, err error)

Get retrieves a field's value, returning ErrInvalidField for invalid declarations and ErrKeyNotFound for missing members. In-memory values must already have type T, including jsontext.Value supplied through New or Put. Only fields actually decoded from a stored record permit typed JSON decoding. Replacing a loaded field with Put restores runtime type checking. Decode and type failures return ErrInvalidValueType without exposing names or values. An existing nil interface returns nil for interface fields, not ErrKeyNotFound; it cannot be read as a concrete type without the ordinary matching Go value.

Example:

userID, err := current.Get(session.NewField[int]("user_id"))
if err != nil {
	return err
}
_ = userID

func (*Session) HasChanged

func (session *Session) HasChanged() bool

HasChanged returns true if the session data has been modified since it was loaded. This tracks field/lifecycle operations, not mutations through borrowed values.

Example: changed := current.HasChanged()

func (*Session) HasExpired

func (session *Session) HasExpired() bool

HasExpired reports whether the expiration instant has been reached. Equality counts as expired, matching cache expiry and preventing a zero remaining TTL from being mistaken for a persistent record by middleware.

Example:

current := session.New(time.Unix(0, 0), nil)
_ = current.HasExpired() // true

func (*Session) HasRegenerated

func (session *Session) HasRegenerated() bool

HasRegenerated returns true if the session has been regenerated (i.e., the session ID has changed). Regeneration does not reset original creation or prove storage succeeded.

Example: rotated := current.HasRegenerated()

func (*Session) ID

func (session *Session) ID() string

ID returns the current session identifier. This may differ from the original session ID if the session has been regenerated. It reveals the bearer identifier; do not log it indiscriminately.

Example: id := current.ID() // Use only where the session identifier is required.

func (*Session) MarkAsUnchanged

func (session *Session) MarkAsUnchanged()

MarkAsUnchanged resets the change tracking flag, preventing the session from being persisted on the current request. Middleware renewal can mark it changed again. This does not undo any data mutation and should not be used to claim an unsaved change is durable.

Example:

current.MarkAsUnchanged()
_ = current.HasChanged() // false until another mutation or renewal.

func (*Session) OriginalID

func (session *Session) OriginalID() string

OriginalID returns the session identifier that was assigned when the session was initially created. Regenerate preserves it so middleware can retire the original record.

Example: original := current.OriginalID()

func (*Session) Put

func (session *Session) Put[T any](field Field[T], value T) error

Put stores a value under the field's literal name and marks the session as changed. Invalid fields return ErrInvalidField without changing any state. Values are retained, not copied; persistence still uses ordinary JSON.

WARNING: When storing authentication-related state, callers MUST call Session.Regenerate immediately after to prevent session fixation attacks.

Example:

if err := current.Put(session.NewField[int]("user_id"), 42); err != nil {
	return err
}
current.Regenerate()

func (*Session) Regenerate

func (session *Session) Regenerate()

Regenerate generates a new cryptographically random session ID. The original ID and session data are preserved, and the session is marked as changed. This method performs no storage I/O. With Middleware, change session state before the first final response headers. Unless the response is 5xx, the middleware saves the new record, issues its cookie, then replaces the old record with a tombstone so stale requests cannot revive the retired ID. The old record is not physically deleted. A failed save leaves the old session intact; a failed tombstone write can leave the old ID valid. Observe Builder.ErrorHandler or PersistFailed for persistence failures.

WARNING: Call Regenerate after login or privilege changes to prevent session fixation. It preserves authentication data and does not log the user out. Use Session.Destroy for logout instead.

Example:

session.Regenerate()

Directories

Path Synopsis
internal
persistence
Package persistence owns session records, guarded writes, and retirement.
Package persistence owns session records, guarded writes, and retirement.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL