2026-08-13 21:11:09 +02:00
// Command seed-platform-admin upserts the first (or additional) platform admin
// for greenfield / production deploys where no legacy admin_users cutover ran
// and seed-demo must not be used.
//
// Usage:
//
// cd apps/api
// go run ./cmd/seed-platform-admin \
// -postgres "$env:DATABASE_URL" \
// -email you@example.com \
// -password 'choose-a-strong-password' \
// -confirm
//
2026-08-13 21:38:42 +02:00
// Env aliases: none for credentials — pass -email / -password (or use
// `npm run seed:platform-admin -- --email … --password …`). DATABASE_URL may
// come from the shell or monorepo-root .env via config.LoadDotEnv.
2026-08-13 21:11:09 +02:00
//
// -promote-only grants admin on an existing user without changing the password
// (-password is ignored). Always requires -confirm.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
2026-08-13 21:38:42 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
2026-08-13 21:11:09 +02:00
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func main () {
2026-08-13 21:38:42 +02:00
config . LoadDotEnv ()
2026-08-13 21:11:09 +02:00
postgresURL := flag . String ( "postgres" , os . Getenv ( "DATABASE_URL" ), "Postgres URL" )
2026-08-13 21:38:42 +02:00
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)" )
2026-08-13 21:11:09 +02:00
name := flag . String ( "name" , "" , "Display name (defaults to email local-part)" )
promoteOnly := flag . Bool ( "promote-only" , false , "Grant platform admin on an existing user; do not set password" )
confirm := flag . Bool ( "confirm" , false , "Required: acknowledge this writes is_platform_admin + staff_role=admin" )
flag . Parse ()
opts , err := parseOptions ( * postgresURL , * email , * password , * name , * promoteOnly , * 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)" )
}
fmt . Println ()
fmt . Println ( "Sign in on the web app, then open /admin." )
}
type options struct {
PostgresURL string
Email string
Password string
Name string
PromoteOnly bool
}
func parseOptions ( postgresURL , email , password , name string , promoteOnly , 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 == "" {
2026-08-13 21:38:42 +02:00
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)" )
2026-08-13 21:11:09 +02:00
}
emailNorm := strings . ToLower ( strings . TrimSpace ( email ))
if emailNorm == "" || ! strings . Contains ( emailNorm , "@" ) {
2026-08-13 21:38:42 +02:00
return options {}, fmt . Errorf ( "-email is required (pass on the CLI; do not store admin credentials in .env)" )
2026-08-13 21:11:09 +02:00
}
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 ]
}
}
opts := options {
PostgresURL : pg ,
Email : emailNorm ,
Name : display ,
PromoteOnly : promoteOnly ,
}
if promoteOnly {
return opts , nil
}
pass := password // keep as provided (do not trim interior spaces)
if len ( pass ) < 8 {
2026-08-13 21:38:42 +02:00
return options {}, fmt . Errorf ( "-password must be at least 8 characters (pass on the CLI; do not store in .env)" )
2026-08-13 21:11:09 +02:00
}
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
}