code.oscarkilo.com/okg

Hash:
08462a1102da53253e3092f9324231f67cda5d1d
Author:
Igor Naverniouk <[email protected]>
Date:
Sat Jun 6 12:32:00 2026 -0400
Message:
okg/chat: add SetPermissions POST /chat/permissions/set, mirroring the //chat server's endpoint. SetPermissionsRequest type matches the wire shape. This completes //okg/chat's parity with //chat's HTTP API (Send, Search, SetPermissions). The upcoming //chat/e2e_test rewrite will drive the //chat server through //okg/chat for all chat operations, with //clients only for bootstrap (creating users + groups + bearer mappings). A future `okg chat permissions set` CLI subcommand can also build on this. Test: TestSetPermissions pins the POST path + body shape.
diff --git a/chat/chat.go b/chat/chat.go
index c31a40e..b7202be 100644
--- a/chat/chat.go
+++ b/chat/chat.go
@@ -47,6 +47,26 @@ func (c *HTTPClient) Send(req SendRequest) (*Message, error) {
return &msg, nil
}

+// SetPermissionsRequest is the body for POST
+// /chat/permissions/set. Group is the //who username naming the
+// channel; Permission ∈ {read, post, join, pin, ban}; Owner
+// and Reader are //who usernames (or "" for "anyone" — only
+// honored where the backend allows it).
+type SetPermissionsRequest struct {
+ Group string `json:"group"`
+ Permission string `json:"permission"`
+ Owner string `json:"owner"`
+ Reader string `json:"reader"`
+}
+
+// SetPermissions writes a //chat authz entry on
+// chat://<group>#<permission>.
+func (c *HTTPClient) SetPermissions(
+ req SetPermissionsRequest,
+) error {
+ return c.PostJSON("/chat/permissions/set", req, nil)
+}
+
// SearchRequest is the body for POST /chat/search.
type SearchRequest struct {
To string `json:"to,omitempty"`
diff --git a/chat/chat_test.go b/chat/chat_test.go
index 67e66d2..4eb6687 100644
--- a/chat/chat_test.go
+++ b/chat/chat_test.go
@@ -39,6 +39,36 @@ func TestSend(t *testing.T) {
}
}

