diff --git a/apps/api/internal/httpapi/features.go b/apps/api/internal/httpapi/features.go index 21232386..e812aaaf 100644 --- a/apps/api/internal/httpapi/features.go +++ b/apps/api/internal/httpapi/features.go @@ -1576,15 +1576,11 @@ func (s *Server) slashCommand(w http.ResponseWriter, r *http.Request) { } registered, err := s.store.GetSlashCommandForChannel(r.Context(), chi.URLParam(r, "channel_id"), command, act.user.ID) if err == nil { - if err := s.store.CanPublishEphemeral(r.Context(), registered.WorkspaceID, chi.URLParam(r, "channel_id"), "", act.user.ID); err != nil { - writeStoreError(w, err) - return - } s.invokeRegisteredSlashCommand(w, r, act, registered, text) return } if !errors.Is(err, sql.ErrNoRows) { - writeError(w, http.StatusBadRequest, err) + writeStoreError(w, err) return } body := strings.TrimSpace(command + " " + text) @@ -1625,7 +1621,7 @@ func (s *Server) invokeRegisteredSlashCommand(w http.ResponseWriter, r *http.Req PayloadJSON: string(payloadJSON), }) if err != nil { - writeError(w, http.StatusBadRequest, err) + writeStoreError(w, err) return } payload["trigger_id"] = invocation.ID diff --git a/apps/api/internal/httpapi/integration_security_test.go b/apps/api/internal/httpapi/integration_security_test.go index 6ac599fb..a51f3250 100644 --- a/apps/api/internal/httpapi/integration_security_test.go +++ b/apps/api/internal/httpapi/integration_security_test.go @@ -138,7 +138,9 @@ func TestRegisteredSlashCommandHonorsCallerModeration(t *testing.T) { t.Fatalf("blocked member invocation reached callback %d times", got) } - for i := 0; i < store.GuestPostLimit; i++ { + // The successful registered invocation above consumes one slot from the + // same rolling guest write budget as waiting-room messages. + for i := 1; i < store.GuestPostLimit; i++ { if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: guestChannelID, AuthorID: guest.ID, Body: "budget"}); err != nil { t.Fatal(err) } diff --git a/apps/api/internal/httpapi/slash_commands_authorization_test.go b/apps/api/internal/httpapi/slash_commands_authorization_test.go new file mode 100644 index 00000000..af0dc53c --- /dev/null +++ b/apps/api/internal/httpapi/slash_commands_authorization_test.go @@ -0,0 +1,279 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +type slashAuthorizationStore struct { + store.Store + persistedInvocations atomic.Int64 +} + +func (s *slashAuthorizationStore) CreateSlashCommandInvocation(ctx context.Context, input store.CreateSlashCommandInvocationInput) (store.SlashCommandInvocation, error) { + invocation, err := s.Store.CreateSlashCommandInvocation(ctx, input) + if err == nil { + s.persistedInvocations.Add(1) + } + return invocation, err +} + +func TestHTTPSlashCommandRequiresChannelWriteAuthorityBeforeCallback(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newEmptyHTTPStore(t) + + moderator, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Moderator", Email: "http-slash-authz-moderator@example.com"}) + if err != nil { + t.Fatal(err) + } + workspace, err := st.EnsureDefaultGuestWorkspaceMember(ctx, moderator.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + createMember := func(name, email, role string) store.User { + t.Helper() + user, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: name, Email: email}) + if err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDefaultGuestWorkspaceMember(ctx, user.ID, role); err != nil { + t.Fatal(err) + } + return user + } + member := createMember("Member", "http-slash-authz-member@example.com", store.WorkspaceRoleMember) + blockedMember := createMember("Blocked Member", "http-slash-authz-blocked@example.com", store.WorkspaceRoleMember) + timedMember := createMember("Timed Member", "http-slash-authz-timed@example.com", store.WorkspaceRoleMember) + guest := createMember("Guest", "http-slash-authz-guest@example.com", store.WorkspaceRoleGuest) + + channels, err := st.ListChannels(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + var generalChannelID, guestChannelID string + for _, channel := range channels { + switch channel.Name { + case "general": + generalChannelID = channel.ID + case "guest": + guestChannelID = channel.ID + } + } + if generalChannelID == "" || guestChannelID == "" { + t.Fatalf("expected general and guest channels, got %#v", channels) + } + otherWorkspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "HTTP Slash Other Workspace"}, moderator.ID) + if err != nil { + t.Fatal(err) + } + otherGeneralChannel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{ + WorkspaceID: otherWorkspace.ID, + Name: "general", + UserID: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + otherGeneralChannelID := otherGeneralChannel.ID + + blocked := true + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: blockedMember.ID, + Blocked: &blocked, + }); err != nil { + t.Fatal(err) + } + timeoutUntil := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: timedMember.ID, + TimeoutUntil: &timeoutUntil, + }); err != nil { + t.Fatal(err) + } + + bot, botToken, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + DisplayName: "Slash Bot", + Scopes: []string{"messages:write"}, + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + var callbackCount atomic.Int64 + callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callbackCount.Add(1) + if r.URL.Path == "/fail" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "callback failed"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "response_type": "in_channel", + "text": "command accepted", + }) + })) + t.Cleanup(callback.Close) + registeredCommand, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspace.ID, + Command: "/deploy", + CallbackURL: callback.URL, + BotUserID: bot.ID, + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspace.ID, + Command: "/broken", + CallbackURL: callback.URL + "/fail", + BotUserID: bot.ID, + CreatedBy: moderator.ID, + }); err != nil { + t.Fatal(err) + } + + trackedStore := &slashAuthorizationStore{Store: st} + server := httptest.NewServer(New(trackedStore, realtime.NewHub(), Options{ + callbackClient: &http.Client{Timeout: callbackTimeout}, + }).Handler()) + t.Cleanup(server.Close) + + invoke := func(userID, bearerToken, channelID, command string) (int, map[string]any) { + t.Helper() + form := url.Values{"command": {command}, "text": {"prod"}} + req, err := http.NewRequest(http.MethodPost, server.URL+"/api/hooks/slash/"+channelID, bytes.NewBufferString(form.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } else { + req.Header.Set("X-ClickClack-User", userID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("decode slash response %s: %v", string(payload), err) + } + return resp.StatusCode, body + } + assertRegisteredSuccess := func(name, userID, token, channelID string) { + t.Helper() + beforeCallbacks := callbackCount.Load() + beforeInvocations := trackedStore.persistedInvocations.Load() + status, body := invoke(userID, token, channelID, "/deploy") + if status != http.StatusOK { + t.Fatalf("%s: expected 200, got %d %#v", name, status, body) + } + message, _ := body["message"].(map[string]any) + invocation, _ := body["invocation"].(map[string]any) + if body["response_type"] != "in_channel" || body["text"] != "command accepted" || message["author_id"] != bot.ID || invocation["id"] == "" { + t.Fatalf("%s: registered response shape changed: %#v", name, body) + } + if got := callbackCount.Load(); got != beforeCallbacks+1 { + t.Fatalf("%s: callback count=%d, want %d", name, got, beforeCallbacks+1) + } + if got := trackedStore.persistedInvocations.Load(); got != beforeInvocations+1 { + t.Fatalf("%s: persisted invocation count=%d, want %d", name, got, beforeInvocations+1) + } + } + assertRegisteredFailure := func(name, userID, channelID string) { + t.Helper() + beforeCallbacks := callbackCount.Load() + beforeInvocations := trackedStore.persistedInvocations.Load() + status, body := invoke(userID, "", channelID, "/broken") + if status != http.StatusBadGateway { + t.Fatalf("%s: expected 502, got %d %#v", name, status, body) + } + if got := callbackCount.Load(); got != beforeCallbacks+1 { + t.Fatalf("%s: callback count=%d, want %d", name, got, beforeCallbacks+1) + } + if got := trackedStore.persistedInvocations.Load(); got != beforeInvocations+1 { + t.Fatalf("%s: failed callback did not consume a guest slot: count=%d, want %d", name, got, beforeInvocations+1) + } + } + assertDenied := func(name, userID, channelID string, wantStatus int) { + t.Helper() + beforeCallbacks := callbackCount.Load() + beforeInvocations := trackedStore.persistedInvocations.Load() + status, body := invoke(userID, "", channelID, "/deploy") + if status != wantStatus { + t.Fatalf("%s: expected %d, got %d %#v", name, wantStatus, status, body) + } + if got := callbackCount.Load(); got != beforeCallbacks { + t.Fatalf("%s reached callback: before=%d after=%d", name, beforeCallbacks, got) + } + if got := trackedStore.persistedInvocations.Load(); got != beforeInvocations { + t.Fatalf("%s persisted invocation: before=%d after=%d", name, beforeInvocations, got) + } + } + + assertRegisteredSuccess("ordinary member", member.ID, "", generalChannelID) + assertRegisteredSuccess("guest channel", guest.ID, "", guestChannelID) + assertRegisteredSuccess("bot token", "", botToken.Token, generalChannelID) + + beforeCallbacks := callbackCount.Load() + beforeInvocations := trackedStore.persistedInvocations.Load() + status, body := invoke("", botToken.Token, otherGeneralChannelID, "/deploy") + if status != http.StatusForbidden { + t.Fatalf("cross-workspace bot invocation: expected 403, got %d %#v", status, body) + } + if callbackCount.Load() != beforeCallbacks || trackedStore.persistedInvocations.Load() != beforeInvocations { + t.Fatal("cross-workspace bot invocation reached callback or persisted an invocation") + } + encodedBody, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + for _, sensitive := range []string{registeredCommand.ID, registeredCommand.CallbackURL, registeredCommand.SigningSecret} { + if sensitive != "" && strings.Contains(string(encodedBody), sensitive) { + t.Fatalf("cross-workspace error exposed registered command detail %q: %s", sensitive, encodedBody) + } + } + + beforeCallbacks = callbackCount.Load() + beforeInvocations = trackedStore.persistedInvocations.Load() + status, body = invoke(member.ID, "", generalChannelID, "/unregistered") + message, _ := body["message"].(map[string]any) + if status != http.StatusCreated || body["response_type"] != "in_channel" || body["text"] != "/unregistered prod" || message["author_id"] != member.ID { + t.Fatalf("unregistered fallback changed: status=%d body=%#v", status, body) + } + if callbackCount.Load() != beforeCallbacks || trackedStore.persistedInvocations.Load() != beforeInvocations { + t.Fatal("unregistered fallback called the registered command path") + } + + assertDenied("guest hidden channel", guest.ID, generalChannelID, http.StatusForbidden) + assertDenied("blocked member", blockedMember.ID, generalChannelID, http.StatusForbidden) + assertDenied("timed-out member", timedMember.ID, generalChannelID, http.StatusForbidden) + + assertRegisteredFailure("failed guest callback", guest.ID, guestChannelID) + assertRegisteredFailure("failed guest callback retry", guest.ID, guestChannelID) + assertDenied("guest post budget", guest.ID, guestChannelID, http.StatusTooManyRequests) +} diff --git a/apps/api/internal/store/postgres/migrations/0034_slash_command_guest_budget_index.sql b/apps/api/internal/store/postgres/migrations/0034_slash_command_guest_budget_index.sql new file mode 100644 index 00000000..a6bfc240 --- /dev/null +++ b/apps/api/internal/store/postgres/migrations/0034_slash_command_guest_budget_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_slash_command_invocations_guest_budget + ON slash_command_invocations(workspace_id, user_id, channel_id, created_at); diff --git a/apps/api/internal/store/postgres/moderation.go b/apps/api/internal/store/postgres/moderation.go index 09ebc016..b8881b2f 100644 --- a/apps/api/internal/store/postgres/moderation.go +++ b/apps/api/internal/store/postgres/moderation.go @@ -165,10 +165,11 @@ func requireCanPostTx(ctx context.Context, tx *sql.Tx, workspaceID, channelID, u return err } cutoff := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339Nano) - count, err := storedb.New(tx).CountRecentWorkspaceMessagesByAuthor(ctx, storedb.CountRecentWorkspaceMessagesByAuthorParams{ + count, err := storedb.New(tx).CountRecentGuestWritesByAuthor(ctx, storedb.CountRecentGuestWritesByAuthorParams{ WorkspaceID: workspaceID, AuthorID: userID, Cutoff: cutoff, + WriteLimit: int32(store.GuestPostLimit), }) if err != nil { return err @@ -203,10 +204,11 @@ func postsRemainingTx(ctx context.Context, q storedb.DBTX, workspaceID, userID, return 0, 0, nil } cutoff := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339Nano) - count, err := storedb.New(q).CountRecentWorkspaceMessagesByAuthor(ctx, storedb.CountRecentWorkspaceMessagesByAuthorParams{ + count, err := storedb.New(q).CountRecentGuestWritesByAuthor(ctx, storedb.CountRecentGuestWritesByAuthorParams{ WorkspaceID: workspaceID, AuthorID: userID, Cutoff: cutoff, + WriteLimit: int32(store.GuestPostLimit), }) if err != nil { return 0, 0, err diff --git a/apps/api/internal/store/postgres/slash_commands.go b/apps/api/internal/store/postgres/slash_commands.go index f737e362..56ea52d8 100644 --- a/apps/api/internal/store/postgres/slash_commands.go +++ b/apps/api/internal/store/postgres/slash_commands.go @@ -163,7 +163,19 @@ func (s *Store) RotateSlashCommandSecret(ctx context.Context, commandID, request func (s *Store) GetSlashCommandForChannel(ctx context.Context, channelID, command, requesterID string) (store.SlashCommand, error) { command = normalizeSlashCommand(command) - return scanSlashCommand(s.db.QueryRowContext(ctx, slashCommandSelect(true)+` + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.SlashCommand{}, err + } + defer tx.Rollback() + workspaceID, err := s.q.WithTx(tx).GetChannelWorkspace(ctx, channelID) + if err != nil { + return store.SlashCommand{}, err + } + if err := requireCanPostTx(ctx, tx, workspaceID, channelID, requesterID); err != nil { + return store.SlashCommand{}, err + } + return scanSlashCommand(tx.QueryRowContext(ctx, slashCommandSelect(true)+` JOIN channels c ON c.workspace_id = sc.workspace_id JOIN workspace_members wm ON wm.workspace_id = sc.workspace_id AND wm.user_id = $1 WHERE c.id = $2 AND sc.command = $3 AND sc.revoked_at IS NULL`, @@ -187,7 +199,27 @@ func (s *Store) CreateSlashCommandInvocation(ctx context.Context, input store.Cr if invocation.CommandID == "" || invocation.WorkspaceID == "" || invocation.ChannelID == "" || invocation.UserID == "" { return store.SlashCommandInvocation{}, errors.New("slash command invocation is incomplete") } - _, err := s.db.ExecContext(ctx, ` + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.SlashCommandInvocation{}, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + commandWorkspaceID, err := qtx.GetActiveSlashCommandWorkspace(ctx, invocation.CommandID) + if err != nil { + return store.SlashCommandInvocation{}, err + } + channelWorkspaceID, err := qtx.GetChannelWorkspace(ctx, invocation.ChannelID) + if err != nil { + return store.SlashCommandInvocation{}, err + } + if commandWorkspaceID != invocation.WorkspaceID || channelWorkspaceID != invocation.WorkspaceID { + return store.SlashCommandInvocation{}, store.ErrSlashCommandScopeMismatch + } + if err := requireCanPostTx(ctx, tx, channelWorkspaceID, invocation.ChannelID, invocation.UserID); err != nil { + return store.SlashCommandInvocation{}, err + } + _, err = tx.ExecContext(ctx, ` INSERT INTO slash_command_invocations (id, command_id, workspace_id, channel_id, user_id, text, payload_json, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, invocation.ID, @@ -199,7 +231,10 @@ func (s *Store) CreateSlashCommandInvocation(ctx context.Context, input store.Cr invocation.PayloadJSON, invocation.CreatedAt, ) - return invocation, err + if err != nil { + return store.SlashCommandInvocation{}, err + } + return invocation, tx.Commit() } func (s *Store) CompleteSlashCommandInvocation(ctx context.Context, invocationID string, status int, responseBody, invokeError string) (store.SlashCommandInvocation, error) { diff --git a/apps/api/internal/store/postgres/slash_commands_authorization_test.go b/apps/api/internal/store/postgres/slash_commands_authorization_test.go new file mode 100644 index 00000000..52db3856 --- /dev/null +++ b/apps/api/internal/store/postgres/slash_commands_authorization_test.go @@ -0,0 +1,506 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestPostgresSlashCommandGuestBudgetIndexMigration(t *testing.T) { + ctx := context.Background() + st := newIsolatedPostgresTestStore(t) + applyPostgresMigrationsBefore(t, ctx, st, "0034_slash_command_guest_budget_index.sql") + + var before int + if err := st.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = current_schema() + AND indexname = 'idx_slash_command_invocations_guest_budget'`, + ).Scan(&before); err != nil { + t.Fatal(err) + } + if before != 0 { + t.Fatalf("guest budget index existed before its migration: %d", before) + } + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + var indexDefinition string + if err := st.db.QueryRowContext(ctx, ` + SELECT indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND indexname = 'idx_slash_command_invocations_guest_budget'`, + ).Scan(&indexDefinition); err != nil { + t.Fatal(err) + } + if !strings.Contains(indexDefinition, "(workspace_id, user_id, channel_id, created_at)") { + t.Fatalf("unexpected guest budget index definition: %s", indexDefinition) + } +} + +func TestPostgresSlashCommandRevocationWaitsForInvocationLock(t *testing.T) { + ctx := context.Background() + st := newIsolatedPostgresTestStore(t) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + + moderator, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Lock Moderator", Email: "postgres-slash-lock-moderator@example.com"}) + if err != nil { + t.Fatal(err) + } + workspace, err := st.EnsureDefaultGuestWorkspaceMember(ctx, moderator.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + var channelID string + for _, channel := range channels { + if channel.Name == "general" { + channelID = channel.ID + break + } + } + if channelID == "" { + t.Fatalf("expected general channel, got %#v", channels) + } + bot, _, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + DisplayName: "Lock Bot", + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + command, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspace.ID, + Command: "/lock-test", + CallbackURL: "https://example.com/lock-test", + BotUserID: bot.ID, + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + + invocationTx, err := st.db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer invocationTx.Rollback() + if got, err := st.q.WithTx(invocationTx).GetActiveSlashCommandWorkspace(ctx, command.ID); err != nil || got != workspace.ID { + t.Fatalf("active command lock lookup = %q, %v; want workspace %q", got, err, workspace.ID) + } + const invocationID = "sci_postgres_revocation_lock" + if _, err := invocationTx.ExecContext(ctx, ` + INSERT INTO slash_command_invocations (id, command_id, workspace_id, channel_id, user_id, text, payload_json, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + invocationID, + command.ID, + workspace.ID, + channelID, + moderator.ID, + "lock", + `{}`, + now(), + ); err != nil { + t.Fatal(err) + } + + revokeResult := make(chan error, 1) + go func() { + _, err := st.RevokeSlashCommand(ctx, command.ID, moderator.ID) + revokeResult <- err + }() + // The revocation UPDATE must be observable as a blocked PostgreSQL query + // while the invocation transaction holds FOR SHARE on the command row. + waitForBlockedPostgresQuery(t, ctx, st.db, "UPDATE slash_commands") + select { + case err := <-revokeResult: + t.Fatalf("revocation completed before the invocation transaction committed: %v", err) + default: + } + + if err := invocationTx.Commit(); err != nil { + t.Fatal(err) + } + select { + case err := <-revokeResult: + if err != nil { + t.Fatalf("revocation failed after the invocation committed: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("revocation did not complete after the invocation transaction committed") + } + + var before int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&before); err != nil { + t.Fatal(err) + } + if _, err := st.CreateSlashCommandInvocation(ctx, store.CreateSlashCommandInvocationInput{ + CommandID: command.ID, + WorkspaceID: workspace.ID, + ChannelID: channelID, + UserID: moderator.ID, + Text: "after-revocation", + PayloadJSON: `{}`, + }); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("subsequent invocation after revocation returned %v, want sql.ErrNoRows", err) + } + var after int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&after); err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("revoked invocation was persisted: before=%d after=%d", before, after) + } +} + +func TestPostgresSlashCommandInvocationRequiresChannelWriteAuthority(t *testing.T) { + ctx := context.Background() + st := newIsolatedPostgresTestStore(t) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + + moderator, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Moderator", Email: "postgres-slash-authz-moderator@example.com"}) + if err != nil { + t.Fatal(err) + } + workspace, err := st.EnsureDefaultGuestWorkspaceMember(ctx, moderator.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "postgres-slash-authz-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDefaultGuestWorkspaceMember(ctx, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + guest, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Guest", Email: "postgres-slash-authz-guest@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDefaultGuestWorkspaceMember(ctx, guest.ID, store.WorkspaceRoleGuest); err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + var generalChannelID, guestChannelID string + for _, channel := range channels { + switch channel.Name { + case "general": + generalChannelID = channel.ID + case "guest": + guestChannelID = channel.ID + } + } + if generalChannelID == "" || guestChannelID == "" { + t.Fatalf("expected general and guest channels, got %#v", channels) + } + bot, _, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + DisplayName: "Slash Bot", + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + command, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspace.ID, + Command: "/deploy", + CallbackURL: "https://example.com/slash", + BotUserID: bot.ID, + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + + invocationCount := func() int { + t.Helper() + var count int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + invoke := func(userID, channelID string) error { + t.Helper() + _, err := st.CreateSlashCommandInvocation(ctx, store.CreateSlashCommandInvocationInput{ + CommandID: command.ID, + WorkspaceID: workspace.ID, + ChannelID: channelID, + UserID: userID, + Text: "prod", + PayloadJSON: `{}`, + }) + return err + } + assertDenied := func(name, userID, channelID string, want error) { + t.Helper() + before := invocationCount() + if _, err := st.GetSlashCommandForChannel(ctx, channelID, "/deploy", userID); !errors.Is(err, want) { + t.Fatalf("%s lookup: expected %v, got %v", name, want, err) + } + if err := invoke(userID, channelID); !errors.Is(err, want) { + t.Fatalf("%s invocation: expected %v, got %v", name, want, err) + } + if after := invocationCount(); after != before { + t.Fatalf("%s persisted a denied invocation: before=%d after=%d", name, before, after) + } + } + + for _, valid := range []struct { + name string + userID string + channelID string + }{ + {name: "member", userID: member.ID, channelID: generalChannelID}, + {name: "guest channel", userID: guest.ID, channelID: guestChannelID}, + {name: "bot", userID: bot.ID, channelID: generalChannelID}, + } { + if _, err := st.GetSlashCommandForChannel(ctx, valid.channelID, "/deploy", valid.userID); err != nil { + t.Fatalf("%s lookup should succeed: %v", valid.name, err) + } + before := invocationCount() + if err := invoke(valid.userID, valid.channelID); err != nil { + t.Fatalf("%s invocation should succeed: %v", valid.name, err) + } + if after := invocationCount(); after != before+1 { + t.Fatalf("%s invocation was not persisted: before=%d after=%d", valid.name, before, after) + } + } + + assertDenied("guest hidden channel", guest.ID, generalChannelID, store.ErrModerationRestricted) + + timeoutUntil := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: member.ID, + TimeoutUntil: &timeoutUntil, + }); err != nil { + t.Fatal(err) + } + assertDenied("timed-out member", member.ID, generalChannelID, store.ErrModerationRestricted) + + blocked := true + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: member.ID, + ClearTimeout: true, + Blocked: &blocked, + }); err != nil { + t.Fatal(err) + } + assertDenied("blocked member", member.ID, generalChannelID, store.ErrModerationRestricted) + + for i := 1; i < store.GuestPostLimit; i++ { + if err := invoke(guest.ID, guestChannelID); err != nil { + t.Fatalf("guest budget invocation %d failed: %v", i+1, err) + } + } + assertDenied("guest post budget", guest.ID, guestChannelID, store.ErrPostRateLimited) + members, err := st.ListWorkspaceMembers(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + for _, item := range members { + if item.User.ID == guest.ID && item.PostsRemaining != 0 { + t.Fatalf("guest slash invocations did not consume the shared post budget: %#v", item) + } + } +} + +func TestPostgresSlashCommandInvocationRejectsScopeAndStaleAuthorization(t *testing.T) { + ctx := context.Background() + st := newIsolatedPostgresTestStore(t) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + + owner, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Scope Owner", Email: "postgres-slash-scope-owner@example.com"}) + if err != nil { + t.Fatal(err) + } + workspaceA, err := st.EnsureDefaultGuestWorkspaceMember(ctx, owner.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + workspaceB, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Slash Scope B"}, owner.ID) + if err != nil { + t.Fatal(err) + } + listChannel := func(workspaceID string) (generalID string) { + t.Helper() + channels, err := st.ListChannels(ctx, workspaceID, owner.ID) + if err != nil { + t.Fatal(err) + } + for _, channel := range channels { + if channel.Name == "general" { + generalID = channel.ID + } + } + if generalID == "" { + t.Fatalf("expected general channel for workspace %s, got %#v", workspaceID, channels) + } + return generalID + } + createChannel := func(workspaceID, name string) string { + t.Helper() + channel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{ + WorkspaceID: workspaceID, + Name: name, + UserID: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + return channel.ID + } + generalA := listChannel(workspaceA.ID) + generalB := createChannel(workspaceB.ID, "general") + sentinelChannelB := createChannel(workspaceB.ID, "sentinel") + + botA, _, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspaceA.ID, DisplayName: "Scope Bot A", CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + botB, _, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspaceB.ID, DisplayName: "Scope Bot B", CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + commandA, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspaceA.ID, + Command: "/scope-a", + CallbackURL: "https://example.com/scope-a", + BotUserID: botA.ID, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + commandB, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspaceB.ID, + Command: "/scope-b", + CallbackURL: "https://example.com/scope-b", + BotUserID: botB.ID, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + sentinel, err := st.CreateSlashCommandInvocation(ctx, store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, + WorkspaceID: workspaceB.ID, + ChannelID: sentinelChannelB, + UserID: owner.ID, + Text: "sentinel", + PayloadJSON: `{"sentinel":true}`, + }) + if err != nil { + t.Fatal(err) + } + + invocationCount := func() int { + t.Helper() + var count int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + sentinelState := func() (string, int64) { + t.Helper() + var payload, responseBody string + var status int64 + if err := st.db.QueryRowContext(ctx, ` + SELECT payload_json, response_status, response_body + FROM slash_command_invocations + WHERE id = $1`, sentinel.ID).Scan(&payload, &status, &responseBody); err != nil { + t.Fatal(err) + } + return payload + "\x00" + responseBody, status + } + assertDenied := func(name string, input store.CreateSlashCommandInvocationInput, want error) { + t.Helper() + beforeCount := invocationCount() + beforeState, beforeStatus := sentinelState() + if _, err := st.CreateSlashCommandInvocation(ctx, input); !errors.Is(err, want) { + t.Fatalf("%s: expected %v, got %v", name, want, err) + } + if afterCount := invocationCount(); afterCount != beforeCount { + t.Fatalf("%s inserted an invocation: before=%d after=%d", name, beforeCount, afterCount) + } + afterState, afterStatus := sentinelState() + if afterState != beforeState || afterStatus != beforeStatus { + t.Fatalf("%s modified unrelated invocation: before=(%q,%d) after=(%q,%d)", name, beforeState, beforeStatus, afterState, afterStatus) + } + } + + assertDenied("command workspace mismatch", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + assertDenied("channel workspace mismatch", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceA.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + assertDenied("forged supplied workspace", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceB.ID, ChannelID: generalA, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + + if _, err := st.RevokeSlashCommand(ctx, commandA.ID, owner.ID); err != nil { + t.Fatal(err) + } + assertDenied("revoked command", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceA.ID, ChannelID: generalA, UserID: owner.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) + + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Removed Member", Email: "postgres-slash-scope-removed@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaceB.ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + if _, err := st.GetSlashCommandForChannel(ctx, generalB, "/scope-b", member.ID); err != nil { + t.Fatalf("scope lookup should succeed before membership removal: %v", err) + } + if _, err := st.db.ExecContext(ctx, `DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2`, workspaceB.ID, member.ID); err != nil { + t.Fatal(err) + } + assertDenied("membership removed after lookup", store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: member.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) + + if _, err := st.GetSlashCommandForChannel(ctx, generalB, "/scope-b", owner.ID); err != nil { + t.Fatalf("scope lookup should succeed before channel removal: %v", err) + } + if _, err := st.db.ExecContext(ctx, `DELETE FROM channels WHERE id = $1`, generalB); err != nil { + t.Fatal(err) + } + assertDenied("channel removed after lookup", store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) +} diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index 381af480..d1ce3ccb 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -829,14 +829,25 @@ UPDATE bot_tokens SET last_used_at = sqlc.arg(last_used_at) WHERE id = sqlc.arg(id); --- name: CountRecentWorkspaceMessagesByAuthor :one +-- name: CountRecentGuestWritesByAuthor :one SELECT COUNT(*) -FROM messages m -WHERE m.workspace_id = sqlc.arg(workspace_id) - AND m.author_id = sqlc.arg(author_id) - AND m.direct_conversation_id IS NULL - AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') - AND m.created_at >= sqlc.arg(cutoff); +FROM ( + SELECT 1 AS guest_write + FROM messages m + WHERE m.workspace_id = sqlc.arg(workspace_id) + AND m.author_id = sqlc.arg(author_id) + AND m.direct_conversation_id IS NULL + AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') + AND m.created_at >= sqlc.arg(cutoff) + UNION ALL + SELECT 1 AS guest_write + FROM slash_command_invocations sci + WHERE sci.workspace_id = sqlc.arg(workspace_id) + AND sci.user_id = sqlc.arg(author_id) + AND sci.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') + AND sci.created_at >= sqlc.arg(cutoff) + LIMIT sqlc.arg(write_limit) +) AS guest_writes; -- name: ListWorkspaceMembersForModeration :many SELECT u.id, u.kind, COALESCE(u.owner_user_id, '') AS owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at, @@ -1027,6 +1038,12 @@ SELECT workspace_id FROM channels WHERE id = sqlc.arg(id); +-- name: GetActiveSlashCommandWorkspace :one +SELECT workspace_id +FROM slash_commands +WHERE id = sqlc.arg(id) AND revoked_at IS NULL +FOR SHARE; + -- name: GetDirectConversationWorkspace :one SELECT workspace_id FROM direct_conversations diff --git a/apps/api/internal/store/postgres/sqlc/schema.sql b/apps/api/internal/store/postgres/sqlc/schema.sql index 89eb7e2d..12f66537 100644 --- a/apps/api/internal/store/postgres/sqlc/schema.sql +++ b/apps/api/internal/store/postgres/sqlc/schema.sql @@ -475,6 +475,9 @@ CREATE TABLE slash_command_invocations ( CREATE INDEX idx_slash_command_invocations_command ON slash_command_invocations(command_id, created_at); +CREATE INDEX idx_slash_command_invocations_guest_budget + ON slash_command_invocations(workspace_id, user_id, channel_id, created_at); + CREATE TABLE event_subscriptions ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index 04e1d543..91d2c5a8 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -265,24 +265,41 @@ func (q *Queries) CountPinnedMessages(ctx context.Context, channelID string) (in return count, err } -const countRecentWorkspaceMessagesByAuthor = `-- name: CountRecentWorkspaceMessagesByAuthor :one +const countRecentGuestWritesByAuthor = `-- name: CountRecentGuestWritesByAuthor :one SELECT COUNT(*) -FROM messages m -WHERE m.workspace_id = $1 - AND m.author_id = $2 - AND m.direct_conversation_id IS NULL - AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = $1 AND c.name = 'guest') - AND m.created_at >= $3 -` - -type CountRecentWorkspaceMessagesByAuthorParams struct { +FROM ( + SELECT 1 AS guest_write + FROM messages m + WHERE m.workspace_id = $2 + AND m.author_id = $3 + AND m.direct_conversation_id IS NULL + AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = $2 AND c.name = 'guest') + AND m.created_at >= $4 + UNION ALL + SELECT 1 AS guest_write + FROM slash_command_invocations sci + WHERE sci.workspace_id = $2 + AND sci.user_id = $3 + AND sci.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = $2 AND c.name = 'guest') + AND sci.created_at >= $4 + LIMIT $1 +) AS guest_writes +` + +type CountRecentGuestWritesByAuthorParams struct { + WriteLimit int32 `json:"write_limit"` WorkspaceID string `json:"workspace_id"` AuthorID string `json:"author_id"` Cutoff string `json:"cutoff"` } -func (q *Queries) CountRecentWorkspaceMessagesByAuthor(ctx context.Context, arg CountRecentWorkspaceMessagesByAuthorParams) (int64, error) { - row := q.db.QueryRowContext(ctx, countRecentWorkspaceMessagesByAuthor, arg.WorkspaceID, arg.AuthorID, arg.Cutoff) +func (q *Queries) CountRecentGuestWritesByAuthor(ctx context.Context, arg CountRecentGuestWritesByAuthorParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countRecentGuestWritesByAuthor, + arg.WriteLimit, + arg.WorkspaceID, + arg.AuthorID, + arg.Cutoff, + ) var count int64 err := row.Scan(&count) return count, err @@ -949,6 +966,20 @@ func (q *Queries) GetActiveBotForDeletion(ctx context.Context, botUserID string) return i, err } +const getActiveSlashCommandWorkspace = `-- name: GetActiveSlashCommandWorkspace :one +SELECT workspace_id +FROM slash_commands +WHERE id = $1 AND revoked_at IS NULL +FOR SHARE +` + +func (q *Queries) GetActiveSlashCommandWorkspace(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, getActiveSlashCommandWorkspace, id) + var workspace_id string + err := row.Scan(&workspace_id) + return workspace_id, err +} + const getAppearancePreferences = `-- name: GetAppearancePreferences :one SELECT color_mode, board_theme, message_layout, density FROM user_appearance_preferences diff --git a/apps/api/internal/store/sqlite/migrations/0041_slash_command_guest_budget_index.sql b/apps/api/internal/store/sqlite/migrations/0041_slash_command_guest_budget_index.sql new file mode 100644 index 00000000..d5a98124 --- /dev/null +++ b/apps/api/internal/store/sqlite/migrations/0041_slash_command_guest_budget_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_slash_command_invocations_guest_budget + ON slash_command_invocations(workspace_id, user_id, channel_id, created_at); diff --git a/apps/api/internal/store/sqlite/moderation.go b/apps/api/internal/store/sqlite/moderation.go index 7651b51c..9f1c0df4 100644 --- a/apps/api/internal/store/sqlite/moderation.go +++ b/apps/api/internal/store/sqlite/moderation.go @@ -162,10 +162,11 @@ func requireCanPostTx(ctx context.Context, tx *sql.Tx, workspaceID, channelID, u return err } cutoff := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339Nano) - count, err := storedb.New(tx).CountRecentWorkspaceMessagesByAuthor(ctx, storedb.CountRecentWorkspaceMessagesByAuthorParams{ + count, err := storedb.New(tx).CountRecentGuestWritesByAuthor(ctx, storedb.CountRecentGuestWritesByAuthorParams{ WorkspaceID: workspaceID, AuthorID: userID, Cutoff: cutoff, + WriteLimit: int64(store.GuestPostLimit), }) if err != nil { return err @@ -195,10 +196,11 @@ func postsRemainingTx(ctx context.Context, q storedb.DBTX, workspaceID, userID, return 0, 0, nil } cutoff := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339Nano) - count, err := storedb.New(q).CountRecentWorkspaceMessagesByAuthor(ctx, storedb.CountRecentWorkspaceMessagesByAuthorParams{ + count, err := storedb.New(q).CountRecentGuestWritesByAuthor(ctx, storedb.CountRecentGuestWritesByAuthorParams{ WorkspaceID: workspaceID, AuthorID: userID, Cutoff: cutoff, + WriteLimit: int64(store.GuestPostLimit), }) if err != nil { return 0, 0, err diff --git a/apps/api/internal/store/sqlite/slash_commands.go b/apps/api/internal/store/sqlite/slash_commands.go index fdc18a04..b5f1266b 100644 --- a/apps/api/internal/store/sqlite/slash_commands.go +++ b/apps/api/internal/store/sqlite/slash_commands.go @@ -162,7 +162,19 @@ func (s *Store) RotateSlashCommandSecret(ctx context.Context, commandID, request func (s *Store) GetSlashCommandForChannel(ctx context.Context, channelID, command, requesterID string) (store.SlashCommand, error) { command = normalizeSlashCommand(command) - return scanSlashCommand(s.db.QueryRowContext(ctx, slashCommandSelect(true)+` + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return store.SlashCommand{}, err + } + defer tx.Rollback() + workspaceID, err := s.q.WithTx(tx).GetChannelWorkspace(ctx, channelID) + if err != nil { + return store.SlashCommand{}, err + } + if err := requireCanPostTx(ctx, tx, workspaceID, channelID, requesterID); err != nil { + return store.SlashCommand{}, err + } + return scanSlashCommand(tx.QueryRowContext(ctx, slashCommandSelect(true)+` JOIN channels c ON c.workspace_id = sc.workspace_id JOIN workspace_members wm ON wm.workspace_id = sc.workspace_id AND wm.user_id = ? WHERE c.id = ? AND sc.command = ? AND sc.revoked_at IS NULL`, @@ -186,7 +198,27 @@ func (s *Store) CreateSlashCommandInvocation(ctx context.Context, input store.Cr if invocation.CommandID == "" || invocation.WorkspaceID == "" || invocation.ChannelID == "" || invocation.UserID == "" { return store.SlashCommandInvocation{}, errors.New("slash command invocation is incomplete") } - _, err := s.db.ExecContext(ctx, ` + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.SlashCommandInvocation{}, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + commandWorkspaceID, err := qtx.GetActiveSlashCommandWorkspace(ctx, invocation.CommandID) + if err != nil { + return store.SlashCommandInvocation{}, err + } + channelWorkspaceID, err := qtx.GetChannelWorkspace(ctx, invocation.ChannelID) + if err != nil { + return store.SlashCommandInvocation{}, err + } + if commandWorkspaceID != invocation.WorkspaceID || channelWorkspaceID != invocation.WorkspaceID { + return store.SlashCommandInvocation{}, store.ErrSlashCommandScopeMismatch + } + if err := requireCanPostTx(ctx, tx, channelWorkspaceID, invocation.ChannelID, invocation.UserID); err != nil { + return store.SlashCommandInvocation{}, err + } + _, err = tx.ExecContext(ctx, ` INSERT INTO slash_command_invocations (id, command_id, workspace_id, channel_id, user_id, text, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, invocation.ID, @@ -198,7 +230,10 @@ func (s *Store) CreateSlashCommandInvocation(ctx context.Context, input store.Cr invocation.PayloadJSON, invocation.CreatedAt, ) - return invocation, err + if err != nil { + return store.SlashCommandInvocation{}, err + } + return invocation, tx.Commit() } func (s *Store) CompleteSlashCommandInvocation(ctx context.Context, invocationID string, status int, responseBody, invokeError string) (store.SlashCommandInvocation, error) { diff --git a/apps/api/internal/store/sqlite/slash_commands_authorization_test.go b/apps/api/internal/store/sqlite/slash_commands_authorization_test.go new file mode 100644 index 00000000..5d1fc0c5 --- /dev/null +++ b/apps/api/internal/store/sqlite/slash_commands_authorization_test.go @@ -0,0 +1,390 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestSlashCommandGuestBudgetIndexMigration(t *testing.T) { + t.Parallel() + ctx := context.Background() + st, err := Open("sqlite://" + filepath.Join(t.TempDir(), "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + applySQLiteMigrationsBefore(t, ctx, st, "0041_slash_command_guest_budget_index.sql") + + indexCount := func() int { + t.Helper() + var count int + if err := st.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM sqlite_master + WHERE type = 'index' AND name = 'idx_slash_command_invocations_guest_budget'`, + ).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + if got := indexCount(); got != 0 { + t.Fatalf("guest budget index existed before its migration: %d", got) + } + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + if got := indexCount(); got != 1 { + t.Fatalf("guest budget migration did not create its index: %d", got) + } + var indexColumns string + if err := st.db.QueryRowContext(ctx, ` + SELECT group_concat(name, ',') + FROM (SELECT name FROM pragma_index_info('idx_slash_command_invocations_guest_budget') ORDER BY seqno)`, + ).Scan(&indexColumns); err != nil { + t.Fatal(err) + } + if indexColumns != "workspace_id,user_id,channel_id,created_at" { + t.Fatalf("unexpected guest budget index columns: %s", indexColumns) + } +} + +func TestSlashCommandInvocationRequiresChannelWriteAuthority(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + moderator, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Moderator", Email: "slash-authz-moderator@example.com"}) + if err != nil { + t.Fatal(err) + } + workspace, err := st.EnsureDefaultGuestWorkspaceMember(ctx, moderator.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "slash-authz-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDefaultGuestWorkspaceMember(ctx, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + guest, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Guest", Email: "slash-authz-guest@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDefaultGuestWorkspaceMember(ctx, guest.ID, store.WorkspaceRoleGuest); err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + var generalChannelID, guestChannelID string + for _, channel := range channels { + switch channel.Name { + case "general": + generalChannelID = channel.ID + case "guest": + guestChannelID = channel.ID + } + } + if generalChannelID == "" || guestChannelID == "" { + t.Fatalf("expected general and guest channels, got %#v", channels) + } + bot, _, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + DisplayName: "Slash Bot", + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + command, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspace.ID, + Command: "/deploy", + CallbackURL: "https://example.com/slash", + BotUserID: bot.ID, + CreatedBy: moderator.ID, + }) + if err != nil { + t.Fatal(err) + } + + invocationCount := func() int { + t.Helper() + var count int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + invoke := func(userID, channelID string) error { + t.Helper() + _, err := st.CreateSlashCommandInvocation(ctx, store.CreateSlashCommandInvocationInput{ + CommandID: command.ID, + WorkspaceID: workspace.ID, + ChannelID: channelID, + UserID: userID, + Text: "prod", + PayloadJSON: `{}`, + }) + return err + } + assertDenied := func(name, userID, channelID string, want error) { + t.Helper() + before := invocationCount() + if _, err := st.GetSlashCommandForChannel(ctx, channelID, "/deploy", userID); !errors.Is(err, want) { + t.Fatalf("%s lookup: expected %v, got %v", name, want, err) + } + if err := invoke(userID, channelID); !errors.Is(err, want) { + t.Fatalf("%s invocation: expected %v, got %v", name, want, err) + } + if after := invocationCount(); after != before { + t.Fatalf("%s persisted a denied invocation: before=%d after=%d", name, before, after) + } + } + + for _, valid := range []struct { + name string + userID string + channelID string + }{ + {name: "member", userID: member.ID, channelID: generalChannelID}, + {name: "guest channel", userID: guest.ID, channelID: guestChannelID}, + {name: "bot", userID: bot.ID, channelID: generalChannelID}, + } { + if _, err := st.GetSlashCommandForChannel(ctx, valid.channelID, "/deploy", valid.userID); err != nil { + t.Fatalf("%s lookup should succeed: %v", valid.name, err) + } + before := invocationCount() + if err := invoke(valid.userID, valid.channelID); err != nil { + t.Fatalf("%s invocation should succeed: %v", valid.name, err) + } + if after := invocationCount(); after != before+1 { + t.Fatalf("%s invocation was not persisted: before=%d after=%d", valid.name, before, after) + } + } + + assertDenied("guest hidden channel", guest.ID, generalChannelID, store.ErrModerationRestricted) + + timeoutUntil := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: member.ID, + TimeoutUntil: &timeoutUntil, + }); err != nil { + t.Fatal(err) + } + assertDenied("timed-out member", member.ID, generalChannelID, store.ErrModerationRestricted) + + blocked := true + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: workspace.ID, + ActorUserID: moderator.ID, + TargetUserID: member.ID, + ClearTimeout: true, + Blocked: &blocked, + }); err != nil { + t.Fatal(err) + } + assertDenied("blocked member", member.ID, generalChannelID, store.ErrModerationRestricted) + + for i := 1; i < store.GuestPostLimit; i++ { + if err := invoke(guest.ID, guestChannelID); err != nil { + t.Fatalf("guest budget invocation %d failed: %v", i+1, err) + } + } + assertDenied("guest post budget", guest.ID, guestChannelID, store.ErrPostRateLimited) + members, err := st.ListWorkspaceMembers(ctx, workspace.ID, moderator.ID) + if err != nil { + t.Fatal(err) + } + for _, item := range members { + if item.User.ID == guest.ID && item.PostsRemaining != 0 { + t.Fatalf("guest slash invocations did not consume the shared post budget: %#v", item) + } + } +} + +func TestSlashCommandInvocationRejectsScopeAndStaleAuthorization(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + owner, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Scope Owner", Email: "sqlite-slash-scope-owner@example.com"}) + if err != nil { + t.Fatal(err) + } + workspaceA, err := st.EnsureDefaultGuestWorkspaceMember(ctx, owner.ID, store.WorkspaceRoleModerator) + if err != nil { + t.Fatal(err) + } + workspaceB, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Slash Scope B"}, owner.ID) + if err != nil { + t.Fatal(err) + } + listChannel := func(workspaceID string) (generalID string) { + t.Helper() + channels, err := st.ListChannels(ctx, workspaceID, owner.ID) + if err != nil { + t.Fatal(err) + } + for _, channel := range channels { + if channel.Name == "general" { + generalID = channel.ID + } + } + if generalID == "" { + t.Fatalf("expected general channel for workspace %s, got %#v", workspaceID, channels) + } + return generalID + } + createChannel := func(workspaceID, name string) string { + t.Helper() + channel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{ + WorkspaceID: workspaceID, + Name: name, + UserID: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + return channel.ID + } + generalA := listChannel(workspaceA.ID) + generalB := createChannel(workspaceB.ID, "general") + sentinelChannelB := createChannel(workspaceB.ID, "sentinel") + + botA, _, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspaceA.ID, DisplayName: "Scope Bot A", CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + botB, _, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspaceB.ID, DisplayName: "Scope Bot B", CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + commandA, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspaceA.ID, + Command: "/scope-a", + CallbackURL: "https://example.com/scope-a", + BotUserID: botA.ID, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + commandB, err := st.CreateSlashCommand(ctx, store.CreateSlashCommandInput{ + WorkspaceID: workspaceB.ID, + Command: "/scope-b", + CallbackURL: "https://example.com/scope-b", + BotUserID: botB.ID, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + sentinel, err := st.CreateSlashCommandInvocation(ctx, store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, + WorkspaceID: workspaceB.ID, + ChannelID: sentinelChannelB, + UserID: owner.ID, + Text: "sentinel", + PayloadJSON: `{"sentinel":true}`, + }) + if err != nil { + t.Fatal(err) + } + + invocationCount := func() int { + t.Helper() + var count int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM slash_command_invocations`).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + sentinelState := func() (string, int64) { + t.Helper() + var payload, responseBody string + var status int64 + if err := st.db.QueryRowContext(ctx, ` + SELECT payload_json, response_status, response_body + FROM slash_command_invocations + WHERE id = ?`, sentinel.ID).Scan(&payload, &status, &responseBody); err != nil { + t.Fatal(err) + } + return payload + "\x00" + responseBody, status + } + assertDenied := func(name string, input store.CreateSlashCommandInvocationInput, want error) { + t.Helper() + beforeCount := invocationCount() + beforeState, beforeStatus := sentinelState() + if _, err := st.CreateSlashCommandInvocation(ctx, input); !errors.Is(err, want) { + t.Fatalf("%s: expected %v, got %v", name, want, err) + } + if afterCount := invocationCount(); afterCount != beforeCount { + t.Fatalf("%s inserted an invocation: before=%d after=%d", name, beforeCount, afterCount) + } + afterState, afterStatus := sentinelState() + if afterState != beforeState || afterStatus != beforeStatus { + t.Fatalf("%s modified unrelated invocation: before=(%q,%d) after=(%q,%d)", name, beforeState, beforeStatus, afterState, afterStatus) + } + } + + assertDenied("command workspace mismatch", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + assertDenied("channel workspace mismatch", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceA.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + assertDenied("forged supplied workspace", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceB.ID, ChannelID: generalA, UserID: owner.ID, + PayloadJSON: `{}`, + }, store.ErrSlashCommandScopeMismatch) + + if _, err := st.RevokeSlashCommand(ctx, commandA.ID, owner.ID); err != nil { + t.Fatal(err) + } + assertDenied("revoked command", store.CreateSlashCommandInvocationInput{ + CommandID: commandA.ID, WorkspaceID: workspaceA.ID, ChannelID: generalA, UserID: owner.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) + + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Removed Member", Email: "sqlite-slash-scope-removed@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaceB.ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + if _, err := st.GetSlashCommandForChannel(ctx, generalB, "/scope-b", member.ID); err != nil { + t.Fatalf("scope lookup should succeed before membership removal: %v", err) + } + if _, err := st.db.ExecContext(ctx, `DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, workspaceB.ID, member.ID); err != nil { + t.Fatal(err) + } + assertDenied("membership removed after lookup", store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: member.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) + + if _, err := st.GetSlashCommandForChannel(ctx, generalB, "/scope-b", owner.ID); err != nil { + t.Fatalf("scope lookup should succeed before channel removal: %v", err) + } + if _, err := st.db.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, generalB); err != nil { + t.Fatal(err) + } + assertDenied("channel removed after lookup", store.CreateSlashCommandInvocationInput{ + CommandID: commandB.ID, WorkspaceID: workspaceB.ID, ChannelID: generalB, UserID: owner.ID, + PayloadJSON: `{}`, + }, sql.ErrNoRows) +} diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index b22020cb..b3f3c64d 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -807,14 +807,25 @@ UPDATE bot_tokens SET last_used_at = sqlc.arg(last_used_at) WHERE id = sqlc.arg(id); --- name: CountRecentWorkspaceMessagesByAuthor :one +-- name: CountRecentGuestWritesByAuthor :one SELECT COUNT(*) -FROM messages m -WHERE m.workspace_id = sqlc.arg(workspace_id) - AND m.author_id = sqlc.arg(author_id) - AND m.direct_conversation_id IS NULL - AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') - AND m.created_at >= sqlc.arg(cutoff); +FROM ( + SELECT 1 AS guest_write + FROM messages m + WHERE m.workspace_id = sqlc.arg(workspace_id) + AND m.author_id = sqlc.arg(author_id) + AND m.direct_conversation_id IS NULL + AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') + AND m.created_at >= sqlc.arg(cutoff) + UNION ALL + SELECT 1 AS guest_write + FROM slash_command_invocations sci + WHERE sci.workspace_id = sqlc.arg(workspace_id) + AND sci.user_id = sqlc.arg(author_id) + AND sci.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = sqlc.arg(workspace_id) AND c.name = 'guest') + AND sci.created_at >= sqlc.arg(cutoff) + LIMIT sqlc.arg(write_limit) +) AS guest_writes; -- name: ListWorkspaceMembersForModeration :many SELECT u.id, u.kind, COALESCE(u.owner_user_id, '') AS owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at, @@ -1004,6 +1015,11 @@ SELECT workspace_id FROM channels WHERE id = sqlc.arg(id); +-- name: GetActiveSlashCommandWorkspace :one +SELECT workspace_id +FROM slash_commands +WHERE id = sqlc.arg(id) AND revoked_at IS NULL; + -- name: GetDirectConversationWorkspace :one SELECT workspace_id FROM direct_conversations diff --git a/apps/api/internal/store/sqlite/sqlc/schema.sql b/apps/api/internal/store/sqlite/sqlc/schema.sql index 27c53093..e4560b18 100644 --- a/apps/api/internal/store/sqlite/sqlc/schema.sql +++ b/apps/api/internal/store/sqlite/sqlc/schema.sql @@ -467,6 +467,9 @@ CREATE TABLE slash_command_invocations ( CREATE INDEX idx_slash_command_invocations_command ON slash_command_invocations(command_id, created_at); +CREATE INDEX idx_slash_command_invocations_guest_budget + ON slash_command_invocations(workspace_id, user_id, channel_id, created_at); + CREATE TABLE event_subscriptions ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index 2e51244b..ee8f8b16 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -262,24 +262,41 @@ func (q *Queries) CountPinnedMessages(ctx context.Context, channelID string) (in return count, err } -const countRecentWorkspaceMessagesByAuthor = `-- name: CountRecentWorkspaceMessagesByAuthor :one +const countRecentGuestWritesByAuthor = `-- name: CountRecentGuestWritesByAuthor :one SELECT COUNT(*) -FROM messages m -WHERE m.workspace_id = ?1 - AND m.author_id = ?2 - AND m.direct_conversation_id IS NULL - AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = ?1 AND c.name = 'guest') - AND m.created_at >= ?3 -` - -type CountRecentWorkspaceMessagesByAuthorParams struct { +FROM ( + SELECT 1 AS guest_write + FROM messages m + WHERE m.workspace_id = ?2 + AND m.author_id = ?3 + AND m.direct_conversation_id IS NULL + AND m.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = ?2 AND c.name = 'guest') + AND m.created_at >= ?4 + UNION ALL + SELECT 1 AS guest_write + FROM slash_command_invocations sci + WHERE sci.workspace_id = ?2 + AND sci.user_id = ?3 + AND sci.channel_id IN (SELECT c.id FROM channels c WHERE c.workspace_id = ?2 AND c.name = 'guest') + AND sci.created_at >= ?4 + LIMIT ?1 +) AS guest_writes +` + +type CountRecentGuestWritesByAuthorParams struct { + WriteLimit int64 `json:"write_limit"` WorkspaceID string `json:"workspace_id"` AuthorID string `json:"author_id"` Cutoff string `json:"cutoff"` } -func (q *Queries) CountRecentWorkspaceMessagesByAuthor(ctx context.Context, arg CountRecentWorkspaceMessagesByAuthorParams) (int64, error) { - row := q.db.QueryRowContext(ctx, countRecentWorkspaceMessagesByAuthor, arg.WorkspaceID, arg.AuthorID, arg.Cutoff) +func (q *Queries) CountRecentGuestWritesByAuthor(ctx context.Context, arg CountRecentGuestWritesByAuthorParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countRecentGuestWritesByAuthor, + arg.WriteLimit, + arg.WorkspaceID, + arg.AuthorID, + arg.Cutoff, + ) var count int64 err := row.Scan(&count) return count, err @@ -945,6 +962,19 @@ func (q *Queries) GetActiveBotForDeletion(ctx context.Context, botUserID string) return i, err } +const getActiveSlashCommandWorkspace = `-- name: GetActiveSlashCommandWorkspace :one +SELECT workspace_id +FROM slash_commands +WHERE id = ?1 AND revoked_at IS NULL +` + +func (q *Queries) GetActiveSlashCommandWorkspace(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, getActiveSlashCommandWorkspace, id) + var workspace_id string + err := row.Scan(&workspace_id) + return workspace_id, err +} + const getAppearancePreferences = `-- name: GetAppearancePreferences :one SELECT color_mode, board_theme, message_layout, density FROM user_appearance_preferences diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go index 3894f6c2..0a61be22 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -44,6 +44,10 @@ var ErrDirectConversationNoActivePeer = errors.New("direct conversation has no a // daily post budget. var ErrPostRateLimited = errors.New("waiting room post limit reached") +// ErrSlashCommandScopeMismatch is returned when an invocation's supplied +// workspace does not match the registered command and channel workspaces. +var ErrSlashCommandScopeMismatch = errors.New("slash command invocation scope does not match command and channel") + // ErrSetupCodeInvalid is returned for any unusable bot setup code — unknown, // expired, already claimed, or pointing at a bot that is no longer eligible. // The single error keeps claim responses uniform so callers cannot probe diff --git a/docs/features/integrations.md b/docs/features/integrations.md index b7f972ff..8269cf06 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -125,6 +125,8 @@ Behavior: `.`. - Invocation requires the caller's current write authority for the exact channel, including guest-channel, timeout, block, and guest-budget checks. +- Registered invocation attempts consume the guest write budget once persisted, + including invocations whose callback later fails. - Callback delivery connects directly to public IP addresses only. It rejects private, loopback, link-local, reserved, or mixed public/private DNS answers, does not use environment-configured proxies, and does not follow redirects.