Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
COMPOSE_PROJECT_NAME=tilezo
SERVER_PORT=3000
CLIENT_PORT=3001
DB_PORT=5432
DB_PORT=5432 # compose binds this to 127.0.0.1 only
POSTGRES_DB=tilezo
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD=replace-with-worktree-generated-password
HOST=0.0.0.0
PORT=3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tilezo
# Development-only secret. Production requires a strong, random value of at least 32
# characters that is NOT a placeholder phrase (the server refuses to start otherwise).
# Generate one with: openssl rand -base64 48
AUTH_SECRET=dev-only-insecure-secret-change-before-production
AUTH_SECRET=replace-with-worktree-generated-auth-secret
NODE_ENV=development
# Set TRUST_PROXY=true only when running behind a proxy that overwrites x-forwarded-for /
# x-real-ip; otherwise rate-limit keys use the real socket peer address.
# TRUST_PROXY=false
# Required to expose GET /debug/metrics in production (request with ?token=... ); when
# Required to expose GET /debug/metrics in production (request with Authorization: Bearer ... ); when
# unset, the metrics endpoint returns 404 in production.
# METRICS_TOKEN=
PUBLIC_API_URL=http://localhost:3000
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
branches:
- main

permissions:
contents: read

jobs:
test:
name: Test and coverage
Expand All @@ -19,7 +22,7 @@ jobs:

services:
postgres:
image: postgres:16-alpine
image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
Expand All @@ -34,10 +37,10 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"

Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1

FROM oven/bun:1.3.13 AS base
FROM oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e AS base
WORKDIR /app

COPY package.json bun.lock tsconfig.base.json biome.json ./
Expand All @@ -25,7 +25,7 @@ ENV PUBLIC_API_URL=$PUBLIC_API_URL
ENV PUBLIC_WS_URL=$PUBLIC_WS_URL
RUN bun run --filter '@tilezo/client' build

FROM caddy:2-alpine AS client
FROM caddy:2-alpine@sha256:77c07d5ebfa5be9fd6c820d2094ae662c9e7eeb9bf98346b7f639900263ee2a2 AS client
COPY --from=client-build /app/apps/client/dist /usr/share/caddy
EXPOSE 80

Expand All @@ -38,7 +38,7 @@ ENV NODE_ENV=production
USER bun
CMD ["bun", "run", "--cwd", "apps/server", "db:migrate"]

FROM oven/bun:1.3.13 AS server
FROM oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e AS server
WORKDIR /app
ENV HOST=0.0.0.0
ENV NODE_ENV=production
Expand Down
26 changes: 23 additions & 3 deletions apps/client/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,35 @@ describe("config", () => {
expect(getWebSocketUrl()).toBe(DEFAULT_WS_URL);
});

