feat(server/m7-user-mgmt): forgot + reset password endpoints (U3)

POST /api/auth/forgot-password and POST /api/auth/reset-password.

Forgot-password ALWAYS returns 200 with empty JSON to prevent
enumeration of registered emails. Side effect: when email matches
a user with email-on-file, generates a 32-byte hex token (24h
TTL), inserts into password_resets, and sends the reset email via
the mailer. Mailer failures are logged (not surfaced) and the
audit log carries metadata.email_match for operator visibility.

Reset-password atomically claims the token via UsePasswordReset
(:execrows; concurrent calls can't both succeed). On rows=1,
hashes the new password and writes via ChangeUserPassword.
Returns 204 on success, 400 invalid_token on stale/used/missing
tokens, 400 password_too_short for short passwords. Audits
ActionPasswordResetByEmail.

Wires the mailer.Sender into the handlers struct via Mount;
production sender (NewSMTPSender) constructed in server.Router();
tests inject FakeSender via testHandlers default. The reset
URL embedded in the email is derived from r.Host (no PublicURL
config setting in v1; self-hosted operators see their own
hostname).

Tests cover happy-path send + token-row insertion, unknown email
returns 200 with no send, mailer failure still returns 200, reset
happy path verifies bcrypt match + used_at set, already-used
token 400, expired token 400, short password 400, bogus token 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:49:30 -04:00
parent aa67a09b80
commit cbe838cbe3
7 changed files with 485 additions and 3 deletions
+6 -1
View File
@@ -18,6 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -26,7 +27,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -43,11 +44,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
api.Post("/auth/register", h.handleRegister)
api.Post("/auth/forgot-password", h.handleForgotPassword)
api.Post("/auth/reset-password", h.handleResetPassword)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
@@ -179,4 +183,5 @@ type handlers struct {
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
}
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
)
const (
resetTokenTTL = 24 * time.Hour
resetTokenSize = 32 // bytes; 64 chars hex on the wire
)
type forgotPasswordReq struct {
Email string `json:"email"`
}
// handleForgotPassword implements POST /api/auth/forgot-password.
//
// Always returns 200 with an empty JSON body so the response shape
// doesn't reveal whether the email is registered. The actual side
// effect (token + email send) only happens when the email matches a
// user with email-on-file. SMTP send failures are logged but don't
// propagate to the response.
//
// Audits ActionForgotPasswordInit with metadata.email_match so the
// operator can correlate audit log entries with successful sends
// even though the response is opaque.
func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotPasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Decode failures are also opaque — return 200 even on bad JSON.
writeJSON(w, http.StatusOK, struct{}{})
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
q := dbq.New(h.pool)
var matched bool
var auditTarget pgtype.UUID
if email != "" {
user, err := q.GetUserByEmail(r.Context(), email)
if err == nil && user.Email != nil && *user.Email != "" {
matched = true
auditTarget = user.ID
if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil {
h.logger.Warn("forgot-password: send failed",
"email", email, "err", sendErr)
// fall through; response is still 200
}
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
h.logger.Warn("forgot-password: lookup failed", "err", err)
}
}
// Audit (best-effort).
if err := audit.Write(r.Context(), h.pool, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
map[string]any{"email_match": matched}); err != nil {
h.logger.Warn("forgot-password: audit failed", "err", err)
}
writeJSON(w, http.StatusOK, struct{}{})
}
// sendResetEmail generates a token, persists it, renders + sends the
// email. Returns the underlying error (caller logs it but doesn't
// surface to the HTTP response).
func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error {
if h.mailer == nil {
return errors.New("forgot-password: no mailer configured")
}
tokenBytes := make([]byte, resetTokenSize)
if _, err := rand.Read(tokenBytes); err != nil {
return err
}
token := hex.EncodeToString(tokenBytes)
expiresAt := time.Now().Add(resetTokenTTL)
q := dbq.New(h.pool)
if err := q.CreatePasswordReset(ctx, dbq.CreatePasswordResetParams{
Token: token,
UserID: user.ID,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
}); err != nil {
return err
}
resetURL := buildResetURL(r, token)
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
Username: user.Username,
ResetURL: resetURL,
ExpiryHours: int(resetTokenTTL.Hours()),
})
if err != nil {
return err
}
if user.Email == nil || *user.Email == "" {
// Defensive — sendResetEmail is only called when email is set,
// but we re-check to avoid sending to nil.
return errors.New("forgot-password: user has no email")
}
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
}
func buildResetURL(r *http.Request, token string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
host := r.Host
if host == "" {
host = "localhost"
}
return scheme + "://" + host + "/reset-password/" + token
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
)
func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
fake := &mailer.FakeSender{}
h.mailer = fake
user := seedUser(t, pool, "forgotuser", "pw", false)
if _, err := pool.Exec(context.Background(),
"UPDATE users SET email = 'forgot@example.com' WHERE id = $1", user.ID); err != nil {
t.Fatalf("seed email: %v", err)
}
body := `{"email":"forgot@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewReader([]byte(body)))
req.Host = "minstrel.example.com"
rec := httptest.NewRecorder()
h.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if len(fake.Sent) != 1 {
t.Fatalf("Sent len = %d, want 1", len(fake.Sent))
}
got := fake.LastSent()
if got.To != "forgot@example.com" {
t.Errorf("To = %q, want forgot@example.com", got.To)
}
// Token row was inserted.
var tokenCount int
if err := pool.QueryRow(context.Background(),
"SELECT count(*) FROM password_resets WHERE user_id = $1", user.ID).Scan(&tokenCount); err != nil {
t.Fatalf("verify token: %v", err)
}
if tokenCount == 0 {
t.Errorf("no password_resets row inserted")
}
}
func TestForgotPassword_UnknownEmail_Returns200_NoSend(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
fake := &mailer.FakeSender{}
h.mailer = fake
body := `{"email":"nonexistent@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (always; no enumeration)", rec.Code)
}
if len(fake.Sent) != 0 {
t.Errorf("Sent len = %d, want 0 for unknown email", len(fake.Sent))
}
}
func TestForgotPassword_MailerFailure_StillReturns200(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
fake := &mailer.FakeSender{FailNext: errors.New("simulated SMTP failure")}
h.mailer = fake
user := seedUser(t, pool, "mailfail", "pw", false)
if _, err := pool.Exec(context.Background(),
"UPDATE users SET email = 'mailfail@example.com' WHERE id = $1", user.ID); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"email":"mailfail@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (mailer failures don't propagate)", rec.Code)
}
}
+99
View File
@@ -0,0 +1,99 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
type resetPasswordReq struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// handleResetPassword implements POST /api/auth/reset-password.
//
// Atomically claims the reset token and writes the new password hash.
// Returns:
// - 204 on successful reset
// - 400 password_too_short if new_password < 8 chars
// - 400 invalid_token if the token doesn't exist, was already used,
// or has expired
// - 500 on internal errors
func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "")
return
}
if len(req.NewPassword) < minPasswordLength {
writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars")
return
}
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "invalid_token", "")
return
}
q := dbq.New(h.pool)
// Look up the reset record so we know which user to update before
// we claim the token. We can't reverse-lookup user_id after
// UsePasswordReset (it returns rows-affected, not the row).
reset, err := q.GetPasswordReset(r.Context(), req.Token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusBadRequest, "invalid_token", "")
return
}
h.logger.Error("reset password: lookup failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
// Atomically claim the token. Returns rows-affected (1 if
// claimable, 0 if already used / expired). We do this BEFORE
// hashing the password so concurrent reset attempts can't both
// succeed.
rows, err := q.UsePasswordReset(r.Context(), req.Token)
if err != nil {
h.logger.Error("reset password: use token failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if rows == 0 {
writeErr(w, http.StatusBadRequest, "invalid_token", "")
return
}
// Hash and update. If hashing or update fails after the token
// claim, the token is "burned" but the password isn't reset —
// user has to request another. That's acceptable; bcrypt and
// UPDATE rarely fail in healthy systems.
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
h.logger.Error("reset password: hash failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{
ID: reset.UserID,
PasswordHash: string(hash),
}); err != nil {
h.logger.Error("reset password: update failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "")
return
}
if err := audit.Write(r.Context(), h.pool, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil); err != nil {
h.logger.Warn("reset password: audit failed", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
func TestResetPassword_Happy(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "resetuser", "oldpw1234", false)
// Insert a fresh token.
token := "test-reset-token-abc"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at)
VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(time.Hour),
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"token":"test-reset-token-abc","new_password":"newpassword1"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleResetPassword(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
// New password works?
var hash string
if err := pool.QueryRow(context.Background(),
"SELECT password_hash FROM users WHERE id = $1", user.ID).Scan(&hash); err != nil {
t.Fatalf("read hash: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpassword1")); err != nil {
t.Errorf("new password doesn't match: %v", err)
}
// Token marked used?
var usedAt pgtype.Timestamptz
if err := pool.QueryRow(context.Background(),
"SELECT used_at FROM password_resets WHERE token = $1", token).Scan(&usedAt); err != nil {
t.Fatalf("verify used_at: %v", err)
}
if !usedAt.Valid {
t.Errorf("used_at is NULL after reset; should be set")
}
}
func TestResetPassword_AlreadyUsed_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "alreadyused", "pw", false)
token := "test-already-used"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at, used_at)
VALUES ($1, $2, $3, now())`, token, user.ID, time.Now().Add(time.Hour),
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"token":"test-already-used","new_password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleResetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (invalid_token for already-used)", rec.Code)
}
}
func TestResetPassword_Expired_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "expiredreset", "pw", false)
token := "test-expired-token"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at)
VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(-time.Hour), // already expired
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"token":"test-expired-token","new_password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleResetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (invalid_token for expired)", rec.Code)
}
}
func TestResetPassword_PasswordTooShort_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
body := `{"token":"any-token","new_password":"abc"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleResetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestResetPassword_BogusToken_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
body := `{"token":"bogus-token","new_password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
h.handleResetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (invalid_token for bogus)", rec.Code)
}
}
+2 -1
View File
@@ -26,6 +26,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -69,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool
}
+3 -1
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
@@ -115,7 +116,8 @@ func (s *Server) Router() http.Handler {
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second