Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
9ae870e
docs(specs): admin dashboard for optional user management (sub-projec…
efiten Oct 7, 2026
e5971e8
docs(specs): admin dashboard amendments from the implementation plan
efiten Oct 7, 2026
dd3bc4d
feat(users): global audit list with keyset paging, login-row pruning,…
efiten Oct 7, 2026
ca25630
feat(users): Stats for the admin overview
efiten Oct 7, 2026
32a8a11
feat(users): audit logins, keep login rows 90 days
efiten Oct 7, 2026
3a8341d
feat(users): GET /api/admin/audit, the global audit log
efiten Oct 7, 2026
41617f9
feat(users): GET /api/admin/stats for the admin overview
efiten Oct 7, 2026
4e4758a
fix(users): write login audit rows in the background
efiten Oct 7, 2026
e3f0114
feat(admin): audit tab module
efiten Oct 7, 2026
98286a5
fix(admin): audit tab clears its message and stale rows on filter change
efiten Oct 7, 2026
a63e37d
feat(admin): overview tab module
efiten Oct 7, 2026
ed6c634
feat(admin): admin area with Overview, Users and Audit tabs
efiten Oct 7, 2026
28e53cd
test(e2e): admin overview, audit tab, legacy users link and axe
efiten Oct 7, 2026
aa59a7b
fix(admin): AA contrast for green status text on the hovered row
efiten Oct 7, 2026
996a2b4
fix(admin): audit user filter accepts only account ids from 1 up
efiten Oct 7, 2026
c922edc
fix(admin): Users tab search debounce does nothing after unmount
efiten Oct 7, 2026
a0cb653
fix(admin): one full overview refresh at a time
efiten Oct 7, 2026
bc2e8e6
docs(admin): amendment 7 names the single-connection timing residual
efiten Oct 7, 2026
59c988a
feat(admin): filter bouncing addresses on the server
efiten Oct 7, 2026
48f5e09
docs(specs): proposals and approved hashtag channels (sub-project D)
efiten Oct 7, 2026
c8a39f2
feat(channel): hashtag channel name rules
efiten Oct 7, 2026
dc22e11
feat(users): proposals table and state machine (schema v4)
efiten Oct 7, 2026
f61895d
feat(server): channel proposal routes behind userManagement.channelPr…
efiten Oct 7, 2026
0155430
fix(server): ignore a non-object channelKeys instead of failing confi…
efiten Oct 7, 2026
947ac9c
feat(server): list approved hashtag channels in /api/channels
efiten Oct 7, 2026
9467fbf
feat(ingestor): decrypt approved hashtag channels from users.db
efiten Oct 7, 2026
fe8e34a
feat(ui): channel proposals module and My proposals
efiten Oct 7, 2026
7a5b932
feat(channels): propose for everyone, approved channels in the list
efiten Oct 7, 2026
09cef5a
feat(admin): Proposals tab
efiten Oct 7, 2026
610340c
fix(admin): guard proposal decisions against double submit and stale …
efiten Oct 7, 2026
57a32d2
test(e2e): channel proposals flow; docs and CI config
efiten Oct 7, 2026
71e6238
fix(packets): escape the channel name in the packet detail message line
efiten Oct 7, 2026
d9566c2
fix(channel): refuse invisible fillers and non-ASCII spaces in hashta…
efiten Oct 7, 2026
9770a0f
fix(users): log the absolute users.db path in the server and the inge…
efiten Oct 7, 2026
58bb569
fix(admin): hide Proposed by and Decision on narrow screens in Proposals
efiten Oct 7, 2026
161dd84
fix(admin): let the proposal note input shrink with its cell on narro…
efiten Oct 7, 2026
e92d025
docs(proposals): full 409 text, absolute log paths, tightened name rules
efiten Oct 7, 2026
93e04b9
docs(proposals): ZWNJ and tag characters stay refused in channel names
efiten Oct 7, 2026
9f164df
Merge remote-tracking branch 'upstream/master' into feat/admin-dashboard
efiten Oct 8, 2026
258deb3
Merge branch 'feat/admin-dashboard' into feat/channel-proposals
efiten Oct 8, 2026
9cc0be9
Merge upstream/master (#2138 squash of the part C branch) into feat/c…
efiten Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,8 @@ jobs:
"dbPath": "/tmp/cs-um/users.db",
"adminEmails": ["admin@e2e.test"],
"publicBaseUrl": "http://localhost:13582",
"mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" }
"mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" },
"channelProposals": { "enabled": true }
}
}
JSON
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in
`TestUsersOpenIsTheOnlyServerWritePath` pins the one call site. Account data
never goes into the analyzer DB, and measurement writes never go through
`internal/users`.
- **The ingestor reads `users.db`, never writes it.** With
`userManagement.channelProposals.enabled` the ingestor opens `users.db`
read-only (`mode=ro`, raw SQL, no `internal/users` import) once a minute
for the approved hashtag channel names. `TestChannelKeySetIsReadOnly` pins
the read-only open.
- Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that
`PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT
methods on the server's `*DB`. If you need a new write, add it to
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ COPY internal/perfio/ ../../internal/perfio/
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
COPY internal/lora/ ../../internal/lora/
COPY internal/users/ ../../internal/users/
COPY internal/channel/ ../../internal/channel/
COPY internal/mailer/ ../../internal/mailer/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
Expand All @@ -83,6 +84,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# Build ingestor
WORKDIR /build/ingestor
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
COPY internal/channel/ ../../internal/channel/
COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
Expand Down
154 changes: 154 additions & 0 deletions cmd/ingestor/approved_channels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package main

import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync/atomic"
"time"

"github.com/meshcore-analyzer/channel"
)

// Approved hashtag channels (docs/specs/2026-10-07-channel-proposals-design.md).
// The server owns users.db; the ingestor only reads it, read-only, with raw
// SQL, once per approvedChannelsRefresh, and never writes it (AGENTS.md,
// read/write separation).

const approvedChannelsRefresh = time.Minute

// approvedChannelsQuery is pinned in internal/users/proposals_test.go
// (ingestorApprovedQuery), which checks it against ApprovedSubjects.
const approvedChannelsQuery = `SELECT subject FROM proposals WHERE kind = 'hashtag_channel' AND status = 'approved' ORDER BY decided_at, id LIMIT ?`

var errUsersDBMissing = errors.New("users.db not found")

// channelKeySet hands each message the current channel key map. Snapshots
// are never mutated after they are stored, so decoders read them without a
// lock. refresh is called from one goroutine only (startup, then the ticker).
type channelKeySet struct {
configured map[string]string
path string
max int
cur atomic.Pointer[map[string]string]

db *sql.DB // opened lazily, read-only
failing bool // the last read failed; logged once per failure streak
approved int // approved keys in the current snapshot
}

// newChannelKeySet starts with the configured keys (the very map
// loadChannelKeys built, so the feature off changes nothing).
func newChannelKeySet(configured map[string]string, usersDBPath string, max int) *channelKeySet {
s := &channelKeySet{configured: configured, path: usersDBPath, max: max}
s.cur.Store(&configured)
return s
}

// Snapshot returns the key map to decode one message with.
func (s *channelKeySet) Snapshot() map[string]string { return *s.cur.Load() }

// refresh re-reads the approved names and swaps in configured + approved.
// A failed read keeps the current snapshot: only a successful read changes
// the set, so a locked or missing users.db never drops a key.
func (s *channelKeySet) refresh() {
names, err := s.readApproved()
if err != nil {
if !s.failing {
log.Printf("[channels] approved channels unavailable (%v); keeping the %d keys in force", err, len(s.Snapshot()))
s.failing = true
}
return
}
if s.failing {
log.Printf("[channels] approved channels readable again from %s", s.path)
s.failing = false
}
next, added := mergeApprovedKeys(s.configured, names)
if added != s.approved {
log.Printf("[channels] %d approved hashtag channel(s) in force", added)
}
s.approved = added
s.cur.Store(&next)
}

func (s *channelKeySet) readApproved() ([]string, error) {
if s.db == nil {
if _, err := os.Stat(s.path); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w at %s", errUsersDBMissing, s.path)
}
return nil, err
}
db, err := sql.Open("sqlite3", usersDBReadOnlyDSN(s.path))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s.db = db
}
rows, err := s.db.Query(approvedChannelsQuery, s.max)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}

// usersDBReadOnlyDSN opens users.db read-only: this process must never
// take a write lock on the server's file.
func usersDBReadOnlyDSN(path string) string {
return "file:" + path + "?mode=ro&_busy_timeout=5000"
}

// mergeApprovedKeys returns a new map: the configured keys plus a derived key
// per approved name that passes channel.ValidateHashtagName unchanged and is
// not configured already (a configured name always wins). The second result
// counts the approved keys added.
func mergeApprovedKeys(configured map[string]string, names []string) (map[string]string, int) {
next := make(map[string]string, len(configured)+len(names))
for k, v := range configured {
next[k] = v
}
added := 0
for _, raw := range names {
name, err := channel.ValidateHashtagName(raw)
if err != nil || name != raw {
continue
}
if _, ok := next[name]; ok {
continue
}
next[name] = deriveHashtagChannelKey(name)
added++
}
return next, added
}

// Close closes the read-only handle, if any.
func (s *channelKeySet) Close() {
if s.db != nil {
s.db.Close()
}
}

// logApprovedChannelsSource logs the users.db file the approved channels are
// read from, resolved to an absolute path so a relative one shows which file
// it means; the path as given when it cannot be resolved.
func logApprovedChannelsSource(path string) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
log.Printf("[proposals] reading approved channels from %s", path)
}
Loading
Loading