Skip to content

Commit 8a7dd57

Browse files
author
Michel Osswald
committed
feat(managed): consolidate ledger payload contract
1 parent 6a8c3b1 commit 8a7dd57

6 files changed

Lines changed: 232 additions & 77 deletions

File tree

internal/ledger/payload.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ledger
2+
3+
import "github.com/kontext-security/kontext-cli/internal/guard/store/sqlite"
4+
5+
const (
6+
SchemaVersion = "authorization-ledger-v1"
7+
DefaultEndpoint = "/api/v1/authorization-ledger/batches"
8+
)
9+
10+
type Payload struct {
11+
SchemaVersion string `json:"schema_version"`
12+
OrganizationID string `json:"organization_id"`
13+
InstallationID string `json:"installation_id"`
14+
BatchID string `json:"batch_id"`
15+
SentAt string `json:"sent_at"`
16+
Device *Device `json:"device,omitempty"`
17+
Sessions []sqlite.LedgerRecord `json:"agent_sessions"`
18+
Actions []sqlite.LedgerRecord `json:"authorization_actions"`
19+
Receipts []sqlite.LedgerRecord `json:"authorization_receipts"`
20+
ReceiptChainAnchor *sqlite.LedgerReceiptChainAnchor `json:"receipt_chain_anchor,omitempty"`
21+
}
22+
23+
type Device struct {
24+
Label string `json:"label,omitempty"`
25+
DeploymentVersion string `json:"deployment_version,omitempty"`
26+
}

internal/ledger/payload_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package ledger
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/kontext-security/kontext-cli/internal/guard/store/sqlite"
8+
)
9+
10+
func TestPayloadJSONShape(t *testing.T) {
11+
payload := Payload{
12+
SchemaVersion: SchemaVersion,
13+
OrganizationID: "org_123",
14+
InstallationID: "ins_123",
15+
BatchID: "batch_abc",
16+
SentAt: "2026-05-31T10:00:00Z",
17+
Device: &Device{Label: "test-mac"},
18+
Sessions: []sqlite.LedgerRecord{},
19+
Actions: []sqlite.LedgerRecord{{"session_id": "claude-session"}},
20+
Receipts: []sqlite.LedgerRecord{},
21+
}
22+
23+
data, err := json.Marshal(payload)
24+
if err != nil {
25+
t.Fatalf("Marshal() error = %v", err)
26+
}
27+
28+
var got map[string]any
29+
if err := json.Unmarshal(data, &got); err != nil {
30+
t.Fatalf("Unmarshal() error = %v", err)
31+
}
32+
33+
for _, key := range []string{
34+
"schema_version",
35+
"organization_id",
36+
"installation_id",
37+
"batch_id",
38+
"sent_at",
39+
"agent_sessions",
40+
"authorization_actions",
41+
"authorization_receipts",
42+
"device",
43+
} {
44+
if _, ok := got[key]; !ok {
45+
t.Fatalf("missing JSON key %q in %s", key, string(data))
46+
}
47+
}
48+
if _, ok := got["receipt_chain_anchor"]; ok {
49+
t.Fatalf("receipt_chain_anchor was present for nil anchor: %s", string(data))
50+
}
51+
52+
device, ok := got["device"].(map[string]any)
53+
if !ok {
54+
t.Fatalf("device = %#v, want object: %s", got["device"], string(data))
55+
}
56+
if device["label"] != "test-mac" {
57+
t.Fatalf("device.label = %#v, want %q", device["label"], "test-mac")
58+
}
59+
if _, ok := device["deployment_version"]; ok {
60+
t.Fatalf("device.deployment_version was present for empty value: %s", string(data))
61+
}
62+
63+
for _, key := range []string{"agent_sessions", "authorization_actions", "authorization_receipts"} {
64+
if _, ok := got[key].([]any); !ok {
65+
t.Fatalf("%s = %#v, want array: %s", key, got[key], string(data))
66+
}
67+
}
68+
}

internal/managedobserve/daemon_test.go

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/kontext-security/kontext-cli/internal/guard/store/sqlite"
1616
"github.com/kontext-security/kontext-cli/internal/hook"
17+
"github.com/kontext-security/kontext-cli/internal/ledger"
1718
"github.com/kontext-security/kontext-cli/internal/localruntime"
1819
)
1920

@@ -91,26 +92,15 @@ func TestDaemonSessionEndClosesHookSessionID(t *testing.T) {
9192
}
9293

