chore: remove leftovers from upstream

Drops the /flash and /html5 sections (old URLs redirect to /browse),
the GitHub star count endpoint, the unused HOME_RECENT_HOURS setting,
upstream's design notes and code review docs, and the issue template's
link to upstream discussions.
This commit is contained in:
2026-10-13 21:19:34 -07:00
parent 5e7b2bc72e
commit cd1d7fbb35
35 changed files with 40 additions and 4501 deletions
-1
View File
@@ -105,7 +105,6 @@ ENABLE_CACHE_PREWARM=true
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# HOME_RECENT_HOURS=24
# ENABLE_CGI=false
# Streamed play for games that can't run in a browser (Java, Shockwave, the
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
-3
View File
@@ -1,8 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Ask a Question
url: https://github.com/darkraise/flashpoint-web/discussions
about: Use Discussions for questions, help, and general conversation
- name: Flashpoint Archive
url: https://github.com/FlashpointProject
about: For issues with Flashpoint itself (not this web interface)
+1 -6
View File
@@ -1193,13 +1193,8 @@ Before submitting code, verify:
## Environment
```bash
FLASHPOINT_PATH=D:/Flashpoint # Required - path to Flashpoint installation
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
JWT_SECRET=your-secret # Required - random string for JWT signing
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
LOG_LEVEL=info # Optional - debug, info, warn, error
```
## Source References
- Flashpoint Launcher: `D:\Repositories\Community\launcher`
- Flashpoint Game Server: `D:\Repositories\Community\FlashpointGameServer`
-1
View File
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
# ENABLE_CGI=false
# Misc
HOME_RECENT_HOURS=24
# ---------------------------------------------------------------------------
# Database Performance Optimization (for network storage)
-2
View File
@@ -167,8 +167,6 @@ export const config = {
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
// Session manager that streams games which can't run in a browser, such as
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
streamingApiUrl: process.env.STREAMING_API_URL || null,
-56
View File
@@ -1,56 +0,0 @@
import { Router, Request, Response } from 'express';
import { logger } from '../utils/logger';
import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
let cachedStars: { count: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 15 * 60 * 1000;
// Proxy to avoid browser CORS issues with GitHub API
router.get(
'/stars',
asyncHandler(async (_req: Request, res: Response) => {
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
const response = await fetch(repoUrl, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Flashpoint-Web-App',
},
});
if (!response.ok) {
// If rate-limited and we have a stale cache, return it
if (cachedStars) {
logger.debug('[GitHub] Rate limited, returning stale cache');
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { stargazers_count: number };
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
res.json({
success: true,
data: {
stars: data.stargazers_count,
},
});
})
);
export default router;
-2
View File
@@ -25,7 +25,6 @@ import ratingsRouter from './ratings';
import gameSavesRouter from './game-saves';
import discoveryRouter from './discovery';
import ruffleRouter from './ruffle';
import githubRouter from './github';
import domainsRouter from './domains';
import healthRouter from './health';
import cacheRouter from './cache';
@@ -40,7 +39,6 @@ export function setupRoutes(app: Express): void {
app.use('/api/settings/auth', authSettingsRouter);
app.use('/api/settings', systemSettingsRouter);
app.use('/api/ruffle', ruffleRouter);
app.use('/api/github', githubRouter);
app.use('/api/domains', domainsRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/users', usersRouter);
-5
View File
@@ -80,11 +80,6 @@ router.get(
Record<string, unknown>
> = systemSettings.getPublicSettings();
if (!publicSettings.app) {
publicSettings.app = {};
}
publicSettings.app.homeRecentHours = config.homeRecentHours;
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
if (!publicSettings.metadata) {
publicSettings.metadata = {};
+1 -1
View File
@@ -51,7 +51,7 @@ const INFO = {
updateAvailable: true,
isUnreleasedBuild: false,
publishedAt: '2026-08-12T07:11:45Z',
releaseUrl: 'https://github.com/darkraise/flashpoint-web/releases/tag/v1.0.42',
releaseUrl: 'https://git.example.com/owner/dotcom-arcade/releases/tag/v1.0.42',
changelog: '## Changes',
checkedAt: '2026-08-12T08:00:00.000Z',
lastCheckFailed: false,
+3 -3
View File
@@ -56,12 +56,12 @@ export class AppUpdateService {
return this.buildInfo();
}
// A forced check still respects a 60s floor. Express rate limiting allows
// 100 requests a minute, which is no protection for a GitHub quota of 60 an
// hour that this shares with the star count and the Ruffle update check.
// 100 requests a minute, which is no protection for a release API's quota
// (GitHub allows 60 an hour, shared with the Ruffle update check).
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
// A failure also respects the 60s floor, even when force is true, so a
// GitHub outage or rate limit can't be hammered every request while it
// source's outage or rate limit can't be hammered every request while it
// still shares the API's blocking timeout with each caller.
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
-1
View File
@@ -56,7 +56,6 @@ services:
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
# Application
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
- ENABLE_CGI=${ENABLE_CGI:-false}
+2 -2
View File
@@ -205,9 +205,9 @@ Routes are defined in `App.tsx` with nested routing structure:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
@@ -286,16 +286,7 @@ interface SidebarProps {
```typescript
const gameNavItems: NavItem[] = [
{
path: '/flash-games',
iconImage: '/images/Flash.png',
label: 'Flash Games',
},
{
path: '/html5-games',
iconImage: '/images/HTML5.png',
label: 'HTML5 Games',
},
{ path: '/', icon: Home, label: 'Home' },
{ path: '/animations', icon: Film, label: 'Animations' },
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
];
@@ -360,11 +351,6 @@ const isNavItemActive = (item: NavItem) => {
);
}
// Special case: Home (/) should highlight Flash Games
if (location.pathname === '/' && itemPath === '/flash-games') {
return true;
}
return location.pathname === itemPath;
};
```
+9 -20
View File
@@ -6,24 +6,24 @@ Dotcom Arcade frontend.
## Route Structure
```
/ → Redirects to /flash-games
/ → HomeView (discovery homepage)
/login → LoginView (no auth required)
/register → RegisterView (no auth required)
/unauthorized → UnauthorizedView
# Game Views (Public/Guest Accessible)
/flash-games → FlashGamesView
/html5-games → HTML5GamesView
/browse → BrowseView
/animations → AnimationsView
/games/:id → GameDetailView
/browse/:id → GamePage (also /animations/:id, /games/:id)
/flash/*, /html5/* → Redirect to the same path under /browse
# Protected Routes (Auth Required)
/games/:id/play → GamePlayerView (requires "games.play" permission)
/browse/:id/play → GamePage, game started (requires "games.play")
/dashboard → DashboardView
/favorites → FavoritesView
/playlists → PlaylistsView
/playlists/:id → PlaylistDetailView
/playlists → UserPlaylistsView
/playlists/:id → UserPlaylistDetailView
/flashpoint-playlists → PlaylistsView
/settings → SettingsView
# Admin Routes (Permission-Based)
@@ -91,17 +91,6 @@ interface ProtectedRouteProps {
## Key Views
### FlashGamesView / HTML5GamesView
Pre-filtered game browsing views.
**Features:**
- Platform-specific filtering
- Grid/List view toggle
- Pagination
- Add to favorites/playlists
### BrowseView
Main game browsing view with full filtering.
@@ -191,9 +180,9 @@ Routes are configured in `App.tsx`:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
-1
View File
@@ -202,7 +202,6 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
- [GET /api/jobs](./admin-api.md#list-all-jobs)
- [GET /api/github/stars](./admin-api.md#get-github-star-count)
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
- [GET /game-files/\*](./admin-api.md#get-game-file)
-13
View File
@@ -213,19 +213,6 @@ Returns `202 Accepted`
Returns progress percentage, stage, estimated completion time.
## GitHub Endpoints
### Get GitHub Star Count
`GET /api/github/stars` - No auth required
```json
{
"success": true,
"data": { "stars": 2847 }
}
```
## Statistics Endpoints
### Get Archive Statistics
@@ -103,7 +103,6 @@ Two notes that save debugging time:
| Variable | Default | Description |
| -------------------- | ------- | ------------------------------------------------ |
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
**Frontend Serving:**
@@ -1,328 +0,0 @@
# Games Filter Panel & Filter Logic - Code Review
**Date:** 2026-02-15
**Reviewer:** Claude
**Status:** Review Complete
## Overview
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
## Files Reviewed
### Frontend Components
- `frontend/src/components/search/FilterPanel.tsx`
- `frontend/src/components/search/FilterDropdown.tsx`
- `frontend/src/components/search/FilterChips.tsx`
- `frontend/src/components/search/filterConfig.ts`
- `frontend/src/components/search/SearchBar.tsx`
- `frontend/src/components/search/SortControls.tsx`
- `frontend/src/components/library/GameBrowseLayout.tsx`
### Frontend Hooks & Types
- `frontend/src/hooks/useGames.ts`
- `frontend/src/hooks/useFilterOptions.ts`
- `frontend/src/hooks/useFilterDropdowns.ts`
- `frontend/src/types/game.ts`
- `frontend/src/lib/api/games.ts`
### Backend
- `backend/src/routes/games.ts`
- `backend/src/services/GameService.ts`
---
## Issues Found
### 1. Type Safety Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
```typescript
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
```
### 2. Missing Year Range Filter in UI
| Location | Issue | Severity |
|----------|-------|----------|
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
### 3. Inconsistent Filter Chips Implementation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
Two different chip rendering systems exist:
- `FilterPanel.tsx` renders inline badges
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
### 4. Performance: Redundant Handlers Recreation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
```typescript
// Called inside map() on every render
const handlers = createFilterHandlers(
config.paramKey,
config.id,
filters,
updateFilter,
dropdowns
);
```
### 5. Missing Search Debouncing in SearchBar
| Location | Issue | Severity |
|----------|-------|----------|
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
| N/A | No "search as you type" with debounce option | Enhancement |
### 6. Backend: Filter Options Limit Inconsistency
| Location | Issue | Severity |
|----------|-------|----------|
| `GameService.ts:706-707` | Series limited to 100 | Low |
| `GameService.ts:730-731` | Developers limited to 100 | Low |
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
| `GameService.ts:849` | Tags limited to 100 | Low |
Play modes query lacks `LIMIT 100` that other filter queries have.
### 7. Missing Input Validation on Frontend
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
```typescript
// Line 72 - could pass NaN to API
page: parseInt(searchParams.get('page') || '1', 10),
```
### 8. URL Encoding Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
```typescript
// Line 125 - key should be encoded for consistency
<Badge key={value} variant="secondary" className="gap-1">
```
### 9. Missing Error Boundary for Filter Options
| Location | Issue | Severity |
|----------|-------|----------|
| `useFilterOptions.ts` | No `onError` handler | Low |
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
### 10. Accessibility Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
```typescript
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
```
---
## Improvements Recommended
### High Priority
1. **Add Year Range Filter UI**
- Add a dual-handle range slider component
- Use `yearRange` from `FilterOptions`
- Connect to `yearFrom`/`yearTo` URL params
2. **Validate Page Parameter**
```typescript
// GameBrowseLayout.tsx:72
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
```
3. **Fix Sort Order Label for Dates**
```typescript
const sortOrderLabel = useMemo(() => {
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
}
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
}, [sortBy, sortOrder]);
```
### Medium Priority
4. **Consolidate Filter Chip Rendering**
- Remove inline badge rendering from `FilterPanel.tsx`
- Use `FilterChips` component consistently
- Pass chip generation logic via props or shared utility
5. **Add Filter Options Loading State**
```typescript
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
// Show skeleton or disabled state while loading
```
6. **Memoize Filter Handlers**
```typescript
const handlers = useMemo(() =>
FILTER_CONFIGS.reduce((acc, config) => ({
...acc,
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
}), {}),
[filters, updateFilter, dropdowns]
);
```
7. **Add Search-as-You-Type with Debounce**
```typescript
// In a new SearchInput component
const [inputValue, setInputValue] = useState('');
const debouncedSearch = useDebounce(inputValue, 300);
useEffect(() => {
if (debouncedSearch) {
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
}
}, [debouncedSearch]);
```
### Low Priority
8. **Consistent LIMIT on All Filter Queries**
```sql
-- GameService.ts:780-781
ORDER BY count DESC, playMode ASC
LIMIT 100 -- Add this
```
9. **Add Filter Presets**
- Save/load common filter combinations
- "Popular Flash Games", "Recent Additions", etc.
10. **Add Filter Count in Dropdown Trigger**
- Show total matching games for current filter combination
- Update counts dynamically based on other selected filters (cascading filters)
11. **Keyboard Navigation Improvements**
- Arrow keys to navigate dropdown options
- Enter to toggle selection
- Escape to close dropdown
---
## Code Quality Observations
### Positive Patterns
| Pattern | Location | Notes |
|---------|----------|-------|
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
### Areas for Improvement
| Area | Current | Recommended |
|------|---------|-------------|
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
| Error handling | Silent fallback to `[]` | Show error state with retry |
| Year range UI | Missing | Add range slider component |
| Filter persistence | URL only | Consider localStorage for user defaults |
---
## Summary
| Category | Issues | Improvements |
|----------|--------|--------------|
| Type Safety | 3 | Validate all URL params |
| Missing Features | 2 | Year filter, presets |
| Performance | 2 | Memoize handlers |
| Consistency | 3 | Unify chip rendering |
| Accessibility | 2 | Sort labels, keyboard nav |
| Backend | 1 | Consistent LIMIT |
| **Total** | **13** | **10 recommendations** |
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
---
## Action Items
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
## Fixes Applied (2026-02-15)
### Files Modified
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
- Added year range chip generation and removal handling
2. **`frontend/src/components/search/SortControls.tsx`**
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
- Improved accessibility with `aria-label`
3. **`frontend/src/components/search/FilterPanel.tsx`**
- Memoized filter handlers using `useMemo` to avoid recreation on every render
- Memoized `updateFilter` with `useCallback`
- Memoized `visibleConfigs` array
- Removed duplicate badge rendering (now uses `FilterChips` only)
- Added loading state for filter options ("Loading..." message)
- Integrated `YearRangeFilter` component
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
- Created year range filter component with from/to inputs
- Supports compact mode for mobile
- Proper validation of year values
5. **`backend/src/services/GameService.ts`**
- Added `LIMIT 100` to play modes query for consistency with other filter queries
## Additional Improvements (2026-02-15)
### Error State with Retry for Filter Options
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
- Error state detection from `useFilterOptions` hook
- User-friendly error message with alert styling
- Retry button to refetch filter options
- Graceful fallback - shows filters if data exists even with stale error
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,798 +0,0 @@
# Persistent Ruffle Installation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Implementer assignments:** each task names its implementer agent in an
> `**Implementer:**` line. When executing with
> superpowers:subagent-driven-development, REQUIRED SUB-SKILL:
> dcc-superpower-companions:dispatching-tiered-implementers. Under
> superpowers:executing-plans these lines are inert; ignore them.
**Goal:** A Ruffle version installed through Settings → Update survives container recreation.
**Architecture:** Ruffle installs into `/app/data/ruffle`, which sits on the already-mounted data volume, instead of `/app/frontend/dist/ruffle`, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at `/ruffle`, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.
**Tech Stack:** Express + TypeScript, vitest + supertest, Docker Compose.
**Spec:** `docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md`
## Global Constraints
- Backend tests run from the `backend/` directory with `npx vitest run`. Do **not** use `npm test` from the repository root — it enters watch mode and hangs.
- Never introduce `any` types or non-null assertions (`!`). Use `??` for defaults, never `||`.
- Use `logger` from `../utils/logger`, never `console`.
- Run `npx prettier --write <files>` on every changed file before committing.
- The environment variable name is exactly `RUFFLE_DATA_PATH`. The production default is exactly `/app/data/ruffle`.
- `config.ruffleDataPath` is the install directory itself, not its parent. `path.join(config.ruffleDataPath, 'ruffle')` is always wrong.
- Ruffle's content-hashed files are detected by a hex run of **16 or more** characters in the basename (`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`). Everything else — `ruffle.js`, `ruffle.js.map`, `package.json` — is stable-named and must revalidate.
- Do not delete `frontend/public/ruffle/`. It is the seed source, and Vite serves it in development.
- Do not add a release-note entry to `docs/09-deployment/upgrade-guide.md`. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.
---
### Task 1: Persisted install path
**Files:**
- Modify: `backend/src/config.ts:118-119`
- Modify: `backend/src/services/RuffleService.ts:41-52`
- Test: `backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157`
**Interfaces:**
- Consumes: nothing.
- Produces: `config.ruffleDataPath: string` — the absolute directory Ruffle is installed into. `RuffleService` gains a private `installPath` (the install target) and a private `bundledPath` (the image's baked copy at `<frontendDistPath>/ruffle`), both read by Task 2.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Add the config value**
In `backend/src/config.ts`, find this block:
```typescript
// __dirname is backend/dist at runtime, so this resolves to the frontend build
// copied next to the backend in the image (/app/frontend/dist).
frontendDistPath:
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
```
Insert directly after it:
```typescript
// Ruffle installs here rather than under the served frontend build. That build
// is part of the image, so it lives in the container's writable layer and an
// in-app update was discarded whenever the container was recreated.
ruffleDataPath:
process.env.RUFFLE_DATA_PATH ??
(process.env.NODE_ENV === 'production'
? '/app/data/ruffle'
: path.resolve(__dirname, '../data/ruffle')),
```
- [ ] **Step 2: Update the test fixtures to a separate data directory**
In `backend/src/services/RuffleService.test.ts`, change the declaration on line 7:
```typescript
let distDir: string;
```
to:
```typescript
let distDir: string;
let dataDir: string;
```
Replace the config mock (lines 78-85):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
get ruffleDataPath() {
return nodePath.join(dataDir, 'ruffle');
},
},
}));
```
Replace the `ruffleDir` helper (lines 124-126):
```typescript
function ruffleDir(): string {
return nodePath.join(distDir, 'ruffle');
}
```
with:
```typescript
function ruffleDir(): string {
return nodePath.join(dataDir, 'ruffle');
}
```
In `beforeEach`, replace the single `distDir` assignment:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));
```
with:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));
```
In `afterEach`, replace:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
```
with:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
nodeFs.rmSync(dataDir, { recursive: true, force: true });
```
- [ ] **Step 3: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: FAIL. The suite now asserts against `dataDir/ruffle` while the service still installs into `distDir/ruffle`, so assertions such as `expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build')` throw ENOENT.
- [ ] **Step 4: Point the service at the persisted path**
In `backend/src/services/RuffleService.ts`, replace the field declaration and constructor (lines 41-52):
```typescript
private readonly frontendPublicPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// Install into the directory that is actually served. In a built deployment
// that is the frontend's dist output; in development Vite serves public/.
// Pointing at public/ in a container made every fresh start re-download
// Ruffle even though the image already ships it in dist/.
this.frontendPublicPath = config.serveFrontend
? path.join(config.frontendDistPath, 'ruffle')
: path.resolve(__dirname, '../../../frontend/public/ruffle');
}
```
with:
```typescript
private readonly installPath: string;
private readonly bundledPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// A built deployment installs onto the mounted data volume and serves it
// from there: the frontend build is part of the image, so anything written
// into it is discarded when the container is recreated. In development Vite
// serves public/, which is the only directory reachable there.
this.installPath = config.serveFrontend
? config.ruffleDataPath
: path.resolve(__dirname, '../../../frontend/public/ruffle');
this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
}
```
Then rename the remaining nine references. Every other occurrence of `this.frontendPublicPath` in the file becomes `this.installPath` — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of `frontendPublicPath` with `installPath` across the file is exact; there are no other matches.
- [ ] **Step 5: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.
- [ ] **Step 6: Typecheck**
Run from the repository root: `npm run typecheck`
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git commit -m "fix(ruffle): install onto the persisted data volume"
```
---
### Task 2: Seed a fresh volume from the bundled copy
**Files:**
- Modify: `backend/src/services/RuffleService.ts` (add two methods after `verifyInstallation`)
- Modify: `backend/src/server.ts:296-314`
- Test: `backend/src/services/RuffleService.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: `this.installPath` and `this.bundledPath` from Task 1; the existing private `copyDirectory(source: string, target: string): void`.
- Produces: `RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>`, called by `backend/src/server.ts`.
**Implementer:** dcc-superpower-companions:impl-sonnet-xhigh
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 1 = 4
- [ ] **Step 1: Write the failing tests**
Append to `backend/src/services/RuffleService.test.ts`:
```typescript
function installBundledRuffle(): void {
const bundled = nodePath.join(distDir, 'ruffle');
nodeFs.mkdirSync(bundled, { recursive: true });
nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
nodeFs.writeFileSync(
nodePath.join(bundled, 'package.json'),
JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
);
}
describe('RuffleService.ensureInstalled', () => {
it('leaves an existing installation alone', async () => {
installExistingRuffle();
installBundledRuffle();
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('present');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
expect(axios.get).not.toHaveBeenCalled();
});
it('seeds from the bundled copy when nothing is installed', async () => {
installBundledRuffle();
const service = new RuffleService();
const result = await service.ensureInstalled();
expect(result).toBe('seeded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
'bundled build'
);
expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
expect(axios.get).not.toHaveBeenCalled();
});
it('downloads when neither an installation nor a bundled copy exists', async () => {
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('downloaded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
expect(axios.get).toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts -t ensureInstalled`
Expected: FAIL with `ensureInstalled is not a function`.
- [ ] **Step 3: Implement the methods**
In `backend/src/services/RuffleService.ts`, add both methods directly after `verifyInstallation()`, inside the class:
```typescript
/**
* Copy the Ruffle shipped inside the image into the install directory. A fresh
* data volume otherwise has no player at all until a download finishes — and
* never gets one on a host that cannot reach GitHub.
*/
private seedFromBundle(): boolean {
if (this.bundledPath === this.installPath) {
return false;
}
if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
return false;
}
try {
this.copyDirectory(this.bundledPath, this.installPath);
logger.info(
`✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
);
return true;
} catch (error: unknown) {
logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
return false;
}
}
/**
* Make a Ruffle available, preferring what is already installed, then the copy
* baked into the image, and only then a download.
*/
async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
if (this.verifyInstallation()) {
logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
return 'present';
}
if (this.seedFromBundle()) {
return 'seeded';
}
logger.info('🎮 Ruffle not found, downloading latest version...');
await this.updateRuffle();
logger.info('✅ Ruffle installation complete');
return 'downloaded';
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, including the three new cases and every pre-existing one.
- [ ] **Step 5: Call it from startup**
In `backend/src/server.ts`, replace lines 296-314:
```typescript
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
// here kept the port closed for minutes on slow storage — indefinitely if the
// download stalled. Flash games need it, everything else does not.
void (async () => {
try {
const ruffleService = new RuffleService();
if (!ruffleService.verifyInstallation()) {
logger.info('🎮 Ruffle not found, downloading latest version...');
await ruffleService.updateRuffle();
logger.info('✅ Ruffle installation complete');
} else {
const version = ruffleService.getCurrentVersion();
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
}
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
with:
```typescript
// Ruffle resolves in the background: seeding copies ~20 MB and the download
// fallback reaches GitHub, and awaiting either here kept the port closed for
// minutes on slow storage. Flash games need it, everything else does not.
void (async () => {
try {
await new RuffleService().ensureInstalled();
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
- [ ] **Step 6: Typecheck and run the full backend suite**
Run from the repository root: `npm run typecheck`
Then run from `backend/`: `npx vitest run`
Expected: no type errors, no new test failures.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"
```
---
### Task 3: Serve the persisted directory with correct cache headers
**Files:**
- Modify: `backend/src/middleware/frontendServing.ts:20-55`
- Modify: `backend/src/middleware/startupOrdering.test.ts:12-17`
- Create: `backend/src/middleware/ruffleServing.test.ts`
**Interfaces:**
- Consumes: `config.ruffleDataPath` from Task 1.
- Produces: nothing consumed by later tasks. `registerFrontendServing(app, distPath?)` keeps its existing signature; the Ruffle mount reads `config.ruffleDataPath` directly.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Write the failing test**
Create `backend/src/middleware/ruffleServing.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let ruffleDir: string;
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get ruffleDataPath() {
return ruffleDir;
},
},
}));
import { registerFrontendServing } from './frontendServing';
const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';
let distDir: string;
let app: Express;
beforeEach(() => {
distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));
fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
fs.mkdirSync(path.join(distDir, 'ruffle'));
fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');
fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');
app = express();
registerFrontendServing(app, distDir);
});
afterEach(() => {
fs.rmSync(distDir, { recursive: true, force: true });
fs.rmSync(ruffleDir, { recursive: true, force: true });
});
describe('Ruffle serving', () => {
it('serves the persisted copy in preference to the bundled one', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('updated loader');
});
it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('bundled extra');
});
it('makes the stable-named loader revalidate', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
it('makes a content-hashed chunk immutable', async () => {
const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('makes the bundled fallback loader revalidate too', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: FAIL. `/ruffle/ruffle.js` returns `bundled loader` because no persisted mount exists, and its `Cache-Control` is `public, max-age=31536000, immutable`.
- [ ] **Step 3: Add the mount and the cache policy**
In `backend/src/middleware/frontendServing.ts`, add this import alongside the existing ones:
```typescript
import type { Response } from 'express';
```
Add these two declarations directly above `export function registerFrontendServing`:
```typescript
/**
* Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
* `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
* rather than listing the stable names keeps this correct when a release adds a
* file.
*/
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;
/**
* `ruffle.js` keeps its name across versions, so serving it immutable pinned
* every returning browser to the loader it first saw — an update reached the
* disk and nothing else.
*/
function setRuffleCacheControl(res: Response, filePath: string): void {
res.setHeader(
'Cache-Control',
HASHED_RUFFLE_ASSET.test(path.basename(filePath))
? 'public, max-age=31536000, immutable'
: 'no-cache, no-store, must-revalidate'
);
}
```
Inside `registerFrontendServing`, insert this immediately after the `index.html` existence guard and before the existing `app.use(express.static(distPath, ...))` call:
```typescript
// Ahead of the dist handler so an updated Ruffle shadows the one baked into
// the image. `fallthrough` lets the baked copy answer for anything the
// persisted directory is missing, including before the first seed finishes.
app.use(
'/ruffle',
express.static(config.ruffleDataPath, {
index: false,
fallthrough: true,
setHeaders: setRuffleCacheControl,
})
);
```
Then replace the `setHeaders` option on the existing dist handler:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
```
with:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
return;
}
if (filePath.startsWith(bundledRufflePath + path.sep)) {
setRuffleCacheControl(res, filePath);
}
},
```
and declare `bundledRufflePath` at the top of the function body, directly below the existing `const indexHtml = path.join(distPath, 'index.html');`:
```typescript
const bundledRufflePath = path.join(distPath, 'ruffle');
```
- [ ] **Step 4: Run the test to verify it passes**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: PASS, all five cases.
- [ ] **Step 5: Repair the sibling middleware test's config mock**
`backend/src/middleware/startupOrdering.test.ts` mocks `../config` without `ruffleDataPath`, and `express.static(undefined)` throws `TypeError: root path required`. Replace its mock (lines 12-17):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
ruffleDataPath: '/unused-in-tests/ruffle',
},
}));
```
- [ ] **Step 6: Run the full backend suite and typecheck**
Run from `backend/`: `npx vitest run`
Then run from the repository root: `npm run typecheck`
Expected: no failures and no type errors. `startupOrdering.test.ts` in particular must stay green — a missing-directory static mount with `fallthrough: true` calls `next()` instead of throwing.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"
```
---
### Task 4: Deployment configuration and documentation
**Files:**
- Modify: `docker-compose.dev.yml:28-40`
- Modify: `docker-compose.yml:29-33`
- Modify: `docs/09-deployment/environment-variables.md:113-116`
**Interfaces:**
- Consumes: the `RUFFLE_DATA_PATH` variable name and the `/app/data/ruffle` default from Task 1.
- Produces: nothing.
**Implementer:** dcc-superpower-companions:impl-sonnet-medium
**Evaluation:** files 1 - spec 0 - coupling 1 - risk 0 = 2
- [ ] **Step 1: Set the variable in the dev compose file**
`docker-compose.dev.yml` runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed default resolves to a relative path inside the container and misses the `backend-db:/app/data` volume. In `docker-compose.dev.yml`, find:
```yaml
# Matches the read-only mount above. Set explicitly because the backend's
# fallback only points here when NODE_ENV=production.
- FLASHPOINT_PATH=/data/flashpoint
```
Insert directly after it:
```yaml
# Same reason as FLASHPOINT_PATH above: this container runs
# NODE_ENV=development, so the production default would put Ruffle outside
# the data volume and lose every update on recreate.
- RUFFLE_DATA_PATH=/app/data/ruffle
```
- [ ] **Step 2: Update both volume comments**
In `docker-compose.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- ${DATA_PATH:-./data}:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- ${DATA_PATH:-./data}:/app/data
```
In `docker-compose.dev.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- backend-db:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- backend-db:/app/data
```
- [ ] **Step 3: Document the variable**
In `docs/09-deployment/environment-variables.md`, find this table:
```markdown
| Variable | Default | Description |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
```
Add one row to it:
```markdown
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
```
- [ ] **Step 4: Verify both compose files still parse**
Run from the repository root:
```bash
docker compose -f docker-compose.yml config --quiet
docker compose -f docker-compose.dev.yml config --quiet
```
Expected: both exit 0 and print nothing. Each file requires `FLASHPOINT_HOST_PATH` and `JWT_SECRET`; if the repository has no `.env`, supply throwaway values inline:
```bash
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet
```
If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:
```bash
node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"
```
The `yaml` package is already present in the repository's `node_modules` as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.
- [ ] **Step 5: Commit**
```bash
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git commit -m "docs(ruffle): document the persisted install path"
```
---
## Verification
After Task 4, confirm the whole change from the repository root:
```bash
npm run typecheck
npm run build
```
Then from `backend/`: `npx vitest run`
Expected: typecheck clean, build succeeds, backend suite green.
Manual confirmation in a container, which is what the reported bug needs:
1. `docker compose -f docker-compose.dev.yml up -d --build`
2. Settings → Update → update Ruffle, and note the version.
3. `docker compose -f docker-compose.dev.yml up -d --force-recreate`
4. Settings → Update still reports the updated version, and a hard-refreshed
browser loads it.
@@ -1,184 +0,0 @@
# Downloaded games: page, filter, and settings
Date: 2026-08-11
## Goal
1. A "Downloaded" sidebar entry and page listing only downloaded games.
2. A downloaded filter in the filter panel on the existing browse pages.
3. An admin setting to show or hide the Downloaded page.
4. An admin setting that lets guests see the Downloaded page, offered only when
the page itself is enabled.
5. An admin setting that turns the downloaded filter on by default.
## Definition of "downloaded"
A game is downloaded when it has at least one `game_data` row with
`presentOnDisk = 1`.
Games with no `game_data` rows at all — legacy titles served from htdocs — can
never be downloaded under this definition and never appear on the page. This
matches the existing per-game badge, which reads the same flag through the
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
## Keeping the flag truthful
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
Three gaps must close before the flag can drive a whole page.
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
writes the ZIP and returns without touching the database. On success it will call
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
file is otherwise a complete, mountable download.
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
source over that file whenever the Launcher's database changes. A new
`DownloadedReconciler` service restores truth from disk:
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
the deterministic name produced by `GameDataDownloader.getFilename`.
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
rows already flagged.
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
- Run once at startup after `DatabaseService` initialization, and again after every
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
games directory logs a warning and yields zero marks.
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
the Launcher can store game data outside `flashpointGamesPath` and we would be
overwriting its state with a false negative.
This scan also backfills every game downloaded through the Play path before this
feature ships, which is otherwise the largest population of invisible downloads.
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
clears the search, flash-SWF, and filter-options caches — per download. Add
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
`markAsDownloaded` and the reconciler. The caller then clears only the search and
filter-options caches, so a freshly downloaded game appears immediately.
A Launcher write landing in the same millisecond as ours could be swallowed by this
check. The consequence is a delayed reload, not data loss, and the next Launcher
write recovers it.
## Backend filter
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
and the filter-options query schema. `true` restricts to downloaded games;
`false` and `undefined` both mean "no restriction", and the frontend omits the
field when the filter is off.
In `GameService.searchGames` the condition is:
```sql
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
```
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
year range narrow to downloaded games; qualify the outer column with its table name
there, since those queries have no alias and `game_data` has its own `id` column.
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
callback so the recorded filter count stays accurate.
## Frontend filter
`GameFilters.downloaded?: boolean` carries the value to the API.
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
explicit off survives `toUrlSafeFormat`.
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
`GameBrowseLayout` resolves it once and passes a plain boolean down.
Because absent means "default", removal cannot clear the parameter when the default
is on — the filter would immediately reapply. When the admin default is on,
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
chip participates in the existing filter hierarchy.
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
That is inherent to the tri-state and accepted.
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
prop hides the switch on the Downloaded page, where the filter is the page.
## Downloaded page
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
gains `forceDownloaded`.
The page passes `sectionKey={null}`, so game links resolve to the existing
`/games/:id` detail route. No new per-section detail or play routes.
## Settings
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
`features` category, following the existing `enable_*` naming:
| Key | Default | Effect |
| --- | --- | --- |
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
admin cannot lock themselves out. The other two are read as `=== true`: they default
off, and the hook's `?? true` fallback would otherwise invert them during the
public-settings loading window and whenever the key is missing.
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
guest deep-linking to `/downloaded` with the guest flag off is redirected to
`/unauthorized`. A flag check alone would let them through, since guests satisfy
`requireAuth`.
These flags are presentation, not access control: `POST /api/games` is open to
guests and accepts `downloaded` regardless, and the page shows no data a guest
cannot already reach by filtering Browse. No backend enforcement is added.
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
while the page flag is on. Its stored value persists while hidden and resurfaces if
the page is re-enabled.
## Sidebar
The Downloaded entry sits in the Library section with Favorites. That block is
currently hidden wholesale for guests, so its items never check guest status
themselves. Relaxing the block requires giving each item its own predicate:
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
the section renders when any item survives. The existing
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
## Testing
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
is unaffected when the field is absent.
- Filter options narrow when `downloaded` is set, and cache keys distinguish
`undefined`, `false`, and `true`.
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
unrelated files, and marks nothing when the directory is unreadable.
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
`buildFilterSearchParams`.
- Chip removal writes an explicit off when the admin default is on.
## Documentation
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
`docs/06-api-reference/settings-api.md` (three new settings), and
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
@@ -1,234 +0,0 @@
# Web app update check on the Update page
Date: 2026-08-12
## Goal
An admin opening Settings → Update sees whether a newer release of Flashpoint
Web exists, what changed in it, and how to upgrade.
The card notifies only. It never pulls an image, restarts a container, or writes
anything: self-updating from inside the container needs the Docker socket
mounted, which hands the container root-equivalent control of the host.
## What counts as an update
The running version comes from `APP_VERSION`, an environment variable baked into
the image from the `VERSION` build arg that CI already passes (see
`.github/workflows/docker-build-push.yml`). This mirrors `VITE_APP_VERSION`,
which the frontend build consumes for the same value.
A version counts as a release build only when it is three clean numbers, with an
optional leading `v` — `1.0.38` or `v1.0.38`. Anything else is an **unreleased
build**: the variable unset, `dev` from a local `docker build`, `latest` from a
`workflow_dispatch` run, a release candidate like `1.1.0-rc1`, or a
`git describe` string like `1.0.38-5-gabc123`. An unreleased build never claims
to be behind, because its commits may already include everything in the latest
release.
The comparison target is GitHub's
`/repos/darkraise/flashpoint-web/releases/latest`, which excludes prereleases by
definition. No prerelease filtering is needed, and release candidates never
surface.
Verified against the live endpoint on 2026-08-12: it answers 200 with
`tag_name: "v1.0.38"`, `prerelease: false`, `published_at`, `html_url`, and a
markdown `body`. The tag carries a leading `v` that `APP_VERSION` does not, so
both sides are stripped before comparing — the tag always has one, and a manual
`docker build --build-arg VERSION=v1.0.38` would otherwise be misread as
unreleased.
## The two version sources must not contradict each other
The frontend already resolves its own version at build time
(`frontend/vite.config.ts`, `frontend/src/lib/version.ts`) and shows it on the
General tab as "Web App Version". This design adds a second, server-side source,
and the two sit one tab apart.
In Docker they agree: both build stages receive the same `VERSION` build arg. In
a source checkout they can differ — the frontend falls back to `git describe`,
while the backend has no `APP_VERSION` at all — so the General tab could read
`1.0.38` while the card reads "unreleased build". The card's copy therefore
attributes the state to the server rather than to the operator: "The server did
not report a released version, so update checks are off."
`APP_VERSION` is documented in `docs/09-deployment/environment-variables.md` as
an optional operator-settable variable, so a non-Docker deployment can opt into
update checks.
## Backend
### `compareVersions(a, b)` — `backend/src/utils/version.ts`
Returns a negative number, zero, or a positive number by comparing major, minor,
and patch numerically, after stripping an optional leading `v` from each side.
Returns `null` when either input is not exactly three numeric parts, which is
what routes an unreleased build away from the comparison. Numeric comparison
matters: string ordering puts `1.0.9` above `1.0.38`.
A `null` result from a malformed *latest* tag — a hand-cut release tagged `v2.0`
— yields `updateAvailable: false`. Reporting nothing is preferable to reporting
a wrong direction.
### `AppUpdateService` — `backend/src/services/AppUpdateService.ts`
One public method, `getUpdateInfo(force = false)`, returning:
```ts
interface AppUpdateInfo {
currentVersion: string | null; // null when APP_VERSION is unset
latestVersion: string | null; // null when GitHub has never been reached
updateAvailable: boolean;
isUnreleasedBuild: boolean;
publishedAt: string | null;
releaseUrl: string | null;
changelog: string | null;
checkedAt: string | null; // when the held result was fetched
lastCheckFailed: boolean;
}
```
The result is cached in memory for one hour and shared by every admin. The cache
holds the last *successful* result; a failed check returns that result with
`lastCheckFailed: true` rather than discarding it.
**Concurrency.** The in-flight fetch promise is stored and returned to
concurrent callers, so a cold cache hit by several requests at once produces one
GitHub call, not several.
**Forced refresh.** `force` bypasses the one-hour cache but not a 60-second
floor: a forced check within 60 seconds of the last fetch returns the held
result. `rateLimitStandard` is 100 requests per 60-second window
(`backend/src/config.ts`, `RATE_LIMIT_MAX_REQUESTS`), which is no protection for
a 60-per-hour unauthenticated GitHub quota that this feature shares with the
star-count proxy and the Ruffle update check. The floor is what keeps the quota
intact.
`isUnreleasedBuild: true` still fetches and reports `latestVersion` and the
changelog for reference, but forces `updateAvailable: false`.
### Failure handling
The route always answers 200. A total failure — no cached result and GitHub
unreachable — returns `latestVersion: null` with `lastCheckFailed: true`, and
the card renders its failed state.
It does **not** throw `AppError(502)`, because the axios interceptor in
`frontend/src/lib/api/client.ts` fires a global "Server error occurred" toast for
any status at or above 500. A card that renders its own inline error would
double up with that toast, which is what the Ruffle card does today.
`backend/src/routes/github.ts` is the precedent for serving a stale result, but
it is a weaker version of this design and should not be copied line for line: it
throws a plain `Error`, has no request timeout, and its stale path covers only a
non-OK response, so a network failure or hang bypasses the cache entirely. Here
the `try` wraps the whole fetch, and the request carries an explicit timeout.
### `GET /api/updates/app`
Added to `backend/src/routes/updates.ts` behind `authenticate` and
`requirePermission('settings.update')` from `../middleware/rbac`. That matches
the tab's own gate in `UpdateSettingsTab.tsx`; note that two GETs in that file
use `settings.read`, so "the same as its neighbours" is not exact.
The handler returns the bare `AppUpdateInfo` object — `res.json(info)` — as
every endpoint in `updates.ts` does and as `updatesApi` expects. The
`{success, data}` envelope in `github.ts` is that router's convention, not this
one.
`?refresh=true` sets `force`, read as `req.query.refresh === 'true'`. Any other
value, array, or object is falsy, so no schema or bounds check is warranted; the
60-second floor is the real control. `rateLimitStandard` already applies to the
whole router.
### `config.appVersion`
`process.env.APP_VERSION?.trim() || null`, added to `backend/src/config.ts`
beside the Flashpoint version fields. `||` is deliberate here and must not be
"corrected" to `??`: an empty or whitespace-only variable has to collapse to
`null`, which `??` would not do.
### Dockerfile
The final stage already declares `ARG VERSION=dev` for the image label. It gains
`ENV APP_VERSION=$VERSION`, since an `ARG` alone does not survive into the
running container.
## Frontend
`AppUpdateCard` — `frontend/src/components/settings/AppUpdateCard.tsx` — renders
first in `UpdateSettingsTab`, above the metadata and Ruffle cards, admin-only
like both of them.
A `useQuery` on mount performs the check. The Check button cannot be a
`refetch()`, which reuses the original query function and so cannot add
`refresh=true`; it is a `useMutation` calling the endpoint with the flag, writing
its result back through `queryClient.setQueryData`. This is the shape the Ruffle
card already uses.
The card reuses the vocabulary of `RuffleManagementCard`: a collapsible
changelog rendered with `react-markdown` and `remark-gfm`, `FormattedDate` for
the release date, the same button placement and toast behaviour. No sanitizer is
added — without `rehype-raw`, react-markdown escapes raw HTML, and the source is
the project's own CI-generated release notes.
Five states:
| State | Condition | Shows |
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Update available | `updateAvailable` | Both versions, release date, changelog expanded, link to the release, upgrade commands |
| Up to date | versions match | Current version and when it was checked |
| Unreleased build | `isUnreleasedBuild` | "The server did not report a released version, so update checks are off", plus the latest release for reference |
| Stale | `lastCheckFailed` with a `latestVersion` | The held result, labelled as a check that failed at `checkedAt` |
| Check failed | `lastCheckFailed`, no `latestVersion` | A muted error and a retry button |
The upgrade command is `docker compose pull && docker compose up -d`, matching
the deployment `docker-compose.yml` documents. That file pins
`image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}`, so the card adds one
line: an operator who set `IMAGE_TAG` to a fixed version must raise it first, or
the pull is a no-op.
The release body generated by CI opens with its own "## Docker Image" section
carrying a `docker pull` line, so the changelog repeats part of this. The card
still states the commands itself, because the compose commands are the actual
upgrade and the changelog is collapsible.
The API call goes through `updatesApi` in `frontend/src/lib/api/updates.ts`, with
`AppUpdateInfo` declared there and re-exported from `lib/api/index.ts`, as the
other update types are.
## Testing
Backend unit tests in the repo's existing style, with no test for the card —
`components/settings` has no test coverage today and this change does not
introduce the pattern.
- `compareVersions`: equal, newer, older, multi-digit ordering (`1.0.38` above
`1.0.9`), a leading `v` on either or both sides, and malformed inputs (`dev`,
`latest`, `1.0.38-5-gabc123`, `1.0`, empty).
- `AppUpdateService` with a mocked fetch: update available, up to date,
unreleased build, malformed latest tag, cache hit inside the hour, forced
refresh honoured after the 60-second floor and ignored inside it, one GitHub
call for concurrent cold-cache requests, a held result returned with
`lastCheckFailed` after a failure, and a 200 with `latestVersion: null` when a
failure has no cache to fall back on.
## Decisions taken
- **Notify only.** Self-update requires the Docker socket; a restart-only button
cannot pull, so it would only ever help someone who had already pulled.
- **The repository is hardcoded** to `darkraise/flashpoint-web`, as it already is
in the star-count proxy. A fork wanting its own release feed would need this
made configurable; nothing here is designed to prevent that later.
- **Docker Compose is the documented deployment**, so it is the only upgrade
command shown.
- **No notification badge** elsewhere in the UI, and no scheduled background
check. The cached on-open check covers the stated goal; a `JobScheduler` job
would be the way to add a badge later.
## Known limitation, not addressed here
A `workflow_dispatch` run of the build workflow tags the image `:latest` and
passes `VERSION=latest`, so that image reports an unreleased build. The card
makes a pre-existing CI wart visible; fixing it means changing what the manual
trigger publishes, which is out of scope for this feature.
@@ -1,131 +0,0 @@
# Persistent Ruffle installation
Date: 2026-08-13
## Goal
A Ruffle version installed through Settings → Update survives container
recreation. Today it does not: the update lands in the container's writable
layer, and the next `docker compose up -d` after an image pull or a config
change throws it away.
## Why it reverts
`RuffleService` installs into `config.frontendDistPath/ruffle` — `/app/frontend/dist/ruffle`
inside the container. The only mounted volumes are `/data/flashpoint`,
`/app/data`, and `/app/logs`, so that path lives entirely in the overlay
writable layer.
`frontend/public/ruffle/` is committed to the repository at
`0.2.0-nightly.2026.1.29`. Vite copies `public/` into `frontend/dist/` at build
time and the Dockerfile bakes that directory into the image. When a recreate
discards the writable layer, the baked copy is what remains.
Startup only downloads when `verifyInstallation()` is false
(`backend/src/server.ts:302`). The baked copy satisfies that check, so the
server logs the stale version and keeps it. The scheduled `ruffle-update` job
would eventually re-apply the update, but only if that job is enabled.
A plain `docker restart` preserves the writable layer and does not trigger this.
The recreate paths do: `docker compose up -d` after an image pull or any change
to the resolved service config, `down` then `up`, `--force-recreate`, and the
update actions in Portainer and Unraid.
## Install location
A new `config.ruffleDataPath` names the install directory:
```
process.env.RUFFLE_DATA_PATH ??
(NODE_ENV === 'production' ? '/app/data/ruffle' : <repo>/backend/data/ruffle)
```
`RuffleService` swaps only its `serveFrontend` branch to this path. The other
branch keeps writing to `frontend/public/ruffle`, because in a source checkout
Vite serves `public/` and an install anywhere else would not be reachable.
`docker-compose.dev.yml` sets `RUFFLE_DATA_PATH=/app/data/ruffle` explicitly. It
runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed
default would miss its `backend-db:/app/data` volume. The file already sets
`FLASHPOINT_PATH` for exactly this reason, and the new variable follows that
precedent. Production compose needs no change: `NODE_ENV=production` already
resolves to the right path.
The entrypoint chowns `/app/data` to the app user on every start, so a non-default
`PUID` can write the new directory without further work.
## Seeding a fresh install
`RuffleService.ensureInstalled()` replaces the inline block at
`backend/src/server.ts:299-314` and resolves in three steps:
1. `ruffle.js` exists at the data path — log the version and stop.
2. Otherwise the image's bundled `frontend/dist/ruffle` exists — copy it into
the data path via the existing private `copyDirectory`.
3. Otherwise — download the latest release from GitHub, which is the current
behaviour.
Seeding from the bundle rather than downloading means Flash works the moment a
fresh container is up, and works at all on a host that cannot reach GitHub. The
copy is roughly 20 MB and happens once per persisted volume, not once per
container.
The call stays fire-and-forget in the background. Awaiting it would hold the
port closed for the duration of a download, which is the regression the current
comment at `server.ts:296-298` records.
## Serving
`registerFrontendServing` mounts the data path at `/ruffle` **before**
`express.static(distPath)`, so the persisted copy shadows the baked one. The
mount uses `fallthrough: true`: if the persisted tree is missing a file, the
baked `dist/ruffle` still answers rather than 404ing into a dead player.
The frontend needs no change. `RufflePlayer.tsx` already loads
`/ruffle/ruffle.js` and sets `publicPath: '/ruffle/'`, both of which the new
mount serves.
## Cache headers
The dist handler currently serves everything with `maxAge: '1y', immutable: true`,
including `ruffle/ruffle.js`, which is not content-hashed. A browser that has
loaded the page once will therefore keep the old loader for a year, so
persistence alone would not make an update visible to returning users.
A shared helper decides the policy from the basename: `immutable, max-age=1y`
when it contains a hex run of 16 or more characters, otherwise
`no-cache, no-store, must-revalidate`. Ruffle's chunks carry such a run
(`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`) while its
stable entry points do not (`ruffle.js`, `ruffle.js.map`, `package.json`).
Detecting the hash rather than listing the stable names keeps the rule correct
when a future Ruffle release adds a file.
The helper applies to both the new `/ruffle` mount and to `ruffle/` paths under
the dist handler, so the fallback cannot reintroduce a stale loader.
## Testing
`RuffleService.test.ts` mocks `../config`; that mock gains `ruffleDataPath` and
the existing `ruffleDir()` helper points at it. The update tests are otherwise
unchanged — they exercise the rename and copy fallbacks, which the new path does
not alter.
New cases cover `ensureInstalled()`:
- an existing install is left alone and no download is attempted;
- an empty data path with a bundled copy present is seeded from the bundle, and
no download is attempted;
- an empty data path with no bundle falls through to the download.
A new `frontendServing` test asserts the `/ruffle` mount is registered ahead of
the dist handler, that `ruffle.js` comes back revalidating, and that a hashed
chunk comes back immutable.
## Out of scope
`config.userDbPath` and `config.tempDownloadPath` use the same
`NODE_ENV === 'production'` keying as the new variable. In the dev compose
container that evaluates to `development`, so `user.db` lands at
`/app/backend/user.db` and the `backend-db:/app/data` volume goes unused — dev
container app data is not persisted today. Real, but unrelated to Ruffle, and
touching database paths carries risk this change does not need.
+9 -67
View File
@@ -1,4 +1,4 @@
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useEffect, lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { MainLayout } from './components/layout/MainLayout';
@@ -41,12 +41,6 @@ const HomeView = lazy(() => import('./views/HomeView').then((m) => ({ default: m
const BrowseView = lazy(() =>
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
);
const FlashGamesView = lazy(() =>
import('./views/FlashGamesView').then((m) => ({ default: m.FlashGamesView }))
);
const HTML5GamesView = lazy(() =>
import('./views/HTML5GamesView').then((m) => ({ default: m.HTML5GamesView }))
);
const AnimationsView = lazy(() =>
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
);
@@ -95,6 +89,12 @@ const ActivitiesView = lazy(() =>
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
);
/** The old /flash and /html5 sections are now part of /browse */
function LegacySectionRedirect() {
const { pathname, search } = useLocation();
return <Navigate to={pathname.replace(/^\/(flash|html5)/, '/browse') + search} replace />;
}
function App() {
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
const isServerStarting = useServerStatusStore((state) => state.isStarting);
@@ -289,6 +289,8 @@ function App() {
{/* ==================== STANDALONE ==================== */}
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/flash/*" element={<LegacySectionRedirect />} />
<Route path="/html5/*" element={<LegacySectionRedirect />} />
{/* ==================== PORTAL LAYOUT ==================== */}
<Route element={<PortalLayout />}>
@@ -304,26 +306,6 @@ function App() {
/>
{/* Public/guest accessible routes */}
<Route
path="/flash"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<FlashGamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<HTML5GamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/browse"
element={
@@ -346,46 +328,6 @@ function App() {
/>
{/* Section-based game routes - must come before /games/:id */}
<Route
path="/flash/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/flash/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/animations/:id"
element={
@@ -35,7 +35,7 @@ interface GameBrowseLayoutProps {
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
forceDownloaded?: boolean;
+1 -1
View File
@@ -29,7 +29,7 @@ interface GameCardProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameGridProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameListProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
@@ -28,7 +28,7 @@ interface GameListItemProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
+8 -24
View File
@@ -12,9 +12,9 @@ export interface SectionIcon {
* Section configuration for game routes
*/
export interface SectionConfig {
/** URL prefix for game routes (e.g., '/flash') */
/** URL prefix for game routes (e.g., '/browse') */
prefix: string;
/** Sidebar item path (e.g., '/flash') */
/** Sidebar item path (e.g., '/browse') */
sidebarPath: string;
/** Label for breadcrumbs */
breadcrumbLabel: string;
@@ -28,20 +28,6 @@ export interface SectionConfig {
* Section configurations keyed by section identifier
*/
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
flash: {
prefix: '/flash',
sidebarPath: '/flash',
breadcrumbLabel: 'Flash Games',
breadcrumbHref: '/flash',
icon: { type: 'image', value: '/images/Flash.png' },
},
html5: {
prefix: '/html5',
sidebarPath: '/html5',
breadcrumbLabel: 'HTML5 Games',
breadcrumbHref: '/html5',
icon: { type: 'image', value: '/images/HTML5.png' },
},
animations: {
prefix: '/animations',
sidebarPath: '/animations',
@@ -65,8 +51,6 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
* When breadcrumb context exists, it takes priority over prefix matching.
*/
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
'/flash': ['/flash/'],
'/html5': ['/html5/'],
'/animations': ['/animations/'],
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
'/playlists': ['/playlists/'],
@@ -76,7 +60,7 @@ export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
/**
* Get section configuration from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section config if pathname matches a section route, null otherwise
*/
export function getSectionFromPath(pathname: string): SectionConfig | null {
@@ -90,8 +74,8 @@ export function getSectionFromPath(pathname: string): SectionConfig | null {
/**
* Get section key from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns Section key ('flash', 'html5', etc.) or null
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section key ('browse', 'animations') or null
*/
export function getSectionKeyFromPath(pathname: string): string | null {
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
@@ -105,9 +89,9 @@ export function getSectionKeyFromPath(pathname: string): string | null {
/**
* Build a section-based game URL
* @param gameId - Game UUID
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
* @param sectionKey - Section key ('animations', 'browse') or null
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
* @returns URL string (e.g., '/browse/abc-123' or '/browse/abc-123/play')
*/
export function buildSectionGameUrl(
gameId: string,
@@ -127,7 +111,7 @@ export function buildSectionGameUrl(
/**
* Derive breadcrumb context from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
*/
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
-1
View File
@@ -154,7 +154,6 @@ export const handlers = [
app: {
maintenance_mode: false,
allow_registration: true,
homeRecentHours: 24,
},
metadata: {
flashpointEdition: 'infinity',
+1 -5
View File
@@ -7,7 +7,6 @@ export interface AppSettings {
maintenanceMessage: string;
defaultTheme?: string;
defaultPrimaryColor?: string;
homeRecentHours?: number;
}
export interface FeatureSettings {
@@ -71,10 +70,7 @@ export interface Domain {
}
export interface PublicSettings {
app: Pick<
AppSettings,
'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'
>;
app: Pick<AppSettings, 'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage'>;
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
features?: Partial<FeatureSettings>;
metadata?: {
-13
View File
@@ -1,13 +0,0 @@
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
export function FlashGamesView() {
return (
<GameBrowseLayout
title="Flash Games"
library="arcade"
platform="Flash"
breadcrumbContext={{ label: 'Flash Games', href: '/flash' }}
sectionKey="flash"
/>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
export function HTML5GamesView() {
return (
<GameBrowseLayout
title="HTML5 Games"
library="arcade"
platform="HTML5"
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5' }}
sectionKey="html5"
/>
);
}