summaryrefslogtreecommitdiff
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/annotation.go58
-rw-r--r--internal/store/annotation_test.go92
-rw-r--r--internal/store/har.go239
-rw-r--r--internal/store/history.go401
-rw-r--r--internal/store/history_test.go190
-rw-r--r--internal/store/intercept.go233
-rw-r--r--internal/store/intruder.go72
-rw-r--r--internal/store/loot.go103
-rw-r--r--internal/store/loot_test.go155
-rw-r--r--internal/store/plugin.go156
-rw-r--r--internal/store/plugin_test.go368
-rw-r--r--internal/store/project.go245
-rw-r--r--internal/store/repeater.go103
-rw-r--r--internal/store/repeater_test.go265
-rw-r--r--internal/store/scope.go116
-rw-r--r--internal/store/scope_test.go135
-rw-r--r--internal/store/search_test.go105
-rw-r--r--internal/store/store_test.go22
-rw-r--r--internal/store/websocket.go198
-rw-r--r--internal/store/websocket_test.go200
20 files changed, 3456 insertions, 0 deletions
diff --git a/internal/store/annotation.go b/internal/store/annotation.go
new file mode 100644
index 0000000..84b0c48
--- /dev/null
+++ b/internal/store/annotation.go
@@ -0,0 +1,58 @@
+package store
+
+import "fmt"
+
+// Annotation holds optional comment and highlight colour for a history row.
+type Annotation struct {
+ HistoryID uint64
+ Comment string
+ Color string // empty means no highlight; one of: "red", "orange", "yellow", "green", "blue", "purple"
+}
+
+// SetAnnotation creates or replaces the annotation for the given history row.
+func (s *Store) SetAnnotation(a Annotation) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`
+ INSERT INTO annotations (history_id, comment, color)
+ VALUES (?, ?, ?)
+ ON CONFLICT(history_id) DO UPDATE SET
+ comment = excluded.comment,
+ color = excluded.color`,
+ a.HistoryID, a.Comment, a.Color,
+ )
+ if err != nil {
+ return fmt.Errorf("store: set annotation %d: %w", a.HistoryID, err)
+ }
+ return nil
+ })
+}
+
+// DeleteAnnotation removes the annotation for the given history row.
+func (s *Store) DeleteAnnotation(historyID uint64) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM annotations WHERE history_id = ?`, historyID)
+ if err != nil {
+ return fmt.Errorf("store: delete annotation %d: %w", historyID, err)
+ }
+ return nil
+ })
+}
+
+// AllAnnotations returns every annotation row. Called once at startup to
+// populate the in-memory annotation map.
+func (s *Store) AllAnnotations() ([]Annotation, error) {
+ rows, err := s.db.Query(`SELECT history_id, comment, color FROM annotations`)
+ if err != nil {
+ return nil, fmt.Errorf("store: all annotations: %w", err)
+ }
+ defer rows.Close()
+ var out []Annotation
+ for rows.Next() {
+ var a Annotation
+ if err := rows.Scan(&a.HistoryID, &a.Comment, &a.Color); err != nil {
+ return nil, fmt.Errorf("store: scan annotation: %w", err)
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/store/annotation_test.go b/internal/store/annotation_test.go
new file mode 100644
index 0000000..72c79e1
--- /dev/null
+++ b/internal/store/annotation_test.go
@@ -0,0 +1,92 @@
+package store_test
+
+import (
+ "testing"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func logTxForAnnotation(t *testing.T, st *store.Store) uint64 {
+ t.Helper()
+ tx := makeTx("GET", "example.com", "https://example.com/", 200)
+ require.NoError(t, st.Log(tx))
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.NotEmpty(t, txs)
+ return txs[0].ID
+}
+
+func TestSetAnnotation_PersistsCommentAndColor(t *testing.T) {
+ st := newTestStore(t)
+ id := logTxForAnnotation(t, st)
+
+ a := store.Annotation{HistoryID: id, Comment: "suspicious", Color: "red"}
+ require.NoError(t, st.SetAnnotation(a))
+
+ all, err := st.AllAnnotations()
+ require.NoError(t, err)
+ require.Len(t, all, 1)
+ assert.Equal(t, id, all[0].HistoryID)
+ assert.Equal(t, "suspicious", all[0].Comment)
+ assert.Equal(t, "red", all[0].Color)
+}
+
+func TestSetAnnotation_UpdatesExisting(t *testing.T) {
+ st := newTestStore(t)
+ id := logTxForAnnotation(t, st)
+
+ require.NoError(t, st.SetAnnotation(store.Annotation{HistoryID: id, Comment: "first", Color: "blue"}))
+ require.NoError(t, st.SetAnnotation(store.Annotation{HistoryID: id, Comment: "second", Color: "green"}))
+
+ all, err := st.AllAnnotations()
+ require.NoError(t, err)
+ require.Len(t, all, 1)
+ assert.Equal(t, "second", all[0].Comment)
+ assert.Equal(t, "green", all[0].Color)
+}
+
+func TestDeleteAnnotation_RemovesRow(t *testing.T) {
+ st := newTestStore(t)
+ id := logTxForAnnotation(t, st)
+
+ require.NoError(t, st.SetAnnotation(store.Annotation{HistoryID: id, Comment: "note", Color: "yellow"}))
+ require.NoError(t, st.DeleteAnnotation(id))
+
+ all, err := st.AllAnnotations()
+ require.NoError(t, err)
+ assert.Empty(t, all)
+}
+
+func TestDeleteAnnotation_NonExistent_NoError(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.DeleteAnnotation(9999))
+}
+
+func TestAllAnnotations_Empty(t *testing.T) {
+ st := newTestStore(t)
+ all, err := st.AllAnnotations()
+ require.NoError(t, err)
+ assert.Empty(t, all)
+}
+
+func TestAllAnnotations_MultipleRows(t *testing.T) {
+ st := newTestStore(t)
+
+ tx1 := makeTx("GET", "a.com", "https://a.com/", 200)
+ tx2 := makeTx("POST", "b.com", "https://b.com/", 201)
+ require.NoError(t, st.Log(tx1))
+ require.NoError(t, st.Log(tx2))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 2)
+
+ require.NoError(t, st.SetAnnotation(store.Annotation{HistoryID: txs[0].ID, Comment: "c1", Color: "red"}))
+ require.NoError(t, st.SetAnnotation(store.Annotation{HistoryID: txs[1].ID, Comment: "c2", Color: "blue"}))
+
+ all, err := st.AllAnnotations()
+ require.NoError(t, err)
+ assert.Len(t, all, 2)
+}
diff --git a/internal/store/har.go b/internal/store/har.go
new file mode 100644
index 0000000..047300a
--- /dev/null
+++ b/internal/store/har.go
@@ -0,0 +1,239 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// HAR 1.2 — https://w3c.github.io/web-performance/specs/HAR/Overview.html
+
+type HAR struct {
+ Log HARLog `json:"log"`
+}
+
+type HARLog struct {
+ Version string `json:"version"`
+ Creator HARCreator `json:"creator"`
+ Entries []HAREntry `json:"entries"`
+}
+
+type HARCreator struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+type HAREntry struct {
+ StartedDateTime string `json:"startedDateTime"`
+ Time int64 `json:"time"`
+ Request HARRequest `json:"request"`
+ Response HARResponse `json:"response"`
+ Timings HARTimings `json:"timings"`
+}
+
+type HARRequest struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []HARNameValue `json:"headers"`
+ QueryString []HARNameValue `json:"queryString"`
+ PostData *HARPostData `json:"postData,omitempty"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+}
+
+type HARResponse struct {
+ Status int `json:"status"`
+ StatusText string `json:"statusText"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []HARNameValue `json:"headers"`
+ Content HARContent `json:"content"`
+ RedirectURL string `json:"redirectURL"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+}
+
+type HARNameValue struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type HARPostData struct {
+ MimeType string `json:"mimeType"`
+ Text string `json:"text"`
+}
+
+type HARContent struct {
+ Size int `json:"size"`
+ MimeType string `json:"mimeType"`
+ Text string `json:"text,omitempty"`
+}
+
+type HARTimings struct {
+ Send int64 `json:"send"`
+ Wait int64 `json:"wait"`
+ Receive int64 `json:"receive"`
+}
+
+// ExportHAR serialises the given transactions into a HAR 1.2 JSON byte slice.
+// Pass only the transactions you want exported — scope and out-of-scope
+// filtering is the caller's responsibility.
+func ExportHAR(txs []Transaction) ([]byte, error) {
+ har := HAR{
+ Log: HARLog{
+ Version: "1.2",
+ Creator: HARCreator{
+ Name: "Shiv",
+ Version: "1.0",
+ },
+ Entries: make([]HAREntry, 0, len(txs)),
+ },
+ }
+
+ for _, tx := range txs {
+ entry := HAREntry{
+ StartedDateTime: tx.Timestamp.UTC().Format(time.RFC3339),
+ Time: tx.DurationMs,
+ Request: buildHARRequest(tx),
+ Response: buildHARResponse(tx),
+ Timings: HARTimings{
+ Send: 0,
+ Wait: tx.DurationMs,
+ Receive: 0,
+ },
+ }
+ har.Log.Entries = append(har.Log.Entries, entry)
+ }
+
+ data, err := json.MarshalIndent(har, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("har: marshal: %w", err)
+ }
+ return data, nil
+}
+
+func stripDefaultPort(rawURL string) string {
+ for _, prefix := range []string{"https://", "http://"} {
+ if !strings.HasPrefix(rawURL, prefix) {
+ continue
+ }
+ rest := rawURL[len(prefix):]
+ slash := strings.Index(rest, "/")
+ if slash < 0 {
+ slash = len(rest)
+ }
+ host := rest[:slash]
+ path := rest[slash:]
+ h, port, err := net.SplitHostPort(host)
+ if err != nil {
+ return rawURL
+ }
+ if (prefix == "https://" && port == "443") || (prefix == "http://" && port == "80") {
+ return prefix + h + path
+ }
+ return rawURL
+ }
+ return rawURL
+}
+
+func buildHARRequest(tx Transaction) HARRequest {
+ headers := make([]HARNameValue, 0, len(tx.ReqHeaders))
+ for k, vals := range tx.ReqHeaders {
+ for _, v := range vals {
+ headers = append(headers, HARNameValue{Name: k, Value: v})
+ }
+ }
+
+ httpVersion := tx.Proto
+ if httpVersion == "" {
+ httpVersion = "HTTP/1.1"
+ }
+
+ req := HARRequest{
+ Method: tx.Method,
+ URL: stripDefaultPort(tx.URL),
+ HTTPVersion: httpVersion,
+ Headers: headers,
+ QueryString: extractQueryString(tx.URL),
+ HeadersSize: -1,
+ BodySize: len(tx.ReqBody),
+ }
+
+ if len(tx.ReqBody) > 0 {
+ mimeType := tx.ReqHeaders.Get("Content-Type")
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+ req.PostData = &HARPostData{
+ MimeType: mimeType,
+ Text: string(tx.ReqBody),
+ }
+ }
+
+ return req
+}
+
+func buildHARResponse(tx Transaction) HARResponse {
+ headers := make([]HARNameValue, 0, len(tx.RespHeaders))
+ for k, vals := range tx.RespHeaders {
+ for _, v := range vals {
+ headers = append(headers, HARNameValue{Name: k, Value: v})
+ }
+ }
+
+ httpVersion := tx.Proto
+ if httpVersion == "" {
+ httpVersion = "HTTP/1.1"
+ }
+
+ mimeType := tx.RespHeaders.Get("Content-Type")
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ redirectURL := tx.RespHeaders.Get("Location")
+
+ return HARResponse{
+ Status: tx.StatusCode,
+ StatusText: statusText(tx.StatusCode),
+ HTTPVersion: httpVersion,
+ Headers: headers,
+ Content: HARContent{
+ Size: len(tx.RespBody),
+ MimeType: mimeType,
+ Text: string(tx.RespBody),
+ },
+ RedirectURL: redirectURL,
+ HeadersSize: -1,
+ BodySize: len(tx.RespBody),
+ }
+}
+
+func extractQueryString(rawURL string) []HARNameValue {
+ idx := strings.Index(rawURL, "?")
+ if idx < 0 {
+ return []HARNameValue{}
+ }
+ parsed, err := url.ParseQuery(rawURL[idx+1:])
+ if err != nil || len(parsed) == 0 {
+ return []HARNameValue{}
+ }
+ pairs := make([]HARNameValue, 0, len(parsed))
+ for k, vals := range parsed {
+ for _, v := range vals {
+ pairs = append(pairs, HARNameValue{Name: k, Value: v})
+ }
+ }
+ return pairs
+}
+
+func statusText(code int) string {
+ if t := http.StatusText(code); t != "" {
+ return t
+ }
+ return fmt.Sprintf("%d", code)
+}
diff --git a/internal/store/history.go b/internal/store/history.go
new file mode 100644
index 0000000..066c7c2
--- /dev/null
+++ b/internal/store/history.go
@@ -0,0 +1,401 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+)
+
+// Transaction is a matched HTTP request/response pair.
+// Host is always a pure hostname — never host:port.
+// Port holds the destination port as an integer.
+type Transaction struct {
+ ID uint64
+ Timestamp time.Time
+ Host string
+ Port int
+ Method string
+ URL string
+ Proto string // "HTTP/2" or "HTTP/1.1"
+ ReqHeaders http.Header
+ ReqBody []byte
+ StatusCode int
+ RespHeaders http.Header
+ RespBody []byte
+ RespSize int
+ DurationMs int64
+ TLS bool
+ InScope bool
+}
+
+// TransactionFilter holds filter criteria for paginated history queries.
+// Zero values mean "no filter" for that field.
+type TransactionFilter struct {
+ Search string // matched against host + url + method + status (case-insensitive)
+ Host string // exact host match (from site map selection)
+ Port int // exact port match; 0 means no filter
+ PathPrefix string // URL path prefix filter (from site map selection)
+ ShowOutScope bool // if false, only in_scope=1 rows are returned
+ Methods []string // whitelist of HTTP methods; empty = all
+ StatusMin int // lower bound on status_code; 0 = no lower bound
+ StatusMax int // upper bound on status_code; 0 = no upper bound
+ HideStatic bool // if true, filter out common static asset extensions in memory
+}
+
+// PageSize is the number of rows returned per page.
+const PageSize = 100
+
+// SiteMapEntry is a minimal host+port+url triplet used to build the site map
+// at startup. Port is required to differentiate apps on the same host at
+// different ports (e.g. localhost:3000 vs localhost:8080).
+type SiteMapEntry struct {
+ Host string
+ Port int
+ URL string
+}
+
+// SiteMapEntries returns the host, port, and url for every row in history.
+// Used once at startup to populate the site map with all historical
+// paths before the first page of transactions is displayed.
+func (s *Store) SiteMapEntries() ([]SiteMapEntry, error) {
+ rows, err := s.db.Query(`SELECT host, port, url FROM history ORDER BY id ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: site map entries: %w", err)
+ }
+ defer rows.Close()
+ var entries []SiteMapEntry
+ for rows.Next() {
+ var e SiteMapEntry
+ if err := rows.Scan(&e.Host, &e.Port, &e.URL); err != nil {
+ return nil, fmt.Errorf("store: scan site map entry: %w", err)
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+func (s *Store) GetTransaction(id uint64) (*Transaction, error) {
+ var transaction Transaction
+ var timestampStr, reqHeaders, respHeaders string
+ var tlsFlag, scopeFlag int
+ err := s.db.QueryRow(`
+ SELECT id, timestamp, host, port, method, url, proto,
+ req_headers, req_body, status_code, resp_headers,
+ resp_body, duration_ms, tls, in_scope, resp_size
+ FROM history WHERE id = ?`, id,
+ ).Scan(
+ &transaction.ID, &timestampStr, &transaction.Host, &transaction.Port,
+ &transaction.Method, &transaction.URL, &transaction.Proto,
+ &reqHeaders, &transaction.ReqBody, &transaction.StatusCode, &respHeaders,
+ &transaction.RespBody, &transaction.DurationMs, &tlsFlag, &scopeFlag,
+ &transaction.RespSize,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("store: get transaction %d: %w", id, err)
+ }
+ transaction.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
+ transaction.TLS = tlsFlag == 1
+ transaction.InScope = scopeFlag == 1
+ if err := json.Unmarshal([]byte(reqHeaders), &transaction.ReqHeaders); err != nil {
+ transaction.ReqHeaders = http.Header{}
+ }
+ if err := json.Unmarshal([]byte(respHeaders), &transaction.RespHeaders); err != nil {
+ transaction.RespHeaders = http.Header{}
+ }
+ return &transaction, nil
+}
+
+// Log writes every transaction as a unique row — no deduplication.
+func (s *Store) Log(t Transaction) error {
+ return s.write(func() error {
+ reqH, err := json.Marshal(t.ReqHeaders)
+ if err != nil {
+ return fmt.Errorf("store: marshal req headers: %w", err)
+ }
+ respH, err := json.Marshal(t.RespHeaders)
+ if err != nil {
+ return fmt.Errorf("store: marshal resp headers: %w", err)
+ }
+ proto := t.Proto
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+ respSize := len(t.RespBody)
+ res, err := s.db.Exec(`
+ INSERT INTO history
+ (timestamp, host, port, method, url, proto, req_headers, req_body,
+ status_code, resp_headers, resp_body, duration_ms, tls, in_scope, resp_size)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.Timestamp.UTC().Format(time.RFC3339),
+ t.Host, t.Port, t.Method, t.URL, proto,
+ string(reqH), t.ReqBody,
+ t.StatusCode, string(respH), t.RespBody,
+ t.DurationMs,
+ boolToInt(t.TLS),
+ boolToInt(t.InScope),
+ respSize,
+ )
+ if err != nil {
+ return fmt.Errorf("store: log transaction: %w", err)
+ }
+ id, _ := res.LastInsertId()
+ t.ID = uint64(id)
+ t.Proto = proto
+ t.RespSize = respSize
+ // Guard against sending on a closed channel when the store is shutting down.
+ select {
+ case <-s.done:
+ default:
+ select {
+ case s.Updates <- t:
+ default:
+ }
+ }
+ return nil
+ })
+}
+
+// TransactionsPage returns up to PageSize transactions with id < beforeID,
+// ordered by id descending (newest first), applying filter criteria.
+// Pass beforeID=0 to start from the newest row.
+// Bodies are omitted; use GetTransaction for the full row.
+func (s *Store) TransactionsPage(beforeID uint64, filter TransactionFilter) ([]Transaction, error) {
+ var conditions []string
+ var args []any
+
+ if beforeID > 0 {
+ conditions = append(conditions, "id < ?")
+ args = append(args, beforeID)
+ }
+
+ if !filter.ShowOutScope {
+ conditions = append(conditions, "in_scope = 1")
+ }
+
+ if filter.Host != "" {
+ conditions = append(conditions, "host = ?")
+ args = append(args, filter.Host)
+ if filter.Port != 0 {
+ conditions = append(conditions, "port = ?")
+ args = append(args, filter.Port)
+ }
+ }
+
+ if filter.PathPrefix != "" {
+ conditions = append(conditions, "(url LIKE ? OR url LIKE ? OR url LIKE ?)")
+ prefix := filter.PathPrefix
+ args = append(args,
+ "%"+prefix,
+ "%"+prefix+"?%",
+ "%"+prefix+"/%",
+ )
+ }
+
+ if filter.Search != "" {
+ terms := strings.Fields(strings.ToLower(filter.Search))
+ for _, term := range terms {
+ conditions = append(conditions, "(LOWER(host) LIKE ? OR LOWER(url) LIKE ? OR LOWER(method) LIKE ? OR CAST(status_code AS TEXT) LIKE ?)")
+ like := "%" + term + "%"
+ args = append(args, like, like, like, like)
+ }
+ }
+
+ if len(filter.Methods) > 0 {
+ placeholders := make([]string, len(filter.Methods))
+ for i, m := range filter.Methods {
+ placeholders[i] = "?"
+ args = append(args, strings.ToUpper(m))
+ }
+ conditions = append(conditions, "method IN ("+strings.Join(placeholders, ",")+")")
+ }
+
+ if filter.StatusMin > 0 {
+ conditions = append(conditions, "status_code >= ?")
+ args = append(args, filter.StatusMin)
+ }
+ if filter.StatusMax > 0 {
+ conditions = append(conditions, "status_code <= ?")
+ args = append(args, filter.StatusMax)
+ }
+
+ query := `
+ SELECT id, timestamp, host, port, method, url, proto,
+ req_headers, '' as req_body, status_code, resp_headers,
+ '' as resp_body, duration_ms, tls, in_scope, resp_size
+ FROM history`
+
+ if len(conditions) > 0 {
+ query += " WHERE " + strings.Join(conditions, " AND ")
+ }
+ query += fmt.Sprintf(" ORDER BY id DESC LIMIT %d", PageSize)
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("store: query history page: %w", err)
+ }
+ defer rows.Close()
+ return scanTransactions(rows)
+}
+
+// SearchTransactions performs a full-text LIKE scan across request/response bodies
+// and/or headers. At least one of inReqBody, inRespBody, inHeaders must be true.
+// Results are ordered by id descending and capped at 200 rows.
+func (s *Store) SearchTransactions(term string, inReqBody, inRespBody, inHeaders bool) ([]Transaction, error) {
+ if term == "" || (!inReqBody && !inRespBody && !inHeaders) {
+ return nil, nil
+ }
+ like := "%" + term + "%"
+ var clauses []string
+ var args []any
+ if inReqBody {
+ clauses = append(clauses, "CAST(req_body AS TEXT) LIKE ?")
+ args = append(args, like)
+ }
+ if inRespBody {
+ clauses = append(clauses, "CAST(resp_body AS TEXT) LIKE ?")
+ args = append(args, like)
+ }
+ if inHeaders {
+ clauses = append(clauses, "req_headers LIKE ?")
+ args = append(args, like)
+ clauses = append(clauses, "resp_headers LIKE ?")
+ args = append(args, like)
+ }
+ query := `
+ SELECT id, timestamp, host, port, method, url, proto,
+ req_headers, '' as req_body, status_code, resp_headers,
+ '' as resp_body, duration_ms, tls, in_scope, resp_size
+ FROM history
+ WHERE (` + strings.Join(clauses, " OR ") + `)
+ ORDER BY id DESC LIMIT 200`
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("store: search transactions: %w", err)
+ }
+ defer rows.Close()
+ return scanTransactions(rows)
+}
+
+// AllTransactions returns all transactions ordered by id descending, capped at
+// 500 rows. Used primarily in tests; production UI uses TransactionsPage.
+func (s *Store) AllTransactions() ([]Transaction, error) {
+ rows, err := s.db.Query(`
+ SELECT id, timestamp, host, port, method, url, proto,
+ req_headers, req_body, status_code, resp_headers,
+ resp_body, duration_ms, tls, in_scope, resp_size
+ FROM history
+ ORDER BY id DESC LIMIT 500`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query all transactions: %w", err)
+ }
+ defer rows.Close()
+ return scanTransactions(rows)
+}
+
+// ClearHistory deletes all rows from the history table.
+func (s *Store) ClearHistory() error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM history`)
+ if err != nil {
+ return fmt.Errorf("store: clear history: %w", err)
+ }
+ _, err = s.db.Exec(`VACUUM`)
+ if err != nil {
+ return fmt.Errorf("store: VACUUM history: %w", err)
+ }
+
+ return nil
+ })
+}
+
+func scanTransactions(rows interface {
+ Next() bool
+ Scan(...any) error
+ Err() error
+}) ([]Transaction, error) {
+ var txs []Transaction
+ for rows.Next() {
+ var tx Transaction
+ var ts, reqH, respH string
+ var tlsInt, scopeInt int
+ if err := rows.Scan(
+ &tx.ID, &ts, &tx.Host, &tx.Port, &tx.Method, &tx.URL, &tx.Proto,
+ &reqH, &tx.ReqBody, &tx.StatusCode, &respH,
+ &tx.RespBody, &tx.DurationMs, &tlsInt, &scopeInt,
+ &tx.RespSize,
+ ); err != nil {
+ return nil, fmt.Errorf("store: scan transaction: %w", err)
+ }
+ tx.Timestamp, _ = time.Parse(time.RFC3339, ts)
+ tx.TLS = tlsInt == 1
+ tx.InScope = scopeInt == 1
+ if err := json.Unmarshal([]byte(reqH), &tx.ReqHeaders); err != nil {
+ tx.ReqHeaders = http.Header{}
+ }
+ if err := json.Unmarshal([]byte(respH), &tx.RespHeaders); err != nil {
+ tx.RespHeaders = http.Header{}
+ }
+ txs = append(txs, tx)
+ }
+ return txs, rows.Err()
+}
+
+// ObserveResponse implements events.ResponseObserver. It splits e.Host
+// (which arrives as host:port from the proxy) into a pure hostname and integer
+// port once here — no downstream consumer ever parses host/port again.
+func (s *Store) ObserveResponse(e events.ResponseEvent) {
+ host, portStr, err := net.SplitHostPort(e.Host)
+ if err != nil {
+ host = e.Host
+ if e.TLS {
+ portStr = "443"
+ } else {
+ portStr = "80"
+ }
+ }
+ port, _ := strconv.Atoi(portStr)
+
+ if err := s.Log(Transaction{
+ Timestamp: e.Timestamp,
+ Host: host,
+ Port: port,
+ Proto: e.Proto,
+ Method: e.Method,
+ URL: e.URL,
+ ReqHeaders: e.ReqHeaders,
+ ReqBody: e.ReqBody,
+ StatusCode: e.StatusCode,
+ RespHeaders: e.RespHeaders,
+ RespBody: e.RespBody,
+ DurationMs: e.DurationMs,
+ TLS: e.TLS,
+ InScope: s.InScope(host),
+ }); err != nil {
+ logger.Error("store: observe response: %v", err)
+ }
+}
+
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+// DeleteTransactionsByHostPort deletes all history rows for the given host and port.
+func (s *Store) DeleteTransactionsByHostPort(host string, port int) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM history WHERE host = ? AND port = ?`, host, port)
+ if err != nil {
+ return fmt.Errorf("store: delete transactions for %s:%d: %w", host, port, err)
+ }
+ return nil
+ })
+}
diff --git a/internal/store/history_test.go b/internal/store/history_test.go
new file mode 100644
index 0000000..d3a8e99
--- /dev/null
+++ b/internal/store/history_test.go
@@ -0,0 +1,190 @@
+package store_test
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func makeTx(method, host, url string, status int) store.Transaction {
+ return store.Transaction{
+ Timestamp: time.Now().UTC(),
+ Host: host,
+ Method: method,
+ URL: url,
+ ReqHeaders: http.Header{"Content-Type": []string{"text/plain"}},
+ ReqBody: []byte("request body"),
+ StatusCode: status,
+ RespHeaders: http.Header{"Content-Type": []string{"text/html"}},
+ RespBody: []byte("response body"),
+ DurationMs: 10,
+ TLS: false,
+ InScope: true,
+ }
+}
+
+func TestLog_Insert(t *testing.T) {
+ st := newTestStore(t)
+
+ tx := makeTx("GET", "example.com", "https://example.com/", 200)
+ require.NoError(t, st.Log(tx))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 1)
+ assert.Equal(t, "GET", txs[0].Method)
+ assert.Equal(t, "example.com", txs[0].Host)
+ assert.Equal(t, 200, txs[0].StatusCode)
+ assert.True(t, txs[0].InScope)
+}
+
+func TestLog_NoDedup_SameMethodHostURLStatus(t *testing.T) {
+ st := newTestStore(t)
+
+ tx := makeTx("GET", "example.com", "https://example.com/", 200)
+ require.NoError(t, st.Log(tx))
+ require.NoError(t, st.Log(tx))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.Len(t, txs, 2, "no dedup — every request is a unique row")
+}
+
+func TestLog_NoDedupOnDifferentStatus(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.Log(makeTx("GET", "example.com", "https://example.com/", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "example.com", "https://example.com/", 404)))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.Len(t, txs, 2)
+}
+
+func TestLog_NoDedupOnDifferentMethod(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.Log(makeTx("GET", "example.com", "https://example.com/", 200)))
+ require.NoError(t, st.Log(makeTx("POST", "example.com", "https://example.com/", 200)))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.Len(t, txs, 2)
+}
+
+func TestLog_NoDedupOnDifferentURL(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.Log(makeTx("GET", "example.com", "https://example.com/a", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "example.com", "https://example.com/b", 200)))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.Len(t, txs, 2)
+}
+
+func TestLog_TLSFlagPersisted(t *testing.T) {
+ st := newTestStore(t)
+
+ tx := makeTx("GET", "example.com", "https://example.com/", 200)
+ tx.TLS = true
+ require.NoError(t, st.Log(tx))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 1)
+ assert.True(t, txs[0].TLS)
+}
+
+func TestAllTransactions_OrderedByIDDesc(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.Log(makeTx("GET", "first.com", "https://first.com/", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "second.com", "https://second.com/", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "third.com", "https://third.com/", 200)))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 3)
+ assert.Equal(t, "third.com", txs[0].Host)
+ assert.Equal(t, "second.com", txs[1].Host)
+ assert.Equal(t, "first.com", txs[2].Host)
+}
+
+func TestAllTransactions_CappedAt500(t *testing.T) {
+ st := newTestStore(t)
+
+ for i := 0; i < 510; i++ {
+ url := fmt.Sprintf("https://example.com/path/%d", i)
+ require.NoError(t, st.Log(makeTx("GET", "example.com", url, 200)))
+ }
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.LessOrEqual(t, len(txs), 500)
+}
+
+func TestGetTransaction_ReturnsFullBodies(t *testing.T) {
+ st := newTestStore(t)
+
+ tx := makeTx("POST", "example.com", "https://example.com/api", 201)
+ tx.ReqBody = []byte(`{"input":"data"}`)
+ tx.RespBody = []byte(`{"output":"result"}`)
+ require.NoError(t, st.Log(tx))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 1)
+
+ full, err := st.GetTransaction(txs[0].ID)
+ require.NoError(t, err)
+ assert.Equal(t, []byte(`{"input":"data"}`), full.ReqBody)
+ assert.Equal(t, []byte(`{"output":"result"}`), full.RespBody)
+ assert.Equal(t, "POST", full.Method)
+ assert.Equal(t, 201, full.StatusCode)
+}
+
+func TestGetTransaction_HeadersDeserialized(t *testing.T) {
+ st := newTestStore(t)
+
+ tx := makeTx("GET", "example.com", "https://example.com/", 200)
+ tx.ReqHeaders = http.Header{"X-Request-Id": []string{"req-123"}}
+ tx.RespHeaders = http.Header{"X-Trace-Id": []string{"trace-456"}}
+ require.NoError(t, st.Log(tx))
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+
+ full, err := st.GetTransaction(txs[0].ID)
+ require.NoError(t, err)
+ assert.Equal(t, "req-123", full.ReqHeaders.Get("X-Request-Id"))
+ assert.Equal(t, "trace-456", full.RespHeaders.Get("X-Trace-Id"))
+}
+
+func TestGetTransaction_NotFound(t *testing.T) {
+ st := newTestStore(t)
+ _, err := st.GetTransaction(99999)
+ assert.Error(t, err)
+}
+
+func TestClearHistory_RemovesAllRows(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.Log(makeTx("GET", "a.com", "https://a.com/", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "b.com", "https://b.com/", 200)))
+ require.NoError(t, st.ClearHistory())
+
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ assert.Empty(t, txs)
+}
+
+func TestClearHistory_EmptyStoreNoError(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.ClearHistory())
+}
diff --git a/internal/store/intercept.go b/internal/store/intercept.go
new file mode 100644
index 0000000..e86c683
--- /dev/null
+++ b/internal/store/intercept.go
@@ -0,0 +1,233 @@
+package store
+
+import (
+ "net"
+ "net/http"
+ "sync"
+
+ "github.com/shiv/internal/events"
+)
+
+// PendingRequest is a request held at the intercept gate waiting for a
+// user decision. The proxy goroutine blocks on Reply until the UI responds.
+// done is closed when the request is cancelled via bypass so watchQueue
+// can skip stale entries without deadlocking.
+type PendingRequest struct {
+ Request *http.Request
+ Body []byte
+ Reply chan Decision
+ done chan struct{}
+}
+
+func newPendingRequest(req *http.Request, body []byte) *PendingRequest {
+ return &PendingRequest{
+ Request: req,
+ Body: body,
+ Reply: make(chan Decision, 1),
+ done: make(chan struct{}),
+ }
+}
+
+// Cancel marks this pending request as cancelled. Safe to call multiple times.
+func (p *PendingRequest) Cancel() {
+ select {
+ case <-p.done:
+ // already cancelled
+ default:
+ close(p.done)
+ }
+}
+
+// IsDone returns true if this request was cancelled before the UI processed it.
+func (p *PendingRequest) IsDone() bool {
+ select {
+ case <-p.done:
+ return true
+ default:
+ return false
+ }
+}
+
+// Decision is the user's response to a pending request.
+type Decision struct {
+ Forward bool
+ Request *http.Request
+ Body []byte
+}
+
+// InterceptGate manages intercept state. When enabled, every request enters
+// a serialising semaphore — one request is shown to the UI at a time, others
+// wait in order. ForwardAll releases all currently waiting goroutines by
+// closing the bypass channel and immediately replacing it so future requests
+// are intercepted normally again.
+type InterceptGate struct {
+ mu sync.RWMutex
+ enabled bool
+
+ queue chan *PendingRequest // UI reads from here
+ sem chan struct{} // capacity 1 — one request shown at a time
+
+ bypassMu sync.Mutex
+ bypass chan struct{} // closed to release all waiting goroutines
+ bypassClosed bool // tracks whether current bypass is already closed
+
+ scopeMu sync.RWMutex
+ scopeCheckFn func(host string) bool // nil = intercept all; non-nil = intercept only if returns true
+}
+
+// NewInterceptGate creates a new gate. Intercept is off by default.
+func NewInterceptGate() *InterceptGate {
+ return &InterceptGate{
+ queue: make(chan *PendingRequest, 1024),
+ sem: make(chan struct{}, 1),
+ bypass: make(chan struct{}),
+ }
+}
+
+// Enabled returns whether intercept is currently on.
+func (g *InterceptGate) Enabled() bool {
+ g.mu.RLock()
+ defer g.mu.RUnlock()
+ return g.enabled
+}
+
+// SetEnabled turns intercept on or off.
+// Turning off triggers bypass so all goroutines currently blocked in Hold
+// are released immediately without deadlocking.
+// Turning on resets the bypass channel so future requests are intercepted.
+// g.mu and bypassMu are never held simultaneously to avoid deadlock with Hold.
+func (g *InterceptGate) SetEnabled(v bool) {
+ g.mu.Lock()
+ g.enabled = v
+ g.mu.Unlock()
+
+ if !v {
+ g.bypassMu.Lock()
+ if !g.bypassClosed {
+ close(g.bypass)
+ g.bypassClosed = true
+ }
+ g.bypassMu.Unlock()
+ } else {
+ g.bypassMu.Lock()
+ g.bypass = make(chan struct{})
+ g.bypassClosed = false
+ g.bypassMu.Unlock()
+ }
+}
+
+// SetScopeFilter sets a predicate that gates which requests enter the queue.
+// Pass nil to intercept all requests. Pass a function returning true for
+// in-scope hosts to intercept scope-only traffic.
+func (g *InterceptGate) SetScopeFilter(fn func(host string) bool) {
+ g.scopeMu.Lock()
+ g.scopeCheckFn = fn
+ g.scopeMu.Unlock()
+}
+
+// Hold blocks until the user makes a decision on this request.
+// If intercept is off, returns immediately.
+// Requests acquire the semaphore in arrival order so the UI sees exactly
+// one request at a time. If bypass fires at any point during Hold, the
+// goroutine exits immediately and the request is passed through unmodified.
+// Any pending already in the queue is marked cancelled so watchQueue skips it.
+func (g *InterceptGate) Hold(req *http.Request, body []byte) (*http.Request, []byte, bool) {
+ if !g.Enabled() {
+ return req, body, true
+ }
+
+ g.scopeMu.RLock()
+ fn := g.scopeCheckFn
+ g.scopeMu.RUnlock()
+ if fn != nil {
+ host := req.Host
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ if !fn(host) {
+ return req, body, true
+ }
+ }
+
+ // Capture the current bypass reference once. This goroutine watches this
+ // exact channel throughout — if it gets closed (ForwardAll or SetEnabled(false)),
+ // we see it no matter where in Hold we are.
+ g.bypassMu.Lock()
+ bypass := g.bypass
+ g.bypassMu.Unlock()
+
+ // Wait for semaphore (one slot) or bypass signal.
+ select {
+ case g.sem <- struct{}{}:
+ case <-bypass:
+ return req, body, true
+ }
+
+ // Re-check enabled — may have changed while we waited for the semaphore.
+ if !g.Enabled() {
+ <-g.sem
+ return req, body, true
+ }
+
+ // Re-check bypass — may have been closed while we raced for the semaphore.
+ select {
+ case <-bypass:
+ <-g.sem
+ return req, body, true
+ default:
+ }
+
+ // We hold the semaphore and bypass is open. Put the request in the queue.
+ pending := newPendingRequest(req, body)
+ select {
+ case g.queue <- pending:
+ case <-bypass:
+ pending.Cancel()
+ <-g.sem
+ return req, body, true
+ }
+
+ // Wait for the user's decision or a bypass signal.
+ select {
+ case decision := <-pending.Reply:
+ <-g.sem
+ return decision.Request, decision.Body, decision.Forward
+ case <-bypass:
+ // Bypass fired while we were waiting. Mark the pending as cancelled
+ // so watchQueue skips this stale queue entry, then release the semaphore.
+ pending.Cancel()
+ <-g.sem
+ return req, body, true
+ }
+}
+
+// ForwardAll releases all goroutines currently blocked in Hold, passing their
+// requests through unmodified. Future requests are intercepted normally —
+// the bypass channel is replaced immediately after closing the old one.
+func (g *InterceptGate) ForwardAll() {
+ g.bypassMu.Lock()
+ if !g.bypassClosed {
+ close(g.bypass)
+ g.bypassClosed = true
+ }
+ // Replace immediately so future requests are intercepted normally.
+ g.bypass = make(chan struct{})
+ g.bypassClosed = false
+ g.bypassMu.Unlock()
+}
+
+// Queue returns the channel the UI reads pending requests from.
+func (g *InterceptGate) Queue() <-chan *PendingRequest {
+ return g.queue
+}
+
+// HandleRequest implements events.RequestMiddleware.
+// This is a thin adapter: it calls Hold and maps the result to RequestResult.
+func (g *InterceptGate) HandleRequest(e events.RequestEvent) events.RequestResult {
+ req, body, forward := g.Hold(e.Request, e.Body)
+ return events.RequestResult{
+ Drop: !forward,
+ Request: req,
+ Body: body,
+ }
+}
diff --git a/internal/store/intruder.go b/internal/store/intruder.go
new file mode 100644
index 0000000..4968f5d
--- /dev/null
+++ b/internal/store/intruder.go
@@ -0,0 +1,72 @@
+package store
+
+import "fmt"
+
+type IntruderConfig struct {
+ DelayMs int
+ StopOnStatus int
+ MaxRedirects int
+ FollowRedirects string // "never", "always", "in-scope"
+ TimeoutMs int
+ RawRequest string
+ Payloads string
+}
+
+func DefaultIntruderConfig() IntruderConfig {
+ return IntruderConfig{
+ DelayMs: 0,
+ StopOnStatus: 0,
+ MaxRedirects: 10,
+ FollowRedirects: "never",
+ TimeoutMs: 30000,
+ RawRequest: "",
+ Payloads: "",
+ }
+}
+
+func (s *Store) LoadIntruderConfig() IntruderConfig {
+ config := DefaultIntruderConfig()
+ err := s.db.QueryRow(`
+ SELECT delay_ms, stop_on_status, max_redirects, follow_redirects, timeout_ms, raw_request, payloads
+ FROM intruder_config WHERE id = 1`).Scan(
+ &config.DelayMs,
+ &config.StopOnStatus,
+ &config.MaxRedirects,
+ &config.FollowRedirects,
+ &config.TimeoutMs,
+ &config.RawRequest,
+ &config.Payloads,
+ )
+ if err != nil {
+ return config
+ }
+ return config
+}
+
+func (s *Store) SaveIntruderConfig(config IntruderConfig) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`
+ INSERT INTO intruder_config (id, delay_ms, stop_on_status, max_redirects, follow_redirects, timeout_ms, raw_request, payloads)
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ delay_ms = excluded.delay_ms,
+ stop_on_status = excluded.stop_on_status,
+ max_redirects = excluded.max_redirects,
+ follow_redirects = excluded.follow_redirects,
+ timeout_ms = excluded.timeout_ms,
+ raw_request = excluded.raw_request,
+ payloads = excluded.payloads`,
+ config.DelayMs,
+ config.StopOnStatus,
+ config.MaxRedirects,
+ config.FollowRedirects,
+ config.TimeoutMs,
+ config.RawRequest,
+ config.Payloads,
+ )
+ if err != nil {
+ return fmt.Errorf("store: save intruder config: %w", err)
+ }
+ return nil
+ })
+}
diff --git a/internal/store/loot.go b/internal/store/loot.go
new file mode 100644
index 0000000..c610db1
--- /dev/null
+++ b/internal/store/loot.go
@@ -0,0 +1,103 @@
+package store
+
+import (
+ "fmt"
+ "time"
+)
+
+type LootEntry struct {
+ ID int64
+ Title string
+ Severity string
+ Notes string
+ HistoryID *uint64
+ RawRequest string
+ RawResponse string
+ CreatedAt time.Time
+}
+
+func (s *Store) AllLoot() ([]LootEntry, error) {
+ rows, err := s.db.Query(`
+ SELECT id, title, severity, notes, history_id, raw_request, raw_response, created_at
+ FROM loot ORDER BY
+ CASE severity
+ WHEN 'Critical' THEN 1
+ WHEN 'High' THEN 2
+ WHEN 'Medium' THEN 3
+ WHEN 'Low' THEN 4
+ WHEN 'Info' THEN 5
+ ELSE 6
+ END ASC, id DESC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query loot: %w", err)
+ }
+ defer rows.Close()
+
+ var entries []LootEntry
+ for rows.Next() {
+ var entry LootEntry
+ var histID *int64
+ var timestampStr string
+ if err := rows.Scan(&entry.ID, &entry.Title, &entry.Severity, &entry.Notes, &histID, &entry.RawRequest, &entry.RawResponse, &timestampStr); err != nil {
+ return nil, fmt.Errorf("store: scan loot: %w", err)
+ }
+ if histID != nil {
+ histIDUint := uint64(*histID)
+ entry.HistoryID = &histIDUint
+ }
+ entry.CreatedAt, _ = time.Parse(time.RFC3339, timestampStr)
+ entries = append(entries, entry)
+ }
+ return entries, rows.Err()
+}
+
+func (s *Store) AddLoot(entry LootEntry) (int64, error) {
+ var id int64
+ err := s.write(func() error {
+ var histID any
+ if entry.HistoryID != nil {
+ histID = *entry.HistoryID
+ }
+ result, err := s.db.Exec(`
+ INSERT INTO loot (title, severity, notes, history_id, raw_request, raw_response, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ entry.Title, entry.Severity, entry.Notes, histID,
+ entry.RawRequest, entry.RawResponse,
+ time.Now().UTC().Format(time.RFC3339),
+ )
+ if err != nil {
+ return fmt.Errorf("store: add loot: %w", err)
+ }
+ id, err = result.LastInsertId()
+ return err
+ })
+ if err != nil {
+ return 0, err
+ }
+ s.notifyLoot()
+ return id, nil
+}
+
+func (s *Store) DeleteLoot(id int64) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM loot WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("store: delete loot: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ s.notifyLoot()
+ return nil
+}
+
+// notifyLoot signals the UI that loot state has changed.
+// Non-blocking — a full buffer means the UI is already scheduled to reload.
+func (s *Store) notifyLoot() {
+ select {
+ case s.LootEntries <- struct{}{}:
+ default:
+ }
+}
diff --git a/internal/store/loot_test.go b/internal/store/loot_test.go
new file mode 100644
index 0000000..59bd4fe
--- /dev/null
+++ b/internal/store/loot_test.go
@@ -0,0 +1,155 @@
+package store_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func makeLoot(title, severity, notes string) store.LootEntry {
+ return store.LootEntry{
+ Title: title,
+ Severity: severity,
+ Notes: notes,
+ }
+}
+
+func TestAddLoot_PersistsAllFields(t *testing.T) {
+ st := newTestStore(t)
+
+ entry := store.LootEntry{
+ Title: "Admin panel exposed",
+ Severity: "High",
+ Notes: "Found at /admin with no auth",
+ }
+ id, err := st.AddLoot(entry)
+ require.NoError(t, err)
+ assert.Positive(t, id)
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+
+ got := entries[0]
+ assert.Equal(t, id, got.ID)
+ assert.Equal(t, "Admin panel exposed", got.Title)
+ assert.Equal(t, "High", got.Severity)
+ assert.Equal(t, "Found at /admin with no auth", got.Notes)
+ assert.Nil(t, got.HistoryID)
+ assert.WithinDuration(t, time.Now(), got.CreatedAt, 5*time.Second)
+}
+
+func TestAddLoot_WithLinkedHistoryID(t *testing.T) {
+ st := newTestStore(t)
+
+ // first log a transaction to get a valid history ID
+ tx := makeTx("GET", "example.com", "https://example.com/secret", 200)
+ require.NoError(t, st.Log(tx))
+ txs, err := st.AllTransactions()
+ require.NoError(t, err)
+ require.Len(t, txs, 1)
+ histID := txs[0].ID
+
+ entry := store.LootEntry{
+ Title: "Secret endpoint",
+ Severity: "Critical",
+ Notes: "Returns sensitive data",
+ HistoryID: &histID,
+ }
+ id, err := st.AddLoot(entry)
+ require.NoError(t, err)
+ assert.Positive(t, id)
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ require.NotNil(t, entries[0].HistoryID)
+ assert.Equal(t, histID, *entries[0].HistoryID)
+}
+
+func TestAllLoot_OrderedBySeverityThenIDDesc(t *testing.T) {
+ st := newTestStore(t)
+
+ // Insert in reverse severity order to confirm sorting
+ _, err := st.AddLoot(makeLoot("Info finding", "Info", ""))
+ require.NoError(t, err)
+ _, err = st.AddLoot(makeLoot("Low finding", "Low", ""))
+ require.NoError(t, err)
+ _, err = st.AddLoot(makeLoot("Critical finding", "Critical", ""))
+ require.NoError(t, err)
+ _, err = st.AddLoot(makeLoot("High finding", "High", ""))
+ require.NoError(t, err)
+ _, err = st.AddLoot(makeLoot("Medium finding", "Medium", ""))
+ require.NoError(t, err)
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, entries, 5)
+
+ assert.Equal(t, "Critical", entries[0].Severity)
+ assert.Equal(t, "High", entries[1].Severity)
+ assert.Equal(t, "Medium", entries[2].Severity)
+ assert.Equal(t, "Low", entries[3].Severity)
+ assert.Equal(t, "Info", entries[4].Severity)
+}
+
+func TestAllLoot_MultipleSameSeverityOrderedByIDDesc(t *testing.T) {
+ st := newTestStore(t)
+
+ id1, err := st.AddLoot(makeLoot("First High", "High", ""))
+ require.NoError(t, err)
+ id2, err := st.AddLoot(makeLoot("Second High", "High", ""))
+ require.NoError(t, err)
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, entries, 2)
+
+ // same severity — ordered by id DESC so id2 comes first
+ assert.Equal(t, id2, entries[0].ID)
+ assert.Equal(t, id1, entries[1].ID)
+}
+
+func TestDeleteLoot(t *testing.T) {
+ st := newTestStore(t)
+
+ id, err := st.AddLoot(makeLoot("To delete", "Low", ""))
+ require.NoError(t, err)
+
+ require.NoError(t, st.DeleteLoot(id))
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestDeleteLoot_NonExistent(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.DeleteLoot(9999))
+}
+
+func TestDeleteLoot_DoesNotAffectOtherEntries(t *testing.T) {
+ st := newTestStore(t)
+
+ idKeep, err := st.AddLoot(makeLoot("Keep", "High", ""))
+ require.NoError(t, err)
+ idDel, err := st.AddLoot(makeLoot("Delete", "Low", ""))
+ require.NoError(t, err)
+
+ require.NoError(t, st.DeleteLoot(idDel))
+
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, idKeep, entries[0].ID)
+}
+
+func TestAllLoot_Empty(t *testing.T) {
+ st := newTestStore(t)
+ entries, err := st.AllLoot()
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
diff --git a/internal/store/plugin.go b/internal/store/plugin.go
new file mode 100644
index 0000000..545d9b4
--- /dev/null
+++ b/internal/store/plugin.go
@@ -0,0 +1,156 @@
+package store
+
+import (
+ "fmt"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+)
+
+const maxPluginLogLines = 500
+
+// PluginEntry represents a loaded plugin and its persistent enabled state.
+type PluginEntry struct {
+ Name string
+ Path string
+ Enabled bool
+}
+
+// RegisterPlugin records a plugin in the database. If the plugin is new,
+// enabled defaults to true. If it already exists, only the path is updated —
+// the existing enabled state is preserved. Notifies the UI via PluginEntries.
+func (s *Store) RegisterPlugin(name, path string) error {
+ if err := s.write(func() error {
+ _, err := s.db.Exec(`
+ INSERT INTO plugins (name, path, enabled) VALUES (?, ?, 1)
+ ON CONFLICT(name) DO UPDATE SET path = excluded.path`,
+ name, path,
+ )
+ if err != nil {
+ return fmt.Errorf("store: register plugin %s: %w", name, err)
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ select {
+ case s.PluginEntries <- struct{}{}:
+ default:
+ }
+ return nil
+}
+
+// Scans for plugins on the disk, removes them from DB if they are deleted from disk.
+func (s *Store) ScanPlugins(active []string) error {
+ set := make(map[string]struct{}, len(active))
+ for _, name := range active {
+ set[name] = struct{}{}
+ }
+ all, err := s.AllPlugins()
+ if err != nil {
+ return err
+ }
+ for _, e := range all {
+ if _, ok := set[e.Name]; ok {
+ continue
+ }
+ if err := s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, e.Name)
+ return err
+ }); err != nil {
+ return fmt.Errorf("store: scan plugins remove %s: %w", e.Name, err)
+ }
+ }
+ select {
+ case s.PluginEntries <- struct{}{}:
+ default:
+ }
+ return nil
+}
+
+// PluginEnabled returns the persisted enabled state for a plugin.
+// Returns true if the plugin record is not found (safe default for new plugins).
+func (s *Store) PluginEnabled(name string) (bool, error) {
+ var enabled int
+ err := s.db.QueryRow(`SELECT enabled FROM plugins WHERE name = ?`, name).Scan(&enabled)
+ if err != nil {
+ return true, err
+ }
+ return enabled == 1, nil
+}
+
+// AllPlugins returns all known plugins ordered by name.
+func (s *Store) AllPlugins() ([]PluginEntry, error) {
+ rows, err := s.db.Query(`SELECT name, path, enabled FROM plugins ORDER BY name ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query plugins: %w", err)
+ }
+ defer rows.Close()
+
+ var entries []PluginEntry
+ for rows.Next() {
+ var e PluginEntry
+ var enabledInt int
+ if err := rows.Scan(&e.Name, &e.Path, &enabledInt); err != nil {
+ return nil, fmt.Errorf("store: scan plugin: %w", err)
+ }
+ e.Enabled = enabledInt == 1
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+// PluginLogs returns the in-memory log lines for a plugin, oldest first.
+// Returns a copy to avoid races with the writer.
+func (s *Store) PluginLogs(name string) []string {
+ s.pluginLogsMu.RLock()
+ defer s.pluginLogsMu.RUnlock()
+ lines := s.pluginLogs[name]
+ if len(lines) == 0 {
+ return nil
+ }
+ result := make([]string, len(lines))
+ copy(result, lines)
+ return result
+}
+
+// ObservePluginLog implements events.PluginLogObserver.
+// Appends the log line to the in-memory ring buffer and notifies the UI.
+func (s *Store) ObservePluginLog(e events.PluginLogEvent) {
+ s.pluginLogsMu.Lock()
+ lines := s.pluginLogs[e.Name]
+ lines = append(lines, e.Message)
+ if len(lines) > maxPluginLogLines {
+ lines = lines[len(lines)-maxPluginLogLines:]
+ }
+ s.pluginLogs[e.Name] = lines
+ s.pluginLogsMu.Unlock()
+
+ select {
+ case s.PluginEntries <- struct{}{}:
+ default:
+ }
+}
+
+// ObservePluginEnabled implements events.PluginEnabledObserver.
+// Persists the new enabled state to the database and notifies the UI.
+func (s *Store) ObservePluginEnabled(e events.SetPluginEnabledEvent) {
+ if err := s.write(func() error {
+ _, err := s.db.Exec(`UPDATE plugins SET enabled = ? WHERE name = ?`,
+ boolToInt(e.Enabled), e.Name,
+ )
+ if err != nil {
+ return fmt.Errorf("store: set plugin enabled %s: %w", e.Name, err)
+ }
+ return nil
+ }); err != nil {
+ logger.Error("store: observe plugin enabled: %v", err)
+ return
+ }
+
+ select {
+ case s.PluginEntries <- struct{}{}:
+ default:
+ }
+}
diff --git a/internal/store/plugin_test.go b/internal/store/plugin_test.go
new file mode 100644
index 0000000..b640628
--- /dev/null
+++ b/internal/store/plugin_test.go
@@ -0,0 +1,368 @@
+package store_test
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── RegisterPlugin ────────────────────────────────────────────────────────────
+
+func TestRegisterPlugin_NewPlugin_DefaultsToEnabled(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "test.lua", entries[0].Name)
+ assert.Equal(t, "/plugins/test.lua", entries[0].Path)
+ assert.True(t, entries[0].Enabled)
+}
+
+func TestRegisterPlugin_ExistingPlugin_PreservesEnabledState(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ // Re-register with a new path — enabled state must be preserved.
+ require.NoError(t, st.RegisterPlugin("test.lua", "/new/path/test.lua"))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "/new/path/test.lua", entries[0].Path)
+ assert.False(t, entries[0].Enabled, "enabled state must survive re-registration")
+}
+
+func TestRegisterPlugin_NotifiesPluginEntries(t *testing.T) {
+ st := newTestStore(t)
+
+ // Drain any startup notifications.
+ for len(st.PluginEntries) > 0 {
+ <-st.PluginEntries
+ }
+
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+
+ select {
+ case <-st.PluginEntries:
+ default:
+ t.Fatal("PluginEntries channel was not notified after RegisterPlugin")
+ }
+}
+
+func TestRegisterPlugin_MultiplePlugins(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.RegisterPlugin("a.lua", "/plugins/a.lua"))
+ require.NoError(t, st.RegisterPlugin("b.lua", "/plugins/b.lua"))
+ require.NoError(t, st.RegisterPlugin("c.lua", "/plugins/c.lua"))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ assert.Len(t, entries, 3)
+}
+
+// ── PluginEnabled ─────────────────────────────────────────────────────────────
+
+func TestPluginEnabled_UnknownPlugin_ReturnsTrue(t *testing.T) {
+ st := newTestStore(t)
+
+ enabled, err := st.PluginEnabled("nonexistent.lua")
+ // Error is expected (no row) but the safe default is true.
+ assert.True(t, enabled)
+ _ = err
+}
+
+func TestPluginEnabled_NewPlugin_ReturnsTrue(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+
+ enabled, err := st.PluginEnabled("test.lua")
+ require.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestPluginEnabled_AfterDisable_ReturnsFalse(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ enabled, err := st.PluginEnabled("test.lua")
+ require.NoError(t, err)
+ assert.False(t, enabled)
+}
+
+func TestPluginEnabled_AfterReEnable_ReturnsTrue(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/plugins/test.lua"))
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+
+ enabled, err := st.PluginEnabled("test.lua")
+ require.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+// ── AllPlugins ────────────────────────────────────────────────────────────────
+
+func TestAllPlugins_Empty(t *testing.T) {
+ st := newTestStore(t)
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestAllPlugins_OrderedByNameAsc(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("zebra.lua", "/z.lua"))
+ require.NoError(t, st.RegisterPlugin("alpha.lua", "/a.lua"))
+ require.NoError(t, st.RegisterPlugin("mango.lua", "/m.lua"))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 3)
+ assert.Equal(t, "alpha.lua", entries[0].Name)
+ assert.Equal(t, "mango.lua", entries[1].Name)
+ assert.Equal(t, "zebra.lua", entries[2].Name)
+}
+
+func TestAllPlugins_EnabledFieldReflectsState(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("on.lua", "/on.lua"))
+ require.NoError(t, st.RegisterPlugin("off.lua", "/off.lua"))
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "off.lua", Enabled: false})
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 2)
+
+ byName := make(map[string]store.PluginEntry)
+ for _, e := range entries {
+ byName[e.Name] = e
+ }
+ assert.True(t, byName["on.lua"].Enabled)
+ assert.False(t, byName["off.lua"].Enabled)
+}
+
+// ── ScanPlugins ───────────────────────────────────────────────────────────────
+
+func TestScanPlugins_RemovesStaleRecords(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("keep.lua", "/keep.lua"))
+ require.NoError(t, st.RegisterPlugin("gone.lua", "/gone.lua"))
+
+ require.NoError(t, st.ScanPlugins([]string{"keep.lua"}))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "keep.lua", entries[0].Name)
+}
+
+func TestScanPlugins_EmptyActive_RemovesAll(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("a.lua", "/a.lua"))
+ require.NoError(t, st.RegisterPlugin("b.lua", "/b.lua"))
+
+ require.NoError(t, st.ScanPlugins([]string{}))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestScanPlugins_AllPresent_NothingRemoved(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("a.lua", "/a.lua"))
+ require.NoError(t, st.RegisterPlugin("b.lua", "/b.lua"))
+
+ require.NoError(t, st.ScanPlugins([]string{"a.lua", "b.lua"}))
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ assert.Len(t, entries, 2)
+}
+
+func TestScanPlugins_EmptyStore_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.ScanPlugins([]string{"a.lua"}))
+}
+
+func TestScanPlugins_NotifiesPluginEntries(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("gone.lua", "/gone.lua"))
+
+ for len(st.PluginEntries) > 0 {
+ <-st.PluginEntries
+ }
+
+ require.NoError(t, st.ScanPlugins([]string{}))
+
+ select {
+ case <-st.PluginEntries:
+ default:
+ t.Fatal("PluginEntries channel was not notified after ScanPlugins")
+ }
+}
+
+// ── PluginLogs ────────────────────────────────────────────────────────────────
+
+func TestPluginLogs_UnknownPlugin_ReturnsNil(t *testing.T) {
+ st := newTestStore(t)
+ assert.Nil(t, st.PluginLogs("nonexistent.lua"))
+}
+
+func TestPluginLogs_ReturnsLinesInOrder(t *testing.T) {
+ st := newTestStore(t)
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "line 1"})
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "line 2"})
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "line 3"})
+
+ lines := st.PluginLogs("test.lua")
+ require.Len(t, lines, 3)
+ assert.Equal(t, "line 1", lines[0])
+ assert.Equal(t, "line 2", lines[1])
+ assert.Equal(t, "line 3", lines[2])
+}
+
+func TestPluginLogs_ReturnsACopy(t *testing.T) {
+ st := newTestStore(t)
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "original"})
+
+ lines := st.PluginLogs("test.lua")
+ lines[0] = "mutated"
+
+ // Original must be unchanged.
+ assert.Equal(t, "original", st.PluginLogs("test.lua")[0])
+}
+
+func TestPluginLogs_IsolatedPerPlugin(t *testing.T) {
+ st := newTestStore(t)
+ st.ObservePluginLog(events.PluginLogEvent{Name: "a.lua", Message: "from a"})
+ st.ObservePluginLog(events.PluginLogEvent{Name: "b.lua", Message: "from b"})
+
+ assert.Equal(t, []string{"from a"}, st.PluginLogs("a.lua"))
+ assert.Equal(t, []string{"from b"}, st.PluginLogs("b.lua"))
+}
+
+func TestPluginLogs_RingBuffer_CapsAtMaxLines(t *testing.T) {
+ st := newTestStore(t)
+ // Write more than maxPluginLogLines (500) entries.
+ for i := 0; i < 510; i++ {
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: fmt.Sprintf("line %d", i)})
+ }
+
+ lines := st.PluginLogs("test.lua")
+ assert.Len(t, lines, 500)
+ // The oldest 10 lines (0-9) were evicted; buffer starts at line 10.
+ assert.Equal(t, "line 10", lines[0])
+ assert.Equal(t, "line 509", lines[499])
+}
+
+// ── ObservePluginLog ──────────────────────────────────────────────────────────
+
+func TestObservePluginLog_NotifiesPluginEntries(t *testing.T) {
+ st := newTestStore(t)
+ for len(st.PluginEntries) > 0 {
+ <-st.PluginEntries
+ }
+
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "hello"})
+
+ select {
+ case <-st.PluginEntries:
+ default:
+ t.Fatal("PluginEntries channel was not notified after ObservePluginLog")
+ }
+}
+
+func TestObservePluginLog_ChannelNonBlocking_WhenFull(t *testing.T) {
+ st := newTestStore(t)
+ // Fill the channel to capacity.
+ for i := 0; i < cap(st.PluginEntries); i++ {
+ st.PluginEntries <- struct{}{}
+ }
+ // Must not block even when channel is full.
+ assert.NotPanics(t, func() {
+ st.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "msg"})
+ })
+}
+
+func TestObservePluginLog_ConcurrentWrites_Safe(t *testing.T) {
+ st := newTestStore(t)
+ const goroutines = 50
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func(i int) {
+ defer wg.Done()
+ st.ObservePluginLog(events.PluginLogEvent{
+ Name: "test.lua",
+ Message: fmt.Sprintf("msg %d", i),
+ })
+ }(i)
+ }
+ wg.Wait()
+
+ lines := st.PluginLogs("test.lua")
+ assert.Len(t, lines, goroutines)
+}
+
+// ── ObservePluginEnabled ──────────────────────────────────────────────────────
+
+func TestObservePluginEnabled_PersistsDisabled(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/test.lua"))
+
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ enabled, err := st.PluginEnabled("test.lua")
+ require.NoError(t, err)
+ assert.False(t, enabled)
+}
+
+func TestObservePluginEnabled_PersistsEnabled(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/test.lua"))
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+
+ enabled, err := st.PluginEnabled("test.lua")
+ require.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestObservePluginEnabled_NotifiesPluginEntries(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.RegisterPlugin("test.lua", "/test.lua"))
+
+ for len(st.PluginEntries) > 0 {
+ <-st.PluginEntries
+ }
+
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ select {
+ case <-st.PluginEntries:
+ default:
+ t.Fatal("PluginEntries channel was not notified after ObservePluginEnabled")
+ }
+}
+
+func TestObservePluginEnabled_UnknownPlugin_NoError(t *testing.T) {
+ st := newTestStore(t)
+ // Toggling a plugin not in the DB must not panic or crash.
+ assert.NotPanics(t, func() {
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "ghost.lua", Enabled: false})
+ })
+}
diff --git a/internal/store/project.go b/internal/store/project.go
new file mode 100644
index 0000000..e379f32
--- /dev/null
+++ b/internal/store/project.go
@@ -0,0 +1,245 @@
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "sync"
+
+ _ "modernc.org/sqlite"
+)
+
+type Store struct {
+ db *sql.DB
+ writeCh chan func() error
+ done chan struct{}
+
+ Updates chan Transaction
+ Intercept *InterceptGate
+ WebSocketConnections chan WebSocketConnection
+ WebSocketFrames chan WebSocketFrame
+ LootEntries chan struct{}
+ PluginEntries chan struct{}
+
+ // pluginLogs holds in-memory log lines per plugin name.
+ // Protected by pluginLogsMu — separate from the SQLite write serialiser
+ // because log appends must never block on DB I/O.
+ pluginLogsMu sync.RWMutex
+ pluginLogs map[string][]string
+
+ // scopeCache is an in-memory copy of the scope table.
+ // Populated lazily on first InScope call and invalidated on every write.
+ // Protected by scopeCacheMu — separate from the write serialiser so
+ // InScope reads never block on DB I/O.
+ scopeCacheMu sync.RWMutex
+ scopeCache []ScopeEntry
+ scopeCacheSet bool
+}
+
+func Open(path string) (*Store, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, fmt.Errorf("store: open %s: %w", path, err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
+ return nil, fmt.Errorf("store: enable WAL: %w", err)
+ }
+
+ projectStore := &Store{
+ db: db,
+ writeCh: make(chan func() error, 256),
+ done: make(chan struct{}),
+ Updates: make(chan Transaction, 256),
+ Intercept: NewInterceptGate(),
+ WebSocketConnections: make(chan WebSocketConnection, 256),
+ WebSocketFrames: make(chan WebSocketFrame, 256),
+ LootEntries: make(chan struct{}, 256),
+ PluginEntries: make(chan struct{}, 256),
+ pluginLogs: make(map[string][]string),
+ }
+ if err := projectStore.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ go projectStore.writeLoop()
+ return projectStore, nil
+}
+
+func (s *Store) Close() error {
+ close(s.done)
+ close(s.Updates)
+ close(s.WebSocketConnections)
+ close(s.WebSocketFrames)
+ close(s.LootEntries)
+ close(s.PluginEntries)
+ close(s.Intercept.queue)
+ return s.db.Close()
+}
+
+func (s *Store) write(writeFunc func() error) error {
+ errorChan := make(chan error, 1)
+ select {
+ case s.writeCh <- func() error {
+ err := writeFunc()
+ errorChan <- err
+ return err
+ }:
+ case <-s.done:
+ return fmt.Errorf("store: closed")
+ }
+ select {
+ case err := <-errorChan:
+ return err
+ case <-s.done:
+ return fmt.Errorf("store: closed")
+ }
+}
+
+func (s *Store) writeLoop() {
+ for {
+ select {
+ case fn := <-s.writeCh:
+ _ = fn()
+ case <-s.done:
+
+ for {
+ select {
+ case fn := <-s.writeCh:
+ _ = fn()
+ default:
+ return
+ }
+ }
+ }
+ }
+}
+
+func (s *Store) migrate() error {
+ _, err := s.db.Exec(schema)
+ if err != nil {
+ return fmt.Errorf("store: migrate: %w", err)
+ }
+ // Add new columns to existing tables if they don't exist.
+ // Errors are ignored — column may already exist.
+ migrations := []string{
+ `ALTER TABLE loot ADD COLUMN raw_request TEXT DEFAULT ''`,
+ `ALTER TABLE loot ADD COLUMN raw_response TEXT DEFAULT ''`,
+ `ALTER TABLE history ADD COLUMN proto TEXT NOT NULL DEFAULT 'HTTP/1.1'`,
+ `ALTER TABLE history ADD COLUMN port INTEGER NOT NULL DEFAULT 443`,
+ `ALTER TABLE repeater_tabs ADD COLUMN tab_type TEXT NOT NULL DEFAULT 'http'`,
+ `ALTER TABLE history ADD COLUMN resp_size INTEGER NOT NULL DEFAULT 0`,
+ `CREATE INDEX IF NOT EXISTS idx_history_host_port_id ON history(host, port, id DESC)`,
+ }
+ for _, migration := range migrations {
+ s.db.Exec(migration)
+ }
+ return nil
+}
+
+const schema = `
+CREATE TABLE IF NOT EXISTS meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 443,
+ method TEXT NOT NULL,
+ url TEXT NOT NULL,
+ proto TEXT NOT NULL DEFAULT 'HTTP/1.1',
+ req_headers TEXT NOT NULL,
+ req_body BLOB,
+ status_code INTEGER,
+ resp_headers TEXT,
+ resp_body BLOB,
+ resp_size INTEGER NOT NULL DEFAULT 0,
+ duration_ms INTEGER,
+ tls INTEGER DEFAULT 0,
+ in_scope INTEGER DEFAULT 1
+);
+
+CREATE INDEX IF NOT EXISTS idx_history_host ON history(host);
+CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
+CREATE INDEX IF NOT EXISTS idx_history_scope ON history(in_scope);
+CREATE INDEX IF NOT EXISTS idx_history_dedup ON history(method, host, url, status_code);
+CREATE INDEX IF NOT EXISTS idx_history_host_id ON history(host, id DESC);
+CREATE INDEX IF NOT EXISTS idx_history_scope_id ON history(in_scope, id DESC);
+
+CREATE TABLE IF NOT EXISTS repeater_tabs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL DEFAULT 'Tab',
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 80,
+ tls INTEGER DEFAULT 0,
+ raw_request TEXT NOT NULL,
+ last_response TEXT,
+ position INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS loot (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ severity TEXT NOT NULL,
+ notes TEXT,
+ history_id INTEGER REFERENCES history(id),
+ raw_request TEXT DEFAULT '',
+ raw_response TEXT DEFAULT '',
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS scope (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ host TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS intruder_config (
+ id INTEGER PRIMARY KEY,
+ delay_ms INTEGER DEFAULT 0,
+ stop_on_status INTEGER DEFAULT 0,
+ max_redirects INTEGER DEFAULT 10,
+ follow_redirects TEXT DEFAULT 'never',
+ timeout_ms INTEGER DEFAULT 30000,
+ raw_request TEXT DEFAULT '',
+ payloads TEXT DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS websocket_connections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ history_id INTEGER REFERENCES history(id),
+ host TEXT NOT NULL,
+ url TEXT NOT NULL,
+ tls INTEGER DEFAULT 0,
+ in_scope INTEGER DEFAULT 1,
+ timestamp TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_ws_connections_host ON websocket_connections(host);
+CREATE INDEX IF NOT EXISTS idx_ws_connections_scope ON websocket_connections(in_scope);
+
+CREATE TABLE IF NOT EXISTS websocket_frames (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ connection_id INTEGER NOT NULL REFERENCES websocket_connections(id) ON DELETE CASCADE,
+ timestamp TEXT NOT NULL,
+ direction INTEGER NOT NULL, -- 0 = client→server, 1 = server→client
+ opcode INTEGER NOT NULL,
+ payload BLOB
+);
+
+CREATE INDEX IF NOT EXISTS idx_ws_frames_connection ON websocket_frames(connection_id);
+
+CREATE TABLE IF NOT EXISTS plugins (
+ name TEXT PRIMARY KEY,
+ path TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1
+);
+
+CREATE TABLE IF NOT EXISTS annotations (
+ history_id INTEGER PRIMARY KEY REFERENCES history(id) ON DELETE CASCADE,
+ comment TEXT NOT NULL DEFAULT '',
+ color TEXT NOT NULL DEFAULT ''
+);
+`
diff --git a/internal/store/repeater.go b/internal/store/repeater.go
new file mode 100644
index 0000000..b433559
--- /dev/null
+++ b/internal/store/repeater.go
@@ -0,0 +1,103 @@
+package store
+
+import (
+ "fmt"
+)
+
+// RepeaterTab represents a saved repeater tab.
+type RepeaterTab struct {
+ ID int64
+ Name string
+ Host string
+ Port int
+ TLS bool
+ TabType string // "http" or "websocket"
+ RawRequest string
+ LastResponse string
+ Position int
+}
+
+// AllRepeaterTabs returns all saved repeater tabs ordered by position.
+func (s *Store) AllRepeaterTabs() ([]RepeaterTab, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, host, port, tls, COALESCE(tab_type, 'http'), raw_request, COALESCE(last_response, ''), position
+ FROM repeater_tabs
+ ORDER BY position ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query repeater tabs: %w", err)
+ }
+ defer rows.Close()
+
+ var tabs []RepeaterTab
+ for rows.Next() {
+ var tab RepeaterTab
+ var tlsFlag int
+ if err := rows.Scan(&tab.ID, &tab.Name, &tab.Host, &tab.Port, &tlsFlag, &tab.TabType, &tab.RawRequest, &tab.LastResponse, &tab.Position); err != nil {
+ return nil, fmt.Errorf("store: scan repeater tab: %w", err)
+ }
+ tab.TLS = tlsFlag == 1
+ if tab.TabType == "" {
+ tab.TabType = "http"
+ }
+ tabs = append(tabs, tab)
+ }
+ return tabs, rows.Err()
+}
+
+// SaveRepeaterTab inserts a new repeater tab and returns its ID.
+func (s *Store) SaveRepeaterTab(tab RepeaterTab) (int64, error) {
+ tabType := tab.TabType
+ if tabType == "" {
+ tabType = "http"
+ }
+ var id int64
+ err := s.write(func() error {
+ result, err := s.db.Exec(`
+ INSERT INTO repeater_tabs (name, host, port, tls, tab_type, raw_request, last_response, position)
+ VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM repeater_tabs))`,
+ tab.Name, tab.Host, tab.Port, boolToInt(tab.TLS), tabType, tab.RawRequest, tab.LastResponse,
+ )
+ if err != nil {
+ return fmt.Errorf("store: save repeater tab: %w", err)
+ }
+ id, err = result.LastInsertId()
+ return err
+ })
+ return id, err
+}
+
+// UpdateRepeaterTab updates the request and response for an existing tab.
+func (s *Store) UpdateRepeaterTab(id int64, rawRequest, lastResponse string) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`
+ UPDATE repeater_tabs SET raw_request = ?, last_response = ? WHERE id = ?`,
+ rawRequest, lastResponse, id,
+ )
+ if err != nil {
+ return fmt.Errorf("store: update repeater tab: %w", err)
+ }
+ return nil
+ })
+}
+
+// RenameRepeaterTab updates the name of a tab.
+func (s *Store) RenameRepeaterTab(id int64, name string) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`UPDATE repeater_tabs SET name = ? WHERE id = ?`, name, id)
+ if err != nil {
+ return fmt.Errorf("store: rename repeater tab: %w", err)
+ }
+ return nil
+ })
+}
+
+// DeleteRepeaterTab removes a repeater tab by ID.
+func (s *Store) DeleteRepeaterTab(id int64) error {
+ return s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM repeater_tabs WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("store: delete repeater tab: %w", err)
+ }
+ return nil
+ })
+}
diff --git a/internal/store/repeater_test.go b/internal/store/repeater_test.go
new file mode 100644
index 0000000..c85ad83
--- /dev/null
+++ b/internal/store/repeater_test.go
@@ -0,0 +1,265 @@
+package store_test
+
+import (
+ "testing"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func makeTab(name, host string, port int, tls bool) store.RepeaterTab {
+ return store.RepeaterTab{
+ Name: name,
+ Host: host,
+ Port: port,
+ TLS: tls,
+ TabType: "http",
+ RawRequest: "GET / HTTP/1.1\r\nHost: " + host + "\r\n\r\n",
+ }
+}
+
+func makeWSTab(name, url string, tls bool) store.RepeaterTab {
+ return store.RepeaterTab{
+ Name: name,
+ Host: url,
+ Port: 0,
+ TLS: tls,
+ TabType: "websocket",
+ RawRequest: url,
+ }
+}
+
+// ── HTTP tab ──────────────────────────────────────────────────────────────────
+
+func TestSaveRepeaterTab_PersistsAllFields(t *testing.T) {
+ st := newTestStore(t)
+
+ tab := store.RepeaterTab{
+ Name: "My Tab",
+ Host: "example.com",
+ Port: 443,
+ TLS: true,
+ TabType: "http",
+ RawRequest: "GET /api HTTP/1.1\r\nHost: example.com\r\n\r\n",
+ }
+ id, err := st.SaveRepeaterTab(tab)
+ require.NoError(t, err)
+ assert.Positive(t, id)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+
+ got := tabs[0]
+ assert.Equal(t, id, got.ID)
+ assert.Equal(t, "My Tab", got.Name)
+ assert.Equal(t, "example.com", got.Host)
+ assert.Equal(t, 443, got.Port)
+ assert.True(t, got.TLS)
+ assert.Equal(t, "http", got.TabType)
+ assert.Equal(t, tab.RawRequest, got.RawRequest)
+}
+
+func TestSaveRepeaterTab_TLSFalse(t *testing.T) {
+ st := newTestStore(t)
+
+ id, err := st.SaveRepeaterTab(makeTab("HTTP Tab", "example.com", 80, false))
+ require.NoError(t, err)
+ assert.Positive(t, id)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.False(t, tabs[0].TLS)
+ assert.Equal(t, "http", tabs[0].TabType)
+}
+
+func TestSaveRepeaterTab_DefaultTabType(t *testing.T) {
+ st := newTestStore(t)
+
+ // TabType omitted — should default to "http".
+ tab := store.RepeaterTab{
+ Name: "No Type",
+ Host: "example.com",
+ Port: 80,
+ RawRequest: "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
+ }
+ _, err := st.SaveRepeaterTab(tab)
+ require.NoError(t, err)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, "http", tabs[0].TabType)
+}
+
+func TestAllRepeaterTabs_OrderedByPosition(t *testing.T) {
+ st := newTestStore(t)
+
+ for _, name := range []string{"First", "Second", "Third"} {
+ _, err := st.SaveRepeaterTab(makeTab(name, "example.com", 80, false))
+ require.NoError(t, err)
+ }
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 3)
+ assert.Equal(t, "First", tabs[0].Name)
+ assert.Equal(t, "Second", tabs[1].Name)
+ assert.Equal(t, "Third", tabs[2].Name)
+}
+
+func TestAllRepeaterTabs_PositionAutoIncrements(t *testing.T) {
+ st := newTestStore(t)
+
+ _, err := st.SaveRepeaterTab(makeTab("A", "a.com", 80, false))
+ require.NoError(t, err)
+ _, err = st.SaveRepeaterTab(makeTab("B", "b.com", 80, false))
+ require.NoError(t, err)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 2)
+ assert.Less(t, tabs[0].Position, tabs[1].Position)
+}
+
+func TestUpdateRepeaterTab_UpdatesRequestAndResponse(t *testing.T) {
+ st := newTestStore(t)
+
+ id, err := st.SaveRepeaterTab(makeTab("Tab", "example.com", 80, false))
+ require.NoError(t, err)
+
+ newReq := "POST /submit HTTP/1.1\r\nHost: example.com\r\n\r\nbody"
+ newResp := "HTTP/1.1 200 OK\r\n\r\nok"
+ require.NoError(t, st.UpdateRepeaterTab(id, newReq, newResp))
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, newReq, tabs[0].RawRequest)
+ assert.Equal(t, newResp, tabs[0].LastResponse)
+}
+
+func TestUpdateRepeaterTab_NonExistentIDNoError(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.UpdateRepeaterTab(9999, "req", "resp"))
+}
+
+func TestRenameRepeaterTab(t *testing.T) {
+ st := newTestStore(t)
+
+ id, err := st.SaveRepeaterTab(makeTab("Old", "example.com", 80, false))
+ require.NoError(t, err)
+
+ require.NoError(t, st.RenameRepeaterTab(id, "New Name"))
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, "New Name", tabs[0].Name)
+}
+
+func TestDeleteRepeaterTab(t *testing.T) {
+ st := newTestStore(t)
+
+ id, err := st.SaveRepeaterTab(makeTab("Tab", "example.com", 80, false))
+ require.NoError(t, err)
+
+ require.NoError(t, st.DeleteRepeaterTab(id))
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ assert.Empty(t, tabs)
+}
+
+func TestDeleteRepeaterTab_NonExistent(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.DeleteRepeaterTab(9999))
+}
+
+func TestDeleteRepeaterTab_DoesNotAffectOtherTabs(t *testing.T) {
+ st := newTestStore(t)
+
+ idKeep, err := st.SaveRepeaterTab(makeTab("Keep", "keep.com", 80, false))
+ require.NoError(t, err)
+ idDel, err := st.SaveRepeaterTab(makeTab("Delete", "del.com", 80, false))
+ require.NoError(t, err)
+
+ require.NoError(t, st.DeleteRepeaterTab(idDel))
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, idKeep, tabs[0].ID)
+ assert.Equal(t, "Keep", tabs[0].Name)
+}
+
+// ── WebSocket tab ─────────────────────────────────────────────────────────────
+
+func TestSaveRepeaterTab_WebSocketType(t *testing.T) {
+ st := newTestStore(t)
+
+ tab := makeWSTab("WS Tab", "wss://echo.websocket.org:443/", true)
+ id, err := st.SaveRepeaterTab(tab)
+ require.NoError(t, err)
+ assert.Positive(t, id)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, "websocket", tabs[0].TabType)
+ assert.Equal(t, "wss://echo.websocket.org:443/", tabs[0].RawRequest)
+ assert.True(t, tabs[0].TLS)
+}
+
+func TestAllRepeaterTabs_MixedTypes(t *testing.T) {
+ st := newTestStore(t)
+
+ _, err := st.SaveRepeaterTab(makeTab("HTTP", "example.com", 80, false))
+ require.NoError(t, err)
+ _, err = st.SaveRepeaterTab(makeWSTab("WS", "wss://example.com/ws", true))
+ require.NoError(t, err)
+ _, err = st.SaveRepeaterTab(makeTab("HTTP2", "other.com", 443, true))
+ require.NoError(t, err)
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 3)
+ assert.Equal(t, "http", tabs[0].TabType)
+ assert.Equal(t, "websocket", tabs[1].TabType)
+ assert.Equal(t, "http", tabs[2].TabType)
+}
+
+func TestSaveRepeaterTab_WebSocketPreservesURL(t *testing.T) {
+ st := newTestStore(t)
+
+ url := "wss://api.example.com:8443/realtime?token=abc123"
+ tab := makeWSTab("API WS", url, true)
+ id, err := st.SaveRepeaterTab(tab)
+ require.NoError(t, err)
+
+ full, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, full, 1)
+ assert.Equal(t, id, full[0].ID)
+ assert.Equal(t, url, full[0].RawRequest)
+ assert.Equal(t, "websocket", full[0].TabType)
+}
+
+func TestDeleteRepeaterTab_WebSocket(t *testing.T) {
+ st := newTestStore(t)
+
+ httpID, err := st.SaveRepeaterTab(makeTab("HTTP", "example.com", 80, false))
+ require.NoError(t, err)
+ wsID, err := st.SaveRepeaterTab(makeWSTab("WS", "wss://example.com/ws", true))
+ require.NoError(t, err)
+
+ require.NoError(t, st.DeleteRepeaterTab(wsID))
+
+ tabs, err := st.AllRepeaterTabs()
+ require.NoError(t, err)
+ require.Len(t, tabs, 1)
+ assert.Equal(t, httpID, tabs[0].ID)
+ assert.Equal(t, "http", tabs[0].TabType)
+}
diff --git a/internal/store/scope.go b/internal/store/scope.go
new file mode 100644
index 0000000..8c7c4a0
--- /dev/null
+++ b/internal/store/scope.go
@@ -0,0 +1,116 @@
+package store
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+// ScopeEntry represents a single scope target.
+type ScopeEntry struct {
+ ID int64
+ Host string
+}
+
+// AllScopeEntries returns all scope entries.
+func (s *Store) AllScopeEntries() ([]ScopeEntry, error) {
+ rows, err := s.db.Query(`SELECT id, host FROM scope ORDER BY id ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query scope: %w", err)
+ }
+ defer rows.Close()
+
+ var entries []ScopeEntry
+ for rows.Next() {
+ var e ScopeEntry
+ if err := rows.Scan(&e.ID, &e.Host); err != nil {
+ return nil, fmt.Errorf("store: scan scope: %w", err)
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+// AddScopeEntry adds a new host to scope.
+func (s *Store) AddScopeEntry(host string) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`INSERT INTO scope (host) VALUES (?)`, host)
+ if err != nil {
+ return fmt.Errorf("store: add scope entry: %w", err)
+ }
+ return nil
+ })
+ if err == nil {
+ s.invalidateScopeCache()
+ }
+ return err
+}
+
+// DeleteScopeEntry removes a scope entry by ID.
+func (s *Store) DeleteScopeEntry(id int64) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM scope WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("store: delete scope entry: %w", err)
+ }
+ return nil
+ })
+ if err == nil {
+ s.invalidateScopeCache()
+ }
+ return err
+}
+
+// InScope returns true if the given host matches any scope entry.
+// example.com matches example.com and all subdomains.
+// Uses an in-memory cache populated on first call and invalidated on scope writes.
+func (s *Store) InScope(host string) bool {
+ // Strip port if present; net.SplitHostPort handles IPv6 brackets correctly.
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+
+ s.scopeCacheMu.RLock()
+ if s.scopeCacheSet {
+ entries := s.scopeCache
+ s.scopeCacheMu.RUnlock()
+ return matchesScope(entries, host)
+ }
+ s.scopeCacheMu.RUnlock()
+
+ // Cache miss — populate under write lock.
+ s.scopeCacheMu.Lock()
+ // Re-check after acquiring write lock — another goroutine may have populated it.
+ if !s.scopeCacheSet {
+ entries, err := s.AllScopeEntries()
+ if err == nil {
+ s.scopeCache = entries
+ s.scopeCacheSet = true
+ }
+ }
+ entries := s.scopeCache
+ s.scopeCacheMu.Unlock()
+
+ return matchesScope(entries, host)
+}
+
+// invalidateScopeCache clears the scope cache so the next InScope call reloads from DB.
+func (s *Store) invalidateScopeCache() {
+ s.scopeCacheMu.Lock()
+ s.scopeCache = nil
+ s.scopeCacheSet = false
+ s.scopeCacheMu.Unlock()
+}
+
+// matchesScope returns true if host matches any entry in entries.
+func matchesScope(entries []ScopeEntry, host string) bool {
+ if len(entries) == 0 {
+ return true // no scope defined, everything is in scope
+ }
+ for _, e := range entries {
+ if host == e.Host || strings.HasSuffix(host, "."+e.Host) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/store/scope_test.go b/internal/store/scope_test.go
new file mode 100644
index 0000000..b1267c8
--- /dev/null
+++ b/internal/store/scope_test.go
@@ -0,0 +1,135 @@
+package store_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAddScopeEntry_AndRetrieve(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ entries, err := st.AllScopeEntries()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "example.com", entries[0].Host)
+ assert.Positive(t, entries[0].ID)
+}
+
+func TestAddScopeEntry_Multiple(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.AddScopeEntry("example.com"))
+ require.NoError(t, st.AddScopeEntry("other.com"))
+
+ entries, err := st.AllScopeEntries()
+ require.NoError(t, err)
+ assert.Len(t, entries, 2)
+}
+
+func TestAddScopeEntry_Duplicate_Errors(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.AddScopeEntry("example.com"))
+ err := st.AddScopeEntry("example.com")
+ assert.Error(t, err, "duplicate host must be rejected by UNIQUE constraint")
+}
+
+func TestDeleteScopeEntry(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.AddScopeEntry("example.com"))
+ entries, err := st.AllScopeEntries()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+
+ require.NoError(t, st.DeleteScopeEntry(entries[0].ID))
+
+ entries, err = st.AllScopeEntries()
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestDeleteScopeEntry_NonExistent(t *testing.T) {
+ st := newTestStore(t)
+ assert.NoError(t, st.DeleteScopeEntry(9999))
+}
+
+func TestAllScopeEntries_OrderedByIDAsc(t *testing.T) {
+ st := newTestStore(t)
+
+ require.NoError(t, st.AddScopeEntry("alpha.com"))
+ require.NoError(t, st.AddScopeEntry("beta.com"))
+ require.NoError(t, st.AddScopeEntry("gamma.com"))
+
+ entries, err := st.AllScopeEntries()
+ require.NoError(t, err)
+ require.Len(t, entries, 3)
+ assert.Equal(t, "alpha.com", entries[0].Host)
+ assert.Equal(t, "beta.com", entries[1].Host)
+ assert.Equal(t, "gamma.com", entries[2].Host)
+}
+
+func TestInScope_EmptyScopeAllowsEverything(t *testing.T) {
+ st := newTestStore(t)
+ assert.True(t, st.InScope("anything.com"))
+ assert.True(t, st.InScope("other.net"))
+}
+
+func TestInScope_ExactMatch(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ assert.True(t, st.InScope("example.com"))
+}
+
+func TestInScope_SubdomainMatch(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ assert.True(t, st.InScope("sub.example.com"))
+ assert.True(t, st.InScope("deep.sub.example.com"))
+}
+
+func TestInScope_NoMatch(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ assert.False(t, st.InScope("other.com"))
+ assert.False(t, st.InScope("notexample.com"))
+ assert.False(t, st.InScope("example.com.evil.com"))
+}
+
+func TestInScope_StripsPort(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ assert.True(t, st.InScope("example.com:8080"))
+ assert.True(t, st.InScope("example.com:443"))
+}
+
+func TestInScope_AfterDeleteScopeBecomesOpen(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+
+ entries, err := st.AllScopeEntries()
+ require.NoError(t, err)
+ require.NoError(t, st.DeleteScopeEntry(entries[0].ID))
+
+ // scope is now empty — everything is in scope
+ assert.True(t, st.InScope("example.com"))
+ assert.True(t, st.InScope("completely-unrelated.com"))
+}
+
+func TestInScope_MultipleEntries(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.AddScopeEntry("example.com"))
+ require.NoError(t, st.AddScopeEntry("target.io"))
+
+ assert.True(t, st.InScope("example.com"))
+ assert.True(t, st.InScope("api.target.io"))
+ assert.False(t, st.InScope("other.com"))
+}
diff --git a/internal/store/search_test.go b/internal/store/search_test.go
new file mode 100644
index 0000000..be21b3e
--- /dev/null
+++ b/internal/store/search_test.go
@@ -0,0 +1,105 @@
+package store_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func logTxWithBodies(t *testing.T, st *store.Store, method, host, url string, status int, reqBody, respBody string) {
+ t.Helper()
+ tx := store.Transaction{
+ Timestamp: time.Now().UTC(),
+ Host: host,
+ Method: method,
+ URL: url,
+ ReqBody: []byte(reqBody),
+ RespBody: []byte(respBody),
+ StatusCode: status,
+ InScope: true,
+ }
+ require.NoError(t, st.Log(tx))
+}
+
+func TestSearchTransactions_FindsInRespBody(t *testing.T) {
+ st := newTestStore(t)
+ logTxWithBodies(t, st, "GET", "a.com", "https://a.com/", 200, "", "secret_token_here")
+ logTxWithBodies(t, st, "GET", "b.com", "https://b.com/", 200, "", "nothing special")
+
+ results, err := st.SearchTransactions("secret_token", false, true, false)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, "a.com", results[0].Host)
+}
+
+func TestSearchTransactions_FindsInReqBody(t *testing.T) {
+ st := newTestStore(t)
+ logTxWithBodies(t, st, "POST", "a.com", "https://a.com/login", 200, "password=hunter2", "ok")
+ logTxWithBodies(t, st, "GET", "b.com", "https://b.com/", 200, "", "ok")
+
+ results, err := st.SearchTransactions("hunter2", true, false, false)
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, "a.com", results[0].Host)
+}
+
+func TestSearchTransactions_EmptyTerm_ReturnsNil(t *testing.T) {
+ st := newTestStore(t)
+ logTxWithBodies(t, st, "GET", "a.com", "https://a.com/", 200, "", "data")
+
+ results, err := st.SearchTransactions("", true, true, false)
+ require.NoError(t, err)
+ assert.Nil(t, results)
+}
+
+func TestSearchTransactions_NoFieldsSelected_ReturnsNil(t *testing.T) {
+ st := newTestStore(t)
+ logTxWithBodies(t, st, "GET", "a.com", "https://a.com/", 200, "", "data")
+
+ results, err := st.SearchTransactions("data", false, false, false)
+ require.NoError(t, err)
+ assert.Nil(t, results)
+}
+
+func TestSearchTransactions_NoMatch_ReturnsEmpty(t *testing.T) {
+ st := newTestStore(t)
+ logTxWithBodies(t, st, "GET", "a.com", "https://a.com/", 200, "", "nothing here")
+
+ results, err := st.SearchTransactions("xyzzy_not_found", false, true, false)
+ require.NoError(t, err)
+ assert.Empty(t, results)
+}
+
+func TestTransactionsPage_MethodFilter(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.Log(makeTx("GET", "a.com", "https://a.com/", 200)))
+ require.NoError(t, st.Log(makeTx("POST", "a.com", "https://a.com/login", 200)))
+ require.NoError(t, st.Log(makeTx("PUT", "a.com", "https://a.com/item", 200)))
+
+ results, err := st.TransactionsPage(0, store.TransactionFilter{
+ ShowOutScope: true,
+ Methods: []string{"POST"},
+ })
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, "POST", results[0].Method)
+}
+
+func TestTransactionsPage_StatusRangeFilter(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.Log(makeTx("GET", "a.com", "https://a.com/ok", 200)))
+ require.NoError(t, st.Log(makeTx("GET", "a.com", "https://a.com/redir", 302)))
+ require.NoError(t, st.Log(makeTx("GET", "a.com", "https://a.com/err", 404)))
+
+ results, err := st.TransactionsPage(0, store.TransactionFilter{
+ ShowOutScope: true,
+ StatusMin: 400,
+ StatusMax: 499,
+ })
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, 404, results[0].StatusCode)
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..6b9d012
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,22 @@
+package store_test
+
+import (
+ "os"
+ "testing"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestStore(t *testing.T) *store.Store {
+ t.Helper()
+ f, err := os.CreateTemp("", "shiv-store-test-*.shiv")
+ require.NoError(t, err)
+ f.Close()
+ t.Cleanup(func() { os.Remove(f.Name()) })
+
+ st, err := store.Open(f.Name())
+ require.NoError(t, err)
+ t.Cleanup(func() { st.Close() })
+ return st
+}
diff --git a/internal/store/websocket.go b/internal/store/websocket.go
new file mode 100644
index 0000000..562c487
--- /dev/null
+++ b/internal/store/websocket.go
@@ -0,0 +1,198 @@
+package store
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+)
+
+const wsFrameSizeLimit = 1 << 20 // 1 MB per frame
+
+// WebSocketFrame is a single frame captured during a WebSocket session.
+type WebSocketFrame struct {
+ ID uint64
+ ConnectionID uint64 // foreign key → websocket_connections.id
+ Timestamp time.Time
+ Direction events.WebSocketDirection
+ Opcode events.WebSocketOpcode
+ Payload []byte
+}
+
+// WebSocketConnection represents the initial HTTP upgrade that started a
+// WebSocket session. It is tied to the history transaction of the upgrade
+// request so the UI can link from history to the frame list.
+type WebSocketConnection struct {
+ ID uint64
+ HistoryID uint64
+ Host string
+ URL string
+ TLS bool
+ InScope bool
+ Timestamp time.Time
+ FrameCount int // populated on read, not stored
+}
+
+// LogWebSocketConnection inserts a new WebSocket connection record and returns
+// its ID. Called once when the upgrade handshake completes.
+func (s *Store) LogWebSocketConnection(conn WebSocketConnection) (uint64, error) {
+ var id uint64
+ err := s.write(func() error {
+ res, err := s.db.Exec(`
+ INSERT INTO websocket_connections (history_id, host, url, tls, in_scope, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ conn.HistoryID,
+ conn.Host,
+ conn.URL,
+ boolToInt(conn.TLS),
+ boolToInt(conn.InScope),
+ conn.Timestamp.UTC().Format(time.RFC3339),
+ )
+ if err != nil {
+ return fmt.Errorf("store: log websocket connection: %w", err)
+ }
+ lastID, err := res.LastInsertId()
+ if err != nil {
+ return fmt.Errorf("store: websocket connection last insert id: %w", err)
+ }
+ id = uint64(lastID)
+ return nil
+ })
+ if err != nil {
+ return 0, err
+ }
+
+ // Notify UI listeners — non-blocking, same pattern as Updates.
+ conn.ID = id
+ select {
+ case s.WebSocketConnections <- conn:
+ default:
+ }
+
+ return id, nil
+}
+
+// LogWebSocketFrame appends a single frame to an existing connection.
+func (s *Store) LogWebSocketFrame(frame WebSocketFrame) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`
+ INSERT INTO websocket_frames (connection_id, timestamp, direction, opcode, payload)
+ VALUES (?, ?, ?, ?, ?)`,
+ frame.ConnectionID,
+ frame.Timestamp.UTC().Format(time.RFC3339),
+ int(frame.Direction),
+ int(frame.Opcode),
+ frame.Payload,
+ )
+ if err != nil {
+ return fmt.Errorf("store: log websocket frame: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ // Notify UI listeners — non-blocking.
+ select {
+ case s.WebSocketFrames <- frame:
+ default:
+ }
+
+ return nil
+}
+
+// AllWebSocketConnections returns all WebSocket connections ordered by id
+// descending, with frame counts.
+func (s *Store) AllWebSocketConnections() ([]WebSocketConnection, error) {
+ rows, err := s.db.Query(`
+ SELECT c.id, c.history_id, c.host, c.url, c.tls, c.in_scope, c.timestamp,
+ COUNT(f.id) as frame_count
+ FROM websocket_connections c
+ LEFT JOIN websocket_frames f ON f.connection_id = c.id
+ GROUP BY c.id
+ ORDER BY c.id DESC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query websocket connections: %w", err)
+ }
+ defer rows.Close()
+
+ var conns []WebSocketConnection
+ for rows.Next() {
+ var c WebSocketConnection
+ var ts string
+ var tlsInt, scopeInt int
+ if err := rows.Scan(&c.ID, &c.HistoryID, &c.Host, &c.URL, &tlsInt, &scopeInt, &ts, &c.FrameCount); err != nil {
+ return nil, fmt.Errorf("store: scan websocket connection: %w", err)
+ }
+ c.Timestamp, _ = time.Parse(time.RFC3339, ts)
+ c.TLS = tlsInt == 1
+ c.InScope = scopeInt == 1
+ conns = append(conns, c)
+ }
+ return conns, rows.Err()
+}
+
+// FramesForConnection returns all frames for a given connection ordered by id
+// ascending (chronological).
+func (s *Store) FramesForConnection(connectionID uint64) ([]WebSocketFrame, error) {
+ rows, err := s.db.Query(`
+ SELECT id, connection_id, timestamp, direction, opcode, payload
+ FROM websocket_frames
+ WHERE connection_id = ?
+ ORDER BY id ASC`, connectionID)
+ if err != nil {
+ return nil, fmt.Errorf("store: query websocket frames: %w", err)
+ }
+ defer rows.Close()
+
+ var frames []WebSocketFrame
+ for rows.Next() {
+ var f WebSocketFrame
+ var ts string
+ if err := rows.Scan(&f.ID, &f.ConnectionID, &ts, &f.Direction, &f.Opcode, &f.Payload); err != nil {
+ return nil, fmt.Errorf("store: scan websocket frame: %w", err)
+ }
+ f.Timestamp, _ = time.Parse(time.RFC3339, ts)
+ frames = append(frames, f)
+ }
+ return frames, rows.Err()
+}
+
+// ObserveWebSocketConnection implements events.WebSocketConnectionObserver.
+func (s *Store) ObserveWebSocketConnection(e events.WebSocketConnectionEvent) uint64 {
+ id, err := s.LogWebSocketConnection(WebSocketConnection{
+ Host: e.Host,
+ URL: e.URL,
+ TLS: e.TLS,
+ InScope: s.InScope(e.Host),
+ Timestamp: e.Timestamp,
+ })
+ if err != nil {
+ logger.Error("store: observe ws connection: %v", err)
+ return 0
+ }
+ return id
+}
+
+// ObserveWebSocketFrame implements events.WebSocketFrameObserver.
+func (s *Store) ObserveWebSocketFrame(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ if e.ConnectionID == 0 {
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }
+ payload := e.Payload
+ if len(payload) > wsFrameSizeLimit {
+ payload = payload[:wsFrameSizeLimit]
+ }
+ if err := s.LogWebSocketFrame(WebSocketFrame{
+ ConnectionID: e.ConnectionID,
+ Timestamp: e.Timestamp,
+ Direction: e.Direction,
+ Opcode: e.Opcode,
+ Payload: payload,
+ }); err != nil {
+ logger.Error("store: observe ws frame: %v", err)
+ }
+ return events.WebSocketFrameResult{Payload: e.Payload}
+}
diff --git a/internal/store/websocket_test.go b/internal/store/websocket_test.go
new file mode 100644
index 0000000..6340080
--- /dev/null
+++ b/internal/store/websocket_test.go
@@ -0,0 +1,200 @@
+package store_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLogWebSocketConnection_PersistsAllFields(t *testing.T) {
+ st := newTestStore(t)
+
+ conn := store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/chat",
+ TLS: true,
+ InScope: true,
+ Timestamp: time.Now().UTC().Truncate(time.Second),
+ }
+ id, err := st.LogWebSocketConnection(conn)
+ require.NoError(t, err)
+ assert.NotZero(t, id)
+
+ conns, err := st.AllWebSocketConnections()
+ require.NoError(t, err)
+ require.Len(t, conns, 1)
+ assert.Equal(t, id, conns[0].ID)
+ assert.Equal(t, "example.com:443", conns[0].Host)
+ assert.Equal(t, "wss://example.com:443/chat", conns[0].URL)
+ assert.True(t, conns[0].TLS)
+ assert.True(t, conns[0].InScope)
+}
+
+func TestLogWebSocketFrame_PersistsAllFields(t *testing.T) {
+ st := newTestStore(t)
+
+ connID, err := st.LogWebSocketConnection(store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/",
+ TLS: true,
+ Timestamp: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+
+ frame := store.WebSocketFrame{
+ ConnectionID: connID,
+ Timestamp: time.Now().UTC().Truncate(time.Second),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: []byte("hello world"),
+ }
+ require.NoError(t, st.LogWebSocketFrame(frame))
+
+ frames, err := st.FramesForConnection(connID)
+ require.NoError(t, err)
+ require.Len(t, frames, 1)
+ assert.Equal(t, connID, frames[0].ConnectionID)
+ assert.Equal(t, events.WebSocketClient, frames[0].Direction)
+ assert.Equal(t, events.WebSocketText, frames[0].Opcode)
+ assert.Equal(t, []byte("hello world"), frames[0].Payload)
+}
+
+func TestAllWebSocketConnections_OrderedByIDDesc(t *testing.T) {
+ st := newTestStore(t)
+
+ for _, host := range []string{"first.com:443", "second.com:443", "third.com:443"} {
+ _, err := st.LogWebSocketConnection(store.WebSocketConnection{
+ Host: host,
+ URL: "wss://" + host + "/",
+ Timestamp: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+ }
+
+ conns, err := st.AllWebSocketConnections()
+ require.NoError(t, err)
+ require.Len(t, conns, 3)
+ assert.Equal(t, "third.com:443", conns[0].Host)
+ assert.Equal(t, "second.com:443", conns[1].Host)
+ assert.Equal(t, "first.com:443", conns[2].Host)
+}
+
+func TestAllWebSocketConnections_FrameCountIsCorrect(t *testing.T) {
+ st := newTestStore(t)
+
+ connID, err := st.LogWebSocketConnection(store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/",
+ Timestamp: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+
+ for i := 0; i < 5; i++ {
+ require.NoError(t, st.LogWebSocketFrame(store.WebSocketFrame{
+ ConnectionID: connID,
+ Timestamp: time.Now().UTC(),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: []byte("msg"),
+ }))
+ }
+
+ conns, err := st.AllWebSocketConnections()
+ require.NoError(t, err)
+ require.Len(t, conns, 1)
+ assert.Equal(t, 5, conns[0].FrameCount)
+}
+
+func TestFramesForConnection_OrderedByIDAsc(t *testing.T) {
+ st := newTestStore(t)
+
+ connID, err := st.LogWebSocketConnection(store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/",
+ Timestamp: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+
+ directions := []events.WebSocketDirection{
+ events.WebSocketClient,
+ events.WebSocketServer,
+ events.WebSocketClient,
+ }
+ for _, dir := range directions {
+ require.NoError(t, st.LogWebSocketFrame(store.WebSocketFrame{
+ ConnectionID: connID,
+ Timestamp: time.Now().UTC(),
+ Direction: dir,
+ Opcode: events.WebSocketText,
+ Payload: []byte("x"),
+ }))
+ }
+
+ frames, err := st.FramesForConnection(connID)
+ require.NoError(t, err)
+ require.Len(t, frames, 3)
+ assert.Equal(t, events.WebSocketClient, frames[0].Direction)
+ assert.Equal(t, events.WebSocketServer, frames[1].Direction)
+ assert.Equal(t, events.WebSocketClient, frames[2].Direction)
+}
+
+func TestFramesForConnection_EmptyForUnknownConnection(t *testing.T) {
+ st := newTestStore(t)
+ frames, err := st.FramesForConnection(99999)
+ require.NoError(t, err)
+ assert.Empty(t, frames)
+}
+
+func TestLogWebSocketConnection_EmitsToChannel(t *testing.T) {
+ st := newTestStore(t)
+
+ conn := store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/",
+ Timestamp: time.Now().UTC(),
+ }
+ _, err := st.LogWebSocketConnection(conn)
+ require.NoError(t, err)
+
+ select {
+ case c := <-st.WebSocketConnections:
+ assert.Equal(t, "example.com:443", c.Host)
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for WebSocketConnections channel emit")
+ }
+}
+
+func TestLogWebSocketFrame_EmitsToChannel(t *testing.T) {
+ st := newTestStore(t)
+
+ connID, err := st.LogWebSocketConnection(store.WebSocketConnection{
+ Host: "example.com:443",
+ URL: "wss://example.com:443/",
+ Timestamp: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+ // Drain the connection channel.
+ <-st.WebSocketConnections
+
+ err = st.LogWebSocketFrame(store.WebSocketFrame{
+ ConnectionID: connID,
+ Timestamp: time.Now().UTC(),
+ Direction: events.WebSocketServer,
+ Opcode: events.WebSocketText,
+ Payload: []byte("streamed"),
+ })
+ require.NoError(t, err)
+
+ select {
+ case f := <-st.WebSocketFrames:
+ assert.Equal(t, connID, f.ConnectionID)
+ assert.Equal(t, events.WebSocketServer, f.Direction)
+ assert.Equal(t, []byte("streamed"), f.Payload)
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for WebSocketFrames channel emit")
+ }
+}