Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .env.example

This file was deleted.

18 changes: 14 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
node_modules/
.env
npm-debug.log
.DS_Store
.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
59 changes: 59 additions & 0 deletions Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
Comment thread
JayPavlina marked this conversation as resolved.
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}");
}
Comment thread
JayPavlina marked this conversation as resolved.

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();
}
}
39 changes: 39 additions & 0 deletions Endpoints/SetupEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using PlatformSampleGameServer.Models;
using PlatformSampleGameServer.Services;

namespace PlatformSampleGameServer.Endpoints;

/// <summary>
/// Setup-time endpoints. Called by the Unity Editor (and any other tooling)
/// once when standing the game up against a new server / new canary state, to
/// bake server-allocated identifiers into client-side configuration assets.
///
/// Intentionally unauthenticated: setup happens before any player account
/// exists, the values exposed here are not secret (they end up in shipped
/// client builds anyway), and gating this behind a JWT would be a chicken-
/// and-egg problem for fresh installs.
/// </summary>
public static class SetupEndpoints
{
public static void MapSetupEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/setup");

// GET /api/setup/collection-id - returns the on-chain collection id
// 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;
Comment thread
JayPavlina marked this conversation as resolved.
Outdated
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()));
});
}
}
75 changes: 75 additions & 0 deletions Endpoints/TokenEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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<IResult> Run(
string operation,
string tokenIdString,
int amount,
ClaimsPrincipal user,
Func<BigInteger, BigInteger, string, Task> 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);
}
}
}
41 changes: 41 additions & 0 deletions Endpoints/WalletEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
45 changes: 45 additions & 0 deletions Models/Dtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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<TokenAccountDto> 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
// EnjinItem ScriptableObject; the running game itself never calls it. The
// server allocates the collection during bootstrap (Program.cs) and persists
// the id in state.json, so this endpoint is a stable read after that.
public sealed record CollectionIdResponse(string CollectionId);

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<AttributeDto> Attributes);

public sealed record CollectionDto(string CollectionId);

public sealed record AttributeDto(string Key, string Value);
6 changes: 6 additions & 0 deletions Models/User.cs
Original file line number Diff line number Diff line change
@@ -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);
38 changes: 38 additions & 0 deletions PlatformSampleGameServer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>PlatformSampleGameServer</RootNamespace>
<AssemblyName>PlatformSampleGameServer</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Konscious.Security.Cryptography.Blake2" Version="1.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
<!-- Out-of-band utilities live under tools/ and have their own .csproj. -->
<Compile Remove="tools/**" />
<None Remove="tools/**" />
<Content Remove="tools/**" />
</ItemGroup>

<ItemGroup>
<!--
Enjin Platform C# SDK.

For local development against an unreleased SDK, comment the
PackageReference out and uncomment the ProjectReference below. The
ProjectReference expects the SDK repository to be checked out as a sibling
directory of this one.
-->
<PackageReference Include="Enjin.Platform.Sdk" Version="3.0.0" />
<!-- <ProjectReference Include="..\platform-csharp-sdk\src\Enjin.Platform.Sdk\Enjin.Platform.Sdk\Enjin.Platform.Sdk.csproj" /> -->
</ItemGroup>

</Project>
Loading