From c3290a1d7fb23d771042969df0348bb8a6fdbee6 Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Tue, 26 May 2026 19:35:05 -0300 Subject: [PATCH 01/10] Port game server from Node.js to .NET 9 / Enjin C# SDK v3 --- .env.example | 7 - .gitignore | 18 +- Endpoints/AuthEndpoints.cs | 59 ++ Endpoints/TokenEndpoints.cs | 75 ++ Endpoints/WalletEndpoints.cs | 41 + Models/Dtos.cs | 38 + Models/User.cs | 6 + PlatformSampleGameServer.csproj | 25 + Program.cs | 132 +++ Services/AuthService.cs | 76 ++ Services/EnjinService.cs | 519 +++++++++++ Services/Options.cs | 52 ++ Services/UserStore.cs | 25 + appsettings.Sample.json | 11 + appsettings.json | 50 ++ package-lock.json | 1454 ------------------------------- package.json | 24 - src/index.js | 47 - src/middlewares/jwtAuth.js | 33 - src/models/user.js | 24 - src/routes/auth.js | 57 -- src/routes/token.js | 70 -- src/routes/wallet.js | 66 -- src/services/authService.js | 64 -- src/services/enjinService.js | 581 ------------ 25 files changed, 1123 insertions(+), 2431 deletions(-) delete mode 100644 .env.example create mode 100644 Endpoints/AuthEndpoints.cs create mode 100644 Endpoints/TokenEndpoints.cs create mode 100644 Endpoints/WalletEndpoints.cs create mode 100644 Models/Dtos.cs create mode 100644 Models/User.cs create mode 100644 PlatformSampleGameServer.csproj create mode 100644 Program.cs create mode 100644 Services/AuthService.cs create mode 100644 Services/EnjinService.cs create mode 100644 Services/Options.cs create mode 100644 Services/UserStore.cs create mode 100644 appsettings.Sample.json create mode 100644 appsettings.json delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src/index.js delete mode 100644 src/middlewares/jwtAuth.js delete mode 100644 src/models/user.js delete mode 100644 src/routes/auth.js delete mode 100644 src/routes/token.js delete mode 100644 src/routes/wallet.js delete mode 100644 src/services/authService.js delete mode 100644 src/services/enjinService.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 1baa4cf..0000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -PORT=3000 -JWT_SECRET= -ENJIN_API_URL=https://platform.canary.enjin.io/graphql -ENJIN_API_KEY= - -DAEMON_WALLET_ADDRESS= -ENJIN_COLLECTION_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8afb0d3..922ba49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ -node_modules/ -.env -npm-debug.log -.DS_Store \ No newline at end of file +.DS_Store + +# .NET +bin/ +obj/ +*.user + +# Local-only runtime state (persisted bootstrap collection id, etc.) +state.json + +# Local-only secrets (URL, API key, JWT secret). Use appsettings.Development.json +# or environment variables. +appsettings.Development.json +appsettings.Local.json diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..fa8d8d2 --- /dev/null +++ b/Endpoints/AuthEndpoints.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Mvc; +using PlatformSampleGameServer.Models; +using PlatformSampleGameServer.Services; + +namespace PlatformSampleGameServer.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth"); + + // Anonymous - the Unity client polls this to confirm connectivity. + group.MapGet("/health-check", () => Results.Ok(new HealthCheckResponse("OK"))) + .AllowAnonymous(); + + // Register-or-login. The Unity client uses this single endpoint for both + // first-time signup and returning sessions; behaviour matches the original + // Node.js sample (src/routes/auth.js). + group.MapPost("/register", async ( + AuthRequest body, + AuthService auth, + EnjinService enjin, + CancellationToken ct) => + { + try + { + var (token, email) = auth.RegisterOrLogin(body.Email, body.Password); + + // Ensure a managed wallet exists for this player so subsequent + // operations (mint/melt/transfer) can target it immediately. + string? wallet = null; + try + { + wallet = await enjin.EnsureManagedWalletAsync(email, ct); + } + catch (Exception ex) + { + // Don't fail registration on a wallet-provisioning hiccup; + // the client can retry against /api/wallet/get-tokens later. + wallet = null; + Console.Error.WriteLine($"EnsureManagedWallet failed for {email}: {ex.Message}"); + } + + return Results.Ok(new AuthResponse(email, wallet, token)); + } + catch (UnauthorizedAccessException ex) + { + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 401); + } + catch (Exception ex) + { + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 400); + } + }) + .AllowAnonymous(); + } +} diff --git a/Endpoints/TokenEndpoints.cs b/Endpoints/TokenEndpoints.cs new file mode 100644 index 0000000..46e8152 --- /dev/null +++ b/Endpoints/TokenEndpoints.cs @@ -0,0 +1,75 @@ +using System.Numerics; +using System.Security.Claims; +using PlatformSampleGameServer.Models; +using PlatformSampleGameServer.Services; + +namespace PlatformSampleGameServer.Endpoints; + +public static class TokenEndpoints +{ + public static void MapTokenEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/token").RequireAuthorization(); + + group.MapPost("/mint", async ( + MintRequest req, + ClaimsPrincipal user, + EnjinService enjin, + CancellationToken ct) => + await Run("mint", req.TokenId, req.Amount, user, async (tokenId, amount, email) => + { + // Mint to the player's wallet. The daemon signs implicitly. + var address = await enjin.EnsureManagedWalletAsync(email, ct); + await enjin.MintTokenAsync(tokenId, amount, address, ct); + })); + + group.MapPost("/melt", async ( + MeltRequest req, + ClaimsPrincipal user, + EnjinService enjin, + CancellationToken ct) => + await Run("melt", req.TokenId, req.Amount, user, async (tokenId, amount, email) => + { + // Burn from the player's wallet; daemon signs on their behalf via externalId. + await enjin.MeltTokenAsync(tokenId, amount, email, ct); + })); + + group.MapPost("/transfer", async ( + TransferRequest req, + ClaimsPrincipal user, + EnjinService enjin, + CancellationToken ct) => + await Run("transfer", req.TokenId, req.Amount, user, async (tokenId, amount, email) => + { + if (string.IsNullOrWhiteSpace(req.Recipient)) + throw new ArgumentException("Recipient is required."); + await enjin.TransferTokenAsync(tokenId, amount, req.Recipient, email, ct); + })); + } + + private static async Task Run( + string operation, + string tokenIdString, + int amount, + ClaimsPrincipal user, + Func action) + { + var email = user.FindFirst(AuthService.EmailClaim)?.Value; + if (string.IsNullOrEmpty(email)) return Results.Unauthorized(); + + if (!BigInteger.TryParse(tokenIdString, out var tokenId)) + return Results.Json(new BoolResponse(false, $"Invalid tokenId '{tokenIdString}'."), statusCode: 400); + if (amount <= 0) + return Results.Json(new BoolResponse(false, "Amount must be positive."), statusCode: 400); + + try + { + await action(tokenId, new BigInteger(amount), email); + return Results.Ok(new BoolResponse(true)); + } + catch (Exception ex) + { + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500); + } + } +} diff --git a/Endpoints/WalletEndpoints.cs b/Endpoints/WalletEndpoints.cs new file mode 100644 index 0000000..283fcae --- /dev/null +++ b/Endpoints/WalletEndpoints.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using PlatformSampleGameServer.Models; +using PlatformSampleGameServer.Services; + +namespace PlatformSampleGameServer.Endpoints; + +public static class WalletEndpoints +{ + public static void MapWalletEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/wallet").RequireAuthorization(); + + // GET /api/wallet/get-tokens - returns the player's managed wallet account + // plus the tokens they hold in our collection. Shape matches the Unity + // client's PlatformModels.ManagedWalletAccount. + group.MapGet("/get-tokens", async ( + ClaimsPrincipal user, + EnjinService enjin, + CancellationToken ct) => + { + var email = user.FindFirst(AuthService.EmailClaim)?.Value; + if (string.IsNullOrEmpty(email)) return Results.Unauthorized(); + + try + { + var account = await enjin.GetManagedWalletTokensAsync(email, ct); + if (account is null) + { + return Results.Json( + new BoolResponse(false, $"No managed wallet for {email}"), + statusCode: 404); + } + return Results.Ok(account); + } + catch (Exception ex) + { + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500); + } + }); + } +} diff --git a/Models/Dtos.cs b/Models/Dtos.cs new file mode 100644 index 0000000..c4ec8a7 --- /dev/null +++ b/Models/Dtos.cs @@ -0,0 +1,38 @@ +namespace PlatformSampleGameServer.Models; + +// Wire-format DTOs. Field names use camelCase via System.Text.Json +// default policy + explicit JsonPropertyName where needed, to match the +// shapes the Unity client (Assets/Enjin Integration/Scripts/...) expects. +// BigInteger-valued fields are serialized as decimal strings so the +// client's SerializableBigInteger wrapper can parse them. + +// ---- Request bodies ---- + +public sealed record AuthRequest(string Email, string Password); + +public sealed record MintRequest(string TokenId, int Amount); + +public sealed record MeltRequest(string TokenId, int Amount); + +public sealed record TransferRequest(string TokenId, int Amount, string Recipient); + +// ---- Response bodies ---- + +public sealed record HealthCheckResponse(string Status); + +public sealed record AuthResponse(string Email, string? Wallet, string Token); + +public sealed record BoolResponse(bool Success, string? Message = null); + +// Mirrors PlatformModels.ManagedWalletAccount on the Unity side. +public sealed record ManagedWalletAccountDto(AccountDto Account, IReadOnlyList TokenAccounts); + +public sealed record AccountDto(string PublicKey, string Address); + +public sealed record TokenAccountDto(string Balance, TokenDto Token); + +public sealed record TokenDto(CollectionDto Collection, string TokenId, IReadOnlyList Attributes); + +public sealed record CollectionDto(string CollectionId); + +public sealed record AttributeDto(string Key, string Value); diff --git a/Models/User.cs b/Models/User.cs new file mode 100644 index 0000000..fbec802 --- /dev/null +++ b/Models/User.cs @@ -0,0 +1,6 @@ +namespace PlatformSampleGameServer.Models; + +// In-memory user record. Matches the original Node.js sample's in-memory store +// (src/models/user.js). Persistence is intentionally omitted: this is sample +// code, not a production user database. +public sealed record User(string Email, string PasswordHash); diff --git a/PlatformSampleGameServer.csproj b/PlatformSampleGameServer.csproj new file mode 100644 index 0000000..6e2e0b9 --- /dev/null +++ b/PlatformSampleGameServer.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + PlatformSampleGameServer + PlatformSampleGameServer + true + + + + + + + + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..b576c6a --- /dev/null +++ b/Program.cs @@ -0,0 +1,132 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using PlatformSampleGameServer.Endpoints; +using PlatformSampleGameServer.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Don't remap inbound JWT claim names (e.g. "email" -> long XMLSOAP URI). +// We want claims to round-trip with their original short names so endpoints +// can read them by their well-known JWT names. +JwtSecurityTokenHandler.DefaultMapInboundClaims = false; + +// ----- Configuration ----- +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); +builder.Configuration.AddEnvironmentVariables(); + +builder.Services.Configure(builder.Configuration.GetSection("Server")); +builder.Services.Configure(builder.Configuration.GetSection("Jwt")); +builder.Services.Configure(builder.Configuration.GetSection("Enjin")); + +// ----- Services ----- +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddCors(o => o.AddDefaultPolicy(p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +// JSON: camelCase property names + serialize BigInteger-as-string so the +// Unity client's SerializableBigInteger wrapper can parse it. Records with +// PascalCase properties will be emitted as camelCase by default; this is +// what the Unity client expects (its JsonUtility uses the field name verbatim, +// and its fields are camelCase). +builder.Services.ConfigureHttpJsonOptions(o => +{ + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + o.SerializerOptions.PropertyNameCaseInsensitive = true; +}); + +// ----- JWT auth ----- +var jwt = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions(); +if (string.IsNullOrWhiteSpace(jwt.Secret)) +{ + // Emit a generated dev secret if none is configured so the server still boots + // for local development. Production must set Jwt:Secret explicitly. + jwt.Secret = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + Console.WriteLine("[warn] Jwt:Secret not configured; generated a transient dev secret. " + + "Set Jwt:Secret in appsettings or env for stable sessions across restarts."); +} + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opts => + { + opts.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = !string.IsNullOrEmpty(jwt.Issuer), + ValidIssuer = jwt.Issuer, + ValidateAudience = !string.IsNullOrEmpty(jwt.Audience), + ValidAudience = jwt.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)), + ClockSkew = TimeSpan.FromMinutes(1), + }; + }); +builder.Services.AddAuthorization(); + +// ----- Kestrel: port + request timeout ----- +var serverOptions = builder.Configuration.GetSection("Server").Get() ?? new ServerOptions(); +builder.WebHost.ConfigureKestrel(k => +{ + k.ListenAnyIP(serverOptions.Port); + k.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(serverOptions.RequestTimeoutSeconds); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); + +// ----- Routes ----- +app.MapAuthEndpoints(); +app.MapWalletEndpoints(); +app.MapTokenEndpoints(); + +// ----- Bootstrap the collection + resource tokens before serving any requests ----- +// Pass --skip-bootstrap (or set Enjin:SkipBootstrap=true) to start the server +// without creating/verifying the on-chain collection. Useful for local smoke tests +// where you don't want to mutate canary. +var skipBootstrap = args.Contains("--skip-bootstrap") + || string.Equals(builder.Configuration["Enjin:SkipBootstrap"], "true", StringComparison.OrdinalIgnoreCase); + +using (var scope = app.Services.CreateScope()) +{ + var sp = scope.ServiceProvider; + var state = sp.GetRequiredService(); + // Allow ENJIN_COLLECTION_ID env override to seed state on first run. + state.OverrideFromConfig(builder.Configuration["Enjin:CollectionId"]); + + var log = sp.GetRequiredService>(); + + if (skipBootstrap) + { + log.LogWarning("Bootstrap skipped (--skip-bootstrap). On-chain operations may fail until a collection ID is provided."); + } + else + { + var enjin = sp.GetRequiredService(); + log.LogInformation("Preparing collection and resource tokens. This may take a few minutes on first run."); + try + { + await enjin.PrepareCollectionAsync(CancellationToken.None); + } + catch (Exception ex) + { + log.LogCritical(ex, "Failed to prepare collection. Server will not start."); + return; + } + } + + log.LogInformation("----------------------------------------"); + log.LogInformation("Collection ID: {Id}", state.CollectionId?.ToString() ?? "(unset)"); + log.LogInformation("Server listening on http://0.0.0.0:{Port}", serverOptions.Port); + log.LogInformation("----------------------------------------"); +} + +await app.RunAsync(); diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..7a8e5ad --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,76 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PlatformSampleGameServer.Models; + +namespace PlatformSampleGameServer.Services; + +// Player-facing authentication: bcrypt-hashed passwords + a short-lived JWT +// bearer token returned to the Unity client. Mirrors the original Node.js +// services/authService.js (bcryptjs + jsonwebtoken). +public sealed class AuthService +{ + public const string EmailClaim = "email"; + + private readonly UserStore _users; + private readonly JwtOptions _jwt; + + public AuthService(UserStore users, IOptions jwt) + { + _users = users; + _jwt = jwt.Value; + } + + // Register-or-login: if the email already exists, fall through to login. + // This matches the original Node.js behaviour where /api/auth/register is + // also the login endpoint the Unity client calls. + public (string Token, string Email) RegisterOrLogin(string email, string password) + { + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException("Email and password are required."); + } + + var existing = _users.FindByEmail(email); + if (existing is not null) + { + if (!BCrypt.Net.BCrypt.Verify(password, existing.PasswordHash)) + { + throw new UnauthorizedAccessException("Invalid password."); + } + return (GenerateToken(existing), existing.Email); + } + + var hash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 10); + var created = _users.Create(email, hash); + return (GenerateToken(created), created.Email); + } + + private string GenerateToken(User user) + { + if (string.IsNullOrWhiteSpace(_jwt.Secret)) + { + throw new InvalidOperationException( + "Jwt:Secret is not configured. Set it in appsettings.Development.json or an env var."); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Secret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwt.Issuer, + audience: _jwt.Audience, + claims: new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Email), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(EmailClaim, user.Email), + }, + expires: DateTime.UtcNow.AddHours(_jwt.ExpiryHours), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs new file mode 100644 index 0000000..37698a5 --- /dev/null +++ b/Services/EnjinService.cs @@ -0,0 +1,519 @@ +using System.Numerics; +using System.Text.Json; +using Enjin.Platform.Sdk; +using Microsoft.Extensions.Options; + +namespace PlatformSampleGameServer.Services; + +// All Enjin Platform interaction lives in this single service so the sample is +// easy to read top-to-bottom. +// +// Architecture notes (matters for newcomers reading the sample): +// +// - The Enjin Platform API token is held by this server only. The Unity +// client authenticates against /api/auth and gets a player-scoped JWT; +// the API token is never exposed to clients. +// +// - All blockchain mutations in v3 are submitted via the single +// CreateTransaction mutation with a TransactionInput union populated to +// one of its 46 method fields (MintToken, BurnToken, TransferToken, ...). +// +// - There are no event subscriptions in v3. After submitting a transaction +// we poll GetTransaction until State is terminal +// (Finalized | Failed | Abandoned | Timeout). +// +// - The daemon wallet (configured DaemonWalletAddress) owns the collection +// and mints tokens to player wallets. Per-player melts and transfers are +// signed by the daemon on behalf of the player's managed wallet, addressed +// by the player's email as externalId. +public sealed class EnjinService : IAsyncDisposable +{ + private readonly PlatformClient _client; + private readonly EnjinOptions _opts; + private readonly ServerState _state; + private readonly ILogger _log; + private readonly Network _network; + private readonly Chain _chain; + + public EnjinService(IOptions opts, ServerState state, ILogger log) + { + _opts = opts.Value; + _state = state; + _log = log; + _network = _opts.Network; + _chain = _opts.Chain; + + if (string.IsNullOrWhiteSpace(_opts.ApiUrl)) + throw new InvalidOperationException("Enjin:ApiUrl is not configured."); + if (string.IsNullOrWhiteSpace(_opts.ApiToken)) + throw new InvalidOperationException("Enjin:ApiToken is not configured."); + + _client = new PlatformClient(new Uri(_opts.ApiUrl), userAgent: "platform-sample-game-server/1.0"); + _client.Auth(_opts.ApiToken); + } + + // ------------------------------------------------------------------ + // Bootstrap: ensure the sample game's collection + resource tokens exist. + // Invoked once at server startup before any HTTP request is served. + // ------------------------------------------------------------------ + public async Task PrepareCollectionAsync(CancellationToken ct) + { + var collectionId = _state.CollectionId; + if (collectionId is null) + { + _log.LogInformation("No collection ID on file. Creating new '{Name}' collection...", _opts.CollectionName); + collectionId = await CreateCollectionAsync(ct); + _state.SetCollectionId(collectionId.Value); + _log.LogInformation("Created collection with ID {Id}", collectionId); + } + else + { + _log.LogInformation("Using existing collection ID {Id}", collectionId); + } + + // Create any missing resource tokens. Order matters less than correctness; + // we run them sequentially because the daemon wallet's nonce is shared. + foreach (var token in _opts.ResourceTokens) + { + if (await TokenExistsAsync(collectionId.Value, token.Id, ct)) + { + _log.LogInformation("Resource token #{Id} '{Name}' already exists", token.Id, token.Name); + continue; + } + + _log.LogInformation("Creating resource token #{Id} '{Name}'...", token.Id, token.Name); + await CreateResourceTokenAsync(collectionId.Value, token, ct); + _log.LogInformation("Resource token #{Id} '{Name}' ready", token.Id, token.Name); + } + } + + private async Task CreateCollectionAsync(CancellationToken ct) + { + var transaction = new TransactionInput + { + CreateCollection = new CreateCollectionInput + { + ForceCollapsingSupply = false, + Attributes = new List + { + new() { Key = "name", Value = _opts.CollectionName }, + new() { Key = "banner_image", Value = _opts.CollectionBannerImage }, + new() { Key = "media", Value = _opts.CollectionMedia }, + }, + }, + }; + + var submitted = await CreateTransactionAsync(transaction, signerExternalId: null, ct); + await WaitForFinalizationAsync(submitted.Uuid!, "collection creation", ct); + + // v3 Transaction does not surface emitted events; locate the new collection by + // listing those owned by the daemon wallet and matching on the "name" attribute. + var query = new QueryQueryBuilder().WithGetCollections( + new CollectionQueryBuilder() + .WithId() + .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()), + _network, _chain, + ids: null, + address: _opts.DaemonWalletAddress); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetCollections"); + var match = (resp.Result.Data?.GetCollections ?? Array.Empty()) + .Where(c => c is not null) + .Where(c => c!.Attributes?.Any(a => a is not null && a.Key == "name" && a.Value == _opts.CollectionName) == true) + .OrderByDescending(c => c!.Id) + .FirstOrDefault(); + + if (match is null) + { + throw new InvalidOperationException( + $"CreateCollection finalized but no collection named '{_opts.CollectionName}' " + + $"found owned by {_opts.DaemonWalletAddress}."); + } + + return match!.Id; + } + + // Checks whether a token entry exists in our collection by querying it directly. + // GetToken returns null (not an error) when the (collection, token) pair is unknown. + private async Task TokenExistsAsync(BigInteger collectionId, BigInteger tokenId, CancellationToken ct) + { + var query = new QueryQueryBuilder().WithGetToken( + new TokenQueryBuilder().WithTokenId(), + _network, _chain, + collectionId: collectionId, + tokenId: tokenId); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetToken"); + return resp.Result.Data?.GetToken is not null; + } + + private async Task CreateResourceTokenAsync(BigInteger collectionId, ResourceTokenDefinition token, CancellationToken ct) + { + var transaction = new TransactionInput + { + CreateToken = new CreateTokenInput + { + Recipient = _opts.DaemonWalletAddress, + CollectionId = collectionId, + TokenId = new BigInteger(token.Id), + InitialSupply = BigInteger.One, + Attributes = new List + { + new() { Key = "name", Value = token.Name }, + new() { Key = "media", Value = token.Media }, + }, + }, + }; + + var submitted = await CreateTransactionAsync(transaction, signerExternalId: null, ct); + await WaitForFinalizationAsync(submitted.Uuid!, $"create token #{token.Id} '{token.Name}'", ct); + } + + // ------------------------------------------------------------------ + // Managed wallets + // ------------------------------------------------------------------ + + public async Task GetManagedWalletAddressAsync(string externalId, CancellationToken ct) + { + var query = new QueryQueryBuilder().WithGetManagedWallet( + new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(), + _network, _chain, externalId: externalId); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetManagedWallet"); + // PublicKey on a managed wallet IS the account public key (hex); we resolve to + // SS58 by calling GetAccount. + var publicKey = resp.Result.Data?.GetManagedWallet?.PublicKey; + if (publicKey is null) return null; + return await ResolveAddressAsync(publicKey, ct); + } + + // Ensures the wallet exists; returns the SS58 address. Idempotent: if the + // wallet already exists, we just resolve and return it. + public async Task EnsureManagedWalletAsync(string externalId, CancellationToken ct) + { + var existing = await GetManagedWalletAddressAsync(externalId, ct); + if (existing is not null) return existing; + + var mutation = new MutationQueryBuilder().WithCreateManagedWallet(externalId); + var resp = await _client.SendMutation(mutation); + EnsureSuccess(resp, "CreateManagedWallet"); + + // The platform creates the wallet asynchronously: poll until it's queryable. + for (var attempt = 1; attempt <= _opts.ManagedWalletPollMaxAttempts; attempt++) + { + var address = await GetManagedWalletAddressAsync(externalId, ct); + if (address is not null) return address; + await Task.Delay(TimeSpan.FromSeconds(_opts.ManagedWalletPollIntervalSeconds), ct); + } + + throw new InvalidOperationException( + $"CreateManagedWallet for externalId '{externalId}' did not become queryable " + + $"after {_opts.ManagedWalletPollMaxAttempts} attempts."); + } + + private async Task ResolveAddressAsync(string publicKey, CancellationToken ct) + { + // GetAccount accepts a public key OR SS58 address; pass the public key to + // receive the canonical SS58 form on Address. + var query = new QueryQueryBuilder().WithGetAccount( + new AccountQueryBuilder().WithId().WithAddress(), + _network, _chain, address: publicKey); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetAccount"); + var account = resp.Result.Data?.GetAccount; + if (account?.Address is null) + { + throw new InvalidOperationException($"Could not resolve SS58 address for public key {publicKey}."); + } + return account.Address; + } + + // ------------------------------------------------------------------ + // Wallet token listing (used by Unity backpack UI) + // ------------------------------------------------------------------ + public async Task GetManagedWalletTokensAsync(string externalId, CancellationToken ct) + { + // Step 1: locate the managed wallet's public key. + var mwQuery = new QueryQueryBuilder().WithGetManagedWallet( + new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(), + _network, _chain, externalId: externalId); + + var mwResp = await _client.SendQuery(mwQuery); + EnsureSuccess(mwResp, "GetManagedWallet"); + var wallet = mwResp.Result.Data?.GetManagedWallet; + if (wallet?.PublicKey is null) return null; + + // Step 2: fetch the account (resolves SS58 + lists held tokens). + var collectionId = _state.CollectionId + ?? throw new InvalidOperationException("Collection ID not initialised; PrepareCollection has not run."); + + var accountQuery = new QueryQueryBuilder().WithGetAccount( + new AccountQueryBuilder() + .WithId() + .WithAddress() + .WithTokens( + new TokenQueryBuilder() + .WithTokenId() + .WithCollection(new CollectionQueryBuilder().WithId()) + .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()) + .WithHolders(new TokenHolderQueryBuilder().WithAddress().WithAmount(), limit: 50, page: 1), + limit: 100, + collectionId: collectionId), + _network, _chain, address: wallet.PublicKey); + + var accountResp = await _client.SendQuery(accountQuery); + EnsureSuccess(accountResp, "GetAccount(tokens)"); + var account = accountResp.Result.Data?.GetAccount; + if (account?.Address is null) return null; + + // Per-holder balance: scan Token.Holders for this address. Account.Tokens does + // not expose a balance scalar, so we cross-reference here. + var tokenAccounts = new List(); + foreach (var token in account.Tokens ?? Array.Empty()) + { + var holderBalance = token.Holders? + .Where(h => string.Equals(h.Address, account.Address, StringComparison.OrdinalIgnoreCase)) + .Select(h => h.Amount) + .FirstOrDefault() ?? BigInteger.Zero; + + if (holderBalance.IsZero) continue; + + var attrs = (token.Attributes ?? Enumerable.Empty()) + .Select(a => new Models.AttributeDto(a.Key ?? "", a.Value ?? "")) + .ToList(); + + tokenAccounts.Add(new Models.TokenAccountDto( + Balance: holderBalance.ToString(), + Token: new Models.TokenDto( + Collection: new Models.CollectionDto(token.Collection?.Id.ToString() ?? collectionId.ToString()), + TokenId: token.TokenId ?? "", + Attributes: attrs))); + } + + return new Models.ManagedWalletAccountDto( + Account: new Models.AccountDto(PublicKey: account.Id ?? wallet.PublicKey, Address: account.Address), + TokenAccounts: tokenAccounts); + } + + // ------------------------------------------------------------------ + // Mint / Burn / Transfer (all wait for chain finalization) + // ------------------------------------------------------------------ + + public Task MintTokenAsync(BigInteger tokenId, BigInteger amount, string recipientAddress, CancellationToken ct) + { + var input = new TransactionInput + { + MintToken = new MintTokenInput + { + Recipient = recipientAddress, + CollectionId = RequireCollectionId(), + TokenId = tokenId, + Amount = amount, + }, + }; + return SubmitAndWaitAsync(input, signerExternalId: null, $"mint token #{tokenId}", ct); + } + + public Task MeltTokenAsync(BigInteger tokenId, BigInteger amount, string signerExternalId, CancellationToken ct) + { + var input = new TransactionInput + { + BurnToken = new BurnTokenInput + { + CollectionId = RequireCollectionId(), + TokenId = tokenId, + Amount = amount, + }, + }; + return SubmitAndWaitAsync(input, signerExternalId, $"burn token #{tokenId}", ct); + } + + public Task TransferTokenAsync(BigInteger tokenId, BigInteger amount, string recipientAddress, string signerExternalId, CancellationToken ct) + { + var input = new TransactionInput + { + TransferToken = new TransferTokenInput + { + Recipient = recipientAddress, + CollectionId = RequireCollectionId(), + TokenId = tokenId, + Amount = amount, + }, + }; + return SubmitAndWaitAsync(input, signerExternalId, $"transfer token #{tokenId}", ct); + } + + // ------------------------------------------------------------------ + // Transaction submission + polling + // ------------------------------------------------------------------ + + private async Task SubmitAndWaitAsync( + TransactionInput input, string? signerExternalId, string description, CancellationToken ct) + { + var submitted = await CreateTransactionAsync(input, signerExternalId, ct); + return await WaitForFinalizationAsync(submitted.Uuid!, description, ct); + } + + private async Task CreateTransactionAsync( + TransactionInput input, string? signerExternalId, CancellationToken ct) + { + var mutation = new MutationQueryBuilder().WithCreateTransaction( + new TransactionQueryBuilder().WithUuid().WithState(), + _network, _chain, + transaction: input, + signerExternalId: signerExternalId); + + var resp = await _client.SendMutation(mutation); + EnsureSuccess(resp, "CreateTransaction"); + var txn = resp.Result.Data?.CreateTransaction + ?? throw new InvalidOperationException("CreateTransaction returned no transaction."); + if (string.IsNullOrEmpty(txn.Uuid)) + throw new InvalidOperationException("CreateTransaction returned a transaction with no UUID."); + return txn; + } + + private async Task WaitForFinalizationAsync(string uuid, string description, CancellationToken ct) + { + if (_opts.TransactionInitialDelaySeconds > 0) + { + await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionInitialDelaySeconds), ct); + } + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var query = new QueryQueryBuilder().WithGetTransaction( + new TransactionQueryBuilder().WithUuid().WithState().WithExtrinsicHash(), + _network, _chain, uuid: uuid); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetTransaction"); + var txn = resp.Result.Data?.GetTransaction + ?? throw new InvalidOperationException($"GetTransaction returned nothing for UUID {uuid}."); + + switch (txn.State) + { + case TransactionStateEnum.Finalized: + _log.LogInformation("Transaction {Uuid} ({Desc}) finalized.", uuid, description); + return txn; + case TransactionStateEnum.Failed: + case TransactionStateEnum.Abandoned: + case TransactionStateEnum.Timeout: + throw new InvalidOperationException( + $"Transaction {uuid} ({description}) ended in terminal state {txn.State}."); + default: + _log.LogInformation( + "Waiting for {Desc} (uuid={Uuid}, state={State})...", description, uuid, txn.State); + await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionPollIntervalSeconds), ct); + break; + } + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private BigInteger RequireCollectionId() => + _state.CollectionId ?? throw new InvalidOperationException( + "Collection ID not initialised; PrepareCollection has not run."); + + private static void EnsureSuccess(IPlatformResponse resp, string operation) => + EnsureSuccessCore(resp.IsSuccessStatusCode, resp.StatusCode, resp.Result?.Errors, operation); + + private static void EnsureSuccess(IPlatformResponse resp, string operation) => + EnsureSuccessCore(resp.IsSuccessStatusCode, resp.StatusCode, resp.Result?.Errors, operation); + + private static void EnsureSuccessCore( + bool isSuccess, System.Net.HttpStatusCode status, ICollection? errors, string operation) + { + if (!isSuccess) + { + throw new InvalidOperationException( + $"{operation} returned HTTP {(int)status} {status}."); + } + if (errors is { Count: > 0 }) + { + throw new InvalidOperationException( + $"{operation} returned GraphQL errors: {string.Join("; ", errors.Select(e => e.Message))}"); + } + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } +} + +// Process-local mutable state. Currently just the bootstrapped collection ID, +// persisted to state.json so we don't re-create the collection on every restart. +public sealed class ServerState +{ + private readonly string _path; + private BigInteger? _collectionId; + private readonly object _lock = new(); + + public ServerState(IHostEnvironment env) + { + _path = Path.Combine(env.ContentRootPath, "state.json"); + Load(); + } + + public BigInteger? CollectionId + { + get { lock (_lock) { return _collectionId; } } + } + + public void SetCollectionId(BigInteger id) + { + lock (_lock) + { + _collectionId = id; + Persist(); + } + } + + public void OverrideFromConfig(string? configured) + { + if (string.IsNullOrWhiteSpace(configured)) return; + if (!BigInteger.TryParse(configured, out var parsed)) return; + lock (_lock) + { + // Don't clobber on-disk state if it disagrees; on-disk wins because + // the operator has been running with that one. + _collectionId ??= parsed; + } + } + + private void Load() + { + if (!File.Exists(_path)) return; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + if (doc.RootElement.TryGetProperty("collectionId", out var prop) + && prop.ValueKind == JsonValueKind.String + && BigInteger.TryParse(prop.GetString(), out var parsed)) + { + _collectionId = parsed; + } + } + catch + { + // State file is best-effort; ignore corruption and let the bootstrap re-create. + } + } + + private void Persist() + { + var payload = new { collectionId = _collectionId?.ToString() }; + File.WriteAllText(_path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/Services/Options.cs b/Services/Options.cs new file mode 100644 index 0000000..e407db8 --- /dev/null +++ b/Services/Options.cs @@ -0,0 +1,52 @@ +using Enjin.Platform.Sdk; + +namespace PlatformSampleGameServer.Services; + +// Strongly-typed configuration. Bound to the "Enjin" section of appsettings.json +// (and overrides) via IOptions. +public sealed class EnjinOptions +{ + public string ApiUrl { get; set; } = ""; + public string ApiToken { get; set; } = ""; + + // Default to the Canary test network on Matrix relay. Override per environment. + public Network Network { get; set; } = Network.Canary; + public Chain Chain { get; set; } = Chain.Matrix; + + // Recipient used when minting the initial supply for resource tokens (the + // daemon wallet that holds the master copy). Per-player mints use the + // player's managed-wallet address as recipient. + public string DaemonWalletAddress { get; set; } = ""; + + public int TransactionPollIntervalSeconds { get; set; } = 10; + public int TransactionInitialDelaySeconds { get; set; } = 10; + public int ManagedWalletPollIntervalSeconds { get; set; } = 1; + public int ManagedWalletPollMaxAttempts { get; set; } = 10; + + public List ResourceTokens { get; set; } = new(); + + public string CollectionName { get; set; } = ""; + public string CollectionBannerImage { get; set; } = ""; + public string CollectionMedia { get; set; } = ""; +} + +public sealed class ResourceTokenDefinition +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Media { get; set; } = ""; +} + +public sealed class JwtOptions +{ + public string Issuer { get; set; } = ""; + public string Audience { get; set; } = ""; + public string Secret { get; set; } = ""; + public int ExpiryHours { get; set; } = 24; +} + +public sealed class ServerOptions +{ + public int Port { get; set; } = 3000; + public int RequestTimeoutSeconds { get; set; } = 3600; +} diff --git a/Services/UserStore.cs b/Services/UserStore.cs new file mode 100644 index 0000000..a1e1528 --- /dev/null +++ b/Services/UserStore.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; +using PlatformSampleGameServer.Models; + +namespace PlatformSampleGameServer.Services; + +// Process-local user registry. Matches the original Node.js sample's in-memory +// model (src/models/user.js). Lost on process restart; this is intentional for +// a sample. Replace with a real persistence layer for production use. +public sealed class UserStore +{ + private readonly ConcurrentDictionary _users = new(StringComparer.OrdinalIgnoreCase); + + public User? FindByEmail(string email) => + _users.TryGetValue(email, out var user) ? user : null; + + public User Create(string email, string passwordHash) + { + var user = new User(email, passwordHash); + if (!_users.TryAdd(email, user)) + { + throw new InvalidOperationException($"User '{email}' already exists."); + } + return user; + } +} diff --git a/appsettings.Sample.json b/appsettings.Sample.json new file mode 100644 index 0000000..21dfd99 --- /dev/null +++ b/appsettings.Sample.json @@ -0,0 +1,11 @@ +{ + "// Notes": "Copy this file to appsettings.Development.json (gitignored) and fill in secrets.", + "Jwt": { + "Secret": "replace-with-a-long-random-string-at-least-32-chars" + }, + "Enjin": { + "ApiUrl": "https://platform.canary.enjin.io/graphql", + "ApiToken": "your-enjin-platform-bearer-token-here", + "DaemonWalletAddress": "your-daemon-wallet-ss58-address" + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..c1ece6f --- /dev/null +++ b/appsettings.json @@ -0,0 +1,50 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Server": { + "Port": 3000, + "RequestTimeoutSeconds": 3600 + }, + "Jwt": { + "Issuer": "platform-sample-game-server", + "Audience": "platform-sample-game-client", + "Secret": "", + "ExpiryHours": 24 + }, + "Enjin": { + "ApiUrl": "https://platform.canary.enjin.io/graphql", + "ApiToken": "", + "Network": "Canary", + "Chain": "Matrix", + "DaemonWalletAddress": "5EJDmqEoySnLk8xvPNPQGrb9qUrYLcbf38K4R6zKeNryvfD6", + "TransactionPollIntervalSeconds": 10, + "TransactionInitialDelaySeconds": 10, + "ManagedWalletPollIntervalSeconds": 1, + "ManagedWalletPollMaxAttempts": 10, + "ResourceTokens": [ + { + "Id": 1, + "Name": "Gold Coin", + "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin.png\"}]" + }, + { + "Id": 2, + "Name": "Gold Coin (Blue)", + "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin-blue.png\"}]" + }, + { + "Id": 3, + "Name": "Green Gem", + "Media": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/green-gem.png\"}]" + } + ], + "CollectionName": "Enjin Sample Game", + "CollectionBannerImage": "https://cdn.enjin.io/mint/image/sample-game-collection-banner.png", + "CollectionMedia": "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/sample-game-collection-image.png\"}]" + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 79e32e7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1454 +0,0 @@ -{ - "name": "sample-game-server", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sample-game-server", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "axios": "^1.10.0", - "bcryptjs": "^3.0.2", - "cors": "^2.8.5", - "dotenv": "^17.2.0", - "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { - "nodemon": "^3.1.10" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/bcryptjs": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", - "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dotenv": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", - "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 7cf61ab..0000000 --- a/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "sample-game-server", - "version": "1.0.0", - "main": "src/index.js", - "scripts": { - "start": "node src/index.js", - "dev": "nodemon src/index.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "Sample game server demonstrating Enjin implementation", - "dependencies": { - "axios": "^1.10.0", - "bcryptjs": "^3.0.2", - "cors": "^2.8.5", - "dotenv": "^17.2.0", - "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { - "nodemon": "^3.1.10" - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 7e6f042..0000000 --- a/src/index.js +++ /dev/null @@ -1,47 +0,0 @@ -const express = require('express'); -const cors = require('cors'); -const dotenv = require('dotenv'); -const { prepareCollection } = require('./services/enjinService'); - -// Load environment variables -dotenv.config(); - -// Initialize express app -const app = express(); - -// Middleware -app.use(cors()); -app.use(express.json()); - -// Routes -app.use('/api/auth', require('./routes/auth')); -app.use('/api/wallet', require('./routes/wallet')); -app.use('/api/token', require('./routes/token')); - -// Basic error handling -app.use((err, req, res, next) => { - console.error(err.stack); - res.status(500).json({ - success: false, - message: 'Internal Server Error' - }); -}); - -const PORT = process.env.PORT || 3000; - -// Start server after checking collection -prepareCollection() - .then(() => { - console.log("----------------------------------------") - console.log(`Collection and resource tokens are ready. Using collection ID: ${process.env.ENJIN_COLLECTION_ID}`); - const server = app.listen(PORT, () => { - console.log(`Server is running on port ${PORT}`); - console.log("----------------------------------------") - }); - // Set the timeout for all requests to 1 hour (3,600,000 ms) - server.timeout = 3600000; - }) - .catch(error => { - console.error('Failed to start server:', error); - process.exit(1); - }); \ No newline at end of file diff --git a/src/middlewares/jwtAuth.js b/src/middlewares/jwtAuth.js deleted file mode 100644 index 8aa6932..0000000 --- a/src/middlewares/jwtAuth.js +++ /dev/null @@ -1,33 +0,0 @@ -const jwt = require('jsonwebtoken'); -const User = require('../models/user'); - -const jwtAuth = (req, res, next) => { - const authHeader = req.headers["authorization"]; - - if (!authHeader) { - return res.status(401).json({ - success: false, - message: 'Authorization header is required' - }); - } - - try { - const token = authHeader.split(' ')[1]; // Format: "Bearer token" - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - const user = User.findByEmail(decoded.email); - if (!user) { - throw new Error('User not found'); - } - - req.user = user; - next(); - } catch (error) { - return res.status(401).json({ - success: false, - message: 'Invalid token' - }); - } -}; - -module.exports = jwtAuth; \ No newline at end of file diff --git a/src/models/user.js b/src/models/user.js deleted file mode 100644 index 39134f0..0000000 --- a/src/models/user.js +++ /dev/null @@ -1,24 +0,0 @@ -const users = new Map(); - -class User { - constructor(email, password) { - this.email = email; - this.password = password; - } - - static findByEmail(email) { - return users.get(email); - } - - static create(email, password) { - if (users.has(email)) { - throw new Error('User already exists'); - } - - const user = new User(email, password); - users.set(email, user); - return user; - } -} - -module.exports = User; \ No newline at end of file diff --git a/src/routes/auth.js b/src/routes/auth.js deleted file mode 100644 index 09e9ced..0000000 --- a/src/routes/auth.js +++ /dev/null @@ -1,57 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const AuthService = require('../services/authService'); -const { getManagedWallet, createManagedWallet } = require('../services/enjinService'); - -// Health check endpoint -router.get('/health-check', async (req, res) => { - res.status(200).json({ - status: "OK" - }); -}); - -// Register endpoint -router.post('/register', async (req, res) => { - try { - const { email, password } = req.body; - const token = await AuthService.register(email, password); - const createManagedWalletResponse = await createManagedWallet(email); - let wallet = null; - if (createManagedWalletResponse) - wallet = createManagedWalletResponse.account.address; - - res.status(201).json({ - wallet: wallet, - token: token - }); - } catch (error) { - res.status(400).json({ - success: false, - message: error.message - }); - } -}); - -// Login endpoint -router.post('/login', async (req, res) => { - try { - const { email, password } = req.body; - const token = await AuthService.login(email, password); - const getManagedWalletResponse = await getManagedWallet(email); - let wallet = null; - if (getManagedWalletResponse) - wallet = getManagedWalletResponse.account.address; - - res.status(200).json({ - wallet: wallet, - token: token - }); - } catch (error) { - res.status(401).json({ - success: false, - message: error.message - }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/src/routes/token.js b/src/routes/token.js deleted file mode 100644 index b95d0ef..0000000 --- a/src/routes/token.js +++ /dev/null @@ -1,70 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const axios = require('axios'); -const jwtAuth = require('../middlewares/jwtAuth'); -const { mintTokenAndWaitForTransaction, meltTokenAndWaitForTransaction, transferTokenAndWaitForTransaction, getManagedWallet } = require('../services/enjinService'); - -// Mint token endpoint -router.post('/mint', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const getManagedWalletResponse = await getManagedWallet(userEmail) - if (!getManagedWalletResponse) - throw new Error(`Failed to get managed wallet for external id ${userEmail}`); - const userWalletAddress = getManagedWalletResponse.account.address; - await mintTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress); - - res.json({ - success: true - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -// Melt token endpoint -router.post('/melt', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const getManagedWalletResponse = await getManagedWallet(userEmail) - if (!getManagedWalletResponse) - throw new Error(`Failed to get managed wallet for external id ${userEmail}`); - const userWalletAddress = getManagedWalletResponse.account.address; - await meltTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress); - - res.json({ - success: true - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -// Transfer token endpoint -router.post('/transfer', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const getManagedWalletResponse = await getManagedWallet(userEmail) - if (!getManagedWalletResponse) - throw new Error(`Failed to get managed wallet for external id ${userEmail}`); - const userWalletAddress = getManagedWalletResponse.account.address; - await transferTokenAndWaitForTransaction(req.body.tokenId, req.body.amount, userWalletAddress, req.body.recipient); - - res.json({ - success: true - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/src/routes/wallet.js b/src/routes/wallet.js deleted file mode 100644 index d4783eb..0000000 --- a/src/routes/wallet.js +++ /dev/null @@ -1,66 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const axios = require('axios'); -const jwtAuth = require('../middlewares/jwtAuth'); -const { getManagedWallet, createManagedWallet, getManagedWalletTokens } = require('../services/enjinService'); - -// Get managed wallet endpoint -router.post('/get', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const getManagedWalletResponse = await getManagedWallet(userEmail) - if (!getManagedWalletResponse) - throw new Error(`Failed to get managed wallet for external id ${userEmail}`); - const wallet = getManagedWalletResponse.account.address; - res.json({ - success: true, - wallet: wallet - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -// Create and return managed wallet endpoint -router.post('/create', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const createManagedWalletResponse = await createManagedWallet(userEmail) - if (!createManagedWalletResponse) - throw new Error(`Failed to get managed wallet for external id ${userEmail}`); - const wallet = createManagedWalletResponse.account.address; - res.json({ - success: true, - wallet: wallet - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -// Get managed wallet endpoint -router.get('/get-tokens', jwtAuth, async (req, res) => { - try { - const userEmail = req.user.email; - const getManagedWalletTokensResponse = await getManagedWalletTokens(userEmail) - if (!getManagedWalletTokensResponse) - throw new Error(`Failed to get managed wallet tokens for external id ${userEmail}`); - res.json({ - "account": getManagedWalletTokensResponse.account, - "tokenAccounts": getManagedWalletTokensResponse.tokens - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message - }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/src/services/authService.js b/src/services/authService.js deleted file mode 100644 index 4d5fc54..0000000 --- a/src/services/authService.js +++ /dev/null @@ -1,64 +0,0 @@ -const bcrypt = require('bcryptjs'); -const jwt = require('jsonwebtoken'); -const User = require('../models/user'); - -class AuthService { - static async hashPassword(password) { - const salt = await bcrypt.genSalt(10); - return bcrypt.hash(password, salt); - } - - static async comparePasswords(password, hashedPassword) { - return bcrypt.compare(password, hashedPassword); - } - - static generateToken(user) { - return jwt.sign( - { email: user.email }, - process.env.JWT_SECRET, - { expiresIn: '24h' } - ); - } - - static async register(email, password) { - if (!email || !password) { - throw new Error('Email and password are required'); - } - - const existingUser = User.findByEmail(email); - if (existingUser) { - // User already exists, try to login - try { - const token = await this.login(email, password); - return token; - } catch (loginError) { - throw new Error('Registration failed: User already exists, but login failed.'); - } - } - - const hashedPassword = await this.hashPassword(password); - const user = User.create(email, hashedPassword); - const token = this.generateToken(user); - - return token; - } - - static async login(email, password) { - const user = User.findByEmail(email); - - if (!user) { - throw new Error('User not found'); - } - - const isValidPassword = await this.comparePasswords(password, user.password); - - if (!isValidPassword) { - throw new Error('Invalid password'); - } - - const token = this.generateToken(user); - return token; - } -} - -module.exports = AuthService; \ No newline at end of file diff --git a/src/services/enjinService.js b/src/services/enjinService.js deleted file mode 100644 index 7a23682..0000000 --- a/src/services/enjinService.js +++ /dev/null @@ -1,581 +0,0 @@ -const axios = require('axios'); -const fs = require('fs'); - -// Resource token definitions -const RESOURCE_TOKENS = [ - { id: 1, name: "Gold Coin", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin.png\"}]"}, - { id: 2, name: "Gold Coin (Blue)", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/gold-coin-blue.png\"}]" }, - { id: 3, name: "Green Gem", media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/green-gem.png\"}]" } -]; - -async function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function getTransactionStatus(requestId) { - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `query GetTransaction { - GetTransaction(id: ${requestId}) { - state - result - events { - edges { - node { - params { - type - value - } - } - } - } - } - }` - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response.data.data.GetTransaction; -} - -async function extractCollectionId(transaction) { - if (!transaction.events || !transaction.events.edges) { - return null; - } - - for (const edge of transaction.events.edges) { - const params = edge.node.params; - const collectionIdParam = params.find(p => p.type === 'collection_id'); - if (collectionIdParam) { - return collectionIdParam.value; - } - } - - return null; -} - -async function updateEnvFile(collectionId) { - const envContent = fs.readFileSync('.env', 'utf8'); - const updatedContent = envContent.replace( - /ENJIN_COLLECTION_ID=.*/, - `ENJIN_COLLECTION_ID=${collectionId}` - ); - fs.writeFileSync('.env', updatedContent); - process.env.ENJIN_COLLECTION_ID = collectionId; -} - -async function createCollection(){ - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation CreateCollection( - $forceCollapsingSupply: Boolean - $name: String! - $bannerImage: String! - $media: String! -) { - CreateCollection( - mintPolicy: { forceCollapsingSupply: $forceCollapsingSupply } - attributes:[ - { - key: \"name\", - value: $name - }, - { - key: \"banner_image\", - value: $bannerImage - }, - { - key: \"media\", - value: $media - } - ] - ) { - id - method - state - } -}`, - variables: { - forceCollapsingSupply: false, - name: "Enjin Sample Game", - bannerImage: "https://cdn.enjin.io/mint/image/sample-game-collection-banner.png", - media: "[{\"type\":\"image/png\",\"url\":\"https://cdn.enjin.io/mint/image/sample-game-collection-image.png\"}]" - - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function createToken(collectionId, tokenId, name, media){ - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation CreateToken( - $recipient: String! - $collectionId: BigInt! - $tokenId: BigInt - $name: String! - $media: String! -){ - CreateToken( - recipient: $recipient - collectionId: $collectionId - params:{ - tokenId: {integer: $tokenId} - initialSupply: 1 - attributes: [ - { - key: "name", - value: $name - }, - { - key: "media", - value: $media - } - ] - } - ){ - id - method - state - } -}`, - variables: { - recipient: process.env.DAEMON_WALLET_ADDRESS || "5EJDmqEoySnLk8xvPNPQGrb9qUrYLcbf38K4R6zKeNryvfD6", - collectionId: collectionId, - tokenId: tokenId, - name: name, - media: media - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function getToken(collectionId, tokenId) { - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `query GetToken{ - GetToken( - collectionId: ${collectionId} - tokenId: {integer: ${tokenId}} - ){ - metadata - } -}`, - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function getManagedWallet(externalId){ - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `query GetWallet($externalId: String!){ - GetWallet(externalId: $externalId){ - account{ - publicKey - address - } - } -}`, - variables: { - externalId: externalId - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response.data.data.GetWallet; -} - -async function getManagedWalletTokens(externalId, after = "") { - let allTokenAccounts = []; - let hasNextPage = true; - let endCursor = after; - let account = null; - - while (hasNextPage) { - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `query GetWalletTokens($externalId: String!){ - GetWallet(externalId: $externalId){ - account{ - publicKey - address - } - tokenAccounts(after: "${endCursor}"){ - pageInfo{ - hasNextPage - endCursor - } - edges{ - node{ - balance - token{ - tokenId - collection{ - collectionId - } - attributes{ - key - value - } - } - } - } - } - } -}`, - variables: { - externalId: externalId - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - const walletData = response.data.data.GetWallet; - if (!walletData || !walletData.tokenAccounts) { - console.warn("No token accounts found in the response."); - hasNextPage = false; - break; - } - - account = walletData.account - allTokenAccounts = allTokenAccounts.concat(walletData.tokenAccounts.edges.map(edge => edge.node)); - hasNextPage = walletData.tokenAccounts.pageInfo.hasNextPage; - endCursor = walletData.tokenAccounts.pageInfo.endCursor; - } - - return {account: account, tokens: allTokenAccounts}; -} - -async function createManagedWallet(externalId){ - try { - const existingWallet = await getManagedWallet(externalId); - if (existingWallet) { - console.log(`Managed wallet with external ID ${externalId} already exists.`); - return existingWallet; - } - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation CreateWallet($externalId: String!){ - CreateWallet(externalId: $externalId) -}`, - variables: { - externalId: externalId - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - - const maxRetries = 10; - const retryInterval = 1000; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - const wallet = await getManagedWallet(externalId); - if (wallet && wallet.account && wallet.account.address) { - return wallet; // Wallet found, return it. - } - - // If wallet not found, wait before the next attempt. - await sleep(retryInterval); - } - - // If the loop completes without finding the wallet, handle the failure. - console.error(`Failed to retrieve wallet for ${externalId} after ${maxRetries} attempts.`); - return null; - - } catch (error) { - console.error(`Failed to create managed wallet with external ID ${externalId}:`, error); - throw error; - } - -} - -async function mintToken(tokenId, amount, recipient){ - collectionId = process.env.ENJIN_COLLECTION_ID; - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation mintToken( - $recipient: String! - $collectionId: BigInt! - $tokenId: BigInt - $amount: BigInt! -){ - MintToken( - recipient: $recipient - collectionId: $collectionId - params: { - tokenId: {integer: $tokenId} - amount: $amount - } - ){ - id - method - state - } -}`, - variables: { - recipient: recipient, - collectionId: collectionId, - tokenId: tokenId, - amount: amount - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function mintTokenAndWaitForTransaction(tokenId, amount, recipient) { - console.log('Minting Token, please wait...'); - try { - const mintTokenResponse = await mintToken(tokenId, amount, recipient); - await sleep(10000); - - const requestId = mintTokenResponse.data.data.MintToken.id; - await waitForTransaction(requestId, "'Token #" + tokenId + "' minting"); - console.log(`'Token #${tokenId}' minted successfully.`); - return; - } catch (error) { - console.error("Failed to mint 'token #" + tokenId + "': ", JSON.stringify(error)); - throw error; - } -} - -async function meltToken(tokenId, amount, signingAccount){ - collectionId = process.env.ENJIN_COLLECTION_ID; - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation burnToken( - $collectionId: BigInt! - $tokenId: BigInt - $amount: BigInt! - $signingAccount: String! -){ - Burn( - collectionId: $collectionId - params: { - tokenId: {integer: $tokenId} - amount: $amount - } - signingAccount: $signingAccount - ){ - id - method - state - } -}`, - variables: { - collectionId: collectionId, - tokenId: tokenId, - amount: amount, - signingAccount: signingAccount - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function meltTokenAndWaitForTransaction(tokenId, amount, signingAccount) { - console.log('Melting Token, please wait...'); - try { - const meltTokenResponse = await meltToken(tokenId, amount, signingAccount); - await sleep(10000); - - const requestId = meltTokenResponse.data.data.Burn.id; - await waitForTransaction(requestId, "'Token #" + tokenId + "' melting"); - console.log(`'Token #${tokenId}' melted successfully.`); - return; - } catch (error) { - console.error("Failed to melt 'token #" + tokenId + "': ", JSON.stringify(error)); - throw error; - } -} - -async function transferToken(tokenId, amount, signingAccount, recipient){ - collectionId = process.env.ENJIN_COLLECTION_ID; - const response = await axios.post(process.env.ENJIN_API_URL, { - query: `mutation transferToken( - $collectionId: BigInt! - $tokenId: BigInt! - $amount: BigInt! - $recipient: String! - $signingAccount: String! -){ - SimpleTransferToken( - collectionId: $collectionId - recipient: $recipient - params: { - tokenId: {integer: $tokenId} - amount: $amount - } - signingAccount: $signingAccount - ){ - id - method - state - } -}`, - variables: { - recipient: recipient, - collectionId: collectionId, - tokenId: tokenId, - amount: amount, - signingAccount: signingAccount - } - }, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': process.env.ENJIN_API_KEY - } - }); - - return response; -} - -async function transferTokenAndWaitForTransaction(tokenId, amount, signingAccount, recipient) { - console.log('Transferring Token, please wait...'); - try { - const transferTokenResponse = await transferToken(tokenId, amount, signingAccount, recipient); - await sleep(10000); - - const requestId = transferTokenResponse.data.data.SimpleTransferToken.id; - await waitForTransaction(requestId, "'Token #" + tokenId + "' transfer"); - console.log(`'Token #${tokenId}' transferred successfully.`); - return; - } catch (error) { - console.error("Failed to transfer 'token #" + tokenId + "': ", JSON.stringify(error)); - throw error; - } -} - -async function waitForTransaction(requestId, operationType = 'operation') { - while (true) { - const transaction = await getTransactionStatus(requestId); - - if (transaction.state === 'PENDING') { - console.log(`Please confirm the ${operationType} request in the Enjin Platform. Request ID: ${requestId}`); - await sleep(10000); - continue; - } - - if (transaction.state === 'FINALIZED' && transaction.result === 'EXTRINSIC_SUCCESS') { - return transaction; - } - - if (transaction.state === 'FAILED' || transaction.state === 'ABANDONED' || transaction.result === 'EXTRINSIC_FAILED') { - throw new Error(`${operationType} failed`); - } - - console.log(`Waiting for the ${operationType} to finalize...`); - await sleep(10000); - } -} - -async function checkAndCreateCollection() { - if (!process.env.ENJIN_COLLECTION_ID) { - console.log('No collection ID found. Creating new collection, please wait...'); - try { - const createCollectionResponse = await createCollection(); - await sleep(10000); - - const requestId = createCollectionResponse.data.data.CreateCollection.id; - const transaction = await waitForTransaction(requestId, "'Enjin Sample Game' collection creation"); - - const collectionId = await extractCollectionId(transaction); - if (collectionId) { - await updateEnvFile(collectionId); - console.log(`Created new collection with ID: ${collectionId}.`); - return collectionId; - } - throw new Error('Failed to extract collection ID'); - } catch (error) { - console.error('Failed to create collection:', error); - throw error; - } - } - return process.env.ENJIN_COLLECTION_ID; -} - -async function checkTokenExists(collectionId, tokenId) { - try { - await getToken(collectionId, tokenId); - return true; - } catch (error) { - if (error.response && error.response.status === 400) { - return false; - } - throw error; - } -} - -async function createResourceToken(collectionId, tokenId, name, media) { - console.log(`Creating resource token '${name}', please wait...`); - const createTokenResponse = await createToken(collectionId, tokenId, name, media); - await sleep(10000); - - const requestId = createTokenResponse.data.data.CreateToken.id; - await waitForTransaction(requestId, `'${name}' token creation`); - console.log(`Resource token '${name}' created successfully.`); -} - -async function prepareCollection() { - // Ensure collection exists - const collectionId = await checkAndCreateCollection(); - - // Checks which resource tokens exist - const tokenExistsChecks = await Promise.all( - RESOURCE_TOKENS.map(token => - checkTokenExists(collectionId, token.id) - .then(exists => ({ ...token, exists })) - ) - ); - - // Create missing tokens in parallel - const tokensToCreate = tokenExistsChecks.filter(token => !token.exists); - if (tokensToCreate.length > 0) { - await Promise.all( - tokensToCreate.map(token => - createResourceToken(collectionId, token.id, token.name, token.media) - .catch(error => { - console.error(`Failed to create resource token #${token.id}:`, error); - throw error; - }) - ) - ); - } -} - -module.exports = { - prepareCollection, createManagedWallet, getManagedWallet, getManagedWalletTokens, mintTokenAndWaitForTransaction, meltTokenAndWaitForTransaction, transferTokenAndWaitForTransaction -}; \ No newline at end of file From 6e9e8fdd505a7ef2e4bf75f1c636170c2e25477f Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Tue, 26 May 2026 20:24:22 -0300 Subject: [PATCH 02/10] Bootstrap: reuse existing collection by name; quieter finalization waiter --- Services/EnjinService.cs | 102 ++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs index 37698a5..c0f70b3 100644 --- a/Services/EnjinService.cs +++ b/Services/EnjinService.cs @@ -61,10 +61,27 @@ public async Task PrepareCollectionAsync(CancellationToken ct) var collectionId = _state.CollectionId; if (collectionId is null) { - _log.LogInformation("No collection ID on file. Creating new '{Name}' collection...", _opts.CollectionName); - collectionId = await CreateCollectionAsync(ct); + // Avoid creating duplicate collections on canary if state.json was lost. + // First look for an existing collection owned by the daemon wallet whose + // "name" attribute matches our configured collection name. + _log.LogInformation( + "No collection ID on file. Checking for an existing '{Name}' collection owned by {Owner}...", + _opts.CollectionName, _opts.DaemonWalletAddress); + + var existing = await FindExistingCollectionAsync(ct); + if (existing is not null) + { + _log.LogInformation("Found existing collection {Id}; reusing.", existing); + collectionId = existing; + } + else + { + _log.LogInformation("No matching collection found. Creating new '{Name}' collection...", _opts.CollectionName); + collectionId = await CreateCollectionAsync(ct); + _log.LogInformation("Created collection with ID {Id}", collectionId); + } + _state.SetCollectionId(collectionId.Value); - _log.LogInformation("Created collection with ID {Id}", collectionId); } else { @@ -87,6 +104,33 @@ public async Task PrepareCollectionAsync(CancellationToken ct) } } + // Returns the BigInteger id of an existing collection owned by the daemon + // wallet whose "name" attribute matches the configured collection name, or + // null if no such collection exists. Used both before creating a new + // collection (to avoid duplicates) and after creation (to retrieve the + // new id, since v3 Transaction does not surface emitted events). + private async Task FindExistingCollectionAsync(CancellationToken ct) + { + var query = new QueryQueryBuilder().WithGetCollections( + new CollectionQueryBuilder() + .WithId() + .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()), + _network, _chain, + ids: null, + address: _opts.DaemonWalletAddress); + + var resp = await _client.SendQuery(query); + EnsureSuccess(resp, "GetCollections"); + + var match = (resp.Result.Data?.GetCollections ?? Array.Empty()) + .Where(c => c is not null) + .Where(c => c!.Attributes?.Any(a => a is not null && a.Key == "name" && a.Value == _opts.CollectionName) == true) + .OrderByDescending(c => c!.Id) + .FirstOrDefault(); + + return match?.Id; + } + private async Task CreateCollectionAsync(CancellationToken ct) { var transaction = new TransactionInput @@ -108,30 +152,15 @@ private async Task CreateCollectionAsync(CancellationToken ct) // v3 Transaction does not surface emitted events; locate the new collection by // listing those owned by the daemon wallet and matching on the "name" attribute. - var query = new QueryQueryBuilder().WithGetCollections( - new CollectionQueryBuilder() - .WithId() - .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()), - _network, _chain, - ids: null, - address: _opts.DaemonWalletAddress); - - var resp = await _client.SendQuery(query); - EnsureSuccess(resp, "GetCollections"); - var match = (resp.Result.Data?.GetCollections ?? Array.Empty()) - .Where(c => c is not null) - .Where(c => c!.Attributes?.Any(a => a is not null && a.Key == "name" && a.Value == _opts.CollectionName) == true) - .OrderByDescending(c => c!.Id) - .FirstOrDefault(); - - if (match is null) + var found = await FindExistingCollectionAsync(ct); + if (found is null) { throw new InvalidOperationException( $"CreateCollection finalized but no collection named '{_opts.CollectionName}' " + $"found owned by {_opts.DaemonWalletAddress}."); } - return match!.Id; + return found.Value; } // Checks whether a token entry exists in our collection by querying it directly. @@ -383,6 +412,10 @@ private async Task WaitForFinalizationAsync(string uuid, string des await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionInitialDelaySeconds), ct); } + var started = DateTime.UtcNow; + TransactionStateEnum? lastLoggedState = null; + DateTime lastStuckWarning = DateTime.UtcNow; + while (true) { ct.ThrowIfCancellationRequested(); @@ -399,16 +432,37 @@ private async Task WaitForFinalizationAsync(string uuid, string des switch (txn.State) { case TransactionStateEnum.Finalized: - _log.LogInformation("Transaction {Uuid} ({Desc}) finalized.", uuid, description); + _log.LogInformation("Transaction {Uuid} ({Desc}) finalized after {Elapsed:F0}s.", + uuid, description, (DateTime.UtcNow - started).TotalSeconds); return txn; + case TransactionStateEnum.Failed: case TransactionStateEnum.Abandoned: case TransactionStateEnum.Timeout: throw new InvalidOperationException( $"Transaction {uuid} ({description}) ended in terminal state {txn.State}."); + default: - _log.LogInformation( - "Waiting for {Desc} (uuid={Uuid}, state={State})...", description, uuid, txn.State); + // Log only on state transitions to avoid flooding logs while polling. + if (lastLoggedState != txn.State) + { + _log.LogInformation( + "Waiting for {Desc} (uuid={Uuid}, state={State})...", + description, uuid, txn.State); + lastLoggedState = txn.State; + lastStuckWarning = DateTime.UtcNow; + } + else if (txn.State == TransactionStateEnum.Pending + && (DateTime.UtcNow - lastStuckWarning).TotalSeconds >= 60) + { + var elapsed = (DateTime.UtcNow - started).TotalSeconds; + _log.LogWarning( + "Transaction {Uuid} ({Desc}) has been Pending for {Elapsed:F0}s. " + + "Confirm that a wallet daemon is signing transactions for account {Daemon}.", + uuid, description, elapsed, _opts.DaemonWalletAddress); + lastStuckWarning = DateTime.UtcNow; + } + await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionPollIntervalSeconds), ct); break; } From a8ae79b1a7db14ed27b38185b1e292e0abb6838c Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Wed, 27 May 2026 13:24:33 -0300 Subject: [PATCH 03/10] Fix v3 wallet/token flow: SS58 client-side, per-token holders query, ENJ drip --- PlatformSampleGameServer.csproj | 8 + Services/EnjinService.cs | 206 +++++++++++++++++-------- Services/Options.cs | 22 +++ Services/SubstrateAddress.cs | 125 +++++++++++++++ tools/Ss58SelfTest/Program.cs | 2 + tools/Ss58SelfTest/Ss58SelfTest.csproj | 12 ++ 6 files changed, 313 insertions(+), 62 deletions(-) create mode 100644 Services/SubstrateAddress.cs create mode 100644 tools/Ss58SelfTest/Program.cs create mode 100644 tools/Ss58SelfTest/Ss58SelfTest.csproj diff --git a/PlatformSampleGameServer.csproj b/PlatformSampleGameServer.csproj index 6e2e0b9..6fa657f 100644 --- a/PlatformSampleGameServer.csproj +++ b/PlatformSampleGameServer.csproj @@ -11,9 +11,17 @@ + + + + + + + + - + + diff --git a/README.md b/README.md index d0f6e9c..a56ad4a 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ client works unchanged. - [.NET 9 SDK](https://dotnet.microsoft.com/download). - The [Enjin Platform C# SDK](https://github.com/enjin/platform-csharp-sdk) - checked out as a sibling directory (this project references the SDK via a - `ProjectReference`; see `PlatformSampleGameServer.csproj`). + v3.0.0 or later, pulled from NuGet automatically on `dotnet restore`. + (For local development against an unreleased SDK, swap the + `PackageReference` in `PlatformSampleGameServer.csproj` for the commented-out + `ProjectReference` and check the SDK out as a sibling directory.) - An Enjin Platform account and a generated API token. - A running [Wallet Daemon](https://docs.enjin.io/products/wallet-daemon) configured with the same API token; its SS58 address is what you'll set as @@ -38,17 +40,13 @@ client works unchanged. ## Setup -1. **Clone next to the SDK:** +1. **Clone the repo:** ```bash - git clone https://github.com/enjin/platform-csharp-sdk.git git clone https://github.com/enjin/platform-sample-game-server.git cd platform-sample-game-server ``` - The `.csproj` expects `../platform-csharp-sdk/...` to exist. If you put the - SDK somewhere else, edit the `ProjectReference` path. - 2. **Create a local config:** Copy `appsettings.Sample.json` to `appsettings.Local.json` (gitignored) diff --git a/appsettings.json b/appsettings.json index c1ece6f..b356097 100644 --- a/appsettings.json +++ b/appsettings.json @@ -21,7 +21,7 @@ "ApiToken": "", "Network": "Canary", "Chain": "Matrix", - "DaemonWalletAddress": "5EJDmqEoySnLk8xvPNPQGrb9qUrYLcbf38K4R6zKeNryvfD6", + "DaemonWalletAddress": "", "TransactionPollIntervalSeconds": 10, "TransactionInitialDelaySeconds": 10, "ManagedWalletPollIntervalSeconds": 1, From ecee18dda8aa2c6857ccf359106c21bd32832d65 Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Wed, 27 May 2026 20:37:35 -0300 Subject: [PATCH 07/10] address review feedback --- Endpoints/AuthEndpoints.cs | 4 ++- Program.cs | 25 +++++++++++++++- Services/AuthService.cs | 2 +- Services/EnjinService.cs | 58 +++++++++++++++++++++++++++++++++++--- appsettings.Sample.json | 2 +- 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index fa8d8d2..c69a24f 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -22,8 +22,10 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app) AuthRequest body, AuthService auth, EnjinService enjin, + ILoggerFactory loggerFactory, CancellationToken ct) => { + var log = loggerFactory.CreateLogger("PlatformSampleGameServer.Endpoints.AuthEndpoints"); try { var (token, email) = auth.RegisterOrLogin(body.Email, body.Password); @@ -40,7 +42,7 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app) // Don't fail registration on a wallet-provisioning hiccup; // the client can retry against /api/wallet/get-tokens later. wallet = null; - Console.Error.WriteLine($"EnsureManagedWallet failed for {email}: {ex.Message}"); + log.LogWarning(ex, "EnsureManagedWallet failed for {Email}", email); } return Results.Ok(new AuthResponse(email, wallet, token)); diff --git a/Program.cs b/Program.cs index 87181f4..507888e 100644 --- a/Program.cs +++ b/Program.cs @@ -50,6 +50,15 @@ jwt.Secret = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); Console.WriteLine("[warn] Jwt:Secret not configured; generated a transient dev secret. " + "Set Jwt:Secret in appsettings or env for stable sessions across restarts."); + + // Propagate the generated secret into the bound JwtOptions so AuthService + // (which resolves IOptions) signs tokens with the same key that + // the JwtBearer middleware validates against. Without this, AuthService + // would either throw on an empty Secret or sign with a different key. + builder.Services.PostConfigure(o => + { + if (string.IsNullOrWhiteSpace(o.Secret)) o.Secret = jwt.Secret; + }); } builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -74,12 +83,26 @@ builder.WebHost.ConfigureKestrel(k => { k.ListenAnyIP(serverOptions.Port); - k.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(serverOptions.RequestTimeoutSeconds); +}); + +// Long-running on-chain operations (mint/melt/transfer) can legitimately take +// several minutes while we poll for finalization. Configure a per-request +// execution timeout via the ASP.NET Core RequestTimeouts middleware so the +// server cancels requests that exceed Server:RequestTimeoutSeconds instead of +// letting them run indefinitely. (KeepAliveTimeout is intentionally left at +// its default; it controls idle connection lifetime, not request duration.) +builder.Services.AddRequestTimeouts(o => +{ + o.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy + { + Timeout = TimeSpan.FromSeconds(serverOptions.RequestTimeoutSeconds), + }; }); var app = builder.Build(); app.UseCors(); +app.UseRequestTimeouts(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/Services/AuthService.cs b/Services/AuthService.cs index 7a8e5ad..c934594 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -53,7 +53,7 @@ private string GenerateToken(User user) if (string.IsNullOrWhiteSpace(_jwt.Secret)) { throw new InvalidOperationException( - "Jwt:Secret is not configured. Set it in appsettings.Development.json or an env var."); + "Jwt:Secret is not configured. Set it in appsettings.Local.json or an env var."); } var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Secret)); diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs index f59e8c2..e286515 100644 --- a/Services/EnjinService.cs +++ b/Services/EnjinService.cs @@ -276,6 +276,17 @@ private async Task DripIfNeededAsync(string externalId, string recipientAddress, return; } + // Atomic guard: only one concurrent caller proceeds per externalId. + // Without this, two simultaneous EnsureManagedWalletAsync calls for the + // same player could both pass HasDripped, both await TransferEnj, and + // double-drip. TryBeginDrip marks the externalId as "in progress" + // synchronously; we clear it on failure so a later attempt can retry. + if (!_state.TryBeginDrip(externalId)) + { + _log.LogDebug("Drip already in progress or completed for {ExternalId}; skipping.", externalId); + return; + } + _log.LogInformation( "Dripping {Amount} ENJ from daemon to managed wallet {Address} (externalId={ExternalId}).", amount, recipientAddress, externalId); @@ -289,9 +300,18 @@ private async Task DripIfNeededAsync(string externalId, string recipientAddress, }, }; - // Signed by the daemon (signerExternalId: null). - await SubmitAndWaitAsync(input, signerExternalId: null, $"drip ENJ to {externalId}", ct); - _state.RecordDripped(externalId); + try + { + // Signed by the daemon (signerExternalId: null). + await SubmitAndWaitAsync(input, signerExternalId: null, $"drip ENJ to {externalId}", ct); + _state.RecordDripped(externalId); + } + catch + { + // Clear the in-progress flag so a later attempt can retry. + _state.CancelDrip(externalId); + throw; + } } private Task ResolveAddressAsync(string publicKey, CancellationToken ct) @@ -569,7 +589,15 @@ public sealed class ServerState { private readonly string _path; private BigInteger? _collectionId; - private readonly HashSet _drippedExternalIds = new(StringComparer.Ordinal); + // Case-insensitive to match UserStore (OrdinalIgnoreCase). externalId is an + // email, and "Alice@x.com" / "alice@x.com" are the same user; tracking them + // separately here would let the same player be dripped twice. + private readonly HashSet _drippedExternalIds = new(StringComparer.OrdinalIgnoreCase); + // In-flight drips. Not persisted; lives only for the lifetime of the process + // and exists purely to serialise concurrent EnsureManagedWalletAsync calls + // for the same externalId so we never submit two TransferEnj transactions + // for a single player. + private readonly HashSet _drippingInFlight = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); public ServerState(IHostEnvironment env) @@ -597,10 +625,32 @@ public bool HasDripped(string externalId) lock (_lock) { return _drippedExternalIds.Contains(externalId); } } + /// + /// Atomically reserves an externalId for drip. Returns true if the caller + /// should proceed with the on-chain transfer; false if the externalId has + /// already been dripped, or another caller is currently dripping it. + /// Pair every true return with either + /// on success or on failure. + /// + public bool TryBeginDrip(string externalId) + { + lock (_lock) + { + if (_drippedExternalIds.Contains(externalId)) return false; + return _drippingInFlight.Add(externalId); + } + } + + public void CancelDrip(string externalId) + { + lock (_lock) { _drippingInFlight.Remove(externalId); } + } + public void RecordDripped(string externalId) { lock (_lock) { + _drippingInFlight.Remove(externalId); if (_drippedExternalIds.Add(externalId)) Persist(); } } diff --git a/appsettings.Sample.json b/appsettings.Sample.json index 21dfd99..ec60b47 100644 --- a/appsettings.Sample.json +++ b/appsettings.Sample.json @@ -1,5 +1,5 @@ { - "// Notes": "Copy this file to appsettings.Development.json (gitignored) and fill in secrets.", + "// Notes": "Copy this file to appsettings.Local.json (gitignored) and fill in secrets.", "Jwt": { "Secret": "replace-with-a-long-random-string-at-least-32-chars" }, From 4142aeeb48f928fc6c82f65d000bb906f281e73d Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Wed, 27 May 2026 20:52:21 -0300 Subject: [PATCH 08/10] review feedback --- Program.cs | 11 ++++-- README.md | 3 +- Services/EnjinService.cs | 9 +++++ Services/SubstrateAddress.cs | 9 ++++- tools/Ss58SelfTest/Program.cs | 68 ++++++++++++++++++++++++++++++++++- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/Program.cs b/Program.cs index 507888e..bd14984 100644 --- a/Program.cs +++ b/Program.cs @@ -123,8 +123,15 @@ { var sp = scope.ServiceProvider; var state = sp.GetRequiredService(); - // Allow ENJIN_COLLECTION_ID env override to seed state on first run. - state.OverrideFromConfig(builder.Configuration["Enjin:CollectionId"]); + // Allow the operator to seed state.json on first run by supplying a + // collection id via configuration. Configuration:AddEnvironmentVariables + // accepts the standard double-underscore form (Enjin__CollectionId), but + // the legacy Node.js sample used the flat ENJIN_COLLECTION_ID name, so + // we accept that too as a fallback for operators migrating from the old + // server. + var collectionIdSeed = builder.Configuration["Enjin:CollectionId"] + ?? Environment.GetEnvironmentVariable("ENJIN_COLLECTION_ID"); + state.OverrideFromConfig(collectionIdSeed); var log = sp.GetRequiredService>(); diff --git a/README.md b/README.md index a56ad4a..6ee3705 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,10 @@ managed-wallet APIs. | Path | Purpose | |---|---| | `Program.cs` | Host setup, DI registration, JWT configuration, bootstrap orchestration, port binding. | -| `Services/EnjinService.cs` | Every SDK call: collection bootstrap, managed wallet resolution, mint/melt/transfer, transaction polling, ENJ drip. | +| `Services/EnjinService.cs` | Every SDK call: collection bootstrap, managed wallet resolution, mint/melt/transfer, transaction polling, ENJ drip. Also defines `ServerState`, the persisted state (`state.json`) holding the collection id and the set of `externalId`s already dripped. | | `Services/AuthService.cs` | Bcrypt password hashing, JWT issuance (`sub` + `email` claims). | | `Services/SubstrateAddress.cs` | SS58 encoder (Blake2b + base58check) used to convert managed-wallet public keys returned by the platform into SS58 addresses. | | `Services/Options.cs` | Strongly-typed config classes bound from `appsettings*.json`. | -| `Services/ServerState.cs` | Persisted state (`state.json`): collection id, set of `externalId`s already dripped. | | `Endpoints/AuthEndpoints.cs` | `/api/auth/*` minimal-API routes. | | `Endpoints/WalletEndpoints.cs` | `/api/wallet/*` routes. | | `Endpoints/TokenEndpoints.cs` | `/api/token/*` routes. | diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs index e286515..3274979 100644 --- a/Services/EnjinService.cs +++ b/Services/EnjinService.cs @@ -53,6 +53,15 @@ public EnjinService(IOptions opts, ServerState state, ILogger {SubstrateAddress.Encode("0x1a5f80eb8fdc248e41c42319d70a63fb78d2fe3bbb4b2d06dd71ed1314aa4650", 9030)}"); + +// Regression test for Services/SubstrateAddress.cs. +// +// Asserts the SS58 encoder against canonical published vectors: +// +// - Alice (//Alice sr25519 dev key, public key +// 0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d) +// on the generic Substrate prefix (42), Polkadot mainnet (prefix 0), +// and Kusama (prefix 2). These appear throughout the Substrate / +// Polkadot documentation and in the subkey test fixtures. +// +// The three vectors collectively exercise both single-byte SS58 prefixes +// (0, 2, 42) and the unkeyed Blake2b-512 checksum, which is the property +// the production encoder depends on. (Enjin Matrixchain prefixes 9030 / +// 1110 use the two-byte SS58 prefix form, which we exercise through the +// running platform but do not assert here as we lack a published vector +// for the //Alice dev key on those chains; if the checksum function or +// base58 encoder regress, the single-byte vectors below will catch it.) +// +// Any mismatch prints the divergence and exits with a non-zero status so +// CI / a manual `dotnet run --project tools/Ss58SelfTest` will fail loudly +// if the encoder (or the underlying Blake2b implementation) regresses. + +var vectors = new (string Label, string PublicKeyHex, ushort Prefix, string ExpectedAddress)[] +{ + ( + "Alice / Substrate generic (prefix 42)", + "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + 42, + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ), + ( + "Alice / Polkadot mainnet (prefix 0)", + "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + 0, + "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5" + ), + ( + "Alice / Kusama (prefix 2)", + "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", + 2, + "HNZata7iMYWmk5RvZRTiAsSDhV8366zq2YGb3tLH5Upf74F" + ), +}; + +int failures = 0; +foreach (var v in vectors) +{ + var actual = SubstrateAddress.Encode(v.PublicKeyHex, v.Prefix); + var ok = string.Equals(actual, v.ExpectedAddress, StringComparison.Ordinal); + Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {v.Label}"); + if (!ok) + { + Console.WriteLine($" expected: {v.ExpectedAddress}"); + Console.WriteLine($" actual: {actual}"); + failures++; + } +} + +if (failures > 0) +{ + Console.Error.WriteLine($"\n{failures} SS58 vector(s) failed. The encoder is producing incorrect addresses."); + return 1; +} + +Console.WriteLine($"\nAll {vectors.Length} SS58 vectors passed."); +return 0; From 8f1792cac728b67fbeb2f24763719123d1d1b594 Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Thu, 28 May 2026 12:36:15 -0300 Subject: [PATCH 09/10] add formatting --- .config/dotnet-tools.json | 13 ++ .editorconfig | 84 +++++++ .github/workflows/build.yml | 56 +++++ Endpoints/AuthEndpoints.cs | 70 +++--- Endpoints/SetupEndpoints.cs | 26 ++- Endpoints/TokenEndpoints.cs | 92 +++++--- Endpoints/WalletEndpoints.cs | 16 +- Models/Dtos.cs | 11 +- PlatformSampleGameServer.csproj | 2 - Program.cs | 40 +++- Services/AuthService.cs | 6 +- Services/EnjinService.cs | 397 +++++++++++++++++++++++--------- Services/SubstrateAddress.cs | 35 ++- Services/UserStore.cs | 7 +- scripts/precommit.sh | 46 ++++ tools/Ss58SelfTest/Program.cs | 4 +- 16 files changed, 696 insertions(+), 209 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml create mode 100755 scripts/precommit.sh diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..8d74243 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.2.6", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ba824e2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,84 @@ +# EditorConfig: https://editorconfig.org +root = true + +########################################## +# All files +########################################## +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml,json,md}] +indent_size = 2 + +[*.{csproj,props,targets,xml}] +indent_size = 2 + +########################################## +# C# files +########################################## +[*.cs] +indent_size = 4 + +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning + +# Namespace does not need to match folder structure. +# Also avoids a dotnet-format crash: its IDE0130 fixer attempts file moves that +# MSBuildWorkspace doesn't support (NotSupportedException: Changing document properties). +dotnet_diagnostic.IDE0130.severity = none + +# this. qualification +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Language keywords vs BCL types +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning + +# var preferences +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion + +# Newline preferences (Allman braces) +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Spacing +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_parentheses = false + +# Wrapping +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e9f91f9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,56 @@ +name: Build & Verify + +on: + push: + branches: + - master + tags: + - '*' + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet Packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore Main Project + run: dotnet restore ./PlatformSampleGameServer.csproj + + - name: Restore Tools Project + run: dotnet restore ./tools/Ss58SelfTest/Ss58SelfTest.csproj + + - name: Restore .NET Tools + run: dotnet tool restore + + - name: Verify Formatting (CSharpier) + run: dotnet csharpier check . + + - name: Verify Formatting (dotnet format - main) + run: dotnet format ./PlatformSampleGameServer.csproj --verify-no-changes --no-restore + + - name: Verify Formatting (dotnet format - tools) + run: dotnet format ./tools/Ss58SelfTest/Ss58SelfTest.csproj --verify-no-changes --no-restore + + - name: Build Main Project + run: dotnet build ./PlatformSampleGameServer.csproj --configuration Release --no-restore + + - name: Build Tools Project + run: dotnet build ./tools/Ss58SelfTest/Ss58SelfTest.csproj --configuration Release --no-restore diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index c69a24f..3622697 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -12,50 +12,58 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app) var group = app.MapGroup("/api/auth"); // Anonymous - the Unity client polls this to confirm connectivity. - group.MapGet("/health-check", () => Results.Ok(new HealthCheckResponse("OK"))) + group + .MapGet("/health-check", () => Results.Ok(new HealthCheckResponse("OK"))) .AllowAnonymous(); // Register-or-login. The Unity client uses this single endpoint for both // first-time signup and returning sessions; behaviour matches the original // Node.js sample (src/routes/auth.js). - group.MapPost("/register", async ( - AuthRequest body, - AuthService auth, - EnjinService enjin, - ILoggerFactory loggerFactory, - CancellationToken ct) => - { - var log = loggerFactory.CreateLogger("PlatformSampleGameServer.Endpoints.AuthEndpoints"); - try + group + .MapPost( + "/register", + async ( + AuthRequest body, + AuthService auth, + EnjinService enjin, + ILoggerFactory loggerFactory, + CancellationToken ct + ) => { - var (token, email) = auth.RegisterOrLogin(body.Email, body.Password); - - // Ensure a managed wallet exists for this player so subsequent - // operations (mint/melt/transfer) can target it immediately. - string? wallet = null; + var log = loggerFactory.CreateLogger( + "PlatformSampleGameServer.Endpoints.AuthEndpoints" + ); try { - wallet = await enjin.EnsureManagedWalletAsync(email, ct); + var (token, email) = auth.RegisterOrLogin(body.Email, body.Password); + + // Ensure a managed wallet exists for this player so subsequent + // operations (mint/melt/transfer) can target it immediately. + string? wallet = null; + try + { + wallet = await enjin.EnsureManagedWalletAsync(email, ct); + } + catch (Exception ex) + { + // Don't fail registration on a wallet-provisioning hiccup; + // the client can retry against /api/wallet/get-tokens later. + wallet = null; + log.LogWarning(ex, "EnsureManagedWallet failed for {Email}", email); + } + + return Results.Ok(new AuthResponse(email, wallet, token)); + } + catch (UnauthorizedAccessException ex) + { + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 401); } catch (Exception ex) { - // Don't fail registration on a wallet-provisioning hiccup; - // the client can retry against /api/wallet/get-tokens later. - wallet = null; - log.LogWarning(ex, "EnsureManagedWallet failed for {Email}", email); + return Results.Json(new BoolResponse(false, ex.Message), statusCode: 400); } - - return Results.Ok(new AuthResponse(email, wallet, token)); - } - catch (UnauthorizedAccessException ex) - { - return Results.Json(new BoolResponse(false, ex.Message), statusCode: 401); - } - catch (Exception ex) - { - return Results.Json(new BoolResponse(false, ex.Message), statusCode: 400); } - }) + ) .AllowAnonymous(); } } diff --git a/Endpoints/SetupEndpoints.cs b/Endpoints/SetupEndpoints.cs index c24e13d..c927edb 100644 --- a/Endpoints/SetupEndpoints.cs +++ b/Endpoints/SetupEndpoints.cs @@ -23,17 +23,23 @@ public static void MapSetupEndpoints(this IEndpointRouteBuilder app) // this server has bootstrapped. The Unity Editor menu "Enjin > Stamp // Collection ID onto EnjinItem Assets" calls this and writes the // value onto every EnjinItem ScriptableObject. - group.MapGet("/collection-id", (EnjinService enjin) => - { - var id = enjin.CollectionId; - if (id is null) + group.MapGet( + "/collection-id", + (EnjinService enjin) => { - return Results.Json( - new BoolResponse(false, "Collection id not initialised. " + - "Did the server finish bootstrap?"), - statusCode: 503); + var id = enjin.CollectionId; + if (id is null) + { + return Results.Json( + new BoolResponse( + false, + "Collection id not initialised. " + "Did the server finish bootstrap?" + ), + statusCode: 503 + ); + } + return Results.Ok(new CollectionIdResponse(id.Value.ToString())); } - return Results.Ok(new CollectionIdResponse(id.Value.ToString())); - }); + ); } } diff --git a/Endpoints/TokenEndpoints.cs b/Endpoints/TokenEndpoints.cs index 46e8152..54372d2 100644 --- a/Endpoints/TokenEndpoints.cs +++ b/Endpoints/TokenEndpoints.cs @@ -11,40 +11,70 @@ public static void MapTokenEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/token").RequireAuthorization(); - group.MapPost("/mint", async ( + group.MapPost( + "/mint", + async ( MintRequest req, ClaimsPrincipal user, EnjinService enjin, - CancellationToken ct) => - await Run("mint", req.TokenId, req.Amount, user, async (tokenId, amount, email) => - { - // Mint to the player's wallet. The daemon signs implicitly. - var address = await enjin.EnsureManagedWalletAsync(email, ct); - await enjin.MintTokenAsync(tokenId, amount, address, ct); - })); + CancellationToken ct + ) => + await Run( + "mint", + req.TokenId, + req.Amount, + user, + async (tokenId, amount, email) => + { + // Mint to the player's wallet. The daemon signs implicitly. + var address = await enjin.EnsureManagedWalletAsync(email, ct); + await enjin.MintTokenAsync(tokenId, amount, address, ct); + } + ) + ); - group.MapPost("/melt", async ( + group.MapPost( + "/melt", + async ( MeltRequest req, ClaimsPrincipal user, EnjinService enjin, - CancellationToken ct) => - await Run("melt", req.TokenId, req.Amount, user, async (tokenId, amount, email) => - { - // Burn from the player's wallet; daemon signs on their behalf via externalId. - await enjin.MeltTokenAsync(tokenId, amount, email, ct); - })); + CancellationToken ct + ) => + await Run( + "melt", + req.TokenId, + req.Amount, + user, + async (tokenId, amount, email) => + { + // Burn from the player's wallet; daemon signs on their behalf via externalId. + await enjin.MeltTokenAsync(tokenId, amount, email, ct); + } + ) + ); - group.MapPost("/transfer", async ( + group.MapPost( + "/transfer", + async ( TransferRequest req, ClaimsPrincipal user, EnjinService enjin, - CancellationToken ct) => - await Run("transfer", req.TokenId, req.Amount, user, async (tokenId, amount, email) => - { - if (string.IsNullOrWhiteSpace(req.Recipient)) - throw new ArgumentException("Recipient is required."); - await enjin.TransferTokenAsync(tokenId, amount, req.Recipient, email, ct); - })); + CancellationToken ct + ) => + await Run( + "transfer", + req.TokenId, + req.Amount, + user, + async (tokenId, amount, email) => + { + if (string.IsNullOrWhiteSpace(req.Recipient)) + throw new ArgumentException("Recipient is required."); + await enjin.TransferTokenAsync(tokenId, amount, req.Recipient, email, ct); + } + ) + ); } private static async Task Run( @@ -52,15 +82,23 @@ private static async Task Run( string tokenIdString, int amount, ClaimsPrincipal user, - Func action) + Func action + ) { var email = user.FindFirst(AuthService.EmailClaim)?.Value; - if (string.IsNullOrEmpty(email)) return Results.Unauthorized(); + if (string.IsNullOrEmpty(email)) + return Results.Unauthorized(); if (!BigInteger.TryParse(tokenIdString, out var tokenId)) - return Results.Json(new BoolResponse(false, $"Invalid tokenId '{tokenIdString}'."), statusCode: 400); + return Results.Json( + new BoolResponse(false, $"Invalid tokenId '{tokenIdString}'."), + statusCode: 400 + ); if (amount <= 0) - return Results.Json(new BoolResponse(false, "Amount must be positive."), statusCode: 400); + return Results.Json( + new BoolResponse(false, "Amount must be positive."), + statusCode: 400 + ); try { diff --git a/Endpoints/WalletEndpoints.cs b/Endpoints/WalletEndpoints.cs index 283fcae..d708eb8 100644 --- a/Endpoints/WalletEndpoints.cs +++ b/Endpoints/WalletEndpoints.cs @@ -13,13 +13,13 @@ public static void MapWalletEndpoints(this IEndpointRouteBuilder app) // GET /api/wallet/get-tokens - returns the player's managed wallet account // plus the tokens they hold in our collection. Shape matches the Unity // client's PlatformModels.ManagedWalletAccount. - group.MapGet("/get-tokens", async ( - ClaimsPrincipal user, - EnjinService enjin, - CancellationToken ct) => + group.MapGet( + "/get-tokens", + async (ClaimsPrincipal user, EnjinService enjin, CancellationToken ct) => { var email = user.FindFirst(AuthService.EmailClaim)?.Value; - if (string.IsNullOrEmpty(email)) return Results.Unauthorized(); + if (string.IsNullOrEmpty(email)) + return Results.Unauthorized(); try { @@ -28,7 +28,8 @@ public static void MapWalletEndpoints(this IEndpointRouteBuilder app) { return Results.Json( new BoolResponse(false, $"No managed wallet for {email}"), - statusCode: 404); + statusCode: 404 + ); } return Results.Ok(account); } @@ -36,6 +37,7 @@ public static void MapWalletEndpoints(this IEndpointRouteBuilder app) { return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500); } - }); + } + ); } } diff --git a/Models/Dtos.cs b/Models/Dtos.cs index 9926a4d..b9ca9a5 100644 --- a/Models/Dtos.cs +++ b/Models/Dtos.cs @@ -25,7 +25,10 @@ public sealed record AuthResponse(string Email, string? Wallet, string Token); public sealed record BoolResponse(bool Success, string? Message = null); // Mirrors PlatformModels.ManagedWalletAccount on the Unity side. -public sealed record ManagedWalletAccountDto(AccountDto Account, IReadOnlyList TokenAccounts); +public sealed record ManagedWalletAccountDto( + AccountDto Account, + IReadOnlyList TokenAccounts +); // Returned by GET /api/setup/collection-id. The Unity Editor calls this once // during studio setup to stamp the on-chain collection id onto each @@ -38,7 +41,11 @@ public sealed record AccountDto(string PublicKey, string Address); public sealed record TokenAccountDto(string Balance, TokenDto Token); -public sealed record TokenDto(CollectionDto Collection, string TokenId, IReadOnlyList Attributes); +public sealed record TokenDto( + CollectionDto Collection, + string TokenId, + IReadOnlyList Attributes +); public sealed record CollectionDto(string CollectionId); diff --git a/PlatformSampleGameServer.csproj b/PlatformSampleGameServer.csproj index 4007d9a..f55f012 100644 --- a/PlatformSampleGameServer.csproj +++ b/PlatformSampleGameServer.csproj @@ -1,5 +1,4 @@ - net9.0 enable @@ -34,5 +33,4 @@ - diff --git a/Program.cs b/Program.cs index bd14984..cb8eeca 100644 --- a/Program.cs +++ b/Program.cs @@ -27,8 +27,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddCors(o => o.AddDefaultPolicy(p => - p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); +builder.Services.AddCors(o => + o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()) +); // JSON: camelCase property names + serialize BigInteger-as-string so the // Unity client's SerializableBigInteger wrapper can parse it. Records with @@ -48,8 +49,10 @@ // Emit a generated dev secret if none is configured so the server still boots // for local development. Production must set Jwt:Secret explicitly. jwt.Secret = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); - Console.WriteLine("[warn] Jwt:Secret not configured; generated a transient dev secret. " + - "Set Jwt:Secret in appsettings or env for stable sessions across restarts."); + Console.WriteLine( + "[warn] Jwt:Secret not configured; generated a transient dev secret. " + + "Set Jwt:Secret in appsettings or env for stable sessions across restarts." + ); // Propagate the generated secret into the bound JwtOptions so AuthService // (which resolves IOptions) signs tokens with the same key that @@ -57,11 +60,13 @@ // would either throw on an empty Secret or sign with a different key. builder.Services.PostConfigure(o => { - if (string.IsNullOrWhiteSpace(o.Secret)) o.Secret = jwt.Secret; + if (string.IsNullOrWhiteSpace(o.Secret)) + o.Secret = jwt.Secret; }); } -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) +builder + .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opts => { opts.TokenValidationParameters = new TokenValidationParameters @@ -79,7 +84,8 @@ builder.Services.AddAuthorization(); // ----- Kestrel: port + request timeout ----- -var serverOptions = builder.Configuration.GetSection("Server").Get() ?? new ServerOptions(); +var serverOptions = + builder.Configuration.GetSection("Server").Get() ?? new ServerOptions(); builder.WebHost.ConfigureKestrel(k => { k.ListenAnyIP(serverOptions.Port); @@ -116,8 +122,13 @@ // Pass --skip-bootstrap (or set Enjin:SkipBootstrap=true) to start the server // without creating/verifying the on-chain collection. Useful for local smoke tests // where you don't want to mutate canary. -var skipBootstrap = args.Contains("--skip-bootstrap") - || string.Equals(builder.Configuration["Enjin:SkipBootstrap"], "true", StringComparison.OrdinalIgnoreCase); +var skipBootstrap = + args.Contains("--skip-bootstrap") + || string.Equals( + builder.Configuration["Enjin:SkipBootstrap"], + "true", + StringComparison.OrdinalIgnoreCase + ); using (var scope = app.Services.CreateScope()) { @@ -129,7 +140,8 @@ // the legacy Node.js sample used the flat ENJIN_COLLECTION_ID name, so // we accept that too as a fallback for operators migrating from the old // server. - var collectionIdSeed = builder.Configuration["Enjin:CollectionId"] + var collectionIdSeed = + builder.Configuration["Enjin:CollectionId"] ?? Environment.GetEnvironmentVariable("ENJIN_COLLECTION_ID"); state.OverrideFromConfig(collectionIdSeed); @@ -137,12 +149,16 @@ if (skipBootstrap) { - log.LogWarning("Bootstrap skipped (--skip-bootstrap). On-chain operations may fail until a collection ID is provided."); + log.LogWarning( + "Bootstrap skipped (--skip-bootstrap). On-chain operations may fail until a collection ID is provided." + ); } else { var enjin = sp.GetRequiredService(); - log.LogInformation("Preparing collection and resource tokens. This may take a few minutes on first run."); + log.LogInformation( + "Preparing collection and resource tokens. This may take a few minutes on first run." + ); try { await enjin.PrepareCollectionAsync(CancellationToken.None); diff --git a/Services/AuthService.cs b/Services/AuthService.cs index c934594..4e0737e 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -53,7 +53,8 @@ private string GenerateToken(User user) if (string.IsNullOrWhiteSpace(_jwt.Secret)) { throw new InvalidOperationException( - "Jwt:Secret is not configured. Set it in appsettings.Local.json or an env var."); + "Jwt:Secret is not configured. Set it in appsettings.Local.json or an env var." + ); } var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Secret)); @@ -69,7 +70,8 @@ private string GenerateToken(User user) new Claim(EmailClaim, user.Email), }, expires: DateTime.UtcNow.AddHours(_jwt.ExpiryHours), - signingCredentials: creds); + signingCredentials: creds + ); return new JwtSecurityTokenHandler().WriteToken(token); } diff --git a/Services/EnjinService.cs b/Services/EnjinService.cs index 3274979..7cafad2 100644 --- a/Services/EnjinService.cs +++ b/Services/EnjinService.cs @@ -55,15 +55,21 @@ public EnjinService(IOptions opts, ServerState state, ILogger()) .Where(c => c is not null) - .Where(c => c!.Attributes?.Any(a => a is not null && a.Key == "name" && a.Value == _opts.CollectionName) == true) + .Where(c => + c!.Attributes?.Any(a => + a is not null && a.Key == "name" && a.Value == _opts.CollectionName + ) == true + ) .OrderByDescending(c => c!.Id) .FirstOrDefault(); @@ -171,8 +192,9 @@ private async Task CreateCollectionAsync(CancellationToken ct) if (found is null) { throw new InvalidOperationException( - $"CreateCollection finalized but no collection named '{_opts.CollectionName}' " + - $"found owned by {_opts.DaemonWalletAddress}."); + $"CreateCollection finalized but no collection named '{_opts.CollectionName}' " + + $"found owned by {_opts.DaemonWalletAddress}." + ); } return found.Value; @@ -180,20 +202,30 @@ private async Task CreateCollectionAsync(CancellationToken ct) // Checks whether a token entry exists in our collection by querying it directly. // GetToken returns null (not an error) when the (collection, token) pair is unknown. - private async Task TokenExistsAsync(BigInteger collectionId, BigInteger tokenId, CancellationToken ct) + private async Task TokenExistsAsync( + BigInteger collectionId, + BigInteger tokenId, + CancellationToken ct + ) { var query = new QueryQueryBuilder().WithGetToken( new TokenQueryBuilder().WithTokenId(), - _network, _chain, + _network, + _chain, collectionId: collectionId, - tokenId: tokenId); + tokenId: tokenId + ); var resp = await _client.SendQuery(query); EnsureSuccess(resp, "GetToken"); return resp.Result.Data?.GetToken is not null; } - private async Task CreateResourceTokenAsync(BigInteger collectionId, ResourceTokenDefinition token, CancellationToken ct) + private async Task CreateResourceTokenAsync( + BigInteger collectionId, + ResourceTokenDefinition token, + CancellationToken ct + ) { var transaction = new TransactionInput { @@ -212,7 +244,11 @@ private async Task CreateResourceTokenAsync(BigInteger collectionId, ResourceTok }; var submitted = await CreateTransactionAsync(transaction, signerExternalId: null, ct); - await WaitForFinalizationAsync(submitted.Uuid!, $"create token #{token.Id} '{token.Name}'", ct); + await WaitForFinalizationAsync( + submitted.Uuid!, + $"create token #{token.Id} '{token.Name}'", + ct + ); } // ------------------------------------------------------------------ @@ -223,14 +259,18 @@ private async Task CreateResourceTokenAsync(BigInteger collectionId, ResourceTok { var query = new QueryQueryBuilder().WithGetManagedWallet( new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(), - _network, _chain, externalId: externalId); + _network, + _chain, + externalId: externalId + ); var resp = await _client.SendQuery(query); EnsureSuccess(resp, "GetManagedWallet"); // PublicKey on a managed wallet IS the account public key (hex); we resolve to // SS58 by calling GetAccount. var publicKey = resp.Result.Data?.GetManagedWallet?.PublicKey; - if (publicKey is null) return null; + if (publicKey is null) + return null; return await ResolveAddressAsync(publicKey, ct); } @@ -264,8 +304,9 @@ public async Task EnsureManagedWalletAsync(string externalId, Cancellati } throw new InvalidOperationException( - $"CreateManagedWallet for externalId '{externalId}' did not become queryable " + - $"after {_opts.ManagedWalletPollMaxAttempts} attempts."); + $"CreateManagedWallet for externalId '{externalId}' did not become queryable " + + $"after {_opts.ManagedWalletPollMaxAttempts} attempts." + ); } /// @@ -274,14 +315,27 @@ public async Task EnsureManagedWalletAsync(string externalId, Cancellati /// and the storage reserve for token holding records (canary platform has no /// fuel-tank API to do this in-band). /// - private async Task DripIfNeededAsync(string externalId, string recipientAddress, CancellationToken ct) + private async Task DripIfNeededAsync( + string externalId, + string recipientAddress, + CancellationToken ct + ) { - if (!_opts.DripEnjEnabled) return; - if (_state.HasDripped(externalId)) return; - if (!BigInteger.TryParse(_opts.DripEnjAmount, out var amount) || amount.IsZero || amount.Sign < 0) + if (!_opts.DripEnjEnabled) + return; + if (_state.HasDripped(externalId)) + return; + if ( + !BigInteger.TryParse(_opts.DripEnjAmount, out var amount) + || amount.IsZero + || amount.Sign < 0 + ) { - _log.LogWarning("DripEnjAmount '{Amount}' is invalid; skipping drip for {ExternalId}.", - _opts.DripEnjAmount, externalId); + _log.LogWarning( + "DripEnjAmount '{Amount}' is invalid; skipping drip for {ExternalId}.", + _opts.DripEnjAmount, + externalId + ); return; } @@ -292,27 +346,34 @@ private async Task DripIfNeededAsync(string externalId, string recipientAddress, // synchronously; we clear it on failure so a later attempt can retry. if (!_state.TryBeginDrip(externalId)) { - _log.LogDebug("Drip already in progress or completed for {ExternalId}; skipping.", externalId); + _log.LogDebug( + "Drip already in progress or completed for {ExternalId}; skipping.", + externalId + ); return; } _log.LogInformation( "Dripping {Amount} ENJ from daemon to managed wallet {Address} (externalId={ExternalId}).", - amount, recipientAddress, externalId); + amount, + recipientAddress, + externalId + ); var input = new TransactionInput { - TransferEnj = new TransferEnjInput - { - Recipient = recipientAddress, - Amount = amount, - }, + TransferEnj = new TransferEnjInput { Recipient = recipientAddress, Amount = amount }, }; try { // Signed by the daemon (signerExternalId: null). - await SubmitAndWaitAsync(input, signerExternalId: null, $"drip ENJ to {externalId}", ct); + await SubmitAndWaitAsync( + input, + signerExternalId: null, + $"drip ENJ to {externalId}", + ct + ); _state.RecordDripped(externalId); } catch @@ -336,20 +397,30 @@ private Task ResolveAddressAsync(string publicKey, CancellationToken ct) // ------------------------------------------------------------------ // Wallet token listing (used by Unity backpack UI) // ------------------------------------------------------------------ - public async Task GetManagedWalletTokensAsync(string externalId, CancellationToken ct) + public async Task GetManagedWalletTokensAsync( + string externalId, + CancellationToken ct + ) { // Step 1: locate the managed wallet's public key. var mwQuery = new QueryQueryBuilder().WithGetManagedWallet( new ManagedWalletQueryBuilder().WithPublicKey().WithExternalId(), - _network, _chain, externalId: externalId); + _network, + _chain, + externalId: externalId + ); var mwResp = await _client.SendQuery(mwQuery); EnsureSuccess(mwResp, "GetManagedWallet"); var wallet = mwResp.Result.Data?.GetManagedWallet; - if (wallet?.PublicKey is null) return null; + if (wallet?.PublicKey is null) + return null; - var collectionId = _state.CollectionId - ?? throw new InvalidOperationException("Collection ID not initialised; PrepareCollection has not run."); + var collectionId = + _state.CollectionId + ?? throw new InvalidOperationException( + "Collection ID not initialised; PrepareCollection has not run." + ); var ss58Address = SubstrateAddress.Encode(wallet.PublicKey, _opts.Ss58Prefix); @@ -361,46 +432,62 @@ private Task ResolveAddressAsync(string publicKey, CancellationToken ct) // when nested under `GetTokens` or `Account.tokens`. So we issue one // query per resource token. With ~3 tokens this is fine; we run them // in parallel. - var holderTasks = _opts.ResourceTokens - .Select(rt => FetchTokenForHolderAsync(collectionId, new BigInteger(rt.Id), ss58Address, ct)) + var holderTasks = _opts + .ResourceTokens.Select(rt => + FetchTokenForHolderAsync(collectionId, new BigInteger(rt.Id), ss58Address, ct) + ) .ToList(); var tokenAccountResults = await Task.WhenAll(holderTasks); - var tokenAccounts = tokenAccountResults - .Where(t => t is not null) - .Select(t => t!) - .ToList(); + var tokenAccounts = tokenAccountResults.Where(t => t is not null).Select(t => t!).ToList(); return new Models.ManagedWalletAccountDto( Account: new Models.AccountDto(PublicKey: wallet.PublicKey, Address: ss58Address), - TokenAccounts: tokenAccounts); + TokenAccounts: tokenAccounts + ); } private async Task FetchTokenForHolderAsync( - BigInteger collectionId, BigInteger tokenId, string holderAddress, CancellationToken ct) + BigInteger collectionId, + BigInteger tokenId, + string holderAddress, + CancellationToken ct + ) { var query = new QueryQueryBuilder().WithGetToken( new TokenQueryBuilder() .WithTokenId() .WithCollection(new CollectionQueryBuilder().WithId()) .WithAttributes(new AttributeQueryBuilder().WithKey().WithValue()) - .WithHolders(new TokenHolderQueryBuilder().WithAddress().WithAmount(), limit: 50, page: 1), - _network, _chain, + .WithHolders( + new TokenHolderQueryBuilder().WithAddress().WithAmount(), + limit: 50, + page: 1 + ), + _network, + _chain, id: null, collectionId: collectionId, - tokenId: tokenId); + tokenId: tokenId + ); var resp = await _client.SendQuery(query); EnsureSuccess(resp, $"GetToken(collection={collectionId}, token={tokenId})"); var token = resp.Result.Data?.GetToken; - if (token is null) return null; + if (token is null) + return null; - var holderBalance = token.Holders? - .Where(h => string.Equals(h.Address, holderAddress, StringComparison.OrdinalIgnoreCase)) - .Select(h => h.Amount) - .FirstOrDefault() ?? BigInteger.Zero; + var holderBalance = + token + .Holders?.Where(h => + string.Equals(h.Address, holderAddress, StringComparison.OrdinalIgnoreCase) + ) + .Select(h => h.Amount) + .FirstOrDefault() + ?? BigInteger.Zero; - if (holderBalance.IsZero) return null; + if (holderBalance.IsZero) + return null; var attrs = (token.Attributes ?? Enumerable.Empty()) .Select(a => new Models.AttributeDto(a.Key ?? "", a.Value ?? "")) @@ -409,16 +496,25 @@ private Task ResolveAddressAsync(string publicKey, CancellationToken ct) return new Models.TokenAccountDto( Balance: holderBalance.ToString(), Token: new Models.TokenDto( - Collection: new Models.CollectionDto(token.Collection?.Id.ToString() ?? collectionId.ToString()), + Collection: new Models.CollectionDto( + token.Collection?.Id.ToString() ?? collectionId.ToString() + ), TokenId: token.TokenId ?? tokenId.ToString(), - Attributes: attrs)); + Attributes: attrs + ) + ); } // ------------------------------------------------------------------ // Mint / Burn / Transfer (all wait for chain finalization) // ------------------------------------------------------------------ - public Task MintTokenAsync(BigInteger tokenId, BigInteger amount, string recipientAddress, CancellationToken ct) + public Task MintTokenAsync( + BigInteger tokenId, + BigInteger amount, + string recipientAddress, + CancellationToken ct + ) { var input = new TransactionInput { @@ -433,7 +529,12 @@ public Task MintTokenAsync(BigInteger tokenId, BigInteger amount, s return SubmitAndWaitAsync(input, signerExternalId: null, $"mint token #{tokenId}", ct); } - public Task MeltTokenAsync(BigInteger tokenId, BigInteger amount, string signerExternalId, CancellationToken ct) + public Task MeltTokenAsync( + BigInteger tokenId, + BigInteger amount, + string signerExternalId, + CancellationToken ct + ) { var input = new TransactionInput { @@ -447,7 +548,13 @@ public Task MeltTokenAsync(BigInteger tokenId, BigInteger amount, s return SubmitAndWaitAsync(input, signerExternalId, $"burn token #{tokenId}", ct); } - public Task TransferTokenAsync(BigInteger tokenId, BigInteger amount, string recipientAddress, string signerExternalId, CancellationToken ct) + public Task TransferTokenAsync( + BigInteger tokenId, + BigInteger amount, + string recipientAddress, + string signerExternalId, + CancellationToken ct + ) { var input = new TransactionInput { @@ -467,31 +574,47 @@ public Task TransferTokenAsync(BigInteger tokenId, BigInteger amoun // ------------------------------------------------------------------ private async Task SubmitAndWaitAsync( - TransactionInput input, string? signerExternalId, string description, CancellationToken ct) + TransactionInput input, + string? signerExternalId, + string description, + CancellationToken ct + ) { var submitted = await CreateTransactionAsync(input, signerExternalId, ct); return await WaitForFinalizationAsync(submitted.Uuid!, description, ct); } private async Task CreateTransactionAsync( - TransactionInput input, string? signerExternalId, CancellationToken ct) + TransactionInput input, + string? signerExternalId, + CancellationToken ct + ) { var mutation = new MutationQueryBuilder().WithCreateTransaction( new TransactionQueryBuilder().WithUuid().WithState(), - _network, _chain, + _network, + _chain, transaction: input, - signerExternalId: signerExternalId); + signerExternalId: signerExternalId + ); var resp = await _client.SendMutation(mutation); EnsureSuccess(resp, "CreateTransaction"); - var txn = resp.Result.Data?.CreateTransaction + var txn = + resp.Result.Data?.CreateTransaction ?? throw new InvalidOperationException("CreateTransaction returned no transaction."); if (string.IsNullOrEmpty(txn.Uuid)) - throw new InvalidOperationException("CreateTransaction returned a transaction with no UUID."); + throw new InvalidOperationException( + "CreateTransaction returned a transaction with no UUID." + ); return txn; } - private async Task WaitForFinalizationAsync(string uuid, string description, CancellationToken ct) + private async Task WaitForFinalizationAsync( + string uuid, + string description, + CancellationToken ct + ) { if (_opts.TransactionInitialDelaySeconds > 0) { @@ -508,25 +631,36 @@ private async Task WaitForFinalizationAsync(string uuid, string des var query = new QueryQueryBuilder().WithGetTransaction( new TransactionQueryBuilder().WithUuid().WithState().WithExtrinsicHash(), - _network, _chain, uuid: uuid); + _network, + _chain, + uuid: uuid + ); var resp = await _client.SendQuery(query); EnsureSuccess(resp, "GetTransaction"); - var txn = resp.Result.Data?.GetTransaction - ?? throw new InvalidOperationException($"GetTransaction returned nothing for UUID {uuid}."); + var txn = + resp.Result.Data?.GetTransaction + ?? throw new InvalidOperationException( + $"GetTransaction returned nothing for UUID {uuid}." + ); switch (txn.State) { case TransactionStateEnum.Finalized: - _log.LogInformation("Transaction {Uuid} ({Desc}) finalized after {Elapsed:F0}s.", - uuid, description, (DateTime.UtcNow - started).TotalSeconds); + _log.LogInformation( + "Transaction {Uuid} ({Desc}) finalized after {Elapsed:F0}s.", + uuid, + description, + (DateTime.UtcNow - started).TotalSeconds + ); return txn; case TransactionStateEnum.Failed: case TransactionStateEnum.Abandoned: case TransactionStateEnum.Timeout: throw new InvalidOperationException( - $"Transaction {uuid} ({description}) ended in terminal state {txn.State}."); + $"Transaction {uuid} ({description}) ended in terminal state {txn.State}." + ); default: // Log only on state transitions to avoid flooding logs while polling. @@ -534,22 +668,34 @@ private async Task WaitForFinalizationAsync(string uuid, string des { _log.LogInformation( "Waiting for {Desc} (uuid={Uuid}, state={State})...", - description, uuid, txn.State); + description, + uuid, + txn.State + ); lastLoggedState = txn.State; lastStuckWarning = DateTime.UtcNow; } - else if (txn.State == TransactionStateEnum.Pending - && (DateTime.UtcNow - lastStuckWarning).TotalSeconds >= 60) + else if ( + txn.State == TransactionStateEnum.Pending + && (DateTime.UtcNow - lastStuckWarning).TotalSeconds >= 60 + ) { var elapsed = (DateTime.UtcNow - started).TotalSeconds; _log.LogWarning( - "Transaction {Uuid} ({Desc}) has been Pending for {Elapsed:F0}s. " + - "Confirm that a wallet daemon is signing transactions for account {Daemon}.", - uuid, description, elapsed, _opts.DaemonWalletAddress); + "Transaction {Uuid} ({Desc}) has been Pending for {Elapsed:F0}s. " + + "Confirm that a wallet daemon is signing transactions for account {Daemon}.", + uuid, + description, + elapsed, + _opts.DaemonWalletAddress + ); lastStuckWarning = DateTime.UtcNow; } - await Task.Delay(TimeSpan.FromSeconds(_opts.TransactionPollIntervalSeconds), ct); + await Task.Delay( + TimeSpan.FromSeconds(_opts.TransactionPollIntervalSeconds), + ct + ); break; } } @@ -560,27 +706,45 @@ private async Task WaitForFinalizationAsync(string uuid, string des // ------------------------------------------------------------------ private BigInteger RequireCollectionId() => - _state.CollectionId ?? throw new InvalidOperationException( - "Collection ID not initialised; PrepareCollection has not run."); + _state.CollectionId + ?? throw new InvalidOperationException( + "Collection ID not initialised; PrepareCollection has not run." + ); private static void EnsureSuccess(IPlatformResponse resp, string operation) => - EnsureSuccessCore(resp.IsSuccessStatusCode, resp.StatusCode, resp.Result?.Errors, operation); + EnsureSuccessCore( + resp.IsSuccessStatusCode, + resp.StatusCode, + resp.Result?.Errors, + operation + ); private static void EnsureSuccess(IPlatformResponse resp, string operation) => - EnsureSuccessCore(resp.IsSuccessStatusCode, resp.StatusCode, resp.Result?.Errors, operation); + EnsureSuccessCore( + resp.IsSuccessStatusCode, + resp.StatusCode, + resp.Result?.Errors, + operation + ); private static void EnsureSuccessCore( - bool isSuccess, System.Net.HttpStatusCode status, ICollection? errors, string operation) + bool isSuccess, + System.Net.HttpStatusCode status, + ICollection? errors, + string operation + ) { if (!isSuccess) { throw new InvalidOperationException( - $"{operation} returned HTTP {(int)status} {status}."); + $"{operation} returned HTTP {(int)status} {status}." + ); } if (errors is { Count: > 0 }) { throw new InvalidOperationException( - $"{operation} returned GraphQL errors: {string.Join("; ", errors.Select(e => e.Message))}"); + $"{operation} returned GraphQL errors: {string.Join("; ", errors.Select(e => e.Message))}" + ); } } @@ -598,10 +762,12 @@ public sealed class ServerState { private readonly string _path; private BigInteger? _collectionId; + // Case-insensitive to match UserStore (OrdinalIgnoreCase). externalId is an // email, and "Alice@x.com" / "alice@x.com" are the same user; tracking them // separately here would let the same player be dripped twice. private readonly HashSet _drippedExternalIds = new(StringComparer.OrdinalIgnoreCase); + // In-flight drips. Not persisted; lives only for the lifetime of the process // and exists purely to serialise concurrent EnsureManagedWalletAsync calls // for the same externalId so we never submit two TransferEnj transactions @@ -617,7 +783,13 @@ public ServerState(IHostEnvironment env) public BigInteger? CollectionId { - get { lock (_lock) { return _collectionId; } } + get + { + lock (_lock) + { + return _collectionId; + } + } } public void SetCollectionId(BigInteger id) @@ -631,7 +803,10 @@ public void SetCollectionId(BigInteger id) public bool HasDripped(string externalId) { - lock (_lock) { return _drippedExternalIds.Contains(externalId); } + lock (_lock) + { + return _drippedExternalIds.Contains(externalId); + } } /// @@ -645,14 +820,18 @@ public bool TryBeginDrip(string externalId) { lock (_lock) { - if (_drippedExternalIds.Contains(externalId)) return false; + if (_drippedExternalIds.Contains(externalId)) + return false; return _drippingInFlight.Add(externalId); } } public void CancelDrip(string externalId) { - lock (_lock) { _drippingInFlight.Remove(externalId); } + lock (_lock) + { + _drippingInFlight.Remove(externalId); + } } public void RecordDripped(string externalId) @@ -660,14 +839,17 @@ public void RecordDripped(string externalId) lock (_lock) { _drippingInFlight.Remove(externalId); - if (_drippedExternalIds.Add(externalId)) Persist(); + if (_drippedExternalIds.Add(externalId)) + Persist(); } } public void OverrideFromConfig(string? configured) { - if (string.IsNullOrWhiteSpace(configured)) return; - if (!BigInteger.TryParse(configured, out var parsed)) return; + if (string.IsNullOrWhiteSpace(configured)) + return; + if (!BigInteger.TryParse(configured, out var parsed)) + return; lock (_lock) { // Don't clobber on-disk state if it disagrees; on-disk wins because @@ -678,26 +860,32 @@ public void OverrideFromConfig(string? configured) private void Load() { - if (!File.Exists(_path)) return; + if (!File.Exists(_path)) + return; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); - if (doc.RootElement.TryGetProperty("collectionId", out var prop) + if ( + doc.RootElement.TryGetProperty("collectionId", out var prop) && prop.ValueKind == JsonValueKind.String - && BigInteger.TryParse(prop.GetString(), out var parsed)) + && BigInteger.TryParse(prop.GetString(), out var parsed) + ) { _collectionId = parsed; } - if (doc.RootElement.TryGetProperty("drippedExternalIds", out var arr) - && arr.ValueKind == JsonValueKind.Array) + if ( + doc.RootElement.TryGetProperty("drippedExternalIds", out var arr) + && arr.ValueKind == JsonValueKind.Array + ) { foreach (var item in arr.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String) { var s = item.GetString(); - if (!string.IsNullOrEmpty(s)) _drippedExternalIds.Add(s); + if (!string.IsNullOrEmpty(s)) + _drippedExternalIds.Add(s); } } } @@ -715,6 +903,9 @@ private void Persist() collectionId = _collectionId?.ToString(), drippedExternalIds = _drippedExternalIds.OrderBy(s => s).ToArray(), }; - File.WriteAllText(_path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + File.WriteAllText( + _path, + JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }) + ); } } diff --git a/Services/SubstrateAddress.cs b/Services/SubstrateAddress.cs index fd390bd..417e656 100644 --- a/Services/SubstrateAddress.cs +++ b/Services/SubstrateAddress.cs @@ -38,7 +38,9 @@ public static string Encode(string publicKeyHex, ushort ss58Prefix) : publicKeyHex; if (hex.Length != 64) throw new ArgumentException( - $"Expected 32-byte public key (64 hex chars), got {hex.Length}.", nameof(publicKeyHex)); + $"Expected 32-byte public key (64 hex chars), got {hex.Length}.", + nameof(publicKeyHex) + ); var payload = Convert.FromHexString(hex); return Encode(payload, ss58Prefix); @@ -49,9 +51,13 @@ public static string Encode(string publicKeyHex, ushort ss58Prefix) /// public static string Encode(byte[] publicKey, ushort ss58Prefix) { - if (publicKey is null) throw new ArgumentNullException(nameof(publicKey)); + if (publicKey is null) + throw new ArgumentNullException(nameof(publicKey)); if (publicKey.Length != 32) - throw new ArgumentException($"Expected 32 bytes, got {publicKey.Length}.", nameof(publicKey)); + throw new ArgumentException( + $"Expected 32 bytes, got {publicKey.Length}.", + nameof(publicKey) + ); // Encode prefix. <64 fits in one byte; >=64 uses the two-byte form per // https://docs.substrate.io/reference/address-formats/. @@ -70,15 +76,23 @@ public static string Encode(byte[] publicKey, ushort ss58Prefix) } else { - throw new ArgumentOutOfRangeException(nameof(ss58Prefix), - "SS58 prefixes >= 16384 are reserved."); + throw new ArgumentOutOfRangeException( + nameof(ss58Prefix), + "SS58 prefixes >= 16384 are reserved." + ); } // checksum = first 2 bytes of Blake2b-512("SS58PRE" || prefix || payload) var hashInput = new byte[Ss58Pre.Length + prefixBytes.Length + publicKey.Length]; Buffer.BlockCopy(Ss58Pre, 0, hashInput, 0, Ss58Pre.Length); Buffer.BlockCopy(prefixBytes, 0, hashInput, Ss58Pre.Length, prefixBytes.Length); - Buffer.BlockCopy(publicKey, 0, hashInput, Ss58Pre.Length + prefixBytes.Length, publicKey.Length); + Buffer.BlockCopy( + publicKey, + 0, + hashInput, + Ss58Pre.Length + prefixBytes.Length, + publicKey.Length + ); using var blake = new HMACBlake2B(512); // SS58 specifies an UNKEYED Blake2b-512 over "SS58PRE" || prefix || payload. @@ -104,7 +118,8 @@ private static string Base58Encode(byte[] data) { // Count leading zero bytes int zeros = 0; - while (zeros < data.Length && data[zeros] == 0) zeros++; + while (zeros < data.Length && data[zeros] == 0) + zeros++; // Convert big-endian bytes to base58 by repeated division. // Operate on a copy because we mutate during division. @@ -122,10 +137,12 @@ private static string Base58Encode(byte[] data) remainder = num % 58; } encoded.Add(Base58Alphabet[remainder]); - if (input[startAt] == 0) startAt++; + if (input[startAt] == 0) + startAt++; } - for (int i = 0; i < zeros; i++) encoded.Add(Base58Alphabet[0]); + for (int i = 0; i < zeros; i++) + encoded.Add(Base58Alphabet[0]); encoded.Reverse(); return new string(encoded.ToArray()); } diff --git a/Services/UserStore.cs b/Services/UserStore.cs index a1e1528..d854abe 100644 --- a/Services/UserStore.cs +++ b/Services/UserStore.cs @@ -8,10 +8,11 @@ namespace PlatformSampleGameServer.Services; // a sample. Replace with a real persistence layer for production use. public sealed class UserStore { - private readonly ConcurrentDictionary _users = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _users = new( + StringComparer.OrdinalIgnoreCase + ); - public User? FindByEmail(string email) => - _users.TryGetValue(email, out var user) ? user : null; + public User? FindByEmail(string email) => _users.TryGetValue(email, out var user) ? user : null; public User Create(string email, string passwordHash) { diff --git a/scripts/precommit.sh b/scripts/precommit.sh new file mode 100755 index 0000000..340a7cb --- /dev/null +++ b/scripts/precommit.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Run formatters, build, and tests before committing. +# Usage: ./scripts/precommit.sh +# +# Works under bash and zsh, regardless of the caller's working directory. + +set -euo pipefail + +# Resolve this script's directory in a shell-agnostic way. +# $BASH_SOURCE exists in bash; ${(%):-%x} is the zsh equivalent; $0 is the +# universal fallback when the script is invoked as `sh script.sh`. +if [ -n "${BASH_SOURCE:-}" ]; then + SCRIPT_PATH="${BASH_SOURCE[0]}" +elif [ -n "${ZSH_VERSION:-}" ]; then + # shellcheck disable=SC2296 + SCRIPT_PATH="${(%):-%x}" +else + SCRIPT_PATH="$0" +fi + +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT="$REPO_ROOT/PlatformSampleGameServer.csproj" +TOOLS_PROJECT="$REPO_ROOT/tools/Ss58SelfTest/Ss58SelfTest.csproj" + +cd "$REPO_ROOT" + +echo "==> Restoring .NET tools" +dotnet tool restore + +echo "==> Running CSharpier" +dotnet csharpier format . + +echo "==> Running dotnet format (main project)" +dotnet format "$PROJECT" + +echo "==> Running dotnet format (tools project)" +dotnet format "$TOOLS_PROJECT" + +echo "==> Building main project" +dotnet build "$PROJECT" --configuration Release + +echo "==> Building tools project" +dotnet build "$TOOLS_PROJECT" --configuration Release + +echo "==> All checks passed" diff --git a/tools/Ss58SelfTest/Program.cs b/tools/Ss58SelfTest/Program.cs index 241a72d..634a3d0 100644 --- a/tools/Ss58SelfTest/Program.cs +++ b/tools/Ss58SelfTest/Program.cs @@ -60,7 +60,9 @@ if (failures > 0) { - Console.Error.WriteLine($"\n{failures} SS58 vector(s) failed. The encoder is producing incorrect addresses."); + Console.Error.WriteLine( + $"\n{failures} SS58 vector(s) failed. The encoder is producing incorrect addresses." + ); return 1; } From 7fcdb9b32fab37728b722ae54912c3419fdc99f2 Mon Sep 17 00:00:00 2001 From: Jay Pavlina Date: Wed, 17 Jun 2026 13:44:48 -0300 Subject: [PATCH 10/10] use nuget and add tests --- Endpoints/TokenEndpoints.cs | 40 +++++++++-- Models/Dtos.cs | 6 +- PlatformSampleGameServer.csproj | 11 +-- README.md | 8 ++- scripts/precommit.sh | 17 +++-- tools/TransferAmountSelfTest/Program.cs | 71 +++++++++++++++++++ .../TransferAmountSelfTest.csproj | 12 ++++ 7 files changed, 137 insertions(+), 28 deletions(-) create mode 100644 tools/TransferAmountSelfTest/Program.cs create mode 100644 tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj diff --git a/Endpoints/TokenEndpoints.cs b/Endpoints/TokenEndpoints.cs index 54372d2..10a6c7f 100644 --- a/Endpoints/TokenEndpoints.cs +++ b/Endpoints/TokenEndpoints.cs @@ -80,7 +80,7 @@ await Run( private static async Task Run( string operation, string tokenIdString, - int amount, + string amountString, ClaimsPrincipal user, Func action ) @@ -94,15 +94,12 @@ Func action new BoolResponse(false, $"Invalid tokenId '{tokenIdString}'."), statusCode: 400 ); - if (amount <= 0) - return Results.Json( - new BoolResponse(false, "Amount must be positive."), - statusCode: 400 - ); + if (!TryParseAmount(amountString, out var amount, out var amountError)) + return Results.Json(new BoolResponse(false, amountError), statusCode: 400); try { - await action(tokenId, new BigInteger(amount), email); + await action(tokenId, amount, email); return Results.Ok(new BoolResponse(true)); } catch (Exception ex) @@ -110,4 +107,33 @@ Func action return Results.Json(new BoolResponse(false, ex.Message), statusCode: 500); } } + + /// + /// Parses a token amount supplied as a decimal string. Token amounts are + /// BigIntegers on-chain, so the wire format carries them as decimal strings + /// to allow values above int/long range. Returns false with a user-facing + /// when the string is not a positive integer. + /// + public static bool TryParseAmount( + string? amountString, + out BigInteger amount, + out string? error + ) + { + if (!BigInteger.TryParse(amountString, out amount)) + { + amount = BigInteger.Zero; + error = $"Invalid amount '{amountString}'."; + return false; + } + if (amount <= 0) + { + amount = BigInteger.Zero; + error = "Amount must be positive."; + return false; + } + + error = null; + return true; + } } diff --git a/Models/Dtos.cs b/Models/Dtos.cs index b9ca9a5..3d1832f 100644 --- a/Models/Dtos.cs +++ b/Models/Dtos.cs @@ -10,11 +10,11 @@ namespace PlatformSampleGameServer.Models; public sealed record AuthRequest(string Email, string Password); -public sealed record MintRequest(string TokenId, int Amount); +public sealed record MintRequest(string TokenId, string Amount); -public sealed record MeltRequest(string TokenId, int Amount); +public sealed record MeltRequest(string TokenId, string Amount); -public sealed record TransferRequest(string TokenId, int Amount, string Recipient); +public sealed record TransferRequest(string TokenId, string Amount, string Recipient); // ---- Response bodies ---- diff --git a/PlatformSampleGameServer.csproj b/PlatformSampleGameServer.csproj index f55f012..11592a7 100644 --- a/PlatformSampleGameServer.csproj +++ b/PlatformSampleGameServer.csproj @@ -22,15 +22,6 @@ - - - + diff --git a/README.md b/README.md index 6ee3705..7fd0ecb 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ REST backend for the **Enjin Farmer** Unity sample demonstrating how to integrate NFTs into a game using the [Enjin Platform C# SDK](https://github.com/enjin/platform-csharp-sdk). -This is a rewrite of the previous Node.js server in .NET 9. The wire format -(JSON shape, route paths, JWT handling) is preserved so the existing Unity -client works unchanged. +This is a rewrite of the previous Node.js server in .NET 9, preserving the +route paths and JWT handling. One deliberate wire change came with SDK v3: +token `amount` fields on the mint / melt / transfer endpoints are sent as +decimal strings (the SDK uses `BigInteger` for token amounts), so the matching +Unity client sends them as strings too. ## What this server does diff --git a/scripts/precommit.sh b/scripts/precommit.sh index 340a7cb..f4b5ebf 100755 --- a/scripts/precommit.sh +++ b/scripts/precommit.sh @@ -21,7 +21,8 @@ fi SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT="$REPO_ROOT/PlatformSampleGameServer.csproj" -TOOLS_PROJECT="$REPO_ROOT/tools/Ss58SelfTest/Ss58SelfTest.csproj" +SS58_TEST_PROJECT="$REPO_ROOT/tools/Ss58SelfTest/Ss58SelfTest.csproj" +AMOUNT_TEST_PROJECT="$REPO_ROOT/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj" cd "$REPO_ROOT" @@ -34,13 +35,19 @@ dotnet csharpier format . echo "==> Running dotnet format (main project)" dotnet format "$PROJECT" -echo "==> Running dotnet format (tools project)" -dotnet format "$TOOLS_PROJECT" +echo "==> Running dotnet format (tools projects)" +dotnet format "$SS58_TEST_PROJECT" +dotnet format "$AMOUNT_TEST_PROJECT" echo "==> Building main project" dotnet build "$PROJECT" --configuration Release -echo "==> Building tools project" -dotnet build "$TOOLS_PROJECT" --configuration Release +echo "==> Building tools projects" +dotnet build "$SS58_TEST_PROJECT" --configuration Release +dotnet build "$AMOUNT_TEST_PROJECT" --configuration Release + +echo "==> Running self-tests" +dotnet run --project "$SS58_TEST_PROJECT" --configuration Release +dotnet run --project "$AMOUNT_TEST_PROJECT" --configuration Release echo "==> All checks passed" diff --git a/tools/TransferAmountSelfTest/Program.cs b/tools/TransferAmountSelfTest/Program.cs new file mode 100644 index 0000000..2bf0813 --- /dev/null +++ b/tools/TransferAmountSelfTest/Program.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using PlatformSampleGameServer.Endpoints; + +// Regression test for Endpoints/TokenEndpoints.TryParseAmount. +// +// Mint / melt / transfer amounts arrive on the wire as decimal strings because +// token amounts are BigIntegers on-chain (Enjin Platform C# SDK v3). The parser +// must: +// +// - accept positive integers of arbitrary size, preserving the exact value +// (a value above long range is the property an int/long parser would lose); +// - reject zero and negative amounts; +// - reject non-integer / non-numeric input (decimals, words, empty, null). +// +// Any mismatch prints the divergence and exits non-zero so CI / a manual +// `dotnet run --project tools/TransferAmountSelfTest` fails loudly if the +// amount validation regresses. + +// 2^128, well beyond int and long range — the headline BigInteger case. +var huge = BigInteger.Pow(2, 128); + +var vectors = new ( + string Label, + string? Input, + bool ExpectOk, + BigInteger ExpectedAmount, + string? ExpectedError +)[] +{ + ("small positive", "5", true, 5, null), + ("above int range", "3000000000", true, 3_000_000_000L, null), + ("above long range (2^128)", huge.ToString(), true, huge, null), + ("leading/trailing whitespace tolerated", " 42 ", true, 42, null), + ("zero rejected", "0", false, default, "Amount must be positive."), + ("negative rejected", "-1", false, default, "Amount must be positive."), + ("decimal rejected", "1.5", false, default, "Invalid amount '1.5'."), + ("non-numeric rejected", "abc", false, default, "Invalid amount 'abc'."), + ("empty rejected", "", false, default, "Invalid amount ''."), + ("null rejected", null, false, default, "Invalid amount ''."), +}; + +int failures = 0; +foreach (var v in vectors) +{ + var ok = TokenEndpoints.TryParseAmount(v.Input, out var amount, out var error); + + var passed = + ok == v.ExpectOk && (v.ExpectOk ? amount == v.ExpectedAmount : error == v.ExpectedError); + + Console.WriteLine($"[{(passed ? "PASS" : "FAIL")}] {v.Label}"); + if (!passed) + { + Console.WriteLine($" input: {v.Input ?? ""}"); + Console.WriteLine( + $" expected: ok={v.ExpectOk} amount={v.ExpectedAmount} error={v.ExpectedError ?? ""}" + ); + Console.WriteLine($" actual: ok={ok} amount={amount} error={error ?? ""}"); + failures++; + } +} + +if (failures > 0) +{ + Console.Error.WriteLine( + $"\n{failures} amount vector(s) failed. Transfer amount validation has regressed." + ); + return 1; +} + +Console.WriteLine($"\nAll {vectors.Length} transfer-amount vectors passed."); +return 0; diff --git a/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj b/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj new file mode 100644 index 0000000..4f75d5e --- /dev/null +++ b/tools/TransferAmountSelfTest/TransferAmountSelfTest.csproj @@ -0,0 +1,12 @@ + + + Exe + net9.0 + enable + enable + TransferAmountSelfTest + + + + +