Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5ff0eba
Trigger Railway redeploy
jraya106 May 14, 2026
c7a5912
Add board arrows and hosted fallback clarity
jraya106 May 15, 2026
69de798
Clarify hosted practice and single-player queue
jraya106 May 15, 2026
cbe91fa
Fix hosted hydration regression
jraya106 May 15, 2026
a212133
Fallback to polling when match WebSocket is missing
jraya106 May 15, 2026
94c1b1f
Polish hosted board labels and fallback sync
jraya106 May 15, 2026
467e5d3
Gate hosted board behind real queue matches
jraya106 May 15, 2026
7eec988
Fallback hosted queue to direct guest sessions
jraya106 May 15, 2026
c6d80e6
Advance quality-first launch platform
jraya106 May 17, 2026
892df0a
Redesign hosted play hub and harden proxies
jraya106 May 17, 2026
8cad9a1
Redesign Chess404 competitive shell
jraya106 May 17, 2026
5a07350
feat(ui): implement sidebar nav, remove raw JSON from replays, add li…
jraya106 May 17, 2026
c3bc8a2
feat(ui): implement Milestone 1 Visual Polish and scaffold Match Layout
jraya106 May 18, 2026
52d42da
refactor: extract useMatchTimer hook and centralize display/URL helpers
jraya106 May 19, 2026
63e950e
feat: add Postgres startup scripts and update project status
jraya106 May 19, 2026
c3424dd
Fix UI layouts and seat enforcement for online matches
jraya106 May 19, 2026
20f51c9
Update DEPLOY_RAILWAY.md with missing MATCH_SERVICE_INTERNAL_URL envi…
jraya106 May 19, 2026
dc6c223
Fix Railway launch routing and service fallbacks
jraya106 May 21, 2026
f10bc23
Polish UI pages, harden match engine, add gateway/platform service im…
jraya106 May 22, 2026
0ebb0b0
Fix watch feed phantom games, join-to-spectate bug, and invite link u…
jraya106 May 22, 2026
9bf6c9f
Fix viewer seat restore on match restore and assign seat for waiting …
jraya106 May 22, 2026
3b288fb
Stabilize hosted match recovery, live feed boards, queue pruning, cla…
jraya106 May 22, 2026
24668ac
fix: pre-launch bug fixes, PlatformContext typing, TLS, disconnect pr…
jraya106 May 23, 2026
7142bfc
fix: rate limiting, Elo matchmaking, HTTP timeouts, CORS, Docker USER…
jraya106 May 23, 2026
46d2a14
fix: crash bugs, data races, security hardening, and test fixes
jraya106 May 26, 2026
c51ebf7
fix: rename nobody user to appuser in platform-service Dockerfile
railway-app[bot] May 26, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ coverage
*.exe
*.exe~
.env.local
.pg-creds.env
package-lock.json
client/build/
redis-unstable/
Expand Down
59 changes: 59 additions & 0 deletions DEPLOY_RAILWAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ MATCH_CLAIM_STORE_TTL_SECONDS=43200
```env
MATCHMAKING_TICKET_STORE_BACKEND=redis
MATCHMAKING_TICKET_STORE_REDIS_URL=${{redis.REDIS_URL}}
MATCH_SERVICE_INTERNAL_URL=http://${{match-service.RAILWAY_PRIVATE_DOMAIN}}:${{match-service.PORT}}
```

## First staging checklist
Expand Down Expand Up @@ -151,3 +152,61 @@ Still recommended after first successful staging deploy:
- add custom domains
- add monitoring and alerting
- add load testing before public launch

## Rated beta launch checklist

Use this checklist before calling the hosted `/play` flow launch-ready.

1. Verify Railway service health:
- `web` loads its public domain
- `gateway` returns `200` from `/healthz`
- `match-service` returns `200` from `/healthz`
- `platform-service` returns `200` from `/healthz`
- `matchmaking-service` returns `200` from `/healthz`
2. Verify backend wiring from the web app:
- open `/status`
- confirm gateway, platform, match, and matchmaking all report healthy
- confirm `/api/gateway/bootstrap` returns healthy downstream state instead of localhost connection errors
3. Verify data backends:
- Railway Redis is attached to `matchmaking-service` and `platform-service`
- Railway Postgres is attached to `platform-service` and `match-service`
4. Run the hosted `/play` smoke test:
- signed-out player can join casual
- signed-out player sees `Sign in to join rated`
- signed-in player can join rated
- queued refresh restores queue state
- matched refresh restores the live match or shows `Return to Match`
- private invite link opens the canonical `/match/:id` route
- a fully occupied invite room resolves to spectate or room-full behavior

