AZ
Back to constellation
Web3Draft

GKOI Platform

The backend for a Web3/NFT platform — the mint-critical auth and Merkle-whitelist trust boundaries carved into small, separately-auditable Express services.

Node.jsTypeScriptExpress 5Mongoose 9Redis (ioredis)merkletreejsethers 6IPFS (Pinata)migrate-mongoPrometheus

Overview

What it is

GKOI is a multi-service Web3/NFT platform, deliberately not a monolith. Three cooperating Express 5 + TypeScript + MongoDB + Redis services split the surface along a single fault line — is this code mint-critical? — and everything that touches who is allowed to mint, or who is an admin, is carved into its own small, separately-auditable service.

ServiceOwns (source of truth)Why it's isolatedNotable deps
gkoi-authenticationsWallet login (EIP-4361 / SIWE), JWT issue/verify, the admin-role registryAuth is the identity trust boundary — small enough to audit, blast-radius-limitedviem, jsonwebtoken
gkoi-whitelistThe presale Merkle allowlist (root + proofs) and IPFS contract-metadata pinningThe mint gate; scales and fails independently of the core APImerkletreejs, ethers 6, Pinata
gkoi-serverEverything non-mint-critical: NFT metadata/pricing, collections, contests, galleries, DeFi/swaps, gaming, leaderboards, auditThe large, evolving surface; kept away from the mint pathalchemy-sdk, moralis, @uniswap/v4-sdk, sharp, migrate-mongo

All three share one four-layer convention (routes → controllers → services → models), one IResponseData<T> = { code, message, data } envelope, Redis-backed rate limiting, Prometheus databaseResponseTimeHistogram timers labelled {operation, collection, method, success}, and one HMAC service-to-service auth scheme — so the three services read as one system without sharing a database.

Wallet login without format drift

gkoi-authentications builds an EIP-4361 (Sign-In-with-Ethereum) message server-side, stores it, and verifies the client's signature against the exact stored copy — the client signs verbatim and never reconstructs the message, which eliminates the entire class of client/server format-drift bugs. Verification consumes a single-use nonce, asserts it was issued for that exact account, then checks the signature with viem, and each distinct failure returns its own reason (rather than a bare false) so a slow/replayed nonce can be told apart from a wrong-key signature:

const stored = await consumeLoginNonce(nonce);
if (!stored) return { ok: false, reason: "nonce_missing_or_expired" };
if (stored.account !== account.toLowerCase())
  return { ok: false, reason: "nonce_account_mismatch" };
const verified = await viemPublicClient.verifyMessage({
  address: account as `0x${string}`, message: stored.message,
  signature: signature as `0x${string}`,
});
return verified ? { ok: true } : { ok: false, reason: "signature_mismatch" };

Those reasons feed a failed-signature-burst anomaly ticket — the auth service watches itself.

Service-to-service auth: signed, replay-bounded, path-independent

Services never trust an ambient ADMIN_API_KEY or a shared DB; every cross-service call is HMAC-SHA256 signed over [METHOD, ts, nonce, sha256(body)]. The path is deliberately excluded from the signed message, so a signature survives a proxy/mount-prefix change, and freshness is checked directionally — up to five minutes in the past but only thirty seconds in the future, so a far-future timestamp can't be parked inside the window:

function buildMessage({ method, timestamp, nonce, body }): string {
  return [method.toUpperCase(), timestamp, nonce, sha256(body ?? "")].join("
");
}
function isTimestampFresh(ts: string): boolean {
  const age = Date.now() - Number(ts); // >0 = past, <0 = future
  return age <= SKEW_SECONDS * 1000 && age >= -FUTURE_SKEW_SECONDS * 1000;
}

A one-time Redis nonce (svc:nonce:<uuid>, TTL = 2× the skew) blocks replays, and authorization is per-route by scope keyed on the authenticated x-svc-id — so gkoi-authentications is granted exactly admins:cache:invalidate on the whitelist and nothing else. Least privilege between services, not just between users.

The Merkle whitelist — scaling apart from the mint

Leaves are keccak256(address); the tree is built with merkletreejs under sortPairs: true so the contract's on-chain MerkleProof.verify matches regardless of pair order. Only a 32-byte root ever goes on-chain, so allowlist size never inflates mint gas or contract storage — verification is O(log n) inside the contract. The service's own trick is that it caches the sorted address snapshot (the exact input the Merkle helpers hash), not a serialized tree, so a cached /proof is byte-identical to a cold rebuild — and to the cached /root. Root and snapshot share one logical version, busted together on every add/remove via invalidateWhitelistCaches; a short TTL is only a safety net behind that explicit invalidation. (The companion post drills into this build/proof/verify flow.)

Writes are correct under load, too: batch add is a single unordered insertMany({ ordered: false }) that treats a duplicate-key (11000) as "already present → null" while preserving input order/length, and the full-list read that feeds the tree is hard-capped by an always-applied $limit so an unbounded $sort can never be streamed back.

Schema evolution: guarded, idempotent migrations

Schema changes run through migrate-mongo with useFileHash: true — a content checksum decides re-runs, so every migration is written to be safely re-runnable. The rename of the admin audit collection is representative: existence-checked on both sides so a re-run or a fresh DB is a no-op, and renameCollection preserves every index (including the TTL) so retention is untouched.

async up(db) {
  const src = await db.listCollections({ name: "audit-logs" }).toArray();
  const targetExists =
    (await db.listCollections({ name: "admin-audit-logs" }).toArray()).length > 0;
  if (src.length > 0 && !targetExists)
    await db.collection("audit-logs").rename("admin-audit-logs");
}

The backfill_user_holdings migration goes further — a $group + $merge upsert (unique index on {account, tokenAddress}, allowDiskUse, createdAt preserved on match) that aggregates nfts by owner into a user-holdings collection as the foundation for a co-ownership recommendation signal, idempotent by construction.

Auditing the auditor

Audit is a first-class, two-stream concern (admin + user), and the whitelist controllers carry an explicit rule — actorAccount: req.account ?? "unknown", with the comment "never substitute the target as the actor" — so an admin acting on a user's address is never mislogged as the user.

Testing & security posture

Mocha + Chai + Sinon with nyc coverage; Supertest exercises the HTTP surface on gkoi-server and gkoi-authentications (not the whitelist). Biome plus a ts.check + lint-staged pre-commit gate. The strongest signal here isn't a badge: gkoi-authentications ships its own SECURITY.md (multi-tier Redis rate limits, exact-string CORS matching after a regex-matching vuln was removed) and a dated SECURITY_AUDIT.md that tallies findings with file/line citations. The author security-reviews his own mint-critical services and records the results — which is exactly what carving the trust boundaries small enough to audit was for.

Related write-ups

Write-up in progress

A companion post for this project is being drafted. Check back soon.