test("uses build-time values when no runtime config exists", () => {
test("uses secure build-time values when no runtime config exists", () => {
Reflect.deleteProperty(globalThis, "window");
process.env.PUBLIC_API_URL = "http://build-api.example.test/";
process.env.PUBLIC_API_URL = "https://build-api.example.test/";
process.env.PUBLIC_WS_URL = "wss://build-ws.example.test/socket/";

expect(getApiUrl()).toBe("http://build-api.example.test");
expect(getApiUrl()).toBe("https://build-api.example.test");
expect(getWebSocketUrl()).toBe("wss://build-ws.example.test/socket");
});

test("rejects insecure non-local endpoint overrides", () => {
installWindowConfig({
PUBLIC_API_URL: "http://api.example.test",
PUBLIC_WS_URL: "ws://socket.example.test/ws",
});

expect(getApiUrl()).toBe(DEFAULT_API_URL);
expect(getWebSocketUrl()).toBe(DEFAULT_WS_URL);
});

test("allows insecure localhost endpoint overrides for development", () => {
installWindowConfig({
PUBLIC_API_URL: "http://127.0.0.1:4000/",
PUBLIC_WS_URL: "ws://localhost:4000/ws/",
});

expect(getApiUrl()).toBe("http://127.0.0.1:4000");
expect(getWebSocketUrl()).toBe("ws://localhost:4000/ws");
});

test("derives a secure browser websocket fallback on https pages", () => {
Reflect.deleteProperty(globalThis, "window");
restoreEnv("PUBLIC_WS_URL", undefined);
Expand Down
42 changes: 30 additions & 12 deletions apps/client/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { DEFAULT_API_URL, DEFAULT_WS_URL } from "./assets";

export function getApiUrl(): string {
return normalizeBaseUrl(getConfiguredValue("PUBLIC_API_URL"), DEFAULT_API_URL, [
"http:",
"https:",
]);
return normalizeBaseUrl(getConfiguredValue("PUBLIC_API_URL"), DEFAULT_API_URL, ["https:"], {
allowLocalInsecure: true,
});
}

export function apiUrl(path: string): string {
Expand All @@ -13,10 +12,15 @@ export function apiUrl(path: string): string {

export function getWebSocketUrl(): string {
const browserDefault = getBrowserWebSocketUrl();
return normalizeBaseUrl(getConfiguredValue("PUBLIC_WS_URL"), browserDefault ?? DEFAULT_WS_URL, [
"ws:",
"wss:",
]);
return normalizeBaseUrl(
getConfiguredValue("PUBLIC_WS_URL"),
browserDefault ?? DEFAULT_WS_URL,
["wss:"],
{
allowLocalInsecure: true,
insecureProtocols: ["ws:"],
},
);
}

function getConfiguredValue(key: "PUBLIC_API_URL" | "PUBLIC_WS_URL"): string | undefined {
Expand All @@ -28,23 +32,37 @@ function getConfiguredValue(key: "PUBLIC_API_URL" | "PUBLIC_WS_URL"): string | u
function normalizeBaseUrl(
configured: string | undefined,
fallback: string,
protocols: readonly string[],
secureProtocols: readonly string[],
options: { allowLocalInsecure: boolean; insecureProtocols?: readonly string[] },
): string {
const raw = configured?.trim() || fallback;

try {
const url = new URL(raw);

if (!protocols.includes(url.protocol)) {
return fallback;
if (secureProtocols.includes(url.protocol)) {
return url.toString().replace(/\/$/, "");
}

return url.toString().replace(/\/$/, "");
if (
options.allowLocalInsecure &&
(options.insecureProtocols ?? ["http:"]).includes(url.protocol) &&
isLocalHostname(url.hostname)
) {
return url.toString().replace(/\/$/, "");
}

return fallback;
} catch {
return fallback;
}
}

function isLocalHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}

function getBrowserWebSocketUrl(): string | undefined {
if (typeof location === "undefined") {
return undefined;
Expand Down
18 changes: 18 additions & 0 deletions apps/client/src/game/NetClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ describe("NetClient", () => {
expect(() => client.send({ type: "ping", sentAt: "now" })).toThrow("WebSocket is not open");
});

test("rejects oversized server messages before emitting them", async () => {
installBrowserFakes("http:");
const client = new NetClient();
const statuses: string[] = [];
const received: unknown[] = [];
client.onStatus((status) => statuses.push(status));
client.onMessage((message) => received.push(message));

const connected = client.connect();
const socket = currentSocket();
socket.open();
await connected;
socket.message("x".repeat(64 * 1024 + 1));

expect(statuses).toContain("received invalid server message");
expect(received).toEqual([]);
});

test("reports unexpected disconnects after a socket was open", async () => {
installBrowserFakes("http:");
const client = new NetClient();
Expand Down
19 changes: 5 additions & 14 deletions apps/client/src/game/NetClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseServerMessage } from "@tilezo/protocol";
import { parseRawServerMessage } from "@tilezo/protocol";
import type { ClientMessage, ServerMessage } from "@tilezo/protocol/messages";
import { getWebSocketUrl } from "../config";

Expand Down Expand Up @@ -88,19 +88,10 @@ export class NetClient {
return;
}

let parsedJson: unknown;

try {
parsedJson = JSON.parse(raw);
} catch {
this.emitStatus("received invalid server message");
return;
}

// Validate the server message against the shared schema instead of blindly casting,
// so a malformed or skewed payload is reported cleanly rather than throwing deep in
// the scene/avatar code and silently dropping a state update.
const parsed = parseServerMessage(parsedJson);
// Validate the raw server message against size and schema limits instead of
// blindly parsing/casting, so malformed, oversized, or skewed payloads are
// reported cleanly rather than crashing the browser client.
const parsed = parseRawServerMessage(raw);

if (!parsed.ok) {
this.emitStatus("received invalid server message");
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ function createStore(): BlockStore & { pairs: Set<string> } {
async isBlockedEitherDirection(userId, otherUserId) {
return pairs.has(pairKey(userId, otherUserId)) || pairs.has(pairKey(otherUserId, userId));
},
async countBlockedUsers(blockerUserId) {
return [...pairs].filter((pair) => pair.startsWith(`${blockerUserId}:`)).length;
},
async listBlockedUsers() {
return [];
},
Expand All @@ -44,6 +47,16 @@ describe("BlockService", () => {

await expect(service.block("user_1", "user_1")).rejects.toBeInstanceOf(BlockError);
});

test("enforces a maximum number of blocked users", async () => {
const service = new BlockService(createStore(), { maxBlockedUsers: 1 });

await service.block("user_1", "user_2");
await service.block("user_1", "user_2");
await expect(service.block("user_1", "user_3")).rejects.toMatchObject({
code: "BLOCK_LIMIT_REACHED",
});
});
});

describe("DrizzleBlockStore", () => {
Expand All @@ -56,7 +69,9 @@ describe("DrizzleBlockStore", () => {
};
const store = new DrizzleBlockStore(queryDouble([[row]]));

await expect(store.listBlockedUsers("user_1")).resolves.toEqual([
await expect(
store.listBlockedUsers("user_1", { limit: 10, afterUsername: "ivy" }),
).resolves.toEqual([
{
id: "user_2",
username: "Kai",
Expand Down
Loading
Loading