9394
func TestDaemonStreamsLedgerBatches(t *testing.T) {
94-
type ledgerBatchRequest struct {
95-
OrganizationID string `json:"organization_id"`
96-
InstallationID string `json:"installation_id"`
97-
Device *struct {
98-
Label string `json:"label"`
99-
} `json:"device,omitempty"`
100-
Actions []struct {
101-
SessionID string `json:"session_id"`
102-
} `json:"authorization_actions"`
103-
}
104-
105-
requests := make(chan ledgerBatchRequest, 1)
95+
requests := make(chan ledger.Payload, 1)
10696
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107-
if r.URL.Path != "/api/v1/authorization-ledger/batches" {
97+
if r.URL.Path != ledger.DefaultEndpoint {
10898
t.Fatalf("path = %q", r.URL.Path)
10999
}
110100
if got := r.Header.Get("Authorization"); got != "Bearer test-install-token" {
111101
t.Fatalf("Authorization = %q, want bearer install token", got)
112102
}
113-
var body ledgerBatchRequest
103+
var body ledger.Payload
114104
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
115105
t.Fatalf("Decode() error = %v", err)
116106
}
@@ -180,7 +170,8 @@ func TestDaemonStreamsLedgerBatches(t *testing.T) {
180170
}
181171
found := false
182172
for _, action := range body.Actions {
183-
if action.SessionID == "claude-stream-session" {
173+
sessionID, _ := action["session_id"].(string)
174+
if sessionID == "claude-stream-session" {
184175
found = true
185176
}
186177
}

internal/managedobserve/lifecycle.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ func (l Lifecycle) probe(ctx context.Context) bool {
101101
if err != nil {
102102
return false
103103
}
104-
_ = conn.Close()
104+
if err := conn.Close(); err != nil {
105+
l.Diagnostic.Printf("managed observe probe close: %v\n", err)
106+
}
105107
return true
106108
}
107109

internal/managedstream/stream.go

Lines changed: 68 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@ import (
1717

1818
"github.com/kontext-security/kontext-cli/internal/diagnostic"
1919
"github.com/kontext-security/kontext-cli/internal/guard/store/sqlite"
20+
"github.com/kontext-security/kontext-cli/internal/ledger"
2021
)
2122

2223
const (
23-
SchemaVersion = "authorization-ledger-v1"
24-
DefaultEndpoint = "/api/v1/authorization-ledger/batches"
25-
2624
DefaultBatchLimit = 500
2725
DefaultInterval = 10 * time.Second
2826

@@ -45,27 +43,9 @@ type Options struct {
4543
Diagnostic diagnostic.Logger
4644
}
4745

48-
type Payload struct {
49-
SchemaVersion string `json:"schema_version"`
50-
OrganizationID string `json:"organization_id"`
51-
InstallationID string `json:"installation_id"`
52-
BatchID string `json:"batch_id"`
53-
SentAt string `json:"sent_at"`
54-
Device *Device `json:"device,omitempty"`
55-
Sessions []sqlite.LedgerRecord `json:"agent_sessions"`
56-
Actions []sqlite.LedgerRecord `json:"authorization_actions"`
57-
Receipts []sqlite.LedgerRecord `json:"authorization_receipts"`
58-
ReceiptChainAnchor *sqlite.LedgerReceiptChainAnchor `json:"receipt_chain_anchor,omitempty"`
59-
}
60-
61-
type Device struct {
62-
Label string `json:"label,omitempty"`
63-
DeploymentVersion string `json:"deployment_version,omitempty"`
64-
}
65-
6646
type State struct {
67-
UpdatedAfter string `json:"updated_after,omitempty"`
68-
ActionID string `json:"action_id,omitempty"`
47+
UpdatedAfter *time.Time
48+
ActionID string
6949
}
7050

7151
func Run(ctx context.Context, opts Options) error {
@@ -114,21 +94,12 @@ func Flush(ctx context.Context, opts Options) error {
11494
return err
11595
}
11696

117-
var updatedAfter *time.Time
118-
if state.UpdatedAfter != "" {
119-
parsed, err := time.Parse(time.RFC3339Nano, state.UpdatedAfter)
120-
if err != nil {
121-
return fmt.Errorf("parse managed stream state: %w", err)
122-
}
123-
updatedAfter = &parsed
124-
}
125-
12697
limit := opts.BatchLimit
12798
if limit <= 0 {
12899
limit = DefaultBatchLimit
129100
}
130101
batch, err := store.LedgerBatch(ctx, sqlite.LedgerExportOptions{
131-
UpdatedAfter: updatedAfter,
102+
UpdatedAfter: state.UpdatedAfter,
132103
UpdatedAfterID: state.ActionID,
133104
Limit: limit,
134105
})
@@ -139,8 +110,8 @@ func Flush(ctx context.Context, opts Options) error {
139110
return nil
140111
}
141112

142-
payload := Payload{
143-
SchemaVersion: SchemaVersion,
113+
payload := ledger.Payload{
114+
SchemaVersion: ledger.SchemaVersion,
144115
OrganizationID: opts.OrganizationID,
145116
InstallationID: opts.InstallationID,
146117
BatchID: "batch_" + uuid.NewString(),
@@ -158,22 +129,23 @@ func Flush(ctx context.Context, opts Options) error {
158129
deploymentVersion = strings.TrimSpace(opts.DeploymentVersion())
159130
}
160131
if label != "" || deploymentVersion != "" {
161-
payload.Device = &Device{Label: label, DeploymentVersion: deploymentVersion}
132+
payload.Device = &ledger.Device{Label: label, DeploymentVersion: deploymentVersion}
162133
}
163134
if err := post(ctx, opts, payload); err != nil {
164135
return err
165136
}
166137

167138
if batch.Cursor != nil {
139+
updatedAfter := batch.Cursor.UpdatedAt.UTC()
168140
return SaveState(statePath, State{
169-
UpdatedAfter: batch.Cursor.UpdatedAt.UTC().Format(time.RFC3339Nano),
141+
UpdatedAfter: &updatedAfter,
170142
ActionID: batch.Cursor.ActionID,
171143
})
172144
}
173145
return nil
174146
}
175147

176-
func post(ctx context.Context, opts Options, payload Payload) error {
148+
func post(ctx context.Context, opts Options, payload ledger.Payload) error {
177149
body, err := json.Marshal(payload)
178150
if err != nil {
179151
return err
@@ -209,7 +181,7 @@ func endpointURL(cloudURL string) (string, error) {
209181
if err != nil {
210182
return "", err
211183
}
212-
parsed.Path = DefaultEndpoint
184+
parsed.Path = ledger.DefaultEndpoint
213185
parsed.RawQuery = ""
214186
parsed.Fragment = ""
215187
return parsed.String(), nil
@@ -252,20 +224,53 @@ func LoadState(path string) (State, error) {
252224
}
253225
return State{}, err
254226
}
255-
var state State
227+
228+
type diskState struct {
229+
UpdatedAfter string `json:"updated_after,omitempty"`
230+
ActionID string `json:"action_id,omitempty"`
231+
}
232+
233+
var state diskState
256234
if err := json.Unmarshal(data, &state); err != nil {
257235
return State{}, err
258236
}
259-
state.UpdatedAfter = strings.TrimSpace(state.UpdatedAfter)
260-
state.ActionID = strings.TrimSpace(state.ActionID)
261-
return state, nil
237+
238+
updatedAfter := strings.TrimSpace(state.UpdatedAfter)
239+
actionID := strings.TrimSpace(state.ActionID)
240+
241+
var parsedUpdatedAfter *time.Time
242+
if updatedAfter != "" {
243+
parsed, err := time.Parse(time.RFC3339Nano, updatedAfter)
244+
if err != nil {
245+
return State{}, fmt.Errorf("parse managed stream state updated_after: %w", err)
246+
}
247+
parsedUpdatedAfter = &parsed
248+
}
249+
250+
return State{
251+
UpdatedAfter: parsedUpdatedAfter,
252+
ActionID: actionID,
253+
}, nil
262254
}
263255

264-
func SaveState(path string, state State) error {
256+
func SaveState(path string, state State) (err error) {
265257
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
266258
return err
267259
}
268-
data, err := json.MarshalIndent(state, "", " ")
260+
261+
type diskState struct {
262+
UpdatedAfter string `json:"updated_after,omitempty"`
263+
ActionID string `json:"action_id,omitempty"`
264+
}
265+
266+
updatedAfter := ""
267+
if state.UpdatedAfter != nil {
268+
updatedAfter = state.UpdatedAfter.UTC().Format(time.RFC3339Nano)
269+
}
270+
data, err := json.MarshalIndent(diskState{
271+
UpdatedAfter: updatedAfter,
272+
ActionID: strings.TrimSpace(state.ActionID),
273+
}, "", " ")
269274
if err != nil {
270275
return err
271276
}
@@ -275,22 +280,35 @@ func SaveState(path string, state State) error {
275280
return err
276281
}
277282
tempPath := temp.Name()
283+
closed := false
278284
cleanup := true
279285
defer func() {
280286
if cleanup {
281-
_ = os.Remove(tempPath)
287+
var cleanupErr error
288+
if !closed {
289+
cleanupErr = errors.Join(cleanupErr, temp.Close())
290+
}
291+
cleanupErr = errors.Join(cleanupErr, os.Remove(tempPath))
292+
if cleanupErr == nil {
293+
return
294+
}
295+
if err == nil {
296+
err = cleanupErr
297+
return
298+
}
299+
err = errors.Join(err, cleanupErr)
282300
}
283301
}()
284302
if err := temp.Chmod(0o600); err != nil {
285-
_ = temp.Close()
286303
return err
287304
}
288305
if _, err := temp.Write(data); err != nil {
289-
_ = temp.Close()
290306
return err
291307
}
292-
if err := temp.Close(); err != nil {
293-
return err
308+
closeErr := temp.Close()
309+
closed = true
310+
if closeErr != nil {
311+
return closeErr
294312
}
295313
if err := os.Rename(tempPath, path); err != nil {
296314
return err

0 commit comments

Comments
 (0)