## Beta data reset before launch

If production beta data becomes noisy, clear it before the public rated beta push so recovery logic is restoring real player state instead of test leftovers.

### Redis

Reset stale queue and claim state:

```text
chess404:matchmaking:tickets
chess404:platform:match-claims
```

### Postgres

Review and clear test-only rows from:

```sql
guests
finalized_matches
direct_challenges
```

Suggested approach:

1. Remove stale queue tickets and stale match claims first.
2. Delete or archive test guest rows only if you intentionally want a clean guest ladder.
3. Delete abandoned direct challenges that were created for testing and no longer map to a live product scenario.
4. Redeploy the services after cleanup so fresh hosted sessions repopulate state from a known baseline.

Do not clear production data blindly once real players are using the platform. Snapshot Postgres first, then remove only the stale beta-era rows you intend to reset.
7 changes: 6 additions & 1 deletion PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,19 @@ Completed:
- starting a fresh game now clears old room-bound URL state instead of accidentally reopening the same matched room again
- backend now resolves authoritative seat ownership from stored guest ids, not only `white` / `black` placeholder player ids
- main app now submits authoritative moves, cards, draw/chat/resign intents using the match seat guest ids when available
- `useMatchTimer` hook extracted from `useMatchEngine`: timer, clock, and abort countdown state are now managed in a dedicated reusable hook
- `useMatchReplay` hook extracted from `useMatchEngine`: board review navigation (`reviewIdx`, `reviewBoard`, `goToSnap`, `reviewFirst/Prev/Next/Last`, `isReviewing`) are now managed in a dedicated reusable hook
- `setup-postgres.ps1` added: one-shot script to create the `chess404` Postgres database and save local credentials to `.pg-creds.env`
- `start-local-postgres.ps1` added: full local stack startup with Postgres backends for platform-service (guests, accounts, archive) and match-service (archive); matchmaking stays on file backend until Redis is available

Still missing:

- replace the remaining local match authority with server snapshots/events
- isolate animation state from gameplay state more cleanly
- split `apps/web/src/App.tsx` into smaller modules
- shift focus from gameplay-card migration into platform work: persistence, matchmaking, auth, ratings, reconnects, and replay/history storage
- support optimistic UI with rollback from authoritative backend events
- switch `MATCH_CLAIM_STORE_BACKEND` from `memory` to `redis` once a local Redis instance is available
- switch `MATCHMAKING_TICKET_STORE_BACKEND` from `file` to `redis` once a local Redis instance is available

### 5. Product Systems

Expand Down
75 changes: 75 additions & 0 deletions apps/web/app/ShellLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import AppShell, { ShellNavGroup, ShellNavItem } from '../src/components/layout/AppShell';
import {
AdminIcon,
CardsIcon,
CommunityIcon,
FriendsIcon,
HistoryIcon,
InboxIcon,
PlayIcon,
ProfileIcon,
StatusIcon,
WatchIcon,
} from '../src/components/layout/icons';

const primaryItems: ShellNavItem[] = [
{ key: '/play', label: 'Play', icon: <PlayIcon /> },
{ key: '/watch', label: 'Watch', icon: <WatchIcon /> },
];

const utilityGroups: ShellNavGroup[] = [
{
label: 'Social',
items: [
{ key: '/rankings', label: 'Rankings', icon: <ProfileIcon /> },
{ key: '/profiles', label: 'Profiles', icon: <ProfileIcon /> },
{ key: '/community', label: 'Community', icon: <CommunityIcon /> },
],
},
{
label: 'Library',
items: [
{ key: '/cards', label: 'Card Index', icon: <CardsIcon /> },
{ key: '/history', label: 'History', icon: <HistoryIcon /> },
],
},
];

export function ShellLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [accountOpen, setAccountOpen] = React.useState(false);

// In a real implementation, pageMeta would be dynamic based on pathname
const pageMeta = {
title: pathname === '/' ? 'Home' : pathname.slice(1).charAt(0).toUpperCase() + pathname.slice(2),
};

const handleNavigate = (key: string) => {
router.push(key);
};

return (
<AppShell
brandTitle="Chess404"
brandSubtitle="Arcane Chess Platform"
pageMeta={pageMeta}
primaryItems={primaryItems}
utilityGroups={utilityGroups}
accountLabel="Account"
activeKey={pathname}
onNavigate={handleNavigate}
onOpenAccount={() => setAccountOpen(true)}
>
{children}
{/*
In full implementation, the Auth/Account modal would be rendered here
when accountOpen is true
*/}
</AppShell>
);
}
12 changes: 12 additions & 0 deletions apps/web/app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';
import React from 'react';
import { usePlatform } from '../../src/contexts/PlatformContext';

