Merge remote-tracking branch 'origin/main' into feature/mailbox-fair-use-allowance

This commit is contained in:
Matthew Meszaros
2026-09-04 21:25:53 -07:00
70 changed files with 11208 additions and 18 deletions
+31
View File
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// RevokeOwnAPIKey revokes the key the request was made with.
//
// Deliberately outside the API_KEYS scope gate: a credential must always be
// able to end itself. Requiring a privilege to sign out means a read-only key
// on a laptop someone is handing back stays live, which is the opposite of
// what a `warmbly auth logout` promises.
func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
keyID := middleware.GetAPIKeyID(c)
if keyID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
reason := c.Query("reason")
if reason == "" {
reason = "Revoked by the credential itself"
}
if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// ListAPIPermissions lists all available API permissions
// GET /api-keys/permissions
func (h *Handler) ListAPIPermissions(c *gin.Context) {
+14
View File
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
// DocsURL is where to send someone whose signup was refused by deployment
// policy rather than by anything they did wrong.
DocsURL string `json:"docs_url"`
// WebsocketURL is the realtime gateway. Served here because a developer
// client (the CLI's event stream, an SDK) has no other way to find the
// socket on a self-hosted instance. Empty when the instance runs no
// realtime service.
WebsocketURL string `json:"websocket_url,omitempty"`
// AppURL is the dashboard origin, the same one every emailed link is built
// from. A client that wants to send someone to a page (the CLI's `browse`,
// a chat integration) cannot derive it: on a self-hosted instance the host
// layout is whatever the operator chose.
AppURL string `json:"app_url,omitempty"`
}
// accountsDocsURL is the page every registration refusal points at.
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
WebsocketURL: config.WebsocketURL(),
AppURL: config.AppBaseURL(),
})
}
+126
View File
@@ -0,0 +1,126 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
// what the CLI calls; the session half is the browser approval screen.
func (h *Handler) cliAuthReady(c *gin.Context) bool {
if h.CLIAuthService == nil {
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
return false
}
return true
}
// CLIAuthStart opens a handshake for a CLI that holds no key yet.
func (h *Handler) CLIAuthStart(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req models.CLIAuthStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusCreated, res)
}
// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
// handed out exactly once, on the poll that follows approval.
func (h *Handler) CLIAuthPoll(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req struct {
DeviceCode string `json:"device_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
return
}
res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, res)
}
// CLIAuthDescribeCode shows the approving member what they are authorizing.
func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, code)
}
// CLIAuthApproveCode mints the key into the workspace named in the body, not
// the session's, because a member with several workspaces picks on the screen.
func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req models.CLIAuthApproveRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
orgID, perr := uuid.Parse(req.OrganizationID)
if perr != nil {
if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
orgID = *sessionOrg
} else {
errx.JSON(c, errx.ErrNoOrganization)
return
}
}
code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// Logged against the org that was picked on screen, which is not always the
// session's, so this cannot go through auditOrg.
h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
c.JSON(http.StatusOK, code)
}
// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
// created, and a denial is not a change to the workspace.
func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+4
View File
@@ -14,6 +14,7 @@ import (
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -312,6 +313,9 @@ type Handler struct {
PoolLinkService poollink.Service
CloudLinkService cloudlink.Service
// Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
CLIAuthService cliauth.Service
// Infrastructure liveness probes for the admin System Status page.
// Wired in cmd/backend/main.go where the concrete clients live.
SystemChecker *sysstatus.Checker