Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
20 changes: 0 additions & 20 deletions .env

This file was deleted.

24 changes: 0 additions & 24 deletions .env.example

This file was deleted.

4 changes: 4 additions & 0 deletions packages/backend/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
*/

import type { ApiFromModules, FilterApi, FunctionReference } from "convex/server";
import type * as common from "../common.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as kv from "../kv.js";
import type * as players from "../players.js";
import type * as schemas from "../schemas.js";

/**
* A utility for referencing Convex functions in your app's API.
Expand All @@ -23,10 +25,12 @@ import type * as players from "../players.js";
* ```
*/
declare const fullApi: ApiFromModules<{
common: typeof common;
healthCheck: typeof healthCheck;
http: typeof http;
kv: typeof kv;
players: typeof players;
schemas: typeof schemas;
}>;
export declare const api: FilterApi<typeof fullApi, FunctionReference<any, "public">>;
export declare const internal: FilterApi<typeof fullApi, FunctionReference<any, "internal">>;
11 changes: 11 additions & 0 deletions packages/backend/convex/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NoOp } from "convex-helpers/server/customFunctions";
import { zCustomMutation, zCustomQuery } from "convex-helpers/server/zod";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";

export const zQuery = zCustomQuery(query, NoOp);

export const zMutation = zCustomMutation(mutation, NoOp);

export const zInternalQuery = zCustomQuery(internalQuery, NoOp);

export const zInternalMutation = zCustomMutation(internalMutation, NoOp);
122 changes: 107 additions & 15 deletions packages/backend/convex/http.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { zValidator } from "@hono/zod-validator";
import { HonoWithConvex, HttpRouterWithHono } from "convex-helpers/server/hono";
import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { timing } from "hono/timing";
import { api, internal } from "./_generated/api";
import { ActionCtx } from "./_generated/server";
import { PlayerDocument, playerDocumentSchema } from "./schemas";

const app: HonoWithConvex<ActionCtx> = new Hono();

Expand All @@ -28,6 +30,7 @@ app.get("/kv/:key", async (c) => {
if (value === undefined) {
return c.json({ error: "Key not found" }, 404);
}

return c.json({ key, value });
});

Expand Down Expand Up @@ -56,26 +59,115 @@ app.put("/kv/:key", async (c) => {
return c.json({ message: "Key-Value pair updated successfully", key, value });
});

