Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The `proxy` Worker sits in front of the others: callers authenticate against the
| [`proxy/`](./proxy) | Shared auth gate and router. Forwards requests for known hosts to sibling Workers via service bindings; everything else falls through to a public `fetch()`. |
| [`fivem/`](./fivem) | Resolves a Discord user to a player on a guild's FiveM server, with a KV-backed cache. |
| [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. |
| [`melonly/`](./melonly) | Resolves a Discord user to their linked Roblox account via Melonly, with a KV-backed cache. |

<p align="right">(<a href="#readme-top">back to top</a>)</p>

Expand Down Expand Up @@ -98,6 +99,7 @@ New integrations are picked up automatically — no workflow edits needed.
| `proxy` | `PROXY_AUTH_KEY` | Shared token expected in that header. |
| `fivem` | `FIVEM_AUTH_KEY` | Static guard token; callers must send this in the `Authorization` header. |
| `bloxlink` | `BLOXLINK_AUTH_KEY` | Static guard token; callers must send this in the `Authorization` header. |
| `melonly` | `MELONLY_AUTH_KEY` | Melonly global verification key; callers must send this in the `Authorization` header. |

`SENTRY_DSN` for each Worker is configured in its `wrangler.toml` under `[vars]`.

Expand Down
104 changes: 104 additions & 0 deletions melonly/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as Sentry from "@sentry/cloudflare";

const MELONLY_API = "https://api.melonly.xyz/api/v1";
const CACHE_TTL_SECONDS = 86_400;
const DISCORD_ID_REGEX = /^\d{17,20}$/;

function jsonResponse(body, { status = 200 } = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

const emptyResponse = () => jsonResponse({});

async function fetchConnection(authKey, userId) {
return fetch(`${MELONLY_API}/verification/discord/${userId}/roblox`, {
headers: { Authorization: `Bearer ${authKey}` },
});
}

function buildPayload(connection) {
return {
melonly_user_id: connection.userId,
melonly_name: connection.name,
melonly_nickname: connection.nickname,
melonly_preferred_username: connection.preferredUsername,
melonly_roblox_id: connection.robloxId,
melonly_profile: connection.profile,
melonly_headshot_image: connection.headShotImage,
melonly_created_at: connection.createdAt,
melonly_last_updated_at: connection.lastUpdatedAt,
melonly_roblox_created_at: connection.robloxCreatedAt,
};
}

async function handleLookup(request, env, authKey) {
let body;
try {
body = await request.json();
} catch {
return jsonResponse({ error: "Invalid request body" }, { status: 400 });
}

const { user_id: userId } = body;
if (!DISCORD_ID_REGEX.test(userId)) {
return jsonResponse({ error: "Invalid request body" }, { status: 400 });
}

const cacheKey = `melonly:global:${userId}`;
const cached = await env.INTEGRATION_CACHE.get(cacheKey);
if (cached !== null) {
return new Response(cached, {
status: 200,
headers: { "content-type": "application/json", "x-from-cache": "true" },
});
}

const res = await fetchConnection(authKey, userId);
if (res.status === 404) return emptyResponse();
if (res.status === 401) {
return jsonResponse(
{ error: "Melonly API rejected the verification key" },
{ status: 500 },
);
}
if (!res.ok) {
return jsonResponse({ error: `Melonly API responded with ${res.status}` }, { status: 500 });
}

const payload = JSON.stringify(buildPayload(await res.json()));
await env.INTEGRATION_CACHE.put(cacheKey, payload, { expirationTtl: CACHE_TTL_SECONDS });

return new Response(payload, {
status: 200,
headers: { "content-type": "application/json", "x-from-cache": "false" },
});
}

async function handleRequest(request, env) {
const authKey = request.headers.get("Authorization");
if (authKey !== env.MELONLY_AUTH_KEY) {
return jsonResponse({ error: "Invalid auth key" }, { status: 401 });
}

if (request.method !== "POST") {
return jsonResponse({ error: "Method Not Allowed" }, { status: 405 });
}

return handleLookup(request, env, authKey);
}

export default Sentry.withSentry(
(env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
sendDefaultPii: true,
}),
{
async fetch(request, env) {
return handleRequest(request, env);
},
},
);
Loading