fix(deploy): route /dict through caddy to the gateway (local eval)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s

The edge caddy @gateway matcher was missing /dict/*, so the client's dictionary
fetch fell to the static landing catch-all: it received a non-dawg blob (200 OK),
which the reader parsed into a bogus finder that reports every word missing — a
valid first move showed as illegal with no network fallback. Add /dict/* to the
gateway route.

Hardening + the cross-test that would have caught it:
- reader: reject a blob whose 32-bit size header != its byte length, so a
  non-dawg page or a truncated download now throws and the loader falls back to
  the network preview instead of silently validating against garbage.
- eval.parity: an adapter conformance suite that drives evaluateLocal through the
  real letter-space path (the server alphabet table, a letter board and letter
  placements) against the engine — the algorithm-level validate.parity did not
  exercise the adapter. validategen now emits the alphabet table for it.
- a CI deploy probe asserts {edge}/dict reaches the gateway (401), not the landing.
- the DebugPanel reports the feature flag, the bad-connection breaker and the
  cached dictionaries with their sizes; its reset also clears the dict cache.
This commit is contained in:
Ilia Denisov
2026-07-01 20:55:20 +02:00
parent d24a127406
commit 2776ef83c2
8 changed files with 233 additions and 22 deletions
+17
View File
@@ -395,6 +395,23 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
# for the local move preview. If caddy does not route /dict to the gateway the request
# falls to the static landing and the client silently gets a non-dawg blob. Probed
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
# 404/200 from the landing catch-all.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -q " 401"; then
echo "ok: /dict reaches the gateway (401 unauthenticated)"
else
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
exit 1
fi
- name: Probe the Telegram validator and bot liveness - name: Probe the Telegram validator and bot liveness
run: | run: |
set -u set -u
+31 -11
View File
@@ -67,18 +67,28 @@ type fixture struct {
Cross []word `json:"cross,omitempty"` Cross []word `json:"cross,omitempty"`
} }
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// variantFile is the whole conformance payload for one variant. // variantFile is the whole conformance payload for one variant.
type variantFile struct { type variantFile struct {
Variant string `json:"variant"` Variant string `json:"variant"`
Rows int `json:"rows"` Rows int `json:"rows"`
Cols int `json:"cols"` Cols int `json:"cols"`
Center int `json:"center"` Center int `json:"center"`
RackSize int `json:"rackSize"` RackSize int `json:"rackSize"`
Bingo int `json:"bingo"` Bingo int `json:"bingo"`
Values []int `json:"values"` Values []int `json:"values"`
Premiums []int `json:"premiums"` // row-major rules.Premium codes Premiums []int `json:"premiums"` // row-major rules.Premium codes
Boards [][]cell `json:"boards"` Alphabet []alphaEntry `json:"alphabet"`
Fixtures []fixture `json:"fixtures"` Boards [][]cell `json:"boards"`
Fixtures []fixture `json:"fixtures"`
} }
func main() { func main() {
@@ -123,7 +133,7 @@ func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
out := variantFile{ out := variantFile{
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center, Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
Premiums: premiumCodes(rs), Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
} }
// Capture under both the standard rule and the single-word rule, building the // Capture under both the standard rule and the single-word rule, building the
@@ -284,6 +294,16 @@ func toWord(w scrabble.Word) *word {
} }
} }
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
n := rs.Alphabet.Size()
out := make([]alphaEntry, n)
for i := range n {
ch, _ := rs.Alphabet.Character(byte(i))
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
}
return out
}
func premiumCodes(rs *rules.Ruleset) []int { func premiumCodes(rs *rules.Ruleset) []int {
codes := make([]int, rs.Rows*rs.Cols) codes := make([]int, rs.Rows*rs.Cols)
for i := range codes { for i := range codes {
+1 -1
View File
@@ -53,7 +53,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request). # tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/* @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/*
handle @gateway { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
+32 -9
View File
@@ -4,18 +4,39 @@
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
// Telegram Android presentation quirks. Drawn from the top, just under the app header. // Telegram Android presentation quirks. Drawn from the top, just under the app header.
import { onMount } from 'svelte';
import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { app, closeDebug, resetOnboarding } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { shareText } from '../lib/share'; import { shareText } from '../lib/share';
import { telegramChromeDiag } from '../lib/telegram'; import { telegramChromeDiag } from '../lib/telegram';
const report = [ // The local move-preview snapshot — the feature flag, the bad-connection breaker and the
`app: ${__APP_VERSION__}`, // dictionaries cached in IndexedDB with their sizes — loads async so a report about the
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, // preview is precise. Dynamically imported so the debug panel keeps the dict code lazy.
`online: ${connection.online} streamAlive: ${app.streamAlive}`, let dictInfo = $state('local eval: …');
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, onMount(() => {
telegramChromeDiag(), void (async () => {
].join('\n'); try {
const m = await import('../lib/dict');
const list = await m.listCachedDicts();
const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none';
dictInfo = `local eval: ${m.LOCAL_EVAL_ENABLED ? 'on' : 'off'} breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`;
} catch {
dictInfo = 'local eval: n/a';
}
})();
});
const report = $derived(
[
`app: ${__APP_VERSION__}`,
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
telegramChromeDiag(),
dictInfo,
].join('\n'),
);
let label = $state('Share'); let label = $state('Share');
async function share(e: MouseEvent): Promise<void> { async function share(e: MouseEvent): Promise<void> {
@@ -35,8 +56,10 @@
resetOnboarding(); resetOnboarding();
// Also clear the local move-preview dictionary cache (safe — it re-downloads). // Also clear the local move-preview dictionary cache (safe — it re-downloads).
// Dynamically imported so the debug panel keeps the dict code out of the app bundle. // Dynamically imported so the debug panel keeps the dict code out of the app bundle.
void import('../lib/dict/store').then((m) => m.clearDictCache()); void import('../lib/dict').then((m) => {
void import('../lib/dict/loader').then((m) => m.clearDictInstances()); m.clearDictCache();
m.clearDictInstances();
});
resetLabel = 'Cleared — relaunch'; resetLabel = 'Cleared — relaunch';
setTimeout(() => (resetLabel = 'Reset visited'), 1800); setTimeout(() => (resetLabel = 'Reset visited'), 1800);
} }
+10
View File
@@ -43,6 +43,16 @@ export class Dawg {
constructor(bytes: Uint8Array) { constructor(bytes: Uint8Array) {
this.bytes = bytes; this.bytes = bytes;
// Reject anything that is not a serialized dawg — e.g. an HTML error page or a
// truncated download routed here by mistake. The 32-bit big-endian header size is
// the total byte length; if it does not match, the blob is not a dawg. Without
// this guard a non-dawg blob would parse into a bogus reader that silently reports
// every word as missing (so the caller must throw here to fall back to the network).
const declaredSize = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
if (declaredSize !== bytes.length) {
throw new Error(`dawg: not a dawg blob (size header ${declaredSize} != ${bytes.length} bytes)`);
}
// Header: 32-bit size (skipped — the whole file is already in memory), then // Header: 32-bit size (skipped — the whole file is already in memory), then
// cbits, abits, the language code string, and the word/node/edge counts. // cbits, abits, the language code string, and the word/node/edge counts.
this.p = 32; this.p = 32;
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { Dawg } from './dawg';
import { evaluateLocal } from './eval';
import { setAlphabet } from '../alphabet';
import type { Board as ClientBoard, BoardCell } from '../board';
import type { PlacedTile } from '../client';
import type { Variant } from '../model';
// End-to-end conformance for the local-eval ADAPTER: it drives evaluateLocal through
// the real letter-space client path (the server-sent alphabet table, a letter board,
// letter placements) and asserts it matches the Go engine's EvalResult for every
// fixture. This is the cross-test the algorithm-level validate.parity suite does not
// cover — it exercises the letter<->index mapping, the ruleset assembly and the word
// decode, not just the index-space validator. Env-gated like the other parity suites.
const dawgDir = process.env.DICT_DAWG_DIR;
const validDir = process.env.DICT_VALID_DIR;
const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir);
const dawgFile: Record<string, string> = {
scrabble_en: 'en_sowpods.dawg',
scrabble_ru: 'ru_scrabble.dawg',
erudit_ru: 'ru_erudit.dawg',
};
interface FxCell {
R: number;
C: number;
Letter: number;
Blank: boolean;
}
interface FxWord {
Letters: number[];
}
interface Fx {
board: number;
ignoreCrossWords: boolean;
tiles: FxCell[];
legal: boolean;
score: number;
main?: FxWord;
cross?: FxWord[];
}
interface Alpha {
index: number;
letter: string;
value: number;
}
interface VF {
rows: number;
cols: number;
alphabet: Alpha[];
boards: (FxCell[] | null)[];
fixtures: Fx[];
}
describe.skipIf(!ready)('local-eval adapter parity vs Go engine', () => {
for (const variant of Object.keys(dawgFile)) {
it(
variant,
() => {
const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8'));
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant]))));
const v = variant as Variant;
// Seed the alphabet cache exactly as the server would (wire entries, lower-cased
// letters); the adapter re-encodes through it, just like production.
setAlphabet(v, vf.alphabet.map((a) => ({ index: a.index, letter: a.letter, value: a.value })));
const upper = vf.alphabet.map((a) => a.letter.toUpperCase());
const lower = vf.alphabet.map((a) => a.letter);
let legalCount = 0;
let mismatches = 0;
const first: string[] = [];
for (let fi = 0; fi < vf.fixtures.length; fi++) {
const fx = vf.fixtures[fi];
// Build the client's letter-space board and placements from the fixture.
const board: ClientBoard = Array.from({ length: vf.rows }, () =>
Array.from({ length: vf.cols }, () => null as BoardCell | null),
);
for (const cl of vf.boards[fx.board] ?? []) board[cl.R][cl.C] = { letter: upper[cl.Letter], blank: cl.Blank };
const tiles: PlacedTile[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: upper[t.Letter], blank: t.Blank }));
const res = evaluateLocal(dawg, v, board, tiles, !fx.ignoreCrossWords);
if (fx.legal) legalCount++;
let ok = res.legal === fx.legal;
if (ok && fx.legal) {
const wantWords = [fx.main!, ...(fx.cross ?? [])].map((w) => w.Letters.map((i) => lower[i]).join(''));
ok = res.score === fx.score && res.words.length === wantWords.length && res.words.every((w, i) => w === wantWords[i]);
}
if (!ok) {
mismatches++;
if (first.length < 8) {
first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.score} words=${JSON.stringify(res.words)}`);
}
}
}
expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0);
expect(mismatches, first.join(' | ')).toBe(0);
},
120_000,
);
}
});
+2 -1
View File
@@ -3,5 +3,6 @@
// of the initial app bundle — it is a progressive enhancement over the network // of the initial app bundle — it is a progressive enhancement over the network
// preview, which remains the fallback. // preview, which remains the fallback.
export { evaluateLocal } from './eval'; export { evaluateLocal } from './eval';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled } from './loader'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, clearDictInstances } from './loader';
export { clearDictCache, listCachedDicts } from './store';
export { LOCAL_EVAL_ENABLED } from './config'; export { LOCAL_EVAL_ENABLED } from './config';
+30
View File
@@ -84,6 +84,36 @@ export async function requestPersist(): Promise<void> {
} }
} }
/**
* listCachedDicts returns the key (variant@version) and byte length of every cached
* dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an
* empty list on any failure.
*/
export async function listCachedDicts(): Promise<Array<{ key: string; bytes: number }>> {
const db = openDb();
if (!db) return [];
try {
const d = await db;
return await new Promise((resolve) => {
const out: Array<{ key: string; bytes: number }> = [];
const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor();
req.onsuccess = () => {
const cur = req.result;
if (!cur) {
resolve(out);
return;
}
const v = cur.value as ArrayBuffer;
out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 });
cur.continue();
};
req.onerror = () => resolve(out);
});
} catch {
return [];
}
}
/** /**
* clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The * clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The
* store is cleared rather than the database deleted, to avoid a delete blocked on an * store is cleared rather than the database deleted, to avoid a delete blocked on an