signup codes: admin-issued invite codes + site /join redemption

While account creation is disabled, the only way in is now an
admin-issued single-use signup code (Discord-invite style), redeemed on
the public site's /join page.

Because the 2020 watch login is device-id-bound with no code field, the
flow captures it server-side instead: a new device refused at the
signups-disabled gate (PlatformLogin / account create) is stashed in a
PendingDevices table keyed by the real client IP (CF-Connecting-IP aware).
The /join page then shows the player the device their own client just
reported, they enter their code + username, and the account is minted
bound to that device — so the next launch logs straight in via the
existing device-id path. No watch changes.

- SignupCodeEntity (code, descriptor, optional expiry, redeemer) +
  PendingDeviceEntity; idempotent CREATE TABLE in DatabaseBootstrap so
  both providers pick them up without a new migration.
- SignupCodeService: generate / list / revoke / redeem + pending-device
  capture, single-use + expiry + revoke validation.
- Admin: GET/POST signup-codes + revoke endpoints and a Signup codes
  admin-ui page (generate with descriptor/expiry, status table, revoke).
- Public: anonymous /api/site/v1/join pending-devices + redeem, and a
  /join site page (+ header link).
- README documents the flow + the post-Initial table-create approach.
This commit is contained in:
Alexa
2026-05-31 20:00:35 +03:00
parent 18989fa6ec
commit 3480ca21fa
18 changed files with 830 additions and 0 deletions
@@ -93,6 +93,14 @@ public static class DatabaseBootstrap
await conn.CloseAsync();
}
// Signup-code + pending-device tables. These post-date the
// consolidated Initial migration, and the Postgres path is
// EnsureCreated-only (no migration replay), so neither provider
// gets them automatically on an existing DB. Create them with
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
// IF NOT EXISTS no-ops when EnsureCreated already built them).
await EnsureSignupCodeTablesAsync(db);
// Coach system account at Player.Id=1. The RR-Original room seeder
// below assigns CreatorPlayerId=1 to every canonical room
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
@@ -157,6 +165,77 @@ public static class DatabaseBootstrap
Controllers.Health.HealthController.MigrationsComplete = true;
}
/// <summary>Idempotently create the SignupCodes + PendingDevices
/// tables on whichever provider is in use. Safe to run every boot:
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
/// created here or by a fresh-DB EnsureCreated).</summary>
private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
{
var pg = db.Database.IsNpgsql();
var statements = pg
? new[]
{
"""
CREATE TABLE IF NOT EXISTS "SignupCodes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Code" text NOT NULL,
"Descriptor" text NOT NULL DEFAULT '',
"CreatedByPlayerId" bigint NOT NULL DEFAULT 0,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"ExpiresAt" timestamp with time zone NULL,
"RedeemedByPlayerId" bigint NULL,
"RedeemedAt" timestamp with time zone NULL,
"Revoked" boolean NOT NULL DEFAULT false
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");",
"""
CREATE TABLE IF NOT EXISTS "PendingDevices" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"DeviceId" text NOT NULL,
"Platform" integer NOT NULL DEFAULT 0,
"PlatformId" text NOT NULL DEFAULT '',
"LastIp" text NULL,
"FirstSeenAt" timestamp with time zone NOT NULL DEFAULT now(),
"LastSeenAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");",
}
: new[]
{
"""
CREATE TABLE IF NOT EXISTS "SignupCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SignupCodes" PRIMARY KEY AUTOINCREMENT,
"Code" TEXT NOT NULL,
"Descriptor" TEXT NOT NULL DEFAULT '',
"CreatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"ExpiresAt" TEXT NULL,
"RedeemedByPlayerId" INTEGER NULL,
"RedeemedAt" TEXT NULL,
"Revoked" INTEGER NOT NULL DEFAULT 0
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");",
"""
CREATE TABLE IF NOT EXISTS "PendingDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_PendingDevices" PRIMARY KEY AUTOINCREMENT,
"DeviceId" TEXT NOT NULL,
"Platform" INTEGER NOT NULL DEFAULT 0,
"PlatformId" TEXT NOT NULL DEFAULT '',
"LastIp" TEXT NULL,
"FirstSeenAt" TEXT NOT NULL,
"LastSeenAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
}
/// <summary>SQLite migration-history reconciliation. Handles two
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
/// <list type="number">