Build verified checkpoint containers for TrueNAS

This commit is contained in:
Quintine committed 2026-09-21 17:48:25 +10:00
1 parent c5508b727a
commit 64e8299aeb
27 files changed
+536 -16

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# Allow only build inputs. Never send saved games, credentials or Git history.
**
!package.json
!package-lock.json
!tsconfig.json
!next.config.ts
!vite.config.ts
!.oxlintrc.json
!components.json
!drizzle.config.ts
!README.md
!AGENTS.md
!THIRD_PARTY_NOTICES.md
!docs/**
!.openai/
!.openai/hosting.json
!app/**
!components/**
!db/**
!drizzle/**
!game/**
!hooks/**
!lib/**
!public/**
!tests/**
!tools/**
!deploy/container-entrypoint.sh
!deploy/serve.mjs
**/*.orig
**/*.rej
**/.env*
**/.dev.vars*
**/*.sqlite*
**/*.pem
+54
View File
@@ -0,0 +1,54 @@
name: Verify and publish checkpoint container
on:
push:
branches: [main, 'codex/**']
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: dune-checkpoint-container
cancel-in-progress: false
jobs:
container:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- uses: docker/setup-buildx-action@v3
- name: Build and run offline checks
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: dune:verify
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify isolated storage and HTTP behavior
run: node tools/verify-container.mjs dune:verify
- uses: docker/login-action@v3
if: github.ref == 'refs/heads/main'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish the verified image
if: github.ref == 'refs/heads/main'
env:
REPOSITORY: ${{ github.repository }}
REVISION: ${{ github.sha }}
run: |
image="ghcr.io/${REPOSITORY,,}"
docker tag dune:verify "$image:sha-$REVISION"
docker tag dune:verify "$image:checkpoint"
docker push "$image:sha-$REVISION"
docker push "$image:checkpoint"
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
ENV CI=true WRANGLER_SEND_METRICS=false GOMEMLIMIT=2GiB GOMAXPROCS=2
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run check && npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production CI=true WRANGLER_SEND_METRICS=false \
WRANGLER_WRITE_LOGS=false MINIFLARE_REGISTRY_PATH=/tmp/dune-registry
LABEL org.opencontainers.image.source="https://github.com/Quintine/Dune"
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
COPY package.json ./
COPY tools/wrangler.local.json ./tools/wrangler.local.json
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
COPY deploy/serve.mjs ./deploy/serve.mjs
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/', {signal: AbortSignal.timeout(8000)}).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["sh", "/app/deploy/container-entrypoint.sh"]
+4
View File
@@ -35,6 +35,10 @@ room; lesson progress stays in your browser and does not change saved games.
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project. Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
For private NAS hosting and manually applied checkpoint image updates, see the
[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts
with new games; the requested host port is 33046.
## Saved seats and reconnecting ## Saved seats and reconnecting
The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated. The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
+4 -3
View File
@@ -1,3 +1,4 @@
import { requestOrigin } from '@/lib/request-origin';
import { import {
authenticate, authenticate,
createSeatHandover, createSeatHandover,
@@ -14,12 +15,12 @@ import {
import { RuleError } from '@/game/engine'; import { RuleError } from '@/game/engine';
import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation'; import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation';
import { resumeRoom } from '@/db/room-continuation'; import { resumeRoom } from '@/db/room-continuation';
import { waitUntil } from 'cloudflare:workers'; import { env, waitUntil } from 'cloudflare:workers';
const noStore = { 'Cache-Control': 'no-store' }; const noStore = { 'Cache-Control': 'no-store' };
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const url = new URL(req.url); const url = new URL(req.url);
if (req.headers.get('origin') && req.headers.get('origin') !== url.origin) if (req.headers.get('origin') && req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN))
throw new SeatControlError( throw new SeatControlError(
'Invalid request origin.', 'Invalid request origin.',
'INVALID_ORIGIN', 'INVALID_ORIGIN',
@@ -158,7 +159,7 @@ export async function POST(req: Request) {
{ {
headers: { headers: {
...noStore, ...noStore,
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`, 'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
}, },
}, },
); );
+3 -1
View File
@@ -1,3 +1,5 @@
import { env } from 'cloudflare:workers';
import { requestOrigin } from '@/lib/request-origin';
import { authenticate } from '@/db/rooms'; import { authenticate } from '@/db/rooms';
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk'; import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
import { RuleError } from '@/game/engine'; import { RuleError } from '@/game/engine';
@@ -8,7 +10,7 @@ async function handle(req: Request, write: boolean) {
if ( if (
write && write &&
req.headers.get('origin') && req.headers.get('origin') &&
req.headers.get('origin') !== url.origin req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
) )
throw new TableTalkError('Invalid request origin.', 403); throw new TableTalkError('Invalid request origin.', 403);
const code = url.pathname.split('/').at(-2)!.toUpperCase(); const code = url.pathname.split('/').at(-2)!.toUpperCase();
+4 -3
View File
@@ -1,3 +1,4 @@
import { requestOrigin } from '@/lib/request-origin';
import { import {
readSeatView, readSeatView,
authenticate, authenticate,
@@ -9,7 +10,7 @@ import {
} from '@/db/rooms'; } from '@/db/rooms';
import { RuleError } from '@/game/engine'; import { RuleError } from '@/game/engine';
import type { FactionId } from '@/game/catalog'; import type { FactionId } from '@/game/catalog';
import { waitUntil } from 'cloudflare:workers'; import { env, waitUntil } from 'cloudflare:workers';
import { resumeRoom } from '@/db/room-continuation'; import { resumeRoom } from '@/db/room-continuation';
const codeOf = (req: Request) => { const codeOf = (req: Request) => {
@@ -68,7 +69,7 @@ export async function POST(req: Request) {
try { try {
if ( if (
req.headers.get('origin') && req.headers.get('origin') &&
req.headers.get('origin') !== new URL(req.url).origin req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
) )
return Response.json( return Response.json(
{ error: 'Invalid request origin.' }, { error: 'Invalid request origin.' },
@@ -115,7 +116,7 @@ export async function POST(req: Request) {
headers: { headers: {
...(r.token ...(r.token
? { ? {
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`, 'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
} }
: {}), : {}),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
+4 -2
View File
@@ -1,3 +1,5 @@
import { env } from 'cloudflare:workers';
import { requestOrigin } from '@/lib/request-origin';
import { createRoom, RoomEntryError } from '@/db/rooms'; import { createRoom, RoomEntryError } from '@/db/rooms';
import { RuleError } from '@/game/engine'; import { RuleError } from '@/game/engine';
import type { FactionId } from '@/game/catalog'; import type { FactionId } from '@/game/catalog';
@@ -5,7 +7,7 @@ export async function POST(req: Request) {
try { try {
if ( if (
req.headers.get('origin') && req.headers.get('origin') &&
req.headers.get('origin') !== new URL(req.url).origin req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
) )
return Response.json( return Response.json(
{ error: 'Invalid request origin.' }, { error: 'Invalid request origin.' },
@@ -40,7 +42,7 @@ export async function POST(req: Request) {
{ {
status: 201, status: 201,
headers: { headers: {
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`, 'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
}, },
}, },
+2 -1
View File
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { randomId } from '@/lib/random-id';
import { useEffect, useId, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import type { GameView } from '@/game/engine'; import type { GameView } from '@/game/engine';
@@ -300,7 +301,7 @@ export function SeatAiDelegation({
void submit({ void submit({
type: 'setSeatAiDelegate', type: 'setSeatAiDelegate',
version: game.version, version: game.version,
grantId: crypto.randomUUID(), grantId: randomId(),
delegateId, delegateId,
difficulty, difficulty,
}) })
+2 -1
View File
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { randomId } from '@/lib/random-id';
import { useEffect, useId, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import type { GameView } from '@/game/engine'; import type { GameView } from '@/game/engine';
import { faction } from '@/game/catalog'; import { faction } from '@/game/catalog';
@@ -152,7 +153,7 @@ export function TableTalk({ game }: { game: GameView }) {
async function submit() { async function submit() {
if (sending.current || !ready) return; if (sending.current || !ready) return;
const draft = record ?? { const draft = record ?? {
id: crypto.randomUUID(), id: randomId(),
recipientId: recipient || null, recipientId: recipient || null,
text, text,
}; };
Vendored
+1
View File
@@ -1,5 +1,6 @@
declare namespace Cloudflare { declare namespace Cloudflare {
interface Env { interface Env {
DB: D1Database; DB: D1Database;
DUNE_PUBLIC_ORIGIN?: string;
} }
} }
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
# A failed migration must stop startup. Both commands use the same local D1 ID
# and storage root. Never reset or replace this directory during updates.
state_path=${DUNE_STATE_PATH:-/data}
# Validate before migration; this is an administrator setting, not a request header.
node --input-type=module -e '
const value = process.env.DUNE_PUBLIC_ORIGIN;
if (value) {
const u = new URL(value);
if (!["http:", "https:"].includes(u.protocol) || u.username || u.password ||
u.search || u.hash || u.pathname !== "/") throw new Error("Invalid DUNE_PUBLIC_ORIGIN");
}
'
node node_modules/wrangler/bin/wrangler.js d1 migrations apply DB \
--local --config tools/wrangler.local.json --persist-to "$state_path"
exec node deploy/serve.mjs
+71
View File
@@ -0,0 +1,71 @@
import { readFile, readdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Miniflare } from 'miniflare';
const server = resolve('dist/server');
const config = JSON.parse(await readFile(resolve(server, 'wrangler.json'), 'utf8'));
const migrations = JSON.parse(await readFile('tools/wrangler.local.json', 'utf8'));
const migrationIds = new Map(migrations.d1_databases.map((db) => [db.binding, db.database_id]));
for (const db of config.d1_databases) {
if (migrationIds.get(db.binding) !== db.database_id) {
throw new Error(`Migration/runtime database identity mismatch for ${db.binding}`);
}
}
const state = resolve(process.env.DUNE_STATE_PATH || '/data');
const port = Number(process.env.DUNE_PORT || 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid DUNE_PORT');
async function modulesIn(directory) {
const modules = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) modules.push(...await modulesIn(path));
else if (/\.m?js$/.test(entry.name)) modules.push({ type: 'ESModule', path });
}
return modules;
}
const entrypoint = resolve(server, config.main);
// Vinext contains computed imports. Register the complete emitted module graph
// explicitly instead of asking Miniflare's static import scanner to infer it.
const modules = [
{ type: 'ESModule', path: entrypoint },
...(await modulesIn(server)).filter((module) => module.path !== entrypoint),
];
// Serve the immutable build directly. Wrangler's development proxy can fail a
// subsequent POST after Vinext cancels a rejected request body with assets on.
const runtime = new Miniflare({
name: config.name,
host: '0.0.0.0',
port,
cf: false,
modules,
modulesRoot: server,
compatibilityDate: config.compatibility_date,
compatibilityFlags: config.compatibility_flags,
bindings: { ...config.vars, DUNE_PUBLIC_ORIGIN: process.env.DUNE_PUBLIC_ORIGIN || '' },
d1Databases: Object.fromEntries(config.d1_databases.map((db) => [db.binding, db.database_id])),
// Match Wrangler's --persist-to layout, including its local migration ledger.
d1Persist: resolve(state, 'v3/d1'),
cachePersist: resolve(state, 'v3/cache'),
assets: {
directory: resolve(server, config.assets.directory),
binding: 'ASSETS',
routerConfig: { has_user_worker: true },
},
});
let stopping = false;
async function stop() {
if (stopping) return;
stopping = true;
await runtime.dispose();
}
process.on('SIGTERM', () => { void stop(); });
process.on('SIGINT', () => { void stop(); });
try {
await runtime.ready;
console.log(`Dune listening on HTTP port ${port}`);
} catch (error) {
await stop();
throw error;
}
+19
View File
@@ -0,0 +1,19 @@
services:
dune:
image: ghcr.io/quintine/dune:checkpoint
init: true
restart: unless-stopped
environment:
DUNE_PUBLIC_ORIGIN: https://dune.procrastination.games
ports:
- "33046:3000"
volumes:
- dune-data:/data
stop_grace_period: 60s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
dune-data:
+107
View File
@@ -0,0 +1,107 @@
# Private TrueNAS container
The requested instance starts with **new games**, serving HTTP at
`http://192.168.2.251:33046` behind the existing NGINX HTTPS endpoint
`https://dune.procrastination.games`. Development games remain in their original
`.wrangler/state`; they are never copied into an image. This private local
runtime prototype does not change public release or rules-mode gates.
## Checkpoint images
Every push to `main` starts `.github/workflows/container.yml`. The Docker build
runs types, lint, offline tests and the production build using the lockfile.
Before publishing, CI creates an isolated container and volume, checks accepted
and rejected HTTP origins, verifies saved-seat restoration through restart and
container replacement, then runs the HTTP integration suite.
Only a passing image is pushed to `ghcr.io/quintine/dune:checkpoint`, alongside
`ghcr.io/quintine/dune:sha-<full-commit>`. GitHub Actions builds; TrueNAS pulls the
image. Failed checks leave the previous update tag untouched. This workflow
targets Linux AMD64, the architecture of ordinary x86 TrueNAS systems.
The user selected a **public image**, matching the existing public source
repository, so TrueNAS needs no registry login. GitHub initially creates packages
privately; after the first successful publication, set this package's visibility
to public in GitHub package settings. Images contain application code and assets,
never saved games or credentials. Publishing uses the workflow's built-in token.
A source push alone does not prove image publication; repository permissions or
package policy may prevent the workflow from publishing.
## Install
On a Docker-based TrueNAS release, use **Apps → Discover Apps → Install via YAML**
and paste [the Compose configuration](../deploy/truenas.compose.yaml), naming the
app `dune`. It maps host port **33046** to container port 3000. A persistent named
volume mounts at `/data`, starts empty and inherits writable ownership from the
image. The container runs as UID/GID 1000. If using a dedicated host dataset
instead, mount it at `/data` and give UID/GID 1000 write access before starting.
Startup applies pending additive migrations before serving. Both commands use
the same database ID and storage root. A failed migration stops startup without
deleting games. The inspector remains on container loopback and is not published.
The container serves the built Worker directly through pinned Miniflare/workerd,
with persisted D1-compatible SQLite. Wrangler only applies startup migrations.
The development proxy produced intermittent POST failures after rejected request
bodies; the direct listener avoids that proxy. This preserves the application without a database
rewrite. It does not supply managed D1 backups, replication, high availability
or durable unattended bot scheduling. Keep this prototype on a trusted LAN;
public hosting remains separately gated. HTTP does not encrypt cookies or seat
recovery kits. Browser operation IDs now use cryptographic `getRandomValues`,
which also works at LAN HTTP origins where `randomUUID` is unavailable.
## Update and preserve games
### Existing NGINX HTTPS termination
Keep the NGINX upstream **HTTP**, `192.168.2.251:33046`. Set the app environment
variable `DUNE_PUBLIC_ORIGIN` to the exact external HTTPS origin (scheme and
hostname, optional port, no path). This keeps strict origin validation and marks
seat cookies Secure, even though the proxy-to-app connection is HTTP. Dune does
not blindly trust client-supplied forwarding headers. When configured, use the
HTTPS address to play; direct HTTP browser POSTs are intentionally rejected.
The supplied Compose file already sets `https://dune.procrastination.games`.
Keep NGINX's usual original Host and forwarded-protocol headers. Enable
WebSocket support in NGINX Proxy Manager, or forward Upgrade/Connection headers
with HTTP/1.1 in an existing NGINX location. Dune currently uses HTTP polling,
not game WebSockets; enabling proxy support does not change its transport.
See [NGINX WebSocket forwarding](https://nginx.org/en/docs/http/websocket.html).
### Applying an update
1. Wait for the checkpoint workflow to succeed. Check for image updates in
TrueNAS and apply the update manually at a safe point between decisions.
2. Before updating, stop the app and back up or snapshot its persistent storage.
Include the named volume in the Apps backup procedure; a dedicated host
dataset provides a more explicit ZFS snapshot boundary.
3. Update while retaining the same app identity and `/data` volume. Never delete
the volume, reinstall with empty storage or reset a database to resolve an
error. Restart and confirm the saved seat restores.
4. Retain the prior immutable image tag. Rollback may require its matching storage
snapshot if a subsequent migration is incompatible; switching the image alone
is not a guaranteed database rollback.
No unattended updater is installed. The health probe checks HTTP availability;
the CI integration and saved-seat checks separately exercise persistence.
## Verification and installation boundary
On a machine with Docker:
```sh
docker build -t dune:verify .
node tools/verify-container.mjs dune:verify
```
The verifier only removes containers and volumes with names it randomly creates.
It never mounts development games or an existing TrueNAS volume.
Container execution and NAS installation require a successful CI run and an
authenticated TrueNAS session. Final acceptance includes create/join at the NAS
URL, saved-seat restoration, restart and update retaining storage. Source checks
alone do not certify these steps.
Sources: [TrueNAS custom apps](https://apps.truenas.com/managing-apps/installing-custom-apps/),
[GitHub container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry),
[Cloudflare local runtime](https://developers.cloudflare.com/workers/local-development/).
+15
View File
@@ -0,0 +1,15 @@
/** UUID v4 for browser operations, including private LAN HTTP origins. */
export function randomId(): string {
// Unlike randomUUID(), getRandomValues is available outside secure contexts.
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10).join(''),
].join('-');
}
+10
View File
@@ -0,0 +1,10 @@
/** The administrator configures the external origin; forwarded headers are untrusted. */
export function requestOrigin(request: Request, configured?: string): string {
if (!configured) return new URL(request.url).origin;
const url = new URL(configured);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username || url.password || url.search || url.hash || url.pathname !== '/'
) throw new Error('DUNE_PUBLIC_ORIGIN must be an HTTP(S) origin without a path.');
return url.origin;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { randomId } from './random-id';
import { EXPANSIONS, FACTIONS } from '../game/catalog'; import { EXPANSIONS, FACTIONS } from '../game/catalog';
import { requestMayHaveCompleted } from './client-request'; import { requestMayHaveCompleted } from './client-request';
@@ -108,7 +109,7 @@ export function createRoomEntry(
roomCode?: string, roomCode?: string,
): RoomEntryAttempt { ): RoomEntryAttempt {
const entry = { const entry = {
operationId: globalThis.crypto.randomUUID(), operationId: randomId(),
sessionToken: Array.from( sessionToken: Array.from(
globalThis.crypto.getRandomValues(new Uint8Array(32)), globalThis.crypto.getRandomValues(new Uint8Array(32)),
(byte) => byte.toString(16).padStart(2, '0'), (byte) => byte.toString(16).padStart(2, '0'),
+3 -2
View File
@@ -1,3 +1,4 @@
import { randomId } from './random-id';
import type { GameView } from '../game/engine'; import type { GameView } from '../game/engine';
export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1'; export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1';
@@ -98,7 +99,7 @@ export function createHandoverKit(
version: 1, version: 1,
roomCode, roomCode,
playerId, playerId,
offerId: crypto.randomUUID(), offerId: randomId(),
handoverSecret: secret(), handoverSecret: secret(),
}), }),
); );
@@ -111,7 +112,7 @@ export function createHandoverClaim(kit: SeatHandoverKit): SavedHandoverClaim {
playerId: kit.playerId, playerId: kit.playerId,
offerId: kit.offerId, offerId: kit.offerId,
handoverSecret: kit.handoverSecret, handoverSecret: kit.handoverSecret,
operationId: crypto.randomUUID(), operationId: randomId(),
newSessionToken: secret(), newSessionToken: secret(),
}), }),
}); });
+2 -1
View File
@@ -1,3 +1,4 @@
import { randomId } from './random-id';
export type SeatRecoveryKit = Readonly<{ export type SeatRecoveryKit = Readonly<{
format: 'dune-seat-recovery'; format: 'dune-seat-recovery';
version: 1; version: 1;
@@ -106,7 +107,7 @@ export function createRecoveryAttempt(
type: 'recoverSeat', type: 'recoverSeat',
playerId: kit.playerId, playerId: kit.playerId,
recoverySecret: kit.recoverySecret, recoverySecret: kit.recoverySecret,
operationId: globalThis.crypto.randomUUID(), operationId: randomId(),
newSessionToken: secret256(), newSessionToken: secret256(),
}); });
} }
+1
View File
@@ -40,6 +40,7 @@
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26", "@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"miniflare": "4.20260515.0",
"oxfmt": "0.61.0", "oxfmt": "0.61.0",
"oxlint": "1.76.0", "oxlint": "1.76.0",
"oxlint-tsgolint": "7.0.2001", "oxlint-tsgolint": "7.0.2001",
+1
View File
@@ -60,6 +60,7 @@
"tailwindcss": "4.2.1", "tailwindcss": "4.2.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.0.13", "vite": "8.0.13",
"miniflare": "4.20260515.0",
"wrangler": "4.92.0", "wrangler": "4.92.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"tsx": "4.23.13" "tsx": "4.23.13"
+4 -1
View File
@@ -1,3 +1,4 @@
import { requestOrigin } from '../lib/request-origin';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto'; import { webcrypto } from 'node:crypto';
@@ -82,6 +83,7 @@ function unitStore(runBots = bots.runBots) {
require: (name: string) => { require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine; if (name === '@/game/engine') return engine;
if (name === '@/lib/request-origin') return { requestOrigin };
if (name === '@/game/bots') return { ...bots, runBots }; if (name === '@/game/bots') return { ...bots, runBots };
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
throw new Error('Unexpected module ' + name); throw new Error('Unexpected module ' + name);
@@ -300,8 +302,9 @@ function route(
}; };
if (name === '@/db/room-continuation') return continuation; if (name === '@/db/room-continuation') return continuation;
if (name === '@/game/engine') return engine; if (name === '@/game/engine') return engine;
if (name === '@/lib/request-origin') return { requestOrigin };
if (name === 'cloudflare:workers') if (name === 'cloudflare:workers')
return { waitUntil: (work: Promise<void>) => pending.push(work) }; return { env: {}, waitUntil: (work: Promise<void>) => pending.push(work) };
throw new Error('Unexpected route dependency ' + name); throw new Error('Unexpected route dependency ' + name);
}, },
}, },
+4
View File
@@ -1,3 +1,4 @@
import { requestOrigin } from '../lib/request-origin';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto'; import { webcrypto } from 'node:crypto';
@@ -81,6 +82,7 @@ function unitStore(runBots = bots.runBots) {
require: (name: string) => { require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine; if (name === '@/game/engine') return engine;
if (name === '@/lib/request-origin') return { requestOrigin };
if (name === '@/game/bots') return { ...bots, runBots }; if (name === '@/game/bots') return { ...bots, runBots };
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
throw new Error('Unexpected module ' + name); throw new Error('Unexpected module ' + name);
@@ -359,8 +361,10 @@ void test('authenticated GET schedules the persisted queue after reconnect while
}; };
if (name === '@/db/room-continuation') return continuation; if (name === '@/db/room-continuation') return continuation;
if (name === '@/game/engine') return engine; if (name === '@/game/engine') return engine;
if (name === '@/lib/request-origin') return { requestOrigin };
if (name === 'cloudflare:workers') if (name === 'cloudflare:workers')
return { return {
env: {},
waitUntil: (work: Promise<unknown>) => pending.push(work), waitUntil: (work: Promise<unknown>) => pending.push(work),
}; };
throw new Error('Unexpected route dependency ' + name); throw new Error('Unexpected route dependency ' + name);
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomId } from '../lib/random-id';
import { createRoomEntry } from '../lib/room-entry';
void test('LAN HTTP operations work with getRandomValues and no randomUUID', (t) => {
const native = globalThis.crypto;
t.mock.getter(globalThis, 'crypto', () => ({
getRandomValues: native.getRandomValues.bind(native),
}) as Crypto);
assert.equal(typeof globalThis.crypto.randomUUID, 'undefined');
const id = randomId();
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(randomId(), id);
const attempt = createRoomEntry({
name: 'LAN player', faction: 'atreides', advanced: false, expansions: [],
});
assert.ok(attempt);
});
void test('UUID creation fails closed when cryptographic randomness fails', (t) => {
t.mock.getter(globalThis, 'crypto', () => ({
getRandomValues() { throw new Error('Randomness unavailable'); },
}) as unknown as Crypto);
assert.throws(() => randomId(), /Randomness unavailable/);
});
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestOrigin } from '../lib/request-origin';
void test('HTTP upstream can explicitly use its HTTPS public origin without trusting forwarded headers', () => {
const request = new Request('http://192.168.2.251:33046/api/rooms', {
headers: { 'x-forwarded-host': 'attacker.invalid', 'x-forwarded-proto': 'https' },
});
assert.equal(requestOrigin(request), 'http://192.168.2.251:33046');
assert.equal(requestOrigin(request, 'https://dune.example.test/'), 'https://dune.example.test');
for (const invalid of ['https://dune.example.test/path', 'https://user:secret@dune.example.test', 'https://dune.example.test/?x=1', 'https://dune.example.test/#x', 'file:///tmp', 'invalid']) {
assert.throws(() => requestOrigin(request, invalid));
}
});
+99
View File
@@ -0,0 +1,99 @@
// Runs only against a disposable container and volume created by this command.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const image = process.argv[2];
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
const name = `dune-verify-${randomUUID()}`;
const volume = `${name}-data`;
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
let base;
async function ready() {
for (let attempt = 0; attempt < 90; attempt++) {
try {
const result = await fetch(base, { signal: AbortSignal.timeout(2000) });
if (result.ok) return;
} catch { /* Startup includes all additive migrations. */ }
await delay(1000);
}
throw new Error('Container did not become ready');
}
function start(publicOrigin = '') {
docker('run', '-d', '--name', name, '--init', '--cap-drop=ALL',
'--security-opt=no-new-privileges:true',
'-p', '127.0.0.1::3000', '-v', `${volume}:/data`,
'-e', `DUNE_PUBLIC_ORIGIN=${publicOrigin}`, image);
base = `http://${docker('port', name, '3000/tcp')}`;
}
try {
docker('volume', 'create', volume);
start();
await ready();
const headers = { 'content-type': 'application/json', origin: base };
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
assert.equal(create.status, 201, 'Mapped host/port must pass origin validation');
const cookie = create.headers.get('set-cookie')?.split(';')[0];
assert.ok(cookie);
const room = await create.json();
const foreign = await fetch(`${base}/api/rooms`, {
method: 'POST', headers: { ...headers, origin: 'http://foreign.invalid' }, body,
});
assert.equal(foreign.status, 403);
async function restored() {
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
assert.equal(result.status, 200);
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
}
docker('restart', '--time', '60', name);
await ready();
await restored();
docker('stop', '--time', '60', name);
docker('rm', name);
start();
await ready();
await restored();
execFileSync('npm', ['run', 'test:integration'], {
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
});
docker('stop', '--time', '60', name);
docker('rm', name);
const publicOrigin = 'https://dune.example.test';
start(publicOrigin);
await ready();
await restored();
const proxyCreate = await fetch(`${base}/api/rooms`, {
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
});
assert.equal(proxyCreate.status, 201, 'HTTPS external origin must work over HTTP upstream');
assert.ok(proxyCreate.headers.get('set-cookie')?.includes('; Secure'));
const proxyCookie = proxyCreate.headers.get('set-cookie').split(';')[0];
const proxyRoom = await proxyCreate.json();
const join = await fetch(`${base}/api/rooms/${proxyRoom.code}`, {
method: 'POST', headers: { ...headers, origin: publicOrigin },
body: JSON.stringify({ type: 'join', name: 'Proxy peer', faction: 'emperor' }),
});
assert.equal(join.status, 200);
assert.ok(join.headers.get('set-cookie')?.includes('; Secure'));
const message = await fetch(`${base}/api/rooms/${proxyRoom.code}/messages`, {
method: 'POST', headers: { ...headers, origin: publicOrigin, cookie: proxyCookie },
body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }),
});
assert.equal(message.status, 200, 'Table discussion must work through HTTPS proxy');
for (const path of ['/api/rooms', `/api/rooms/${proxyRoom.code}`, `/api/rooms/${proxyRoom.code}/control`, `/api/rooms/${proxyRoom.code}/messages`]) {
const rejected = await fetch(base + path, {
method: 'POST', headers: { ...headers, origin: 'https://foreign.invalid' }, body,
});
assert.equal(rejected.status, 403, path);
}
console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.');
} catch (error) {
try { console.error(docker('logs', name)); } catch { /* May not exist yet. */ }
throw error;
} finally {
// Names are unique to this invocation; never remove an existing application.
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
}