2026-08-13 23:40:39 +02:00
// 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".
2026-08-13 21:11:09 +02:00
//
// Usage:
//
2026-08-13 23:40:39 +02:00
// 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'
2026-08-13 21:11:09 +02:00
//
2026-08-13 23:40:39 +02:00
// Credentials are CLI-only (never from .env). DATABASE_URL may come from the
// shell or monorepo-root .env via config.LoadDotEnv. Always requires -confirm.
2026-08-13 21:11:09 +02:00
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
2026-08-13 23:40:39 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
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)" )
2026-08-13 23:40:39 +02:00
company := flag . String ( "company" , "" , "Company/workspace name (default: \"<name> workspace\")" )
noCompany := flag . Bool ( "no-company" , false , "Skip company creation (staff-only; dashboard tenant APIs will 400)" )
2026-08-13 21:11:09 +02:00
promoteOnly := flag . Bool ( "promote-only" , false , "Grant platform admin on an existing user; do not set password" )
2026-08-13 23:40:39 +02:00
confirm := flag . Bool ( "confirm" , false , "Required: acknowledge privilege + optional company writes" )
2026-08-13 21:11:09 +02:00
flag . Parse ()
2026-08-13 23:40:39 +02:00
opts , err := parseOptions ( * postgresURL , * email , * password , * name , * company , * promoteOnly , * noCompany , * confirm , os . Getenv ( "APP_ENV" ))
2026-08-13 21:11:09 +02:00
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)" )
}
2026-08-13 23:40:39 +02:00
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 }
2026-08-13 23:43:53 +02:00
planNote , err := ensureFreePlan ( ctx , pg , billingSvc , companyID )
if err != nil {
2026-08-13 23:40:39 +02:00
log . Fatalf ( "provision Free plan: %v" , err )
}
companyAction := "existing"
if companyCreated {
companyAction = "created"
}
fmt . Printf ( " company: %s (%s) [%s]\n" , companyName , companyID , companyAction )
2026-08-13 23:43:53 +02:00
fmt . Printf ( " plan: %s\n" , planNote )
2026-08-13 21:11:09 +02:00
fmt . Println ()
2026-08-13 23:40:39 +02:00
fmt . Println ( "Sign in on the web app (company is selected on login). Platform panel: /admin." )
2026-08-13 21:11:09 +02:00
}
type options struct {
PostgresURL string
Email string
Password string
Name string
2026-08-13 23:40:39 +02:00
CompanyName string
2026-08-13 21:11:09 +02:00
PromoteOnly bool
2026-08-13 23:40:39 +02:00
NoCompany bool
2026-08-13 21:11:09 +02:00
}
2026-08-13 23:40:39 +02:00
func parseOptions ( postgresURL , email , password , name , company string , promoteOnly , noCompany , confirm bool , appEnv string ) ( options , error ) {
2026-08-13 21:11:09 +02:00
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 ]
}
}
2026-08-13 23:40:39 +02:00
companyName := strings . TrimSpace ( company )
if ! noCompany && companyName == "" {
companyName = display + " workspace"
}
2026-08-13 21:11:09 +02:00
opts := options {
PostgresURL : pg ,
Email : emailNorm ,
Name : display ,
2026-08-13 23:40:39 +02:00
CompanyName : companyName ,
2026-08-13 21:11:09 +02:00
PromoteOnly : promoteOnly ,
2026-08-13 23:40:39 +02:00
NoCompany : noCompany ,
2026-08-13 21:11:09 +02:00
}
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
}
2026-08-13 23:40:39 +02:00
// 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
}
2026-08-13 23:43:53 +02:00
// ensureFreePlan assigns Free when missing. Syncs company_plans id sequence first —
// cutover/import DBs often leave BIGSERIAL behind MAX(id), which surfaces as
// duplicate key on company_plans_pkey during INSERT.
func ensureFreePlan ( ctx context . Context , pg * pgxpool . Pool , billingSvc * billing . Service , companyID uuid . UUID ) ( string , error ) {
if err := billingSvc . EnsureDefaultPlans ( ctx ); err != nil {
return "" , err
}
if _ , err := pg . Exec ( ctx , `
SELECT setval(
pg_get_serial_sequence('company_plans', 'id'),
GREATEST(1, (SELECT COALESCE(MAX(id), 1) FROM company_plans))
)` ); err != nil {
return "" , fmt . Errorf ( "sync company_plans sequence: %w" , err )
}
assigned , err := billingSvc . AssignPlanByNameIfMissing ( ctx , companyID , "Free" )
if err != nil {
// Race / still-stuck sequence: if an active plan exists, treat as OK.
if has , hasErr := billingSvc . HasActivePlan ( ctx , companyID ); hasErr == nil && has {
return "already active (assign skipped after error)" , nil
}
return "" , err
}
if assigned {
return "Free (assigned)" , nil
}
return "already active (unchanged)" , nil
}