// Command seed-platform-admin upserts a platform admin with a tenant company // (membership admin + Free plan), matching self-serve register. Without a // company, login leaves session company empty and every /api/* tenant call // returns 400 "company not selected". // // Usage: // // npm run seed:platform-admin -- --email you@example.com --password '…' // npm run seed:platform-admin -- --email you@example.com --promote-only // npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops' // // Credentials are CLI-only (never from .env). DATABASE_URL may come from the // shell or monorepo-root .env via config.LoadDotEnv. Always requires -confirm. package main import ( "context" "errors" "flag" "fmt" "log" "os" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) func main() { config.LoadDotEnv() postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") email := flag.String("email", "", "Platform admin email (required; do not put in .env)") password := flag.String("password", "", "Password (min 8; ignored with -promote-only; do not put in .env)") name := flag.String("name", "", "Display name (defaults to email local-part)") company := flag.String("company", "", "Company/workspace name (default: \" workspace\")") noCompany := flag.Bool("no-company", false, "Skip company creation (staff-only; dashboard tenant APIs will 400)") promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password") confirm := flag.Bool("confirm", false, "Required: acknowledge privilege + optional company writes") flag.Parse() opts, err := parseOptions(*postgresURL, *email, *password, *name, *company, *promoteOnly, *noCompany, *confirm, os.Getenv("APP_ENV")) if err != nil { log.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() pg, err := pgxpool.New(ctx, opts.PostgresURL) if err != nil { log.Fatalf("postgres: %v", err) } defer pg.Close() userID, created, err := upsertPlatformAdmin(ctx, pg, opts) if err != nil { log.Fatal(err) } action := "updated" if created { action = "created" } fmt.Printf("platform admin %s\n", action) fmt.Printf(" id: %s\n", userID) fmt.Printf(" email: %s\n", opts.Email) fmt.Printf(" is_platform_admin: true\n") fmt.Printf(" staff_role: %s\n", auth.StaffRoleAdmin) if opts.PromoteOnly { fmt.Println(" password: unchanged (-promote-only)") } else { fmt.Println(" password: set (argon2id)") } if opts.NoCompany { fmt.Println(" company: skipped (-no-company)") fmt.Println() fmt.Println("WARNING: without a company, dashboard /api/* calls return 400 company not selected.") return } companyID, companyName, companyCreated, err := ensureAdminCompany(ctx, pg, userID, opts.CompanyName) if err != nil { log.Fatal(err) } billingSvc := &billing.Service{Pool: pg} if err := billingSvc.ProvisionFreePlan(ctx, companyID); err != nil { log.Fatalf("provision Free plan: %v", err) } companyAction := "existing" if companyCreated { companyAction = "created" } fmt.Printf(" company: %s (%s) [%s]\n", companyName, companyID, companyAction) fmt.Println(" plan: Free (provisioned)") fmt.Println() fmt.Println("Sign in on the web app (company is selected on login). Platform panel: /admin.") } type options struct { PostgresURL string Email string Password string Name string CompanyName string PromoteOnly bool NoCompany bool } func parseOptions(postgresURL, email, password, name, company string, promoteOnly, noCompany, confirm bool, appEnv string) (options, error) { if !confirm { return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)") } pg := strings.TrimSpace(postgresURL) if pg == "" { return options{}, fmt.Errorf("-postgres / DATABASE_URL is required (export it, pass -postgres, or put DATABASE_URL in monorepo-root .env — the API loads .env, your shell may not)") } emailNorm := strings.ToLower(strings.TrimSpace(email)) if emailNorm == "" || !strings.Contains(emailNorm, "@") { return options{}, fmt.Errorf("-email is required (pass on the CLI; do not store admin credentials in .env)") } if err := rejectLocalDemoAccount(emailNorm, appEnv); err != nil { return options{}, err } display := strings.TrimSpace(name) if display == "" { display = emailNorm if i := strings.IndexByte(display, '@'); i > 0 { display = display[:i] } } companyName := strings.TrimSpace(company) if !noCompany && companyName == "" { companyName = display + " workspace" } opts := options{ PostgresURL: pg, Email: emailNorm, Name: display, CompanyName: companyName, PromoteOnly: promoteOnly, NoCompany: noCompany, } if promoteOnly { return opts, nil } pass := password // keep as provided (do not trim interior spaces) if len(pass) < 8 { return options{}, fmt.Errorf("-password must be at least 8 characters (pass on the CLI; do not store in .env)") } if err := rejectDemoPassword(pass, appEnv); err != nil { return options{}, err } opts.Password = pass return opts, nil } func rejectLocalDemoAccount(email, appEnv string) error { if !isProductionEnv(appEnv) { return nil } if strings.HasSuffix(email, ".local") || strings.HasSuffix(email, "@descrybe.test") { return fmt.Errorf("refusing demo/local emails in production APP_ENV=%q", strings.TrimSpace(appEnv)) } return nil } func rejectDemoPassword(password, appEnv string) error { if !isProductionEnv(appEnv) { return nil } if password == "DemoPass123!" { return fmt.Errorf("refusing seed-demo password in production") } return nil } func isProductionEnv(appEnv string) bool { switch strings.ToLower(strings.TrimSpace(appEnv)) { case "production", "prod": return true default: return false } } func upsertPlatformAdmin(ctx context.Context, pg *pgxpool.Pool, opts options) (uuid.UUID, bool, error) { if opts.PromoteOnly { var userID uuid.UUID err := pg.QueryRow(ctx, ` UPDATE users SET is_platform_admin = true, staff_role = $2, is_active = true, updated_at = now() WHERE email = $1 RETURNING id`, opts.Email, auth.StaffRoleAdmin).Scan(&userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, false, fmt.Errorf("user %q not found (-promote-only requires an existing account)", opts.Email) } return uuid.Nil, false, fmt.Errorf("promote: %w", err) } return userID, false, nil } hash, err := auth.HashPassword(opts.Password) if err != nil { return uuid.Nil, false, fmt.Errorf("hash password: %w", err) } tx, err := pg.Begin(ctx) if err != nil { return uuid.Nil, false, fmt.Errorf("begin: %w", err) } defer tx.Rollback(ctx) var existed bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, opts.Email).Scan(&existed) if err != nil { return uuid.Nil, false, fmt.Errorf("lookup: %w", err) } var userID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO users ( email, name, password_hash, must_set_password, is_platform_admin, staff_role, is_active, updated_at ) VALUES ($1, $2, $3, false, true, $4, true, now()) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, password_hash = EXCLUDED.password_hash, must_set_password = false, is_platform_admin = true, staff_role = EXCLUDED.staff_role, is_active = true, updated_at = now() RETURNING id`, opts.Email, opts.Name, hash, auth.StaffRoleAdmin).Scan(&userID) if err != nil { return uuid.Nil, false, fmt.Errorf("upsert user: %w", err) } if err := tx.Commit(ctx); err != nil { return uuid.Nil, false, fmt.Errorf("commit: %w", err) } return userID, !existed, nil } // ensureAdminCompany attaches the user to a company as membership admin. // If they already have any active membership, reuses the first company (idempotent repair). func ensureAdminCompany(ctx context.Context, pg *pgxpool.Pool, userID uuid.UUID, companyName string) (uuid.UUID, string, bool, error) { var existingID uuid.UUID var existingName string err := pg.QueryRow(ctx, ` SELECT c.id, c.name FROM memberships m JOIN companies c ON c.id = m.company_id WHERE m.user_id = $1 AND m.status = 'active' ORDER BY m.created_at NULLS LAST, c.name LIMIT 1`, userID).Scan(&existingID, &existingName) if err == nil { _, _ = pg.Exec(ctx, ` UPDATE memberships SET role = 'admin', status = 'active', updated_at = now() WHERE user_id = $1 AND company_id = $2`, userID, existingID) return existingID, existingName, false, nil } if !errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, "", false, fmt.Errorf("lookup memberships: %w", err) } tx, err := pg.Begin(ctx) if err != nil { return uuid.Nil, "", false, fmt.Errorf("begin company: %w", err) } defer tx.Rollback(ctx) var companyID uuid.UUID err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, companyName).Scan(&companyID) if err != nil { return uuid.Nil, "", false, fmt.Errorf("insert company: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, 'admin', 'active')`, companyID, userID) if err != nil { return uuid.Nil, "", false, fmt.Errorf("insert membership: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO company_settings (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID) if err != nil { return uuid.Nil, "", false, fmt.Errorf("company_settings: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID) if err != nil { return uuid.Nil, "", false, fmt.Errorf("credit_balances: %w", err) } if err := tx.Commit(ctx); err != nil { return uuid.Nil, "", false, fmt.Errorf("commit company: %w", err) } return companyID, companyName, true, nil }