fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID

- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
This commit is contained in:
Jakub Zych
2026-10-23 15:59:54 +02:00
parent c1c0c77ddc
commit 240457291f
10 changed files with 217 additions and 39 deletions

View File

@@ -58,10 +58,10 @@ type Pier struct {
version string
instance string
// stopReap ends the session reaper Mount starts. backpack.App has no
// shutdown hook yet, so in production the reaper runs for the life of
// the process, like the store subscription; when backpack gains one,
// Mount registers stopReap there.
// stopReap ends the session reaper (and a Redis store's warm-up) Mount
// starts. backpack.App has no shutdown hook yet, so in production the
// reaper runs for the life of the process, like the store subscription;
// when backpack gains one, Mount registers stopReap there.
stopReap context.CancelFunc
}
@@ -72,8 +72,10 @@ type Pier struct {
// behind the inline throttle. Nothing is mounted when the flag is off. A
// bad binding, a duplicate name, an unknown guard or a route already
// registered at mcp.path fails with an error naming the plugin, so boot
// (and route:list) fail. Nothing dials Redis here. The mounted *Pier is
// published on the application.
// (and route:list) fail. Mount never waits for Redis: a Redis store is
// opened by a background warm-up with a backoff, so the event subscription
// exists before traffic arrives. Mount also starts the session reaper. The
// mounted *Pier is published on the application.
func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
if host == nil || app == nil {
return errors.New("pier: a host and an application are required")
@@ -139,13 +141,17 @@ func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
reapCtx, stop := context.WithCancel(context.Background())
p.stopReap = stop
go p.sessions.reap(reapCtx, reapInterval(cfg.IdleTTL))
if ls, ok := p.store.(*lazyStore); ok {
go ls.warm(reapCtx)
}
return nil
}
// newStore builds the session store mcp.sessions.store selects: the memory
// store, subscribed to its own events at once, or a Redis store opened on
// first use from redis.url under cooler's key prefix (nothing dials at
// mount; a missing URL is a 503 on the first request, logged once).
// mount itself; a background warm-up opens it with a backoff, and a missing
// URL is a 503 on stateful requests, logged).
func (p *Pier) newStore() Store {
if p.cfg.SessionStore != StoreRedis {
ms := newMemoryStore()