app.get("/players/:uuid/joinData", async (c) => {
const uuid = c.req.param("uuid");

const playerData = await c.env.runQuery(api.players.getPlayerByUuid, { uuid });
let firstTimeJoin = false;
app.get("/players/:uuid/document", async (c) => {
try {
const uuid = c.req.param("uuid");
const isJoinEvent = c.req.query("joinEvent") === "true";

let playerData = await c.env.runQuery(internal.players.getPlayerByUuid, { uuid });

const isFirstTimeOnServer = !playerData;

if (isFirstTimeOnServer) {
try {
await c.env.runAction(internal.players.createPlayer, {
uuid,
});
} catch (error) {
if (error instanceof Error && error.message.includes("Player not found on playerdb.co")) {
return c.json({ message: `"${uuid}" is not a valid minecraft uuid` }, { status: 404 });
}

return c.json({ message: "Player data was not able to be created" }, { status: 500 });
}
} else if (isJoinEvent) {
await c.env.runMutation(internal.players.updatePlayerJoin, { uuid });

if (playerData) {
playerData = {
...playerData,
lastJoinDate: new Date().toISOString(),
stats: {
...playerData.stats,
joinCount: (playerData.stats.joinCount as number) || 0 + 1,
},
};
}
}

playerData ??= await c.env.runQuery(internal.players.getPlayerByUuid, { uuid });

if (!playerData) {
return c.json({ message: "Player data was not able to be created" }, { status: 500 });
}

const banData = await c.env.runQuery(internal.players.getPlayerBanByUuid, { uuid });

const document: PlayerDocument = {
avatarUrl: playerData.avatarUrl,
isFirstTimeOnServer,
lastJoinDate: playerData.lastJoinDate,
stats: playerData.stats,
banData: banData
? {
isBanned: true,
reason: banData.reason,
expiresAt: banData.expiresAt,
bannedAt: banData.bannedAt,
bannedBy: banData.bannedBy,
}
: null,
};

return c.json(document);
} catch (error) {
return c.json(
{
message: "Player document was not able to be fetched",
error,
},
{ status: 500 },
);
}
});

if (!playerData) {
firstTimeJoin = true;
app.put("/players/:uuid/document", zValidator("json", playerDocumentSchema), async (c) => {
try {
const uuid = c.req.param("uuid");
const document = c.req.valid("json");

await c.env.runAction(internal.players.createPlayer, {
uuid,
await c.env.runMutation(internal.players.savePlayerDocument, {
uuid: uuid as any,
document,
});
} else {
await c.env.runMutation(internal.players.updatePlayerJoin, { uuid });

return c.json({ message: "Player document saved" });
} catch (error) {
return c.json(
{
message: "Player document was not able to be saved",
error,
},
{ status: 500 },
);
}
});
Comment on lines +112 to 154
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove unsafe casts; return a response on PUT

as any masks a real type mismatch. Also, the handler returns nothing; send a success response.

-app.put("/players/:uuid/document", zValidator("json", playerDocumentSchema), async (c) => {
+app.put("/players/:uuid/document", zValidator("json", playerDocumentSchema), async (c) => {
   const uuid = c.req.param("uuid");
   const document = c.req.valid("json");
 
   await c.env.runMutation(internal.players.savePlayerDocument, {
-    uuid: uuid as any,
+    uuid,
     document,
   });
+  return c.json({ message: "Player document saved" });
 });

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/backend/convex/http.ts around lines 112 to 120, remove the unsafe
"as any" cast on uuid and ensure the types passed to runMutation match the
mutation signature (convert/validate uuid to the expected type, e.g., string or
UUID type) by explicitly typing or parsing the param and using the validated
document type from zValidator; after awaiting runMutation return an appropriate
HTTP response (e.g., JSON success or 204) with correct status so the PUT handler
does not return nothing.


return c.json({
firstTimeJoin,
playerData: firstTimeJoin ? null : playerData,
});
app.delete("/players/:uuid/document", async (c) => {
try {
const uuid = c.req.param("uuid");
await c.env.runMutation(internal.players.deletePlayerDocument, { uuid: uuid as any });

return c.json({ message: "Player data deleted successfully" });
} catch (error) {
return c.json(
{
message: "Player data was not able to be deleted",
error,
},
{ status: 500 },
);
}
});
Comment on lines +122 to +171
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove unsafe cast on DELETE

Aligns with delete mutation accepting external UUID.

 app.delete("/players/:uuid/document", async (c) => {
   const uuid = c.req.param("uuid");
-  await c.env.runMutation(internal.players.deletePlayerDocument, { uuid: uuid as any });
+  await c.env.runMutation(internal.players.deletePlayerDocument, { uuid });
 
   return c.json({ message: "Player data deleted successfully" });
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.delete("/players/:uuid/document", async (c) => {
const uuid = c.req.param("uuid");
await c.env.runMutation(internal.players.deletePlayerDocument, { uuid: uuid as any });
return c.json({ message: "Player data deleted successfully" });
});
app.delete("/players/:uuid/document", async (c) => {
const uuid = c.req.param("uuid");
await c.env.runMutation(internal.players.deletePlayerDocument, { uuid });
return c.json({ message: "Player data deleted successfully" });
});
🤖 Prompt for AI Agents
In packages/backend/convex/http.ts around lines 122 to 127, the DELETE handler
currently casts uuid using "as any" before passing it to
internal.players.deletePlayerDocument; remove the unsafe cast and pass the uuid
with the correct type expected by the mutation (e.g., string or ExternalUuid
type). If necessary, perform a runtime null/undefined check and/or
convert/validate the param to the expected type before calling runMutation so
the call is type-safe and aligns with the delete mutation's external UUID
parameter.


export default new HttpRouterWithHono(app);
103 changes: 94 additions & 9 deletions packages/backend/convex/players.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,69 @@
import { zid } from "convex-helpers/server/zod";
import { v } from "convex/values";
import { z } from "zod";
import { z } from "zod/v3";
import { internal } from "./_generated/api";
import { Id } from "./_generated/dataModel";
import { internalAction, internalMutation, query } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./_generated/server";
import { zInternalMutation } from "./common";
import { playerDocumentSchema } from "./schemas";

export const getPlayerByUuid = query({
export const savePlayerDocument = zInternalMutation({
args: {
uuid: zid("players"),
document: playerDocumentSchema,
},
handler: async (ctx, args) => {
if (args.document.banData) {
const existingBan = await ctx.db
.query("playerBans")
.withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
.unique();

if (existingBan) {
await ctx.db.patch(existingBan._id, {
reason: args.document.banData.reason,
expiresAt: args.document.banData.expiresAt,
});
} else {
await ctx.db.insert("playerBans", {
uuid: args.uuid,
reason: args.document.banData.reason,
expiresAt: args.document.banData.expiresAt,
bannedAt: args.document.banData.bannedAt,
bannedBy: args.document.banData.bannedBy,
});
}
}

return await ctx.db.patch(args.uuid, {
lastJoinDate: args.document.lastJoinDate,
stats: args.document.stats,
avatarUrl: args.document.avatarUrl,
});
},
});
Comment on lines +10 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Fix uuid type mismatch (external UUID), patch by internal _id, add returns, and handle unban.

Current code treats external UUID as a Convex Id and never unbans.

-export const savePlayerDocument = zInternalMutation({
-  args: {
-    uuid: zid("players"),
-    document: playerDocumentSchema,
-  },
-  handler: async (ctx, args) => {
-    if (args.document.banData) {
-      const existingBan = await ctx.db
-        .query("playerBans")
-        .withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
-        .unique();
-
-      if (existingBan) {
-        await ctx.db.patch(existingBan._id, {
-          reason: args.document.banData.reason,
-          expiresAt: args.document.banData.expiresAt,
-        });
-      } else {
-        await ctx.db.insert("playerBans", {
-          uuid: args.uuid,
-          reason: args.document.banData.reason,
-          expiresAt: args.document.banData.expiresAt,
-          bannedAt: args.document.banData.bannedAt,
-          bannedBy: args.document.banData.bannedBy,
-        });
-      }
-    }
-
-    return await ctx.db.patch(args.uuid, {
-      lastJoinedAt: args.document.lastJoinTime,
-      stats: args.document.stats,
-      avatarUrl: args.document.avatarUrl,
-    });
-  },
-});
+export const savePlayerDocument = zInternalMutation({
+  args: {
+    uuid: v.string(), // external Minecraft UUID
+    document: playerDocumentSchema,
+  },
+  returns: v.null(),
+  handler: async (ctx, args) => {
+    const player = await ctx.db
+      .query("players")
+      .withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
+      .unique();
+    if (!player) throw new Error("Player not found");
+
+    const existingBan = await ctx.db
+      .query("playerBans")
+      .withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
+      .unique();
+
+    if (args.document.banData) {
+      if (existingBan) {
+        await ctx.db.patch(existingBan._id, {
+          reason: args.document.banData.reason,
+          expiresAt: args.document.banData.expiresAt,
+        });
+      } else {
+        await ctx.db.insert("playerBans", {
+          uuid: args.uuid,
+          reason: args.document.banData.reason,
+          expiresAt: args.document.banData.expiresAt,
+          bannedAt: args.document.banData.bannedAt,
+          bannedBy: args.document.banData.bannedBy,
+        });
+      }
+    } else if (existingBan) {
+      await ctx.db.delete(existingBan._id); // unban
+    }
+
+    await ctx.db.patch(player._id, {
+      lastJoinedAt: args.document.lastJoinTime,
+      stats: args.document.stats,
+      avatarUrl: args.document.avatarUrl,
+    });
+    return null;
+  },
+});

🏁 Script executed:

#!/bin/bash
# Show type mismatch usage across HTTP and convex layers.
rg -n 'savePlayerDocument' -C2
rg -n 'zid\("players"\)' packages/backend/convex/players.ts

Length of output: 683


Fix UUID type mismatch: accept external UUID, patch by internal _id, return null, and handle unban.

http.ts is calling internal.players.savePlayerDocument with an external UUID (packages/backend/convex/http.ts:116–118 — uuid: uuid as any) while players.ts validates uuid as a Convex id (zid("players")) and calls ctx.db.patch(args.uuid); this treats an external UUID as an internal Id and prevents unban logic. Change players.ts to accept an external string UUID (e.g. uuid: v.string()), add returns: v.null(), resolve the player by the external UUID (.query("players").withIndex("by_uuid", q => q.eq("uuid", args.uuid)).unique()), patch the found player’s internal _id, and when banData is falsy delete the existing playerBans row (ctx.db.delete(existingBan._id)). Also remove the uuid as any cast in http.ts and pass a proper string.

🤖 Prompt for AI Agents
In packages/backend/convex/players.ts around lines 10 to 44, fix the UUID type
mismatch and unban handling by changing the mutation to accept an external UUID
string (uuid: v.string()) and declare returns: v.null(), then resolve the
internal player row via ctx.db.query("players").withIndex("by_uuid", q =>
q.eq("uuid", args.uuid)).unique(); if no player is found return null; when a
player is found use ctx.db.patch(player._id, ...) to update
lastJoinedAt/stats/avatarUrl; for ban handling, if args.document.banData is
truthy insert or patch the playerBans row (by querying by uuid) as before, but
if args.document.banData is falsy delete the existing playerBans row via
ctx.db.delete(existingBan._id); finally remove the uuid as any cast in
packages/backend/convex/http.ts and pass the actual external UUID string.


export const deletePlayerDocument = internalMutation({
args: {
uuid: v.id("players"),
},
handler: async (ctx, args) => {
const player = await ctx.db
.query("players")
.withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
.unique();

if (!player) {
throw new Error("Player not found");
}

await ctx.db.delete(player._id);

return null;
},
});

export const getPlayerByUuid = internalQuery({
args: {
uuid: v.string(),
},
Expand All @@ -16,7 +75,19 @@ export const getPlayerByUuid = query({
},
});

const playerDbSchema = z.object({
export const getPlayerBanByUuid = internalQuery({
args: {
uuid: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("playerBans")
.withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
.unique();
},
});
Comment on lines +78 to +88
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add returns validator to ban query

Same rationale as above.

 export const getPlayerBanByUuid = internalQuery({
   args: {
     uuid: v.string(),
   },
+  returns: v.optional(v.any()),
   handler: async (ctx, args) => {
     return await ctx.db
       .query("playerBans")
       .withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
       .unique();
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getPlayerBanByUuid = internalQuery({
args: {
uuid: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("playerBans")
.withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
.unique();
},
});
export const getPlayerBanByUuid = internalQuery({
args: {
uuid: v.string(),
},
returns: v.optional(v.any()),
handler: async (ctx, args) => {
return await ctx.db
.query("playerBans")
.withIndex("by_uuid", (q) => q.eq("uuid", args.uuid))
.unique();
},
});
🤖 Prompt for AI Agents
In packages/backend/convex/players.ts around lines 67 to 77, the internalQuery
definition for getPlayerBanByUuid is missing a returns validator; add a returns
validator that reflects that unique() can return either a playerBan object or
null (e.g., returns: v.nullable(v.type("playerBan")) or the appropriate nullable
object schema used elsewhere) so the query validator matches the actual return
shape.


const playerDbApiSchema = z.object({
data: z.object({
player: z.object({
username: z.string(),
Expand All @@ -33,10 +104,24 @@ export const createPlayer = internalAction({
},
returns: v.id("players"),
handler: async (ctx, args) => {
const response = await fetch(`https://playerdb.co/api/player/minecraft/${args.uuid}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);

const response = await fetch(`https://playerdb.co/api/player/minecraft/${args.uuid}`, {
signal: controller.signal,
}).finally(() => clearTimeout(timeout));

Comment on lines +107 to +113
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Good timeout/abort handling around fetch

The AbortController with a 10s timeout and cleanup via finally(clearTimeout) is solid. 4xx vs 5xx handling is also clear.

Check for any lingering casts like uuid as any in the HTTP layer that depended on the old validator:

Also applies to: 114-121


🏁 Script executed:

#!/bin/bash
rg -nP 'uuid\s+as\s+any|playersSaveDocument|savePlayerDocument' -S packages

Length of output: 382


Good timeout/abort handling around fetch — remove uuid as any casts in HTTP layer

AbortController/timeout are fine; the HTTP layer still bypasses the new validator with uuid as any—remove those casts and pass a properly-validated/typed uuid to the internal mutations.

  • packages/backend/convex/http.ts — lines 139–140: call to internal.players.savePlayerDocument using uuid: uuid as any.
  • packages/backend/convex/http.ts — line 159: call to internal.players.deletePlayerDocument using uuid: uuid as any.
  • packages/backend/convex/players.ts — line 10: export const savePlayerDocument = zInternalMutation({ ... }) (verify mutation signature/validator).

Remove the as any casts and align the HTTP inputs with the mutation's validated types.

if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
throw new Error("Player not found on playerdb.co");
}

throw new Error("Failed to fetch player data from playerdb.co");
}

const json = await response.json();

const { data } = playerDbSchema.parse(json);
const { data } = playerDbApiSchema.parse(json);

const minecraftPlayerData: Id<"players"> = await ctx.runMutation(
internal.players.insertPlayer,
Expand Down Expand Up @@ -72,10 +157,10 @@ export const insertPlayer = internalMutation({
return await ctx.db.insert("players", {
username: args.player.username,
uuid: args.player.uuid,
firstJoinedAt: new Date().toISOString(),
firstJoinDate: new Date().toISOString(),
lastJoinDate: new Date().toISOString(),
skinTextureUrl: args.minecraftPlayerData.skinTextureUrl,
avatarUrl: args.minecraftPlayerData.avatarUrl,
lastJoinedAt: new Date().toISOString(),
stats: {
joinCount: 1,
},
Expand All @@ -100,7 +185,7 @@ export const updatePlayerJoin = internalMutation({
const currentJoinCount = (player.stats?.joinCount as number) || 0;

await ctx.db.patch(player._id, {
lastJoinedAt: new Date().toISOString(),
lastJoinDate: new Date().toISOString(),
stats: {
...player.stats,
joinCount: currentJoinCount + 1,
Expand Down
Loading