Restored from mai/planck/paliadin-per-user-rls (parked, see m/paliad#12 cancel note). The aichat Phase B path (next commit) consumes mintTurnJWT to sign a short-lived HS256 token per turn, scoped to the calling user (sub=userID, role=authenticated, aud=authenticated, iss=paliad/paliadin). Aichat passes the raw token through to the claude pane on mRiver via a per-turn file (managed by aichat's runner, not paliad's transport). The SKILL.md reads it and `SET LOCAL request.jwt.claims = …` before every paliad.* query, which makes RLS evaluate as the user instead of as service role. TTL: 2 min default — covers aichat's 120 s persona timeout + HTTP slack, short enough that a leaked JWT is uninteresting. Each turn mints fresh; no caching. No call sites yet — paliadin_remote.go / paliadin.go are unchanged on this commit. The plumbing arrives with AichatPaliadinService.
75 lines
2.6 KiB
Go
75 lines
2.6 KiB
Go
package services
|
|
|
|
// Per-turn supabase JWT minting for Paliadin (t-paliad-156, folded into
|
|
// t-paliad-194 / m/paliad#38 Phase B).
|
|
//
|
|
// Each Paliadin turn carries a short-lived JWT scoped to the calling
|
|
// user. The JWT is signed with paliad's existing SUPABASE_JWT_SECRET so
|
|
// it has the same shape Supabase Auth itself issues — same claims, same
|
|
// signature, same role. The aichat backend writes it to a per-turn file
|
|
// the claude pane reads to `SET LOCAL request.jwt.claims = …` before
|
|
// every paliad.* query, which makes RLS evaluate as the user.
|
|
//
|
|
// TTL: short (default 2 min) — long enough to cover the persona's 120 s
|
|
// run-turn budget plus generous slack for queueing, short enough that a
|
|
// leaked JWT is uninteresting. Each turn mints fresh; nothing is cached.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ErrJWTSecretMissing signals that mintTurnJWT was called without the
|
|
// SUPABASE_JWT_SECRET configured. paliad's auth layer fails fast on the
|
|
// same condition at boot, but the per-turn mint path is reachable from
|
|
// tests + the disabled stub, so we surface a typed error rather than
|
|
// panicking.
|
|
var ErrJWTSecretMissing = errors.New("paliadin: SUPABASE_JWT_SECRET not configured")
|
|
|
|
// DefaultPaliadinJWTTTL is the JWT lifetime when the caller doesn't
|
|
// override. 2 minutes covers aichat's 120 s persona timeout plus a few
|
|
// seconds of buffer for HTTP overhead and clock skew.
|
|
const DefaultPaliadinJWTTTL = 2 * time.Minute
|
|
|
|
// mintTurnJWT signs a Supabase-shaped access token for the given user.
|
|
// Claims:
|
|
//
|
|
// sub : userID — RLS reads this via auth.uid()
|
|
// role : "authenticated" — required so SET LOCAL ROLE matches
|
|
// aud : "authenticated" — Supabase convention
|
|
// iss : "paliad/paliadin" — distinguishes from real GoTrue tokens in
|
|
// audit traces; not validated by RLS
|
|
// iat : now
|
|
// exp : now + ttl
|
|
//
|
|
// Signed HS256 with SUPABASE_JWT_SECRET (same secret paliad already
|
|
// verifies session cookies against in internal/auth.Client). The
|
|
// returned string is a standard 3-segment JWT.
|
|
func mintTurnJWT(userID uuid.UUID, ttl time.Duration, secret []byte) (string, error) {
|
|
if len(secret) == 0 {
|
|
return "", ErrJWTSecretMissing
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = DefaultPaliadinJWTTTL
|
|
}
|
|
now := time.Now()
|
|
claims := jwt.MapClaims{
|
|
"sub": userID.String(),
|
|
"role": "authenticated",
|
|
"aud": "authenticated",
|
|
"iss": "paliad/paliadin",
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(ttl).Unix(),
|
|
}
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := tok.SignedString(secret)
|
|
if err != nil {
|
|
return "", fmt.Errorf("paliadin: sign turn JWT: %w", err)
|
|
}
|
|
return signed, nil
|
|
}
|