+func TestSetPermissions(t *testing.T) {
+ var seen SetPermissionsRequest
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/chat/permissions/set" {
+ t.Errorf(
+ "path: got %q, want /chat/permissions/set",
+ r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&seen)
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ err := c.SetPermissions(SetPermissionsRequest{
+ Group: "team",
+ Permission: "post",
+ Owner: "alice",
+ Reader: "team",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.Group != "team" || seen.Permission != "post" {
+ t.Errorf(
+ "got %+v, want team/post", seen)
+ }
+}
+
func TestSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
a/chat/chat.go
b/chat/chat.go
1
// Package chat is the public HTTP client for OscarKilo's chat
1
// Package chat is the public HTTP client for OscarKilo's chat
2
// service. It is used by `okg` and any other tool that talks
2
// service. It is used by `okg` and any other tool that talks
3
// to oscarkilo.com over the network.
3
// to oscarkilo.com over the network.
4
//
4
//
5
// The struct here is named HTTPClient (not just Client) so
5
// The struct here is named HTTPClient (not just Client) so
6
// applications can wrap it in their own Client abstractions
6
// applications can wrap it in their own Client abstractions
7
// without name collision.
7
// without name collision.
8
package chat
8
package chat
9
9
10
import "time"
10
import "time"
11
11
12
import "oscarkilo.com/okg/internal/rest"
12
import "oscarkilo.com/okg/internal/rest"
13
13
14
// HTTPClient talks to a //chat server over HTTPS. Embeds the
14
// HTTPClient talks to a //chat server over HTTPS. Embeds the
15
// shared rest helpers; chat-specific methods live below.
15
// shared rest helpers; chat-specific methods live below.
16
type HTTPClient struct {
16
type HTTPClient struct {
17
*rest.Client
17
*rest.Client
18
}
18
}
19
19
20
func NewHTTPClient(host, apiKey string) *HTTPClient {
20
func NewHTTPClient(host, apiKey string) *HTTPClient {
21
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
21
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
22
}
22
}
23
23
24
// Message is a single chat message. No JSON tags — the server
24
// Message is a single chat message. No JSON tags — the server
25
// marshals with default Go field names (PascalCase). FUTURE:
25
// marshals with default Go field names (PascalCase). FUTURE:
26
// switch to snake_case once the server adds JSON tags.
26
// switch to snake_case once the server adds JSON tags.
27
type Message struct {
27
type Message struct {
28
From string
28
From string
29
To string
29
To string
30
Text string
30
Text string
31
CreatedAt time.Time
31
CreatedAt time.Time
32
}
32
}
33
33
34
// SendRequest is the body for POST /chat/send.
34
// SendRequest is the body for POST /chat/send.
35
type SendRequest struct {
35
type SendRequest struct {
36
To string `json:"to"`
36
To string `json:"to"`
37
Text string `json:"text"`
37
Text string `json:"text"`
38
}
38
}
39
39
40
// Send posts a message to a //chat group. The caller must have
40
// Send posts a message to a //chat group. The caller must have
41
// post rights on chat://<to>#post.
41
// post rights on chat://<to>#post.
42
func (c *HTTPClient) Send(req SendRequest) (*Message, error) {
42
func (c *HTTPClient) Send(req SendRequest) (*Message, error) {
43
var msg Message
43
var msg Message
44
if err := c.PostJSON("/chat/send", req, &msg); err != nil {
44
if err := c.PostJSON("/chat/send", req, &msg); err != nil {
45
return nil, err
45
return nil, err
46
}
46
}
47
return &msg, nil
47
return &msg, nil
48
}
48
}
49
49
50
// SetPermissionsRequest is the body for POST
51
// /chat/permissions/set. Group is the //who username naming the
52
// channel; Permission ∈ {read, post, join, pin, ban}; Owner
53
// and Reader are //who usernames (or "" for "anyone" — only
54
// honored where the backend allows it).
55
type SetPermissionsRequest struct {
56
Group string `json:"group"`
57
Permission string `json:"permission"`
58
Owner string `json:"owner"`
59
Reader string `json:"reader"`
60
}
61
62
// SetPermissions writes a //chat authz entry on
63
// chat://<group>#<permission>.
64
func (c *HTTPClient) SetPermissions(
65
req SetPermissionsRequest,
66
) error {
67
return c.PostJSON("/chat/permissions/set", req, nil)
68
}
69
50
// SearchRequest is the body for POST /chat/search.
70
// SearchRequest is the body for POST /chat/search.
51
type SearchRequest struct {
71
type SearchRequest struct {
52
To string `json:"to,omitempty"`
72
To string `json:"to,omitempty"`
53
}
73
}
54
74
55
// SearchResponse is the body of POST /chat/search's response.
75
// SearchResponse is the body of POST /chat/search's response.
56
type SearchResponse struct {
76
type SearchResponse struct {
57
Messages []*Message `json:"messages"`
77
Messages []*Message `json:"messages"`
58
}
78
}
59
79
60
// Search returns messages matching the filter. The caller must
80
// Search returns messages matching the filter. The caller must
61
// have read rights on chat://<To>#read (otherwise the result
81
// have read rights on chat://<To>#read (otherwise the result
62
// is empty, not an error — per //chat's policy).
82
// is empty, not an error — per //chat's policy).
63
func (c *HTTPClient) Search(
83
func (c *HTTPClient) Search(
64
req SearchRequest,
84
req SearchRequest,
65
) ([]*Message, error) {
85
) ([]*Message, error) {
66
var res SearchResponse
86
var res SearchResponse
67
if err := c.PostJSON(
87
if err := c.PostJSON(
68
"/chat/search", req, &res); err != nil {
88
"/chat/search", req, &res); err != nil {
69
return nil, err
89
return nil, err
70
}
90
}
71
return res.Messages, nil
91
return res.Messages, nil
72
}
92
}
a/chat/chat_test.go
b/chat/chat_test.go
1
package chat
1
package chat
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "net/http"
4
import "net/http"
5
import "net/http/httptest"
5
import "net/http/httptest"
6
import "testing"
6
import "testing"
7
import "time"
7
import "time"
8
8
9
func TestSend(t *testing.T) {
9
func TestSend(t *testing.T) {
10
var seen SendRequest
10
var seen SendRequest
11
srv := httptest.NewServer(http.HandlerFunc(
11
srv := httptest.NewServer(http.HandlerFunc(
12
func(w http.ResponseWriter, r *http.Request) {
12
func(w http.ResponseWriter, r *http.Request) {
13
if r.URL.Path != "/chat/send" {
13
if r.URL.Path != "/chat/send" {
14
t.Errorf(
14
t.Errorf(
15
"path: got %q, want /chat/send", r.URL.Path)
15
"path: got %q, want /chat/send", r.URL.Path)
16
}
16
}
17
json.NewDecoder(r.Body).Decode(&seen)
17
json.NewDecoder(r.Body).Decode(&seen)
18
json.NewEncoder(w).Encode(Message{
18
json.NewEncoder(w).Encode(Message{
19
From: "alice",
19
From: "alice",
20
To: seen.To,
20
To: seen.To,
21
Text: seen.Text,
21
Text: seen.Text,
22
CreatedAt: time.Now(),
22
CreatedAt: time.Now(),
23
})
23
})
24
}))
24
}))
25
defer srv.Close()
25
defer srv.Close()
26
26
27
c := NewHTTPClient(srv.URL, "test-key")
27
c := NewHTTPClient(srv.URL, "test-key")
28
msg, err := c.Send(SendRequest{
28
msg, err := c.Send(SendRequest{
29
To: "team", Text: "hello",
29
To: "team", Text: "hello",
30
})
30
})
31
if err != nil {
31
if err != nil {
32
t.Fatal(err)
32
t.Fatal(err)
33
}
33
}
34
if msg.To != "team" || msg.Text != "hello" {
34
if msg.To != "team" || msg.Text != "hello" {
35
t.Errorf("msg: %+v", msg)
35
t.Errorf("msg: %+v", msg)
36
}
36
}
37
if seen.To != "team" {
37
if seen.To != "team" {
38
t.Errorf("seen.To: got %q", seen.To)
38
t.Errorf("seen.To: got %q", seen.To)
39
}
39
}
40
}
40
}
41
41
42
func TestSetPermissions(t *testing.T) {
43
var seen SetPermissionsRequest
44
srv := httptest.NewServer(http.HandlerFunc(
45
func(w http.ResponseWriter, r *http.Request) {
46
if r.URL.Path != "/chat/permissions/set" {
47
t.Errorf(
48
"path: got %q, want /chat/permissions/set",
49
r.URL.Path)
50
}
51
json.NewDecoder(r.Body).Decode(&seen)
52
w.WriteHeader(200)
53
}))
54
defer srv.Close()
55
56
c := NewHTTPClient(srv.URL, "test-key")
57
err := c.SetPermissions(SetPermissionsRequest{
58
Group: "team",
59
Permission: "post",
60
Owner: "alice",
61
Reader: "team",
62
})
63
if err != nil {
64
t.Fatal(err)
65
}
66
if seen.Group != "team" || seen.Permission != "post" {
67
t.Errorf(
68
"got %+v, want team/post", seen)
69
}
70
}
71
42
func TestSearch(t *testing.T) {
72
func TestSearch(t *testing.T) {
43
srv := httptest.NewServer(http.HandlerFunc(
73
srv := httptest.NewServer(http.HandlerFunc(
44
func(w http.ResponseWriter, r *http.Request) {
74
func(w http.ResponseWriter, r *http.Request) {
45
if r.URL.Path != "/chat/search" {
75
if r.URL.Path != "/chat/search" {
46
t.Errorf(
76
t.Errorf(
47
"path: got %q, want /chat/search", r.URL.Path)
77
"path: got %q, want /chat/search", r.URL.Path)
48
}
78
}
49
json.NewEncoder(w).Encode(SearchResponse{
79
json.NewEncoder(w).Encode(SearchResponse{
50
Messages: []*Message{
80
Messages: []*Message{
51
{From: "alice", To: "team", Text: "hello"},
81
{From: "alice", To: "team", Text: "hello"},
52
{From: "bob", To: "team", Text: "world"},
82
{From: "bob", To: "team", Text: "world"},
53
},
83
},
54
})
84
})
55
}))
85
}))
56
defer srv.Close()
86
defer srv.Close()
57
87
58
c := NewHTTPClient(srv.URL, "test-key")
88
c := NewHTTPClient(srv.URL, "test-key")
59
msgs, err := c.Search(SearchRequest{To: "team"})
89
msgs, err := c.Search(SearchRequest{To: "team"})
60
if err != nil {
90
if err != nil {
61
t.Fatal(err)
91
t.Fatal(err)
62
}
92
}
63
if len(msgs) != 2 {
93
if len(msgs) != 2 {
64
t.Fatalf("len: got %d, want 2", len(msgs))
94
t.Fatalf("len: got %d, want 2", len(msgs))
65
}
95
}
66
if msgs[0].Text != "hello" {
96
if msgs[0].Text != "hello" {
67
t.Errorf("msgs[0].Text: got %q", msgs[0].Text)
97
t.Errorf("msgs[0].Text: got %q", msgs[0].Text)
68
}
98
}
69
}
99
}