@@ -779,6 +779,73 @@ func backfillEmailVerified(ctx context.Context, sqlDB *sqlx.DB, dbType string) {
779779 _ , _ = sqlDB .ExecContext (ctx , fmt .Sprintf (`INSERT INTO %s (name) VALUES ($1)` , smTable ), migrationName )
780780}
781781
782+ // backfillUserAPIKeyHashes generates a fresh master API key hash for any Users row
783+ // whose apiKeyHash is NULL. This happens for Sequelize-era accounts where the
784+ // plaintext apiKey column was dropped before its SHA-256 hash was stored.
785+ // Without apiKeyHash, WebSocket connection-token validation always fails because
786+ // the user lookup returns an empty identifier.
787+ //
788+ // The generated key is random — the user never knew it and doesn't need to. It
789+ // exists only so the relay can use apiKeyHash as a stable per-account identifier
790+ // in ClientManager and Redis. If the user later needs to use their master API key
791+ // directly, they can regenerate it from the dashboard (which shows it once).
792+ func backfillUserAPIKeyHashes (ctx context.Context , sqlDB * sqlx.DB , dbType string ) {
793+ const migrationName = "backfill_user_api_key_hashes_v3_1"
794+ smTable := "SchemaMigrations"
795+ usersTable := "Users"
796+ if dbType != "sqlite" {
797+ smTable = `"SchemaMigrations"`
798+ usersTable = `"Users"`
799+ }
800+
801+ var count int
802+ _ = sqlDB .GetContext (ctx , & count , fmt .Sprintf (`SELECT COUNT(*) FROM %s WHERE name = $1` , smTable ), migrationName )
803+ if count > 0 {
804+ return
805+ }
806+
807+ // Find all users with no apiKeyHash.
808+ rows , err := sqlDB .QueryContext (ctx , fmt .Sprintf (`SELECT id FROM %s WHERE "apiKeyHash" IS NULL OR "apiKeyHash" = ''` , usersTable ))
809+ if err != nil {
810+ log .Warn ().Err (err ).Msg ("backfillUserAPIKeyHashes: failed to query users" )
811+ return
812+ }
813+ defer rows .Close ()
814+
815+ var ids []int64
816+ for rows .Next () {
817+ var id int64
818+ if err := rows .Scan (& id ); err == nil {
819+ ids = append (ids , id )
820+ }
821+ }
822+ _ = rows .Close ()
823+
824+ for _ , id := range ids {
825+ // GenerateAPIKey returns 64 random hex chars (32 bytes via crypto/rand).
826+ rawKey , err := model .GenerateAPIKey ()
827+ if err != nil {
828+ log .Warn ().Err (err ).Int64 ("userId" , id ).Msg ("backfillUserAPIKeyHashes: failed to generate key" )
829+ continue
830+ }
831+ sum := sha256 .Sum256 ([]byte (rawKey ))
832+ hash := hex .EncodeToString (sum [:])
833+ if _ , err := sqlDB .ExecContext (ctx ,
834+ fmt .Sprintf (`UPDATE %s SET "apiKeyHash" = $1, "apiKeyRotationRequired" = TRUE WHERE id = $2` , usersTable ),
835+ hash , id ,
836+ ); err != nil {
837+ log .Warn ().Err (err ).Int64 ("userId" , id ).Msg ("backfillUserAPIKeyHashes: failed to update user" )
838+ } else {
839+ log .Info ().Int64 ("userId" , id ).Msg ("backfillUserAPIKeyHashes: generated apiKeyHash for user" )
840+ }
841+ }
842+
843+ if len (ids ) > 0 {
844+ log .Info ().Int ("count" , len (ids )).Msg ("backfillUserAPIKeyHashes: populated missing apiKeyHash values" )
845+ }
846+ _ , _ = sqlDB .ExecContext (ctx , fmt .Sprintf (`INSERT INTO %s (name) VALUES ($1)` , smTable ), migrationName )
847+ }
848+
782849// addWorldIdUniqueConstraint is the v3.0 migration that:
783850// 1. Deletes all KnownClients rows with NULL worldId (unknown-world orphans from v2.x)
784851// 2. Creates a UNIQUE index on (userId, worldId) so each world can only have one row per user
@@ -1143,6 +1210,7 @@ func (db *DB) migratePostgres(ctx context.Context) error {
11431210 `ALTER TABLE "ConnectionTokens" ADD COLUMN IF NOT EXISTS "remoteRequestsPerHour" INTEGER DEFAULT 0` ,
11441211 `ALTER TABLE "ConnectionTokens" ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'dashboard'` ,
11451212 `ALTER TABLE "ConnectionTokens" ADD COLUMN IF NOT EXISTS "clientId" VARCHAR(255) DEFAULT ''` ,
1213+ `ALTER TABLE "ConnectionTokens" ADD COLUMN IF NOT EXISTS "lastUsedAt" TIMESTAMPTZ` ,
11461214 // Pairing code can be bound to an existing clientId for "add browser" flows
11471215 `ALTER TABLE "PairingCodes" ADD COLUMN IF NOT EXISTS "clientId" VARCHAR(255)` ,
11481216 `ALTER TABLE "PairingCodes" ADD COLUMN IF NOT EXISTS "allowedTargetClients" TEXT DEFAULT ''` ,
@@ -1413,6 +1481,10 @@ func (db *DB) migratePostgres(ctx context.Context) error {
14131481 // since email verification was introduced after these accounts were created.
14141482 backfillEmailVerified (ctx , db .sqlDB , "postgres" )
14151483
1484+ // v3.1: generate apiKeyHash for Sequelize-era accounts that had their
1485+ // plaintext apiKey column dropped before the hash was computed.
1486+ backfillUserAPIKeyHashes (ctx , db .sqlDB , "postgres" )
1487+
14161488 return nil
14171489}
14181490
0 commit comments