Changeset 0.24.3 (#159)

This commit is contained in:
2026-03-07 20:49:23 +00:00
parent ea082e2016
commit 937be26578
20 changed files with 836 additions and 29 deletions

View File

@@ -120,6 +120,21 @@ func getUserID(c *gin.Context) string {
return s
}
// isSessionAuth returns true if the request is from a session participant (v0.24.3).
func isSessionAuth(c *gin.Context) bool {
return c.GetString("auth_type") == "session"
}
// sessionCanAccessChannel validates that a session participant is authorized
// for the given channel. The AuthOrSession middleware already verifies this,
// but handlers call this as a defense-in-depth check.
func sessionCanAccessChannel(c *gin.Context, channelID string) bool {
if !isSessionAuth(c) {
return false
}
return c.GetString("channel_id") == channelID
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))

View File

@@ -194,6 +194,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
channelID := req.ChannelID
if channelID == "" {
// v0.24.3: Workflow API routes have channel in URL param
channelID = c.Param("id")
}
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
return
@@ -201,13 +205,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
userID := getUserID(c)
// Verify participation: check channel_participants first, fall back to
// legacy channels.user_id ownership for direct channels.
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
// Use session_id as effective identity for cursor/participant tracking.
// persistMessage will record participant_type=user with this ID —
// acceptable for v0.24.3; v0.25.0 can refine to participant_type=session.
userID = c.GetString("session_id")
} else {
// Verify participation: check channel_participants first, fall back to
// legacy channels.user_id ownership for direct channels.
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
return
}
}
}
// ── ai_mode guard (v0.23.1) ────────────────────────────────────────────────
@@ -630,8 +646,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Budget enforcement and model allowlist only apply when the request draws
// from a team or global provider. BYOK (personal scope) is the user's money
// — no ceiling applies.
// v0.24.3: Session participants bypass user-level budget/allowlist checks.
// They use the workflow channel's allocated resources.
role, _ := c.Get("role")
if role != "admin" && providerScope != "personal" {
if role != "admin" && providerScope != "personal" && !isSessionAuth(c) {
// Token budget
if h.stores.Usage != nil {
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)

View File

@@ -87,7 +87,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
return
}
@@ -193,16 +199,31 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
parentID, _ := getActiveLeaf(channelID, userID)
// Sessions use the same cursor logic (empty userID = channel-level latest)
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
}
parentID, _ := getActiveLeaf(channelID, effectiveUserID)
siblingIdx := nextSiblingIndex(channelID, parentID)
participantType := "user"
participantID := userID
if isSessionAuth(c) {
participantType = "session"
participantID = c.GetString("session_id")
}
if req.Role == "assistant" {
participantType = "model"
if req.Model != "" {