feat: admin endpoints to reset username sync flags
Add POST /v1/admin/users/{pubkey}/reset-username and
POST /v1/admin/users/reset-usernames to clear manual_username
and last_synced_at so nostr profile sync re-evaluates users.
Includes OpenAPI docs, audit actions, and tests.
This commit is contained in:
@@ -14,6 +14,8 @@ const (
|
|||||||
|
|
||||||
ActionUserAdded = "user.added"
|
ActionUserAdded = "user.added"
|
||||||
ActionUserUsernameChanged = "user.username_changed"
|
ActionUserUsernameChanged = "user.username_changed"
|
||||||
|
ActionUserUsernameReset = "user.username_reset"
|
||||||
|
ActionAllUsernamesReset = "users.usernames_reset"
|
||||||
ActionUserExtended = "user.extended"
|
ActionUserExtended = "user.extended"
|
||||||
ActionUserDeleted = "user.deleted"
|
ActionUserDeleted = "user.deleted"
|
||||||
ActionPaymentConfirmed = "payment.confirmed"
|
ActionPaymentConfirmed = "payment.confirmed"
|
||||||
|
|||||||
@@ -290,6 +290,48 @@ paths:
|
|||||||
'200': { description: Deleted }
|
'200': { description: Deleted }
|
||||||
'401': { description: Unauthorized }
|
'401': { description: Unauthorized }
|
||||||
'404': { description: Not found }
|
'404': { description: Not found }
|
||||||
|
/v1/admin/users/reset-usernames:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Reset all usernames (admin)
|
||||||
|
description: |
|
||||||
|
Clears `manual_username` and `last_synced_at` for every active user so
|
||||||
|
the nostr profile sync worker re-evaluates each one on its next cycle.
|
||||||
|
The stored username is left untouched until the worker overwrites it
|
||||||
|
with fresh kind:0 metadata. Idempotent.
|
||||||
|
security: [{ AdminAPIKey: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Reset count
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reset: { type: integer, description: "Number of users whose sync flags were cleared" }
|
||||||
|
'401': { description: Unauthorized }
|
||||||
|
/v1/admin/users/{pubkey}/reset-username:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Reset username for a user (admin)
|
||||||
|
description: |
|
||||||
|
Clears `manual_username` and `last_synced_at` for the given user so the
|
||||||
|
nostr profile sync worker re-evaluates their kind:0 metadata on its next
|
||||||
|
cycle. The stored username is left untouched until the worker overwrites
|
||||||
|
it. Idempotent.
|
||||||
|
security: [{ AdminAPIKey: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: pubkey
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Reset
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/User' } } }
|
||||||
|
'400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
|
||||||
|
'401': { description: Unauthorized }
|
||||||
|
'404': { description: Not found }
|
||||||
/v1/admin/users/{pubkey}/extend:
|
/v1/admin/users/{pubkey}/extend:
|
||||||
post:
|
post:
|
||||||
tags: [Admin]
|
tags: [Admin]
|
||||||
|
|||||||
@@ -144,6 +144,39 @@ func (h *AdminUsers) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
WriteJSON(w, http.StatusOK, userResponse(u))
|
WriteJSON(w, http.StatusOK, userResponse(u))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminUsers) ResetUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.Users.ResetUsername(r.Context(), hexpk)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Audit.Log(r.Context(), audit.ActionUserUsernameReset, audit.ActorAdmin, u.Pubkey, map[string]any{
|
||||||
|
"username": u.Username,
|
||||||
|
})
|
||||||
|
WriteJSON(w, http.StatusOK, userResponse(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminUsers) ResetAllUsernames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
count, err := h.Users.ResetAllUsernames(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Audit.Log(r.Context(), audit.ActionAllUsernamesReset, audit.ActorAdmin, "", map[string]any{
|
||||||
|
"reset": count,
|
||||||
|
})
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]any{"reset": count})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
|
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ func NewServer(d Deps) *http.Server {
|
|||||||
r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey))
|
r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey))
|
||||||
r.Post("/users", adminUsers.Add)
|
r.Post("/users", adminUsers.Add)
|
||||||
r.Get("/users", adminUsers.List)
|
r.Get("/users", adminUsers.List)
|
||||||
|
r.Post("/users/reset-usernames", adminUsers.ResetAllUsernames)
|
||||||
r.Put("/users/{pubkey}", adminUsers.Update)
|
r.Put("/users/{pubkey}", adminUsers.Update)
|
||||||
r.Delete("/users/{pubkey}", adminUsers.Delete)
|
r.Delete("/users/{pubkey}", adminUsers.Delete)
|
||||||
r.Post("/users/{pubkey}/extend", adminExtend.Handle)
|
r.Post("/users/{pubkey}/extend", adminExtend.Handle)
|
||||||
|
r.Post("/users/{pubkey}/reset-username", adminUsers.ResetUsername)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package http_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -353,6 +354,179 @@ func TestAdminAdd_BadInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminResetUsername(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
|
||||||
|
resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||||
|
"pubkey": testHex, "username": "alice",
|
||||||
|
"subscription_type": "yearly", "years": 1,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != 201 {
|
||||||
|
t.Fatalf("add status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body = f.admin(t, "PUT", "/v1/admin/users/"+testHex,
|
||||||
|
map[string]any{"username": "alice2"})
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("update status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var updated map[string]any
|
||||||
|
_ = json.Unmarshal(body, &updated)
|
||||||
|
if updated["manual_username"] != true {
|
||||||
|
t.Fatalf("expected manual_username=true after update, got %v", updated["manual_username"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.db.ExecContext(context.Background(),
|
||||||
|
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
|
||||||
|
"2099-01-01T00:00:00Z", testHex); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("reset status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(body, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["manual_username"] != false {
|
||||||
|
t.Errorf("expected manual_username=false after reset, got %v (body %s)", got["manual_username"], body)
|
||||||
|
}
|
||||||
|
if got["username"] != "alice2" {
|
||||||
|
t.Errorf("expected username unchanged after reset, got %v", got["username"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSynced sql.NullString
|
||||||
|
if err := f.db.QueryRow(`SELECT last_synced_at FROM users WHERE pubkey = ?`, testHex).
|
||||||
|
Scan(&lastSynced); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lastSynced.Valid && lastSynced.String != "" {
|
||||||
|
t.Errorf("expected last_synced_at to be NULL after reset, got %q", lastSynced.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("idempotent reset status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil); resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("delete status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
|
||||||
|
if resp.StatusCode != 404 {
|
||||||
|
t.Errorf("expected 404 for unknown pubkey, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = f.admin(t, "POST", "/v1/admin/users/notapubkey/reset-username", nil)
|
||||||
|
if resp.StatusCode != 400 {
|
||||||
|
t.Errorf("expected 400 for invalid pubkey, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
|
||||||
|
"user.username_reset")
|
||||||
|
var n int
|
||||||
|
if err := row.Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Errorf("expected user.username_reset audit entry, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminResetAllUsernames(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
|
||||||
|
// Two more 64-char hex strings — not necessarily valid Schnorr keys, so we
|
||||||
|
// insert them via raw SQL to bypass the admin endpoint validation.
|
||||||
|
hexB := "1f8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1d"
|
||||||
|
hexInactive := "2a8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1e"
|
||||||
|
|
||||||
|
if resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||||
|
"pubkey": testHex, "username": "alice",
|
||||||
|
"subscription_type": "yearly", "years": 1,
|
||||||
|
}); resp.StatusCode != 201 {
|
||||||
|
t.Fatalf("add A status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if resp, body := f.admin(t, "PUT", "/v1/admin/users/"+testHex,
|
||||||
|
map[string]any{"username": "alice2"}); resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("pin A status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.db.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
|
||||||
|
VALUES (?, 'bob', 'yearly', NULL, 1, 1, ?, datetime('now'))`,
|
||||||
|
hexB, "2099-01-01T00:00:00Z"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.db.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
|
||||||
|
VALUES (?, 'carol', 'yearly', NULL, 0, 1, ?, datetime('now'))`,
|
||||||
|
hexInactive, "2099-01-01T00:00:00Z"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.db.ExecContext(context.Background(),
|
||||||
|
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
|
||||||
|
"2099-01-01T00:00:00Z", testHex); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := f.admin(t, "POST", "/v1/admin/users/reset-usernames", nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("bulk reset status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(body, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cnt, _ := got["reset"].(float64); cnt != 2 {
|
||||||
|
t.Errorf("expected reset=2 (active users only), got %v (body %s)", got["reset"], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := f.db.Query(`SELECT pubkey, manual_username, last_synced_at FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var pk string
|
||||||
|
var manual int
|
||||||
|
var lastSynced sql.NullString
|
||||||
|
if err := rows.Scan(&pk, &manual, &lastSynced); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch pk {
|
||||||
|
case testHex, hexB:
|
||||||
|
if manual != 0 {
|
||||||
|
t.Errorf("%s: expected manual_username=0 after reset, got %d", pk, manual)
|
||||||
|
}
|
||||||
|
if lastSynced.Valid && lastSynced.String != "" {
|
||||||
|
t.Errorf("%s: expected last_synced_at NULL after reset, got %q", pk, lastSynced.String)
|
||||||
|
}
|
||||||
|
case hexInactive:
|
||||||
|
if manual != 1 {
|
||||||
|
t.Errorf("inactive user manual_username should be untouched, got %d", manual)
|
||||||
|
}
|
||||||
|
if !lastSynced.Valid || lastSynced.String == "" {
|
||||||
|
t.Errorf("inactive user last_synced_at should be untouched, got %v", lastSynced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
|
||||||
|
"users.usernames_reset")
|
||||||
|
var n int
|
||||||
|
if err := row.Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("expected 1 users.usernames_reset audit entry, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAPI(t *testing.T) {
|
func TestOpenAPI(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
resp, body := f.get(t, "/openapi.json")
|
resp, body := f.get(t, "/openapi.json")
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ func (r *Repo) Delete(ctx context.Context, pubkey string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetAllSyncFlags clears manual_username and last_synced_at for every active
|
||||||
|
// user so the profile sync worker re-evaluates them on its next tick. Returns
|
||||||
|
// the number of affected rows.
|
||||||
|
func (r *Repo) ResetAllSyncFlags(ctx context.Context) (int64, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `UPDATE users SET
|
||||||
|
manual_username = 0,
|
||||||
|
last_synced_at = NULL
|
||||||
|
WHERE is_active = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// SetActiveExpiry sets a user's expires_at to an absolute value and reactivates
|
// SetActiveExpiry sets a user's expires_at to an absolute value and reactivates
|
||||||
// them. Idempotent: applying the same input twice produces the same end state.
|
// them. Idempotent: applying the same input twice produces the same end state.
|
||||||
func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error {
|
func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error {
|
||||||
|
|||||||
@@ -100,6 +100,29 @@ func (s *Service) Delete(ctx context.Context, pubkey string) error {
|
|||||||
return s.repo.Delete(ctx, pubkey)
|
return s.repo.Delete(ctx, pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetUsername clears the manual_username pin and last_synced_at cooldown for
|
||||||
|
// a single user so the next profile sync cycle re-evaluates their kind:0
|
||||||
|
// metadata. The stored username is left untouched until the worker overwrites
|
||||||
|
// it. Returns the updated user.
|
||||||
|
func (s *Service) ResetUsername(ctx context.Context, pubkey string) (*User, error) {
|
||||||
|
u, err := s.repo.GetByPubkey(ctx, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.ManualUsername = false
|
||||||
|
u.LastSyncedAt = nil
|
||||||
|
if err := s.repo.Update(ctx, u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAllUsernames clears manual_username and last_synced_at for every active
|
||||||
|
// user. Returns the number of affected rows.
|
||||||
|
func (s *Service) ResetAllUsernames(ctx context.Context) (int64, error) {
|
||||||
|
return s.repo.ResetAllSyncFlags(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// computeExpiry returns *time.Time (nil for lifetime).
|
// computeExpiry returns *time.Time (nil for lifetime).
|
||||||
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
|
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
|
||||||
if sub == SubLifetime {
|
if sub == SubLifetime {
|
||||||
|
|||||||
Reference in New Issue
Block a user