export default function Route() {
const platform = usePlatform();
React.useEffect(() => {
platform.setActivePage('Account');
}, [platform.setActivePage]);
return null;
}

12 changes: 12 additions & 0 deletions apps/web/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';
import React from 'react';
import { usePlatform } from '../../src/contexts/PlatformContext';

export default function Route() {
const platform = usePlatform();
React.useEffect(() => {
platform.setActivePage('Admin');
}, [platform.setActivePage]);
return null;
}

149 changes: 149 additions & 0 deletions apps/web/app/api/_lib/internal-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
interface InternalServiceProxyConfig {
explicitUrl?: string;
fallbackUrl: string;
envName: string;
serviceName: string;
}

interface ResolvedInternalService {
baseUrl: string;
usedFallback: boolean;
warning?: string;
}

export async function proxyInternalService(request: Request, path: string, config: InternalServiceProxyConfig): Promise<Response> {
const resolved = resolveInternalServiceBaseUrl(config);
const url = `${resolved.baseUrl}${path}`;
const init: RequestInit = {
method: request.method,
headers: filterHeaders(request.headers),
cache: 'no-store',
};

if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = await request.text();
}

try {
const upstream = await fetch(url, init);
const body = await upstream.text();
const headers = filterResponseHeaders(upstream.headers);
if (resolved.warning) {
headers.set('x-chess404-proxy-warning', resolved.warning);
}
return new Response(body, {
status: upstream.status,
headers,
});
} catch (error) {
return buildProxyFailureResponse(config, resolved, error);
}
}

export async function proxyInternalServiceStream(request: Request, path: string, config: InternalServiceProxyConfig): Promise<Response> {
const resolved = resolveInternalServiceBaseUrl(config);
const url = `${resolved.baseUrl}${path}`;
const init: RequestInit = {
method: request.method,
headers: filterHeaders(request.headers),
cache: 'no-store',
};

if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = await request.text();
}

try {
const upstream = await fetch(url, init);
const headers = filterResponseHeaders(upstream.headers);
if (resolved.warning) {
headers.set('x-chess404-proxy-warning', resolved.warning);
}
return new Response(upstream.body, {
status: upstream.status,
headers,
});
} catch (error) {
return buildProxyFailureResponse(config, resolved, error);
}
}

function buildProxyFailureResponse(
config: InternalServiceProxyConfig,
resolved: ResolvedInternalService,
error: unknown,
): Response {
const detail = error instanceof Error ? error.message : 'unreachable upstream';
const guidance = `${config.envName} must be a full internal URL with a port, for example ${config.fallbackUrl}.`;
const warning = resolved.warning ? `${resolved.warning}. ` : '';
return Response.json({
error: `${config.serviceName} is unreachable. ${warning}${guidance} Attempted ${resolved.baseUrl}. Upstream error: ${detail}`,
}, { status: 502 });
}

function resolveInternalServiceBaseUrl(config: InternalServiceProxyConfig): ResolvedInternalService {
const fallback = sanitizeBaseUrl(config.fallbackUrl) ?? config.fallbackUrl.trim().replace(/\/$/, '');
const explicit = sanitizeBaseUrl(config.explicitUrl);

if (!explicit) {
return { baseUrl: fallback, usedFallback: true };
}

try {
const parsed = new URL(explicit);
if (requiresPortFallback(parsed)) {
return {
baseUrl: fallback,
usedFallback: true,
warning: `${config.envName} omitted the internal service port`,
};
}
return { baseUrl: parsed.toString().replace(/\/$/, ''), usedFallback: false };
} catch {
return {
baseUrl: fallback,
usedFallback: true,
warning: `${config.envName} is not a valid URL`,
};
}
}

function sanitizeBaseUrl(value?: string): string | null {
const trimmed = value?.trim().replace(/\/$/, '');
if (!trimmed || trimmed.includes('${{') || /:\s*$/.test(trimmed)) {
return null;
}
return trimmed;
}

function requiresPortFallback(url: URL): boolean {
if (url.port) {
return false;
}
const hostname = url.hostname.toLowerCase();
return hostname.endsWith('.railway.internal') || hostname === 'localhost' || hostname === '127.0.0.1';
}

function filterHeaders(headers: Headers): Headers {
const next = new Headers();
headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'host' || lower === 'connection' || lower === 'content-length') {
return;
}
next.set(key, value);
});
return next;
}

function filterResponseHeaders(headers: Headers): Headers {
const next = new Headers();
headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') {
return;
}
next.set(key, value);
});
return next;
}
Loading