Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Roadmap: items that need a backend

Two hardening/engagement items cannot be completed client-side and are
deliberately deferred rather than half-done:

## Server-side score authority

Leaderboard scores are computed in the browser. Firestore rules now validate
shape and bounds (`firestore.rules`) and the volume term is capped
(`MAX_SCORED_CHARS` in `lib/score.ts`), which bounds — but cannot eliminate —
forged scores. The complete fix is a Cloud Function that recomputes the score
from the submitted groups/timings on write and rejects mismatches. The session
payload already contains everything needed; no client changes are required
beyond deleting the client-side leaderboard write.

## Push practice reminders

The streak card covers in-app urgency, but true "your streak ends in 3 hours"
notifications require Web Push (a push service + subscription storage), which
needs server infrastructure. The service worker is already in place
(`public/sw.js`); adding `pushManager` subscription handling and an FCM topic
per user is the missing half.

## Firestore configuration notes

- Deploy rules after every change: `npm run deploy:firestore`.
- The weekly leaderboard tab queries `collectionGroup('leaderboard')` filtered
by `date`; if the console reports a missing index, enable collection-group
scope for the `date` field (single-field index settings) or create the
suggested index from the error link. The UI degrades gracefully until then.
64 changes: 48 additions & 16 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {
function isOwner(userId) {
return request.auth != null && request.auth.uid == userId;
}

// Amateur-callsign-shaped identifier: uppercase alphanumerics with optional
// portable suffix (e.g. SP9XYZ, K1ABC/P). Blocks HTML, spaces, and novels.
function validCallSign(cs) {
return cs is string && cs.size() >= 3 && cs.size() <= 12 && cs.matches('^[A-Z0-9/]+$');
}

// Leaderboard entries are globally readable, so writes must be validated:
// without field checks any signed-in user could publish arbitrary scores.
// This bounds the damage; true trust requires server-side scoring.
Expand All @@ -14,40 +24,62 @@ service cloud.firestore {
&& d.accuracy is number && d.accuracy >= 0 && d.accuracy <= 1
&& d.totalChars is number && d.totalChars >= 1 && d.totalChars <= 20000
&& d.avgResponseMs is number && d.avgResponseMs >= 0
&& (!('callSign' in d) || d.callSign == null || (d.callSign is string && d.callSign.size() <= 20));
&& (!('callSign' in d) || d.callSign == null || validCallSign(d.callSign));
}

// NOTE: user data is matched per-subcollection on purpose. Firestore grants
// access when ANY match allows it, so a users/{userId}/{document=**}
// catch-all would silently bypass every validated rule below.

// Allow reading leaderboard entries from any user for global leaderboard
// This enables collectionGroup queries on 'leaderboard' subcollection
match /users/{userId}/leaderboard/{document} {
allow read: if request.auth != null;
// Only the owner can create entries, and only well-formed ones
allow create: if request.auth != null && request.auth.uid == userId
&& isValidLeaderboardEntry();
allow create: if isOwner(userId) && isValidLeaderboardEntry();
// Existing entries are immutable except for call-sign refresh
allow update: if request.auth != null && request.auth.uid == userId
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['callSign']);
allow delete: if request.auth != null && request.auth.uid == userId;
allow update: if isOwner(userId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['callSign'])
&& (!('callSign' in request.resource.data)
|| request.resource.data.callSign == null
|| validCallSign(request.resource.data.callSign));
allow delete: if isOwner(userId);
}

// Allow reading call-sign from any user's profile for leaderboard display
match /users/{userId}/meta/profile {
allow read: if request.auth != null;
// Only the owner can write their own profile
allow write: if request.auth != null && request.auth.uid == userId;
// meta/profile is readable by any signed-in user (leaderboard call-sign
// display); other meta docs (deletedSessions, autoAdjust, achievements)
// are private. A single match with conditions avoids overlapping rules.
match /users/{userId}/meta/{metaDoc} {
allow read: if isOwner(userId) || (metaDoc == 'profile' && request.auth != null);
allow write: if isOwner(userId)
&& (metaDoc != 'profile'
|| !('callSign' in request.resource.data)
|| request.resource.data.callSign == null
|| validCallSign(request.resource.data.callSign));
}

// Public trophy/profile snapshots (doc id = Firebase uid; publicId is a field for URLs).
match /publicProfiles/{profileUid} {
allow read: if true;
allow create, update: if request.auth != null && request.auth.uid == profileUid;
allow create, update: if request.auth != null && request.auth.uid == profileUid
&& (!('callSign' in request.resource.data)
|| request.resource.data.callSign == null
|| validCallSign(request.resource.data.callSign));
allow delete: if request.auth != null && request.auth.uid == profileUid;
}

// Protect all other user data - users can only access their own data
match /users/{userId}/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
// Private user data — owner only.
match /users/{userId}/sessions/{document=**} {
allow read, write: if isOwner(userId);
}
match /users/{userId}/stats/{document=**} {
allow read, write: if isOwner(userId);
}
match /users/{userId}/settings/{document=**} {
allow read, write: if isOwner(userId);
}
match /users/{userId}/clientErrors/{document=**} {
allow read, write: if isOwner(userId);
}
}
}

74 changes: 74 additions & 0 deletions lib/kochAutoAdjust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,78 @@ export interface AutoLevelAdjustProgressView {

// ── Local-storage helpers ──────────────────────────────────────────────

const AUTO_ADJUST_KEY_PREFIX = 'cw_auto_adjust_';

/**
* Cross-device sync hook. Counters live in localStorage for synchronous access
* during result processing, but localStorage is per-browser — without a mirror,
* progress toward the next level silently resets on every new device while
* sessions themselves sync. The app store registers a listener that persists
* the exported snapshot to the user's Firestore meta document.
*/
type AutoAdjustSyncListener = (counters: Record<string, { above: number; below: number }>) => void;
let autoAdjustSyncListener: AutoAdjustSyncListener | null = null;

export function registerAutoAdjustSyncListener(listener: AutoAdjustSyncListener | null): void {
autoAdjustSyncListener = listener;
}

function notifyAutoAdjustSync(): void {
if (!autoAdjustSyncListener) return;
try {
autoAdjustSyncListener(exportAutoAdjustCounters());
} catch {
/* sync is best-effort */
}
}

/** Snapshot every persisted counter, keyed by storage key suffix (mode_level[_digits]). */
export function exportAutoAdjustCounters(): Record<string, { above: number; below: number }> {
const out: Record<string, { above: number; below: number }> = {};
try {
if (typeof window === 'undefined') return out;
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i);
if (!key || !key.startsWith(AUTO_ADJUST_KEY_PREFIX)) continue;
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(key) ?? '');
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
out[key.slice(AUTO_ADJUST_KEY_PREFIX.length)] = {
above: typeof obj['above'] === 'number' ? obj['above'] : 0,
below: typeof obj['below'] === 'number' ? obj['below'] : 0,
};
}
} catch {
/* skip corrupted entry */
}
}
} catch {
/* localStorage unavailable */
}
return out;
}

/** Hydrate counters from a cloud snapshot. Local values win — cloud only fills gaps. */
export function importAutoAdjustCounters(
counters: Readonly<Record<string, { above: number; below: number }>>,
): void {
try {
if (typeof window === 'undefined') return;
Object.entries(counters).forEach(([suffix, value]) => {
if (!suffix || typeof value !== 'object' || value === null) return;
const key = `${AUTO_ADJUST_KEY_PREFIX}${suffix}`;
if (window.localStorage.getItem(key) !== null) return; // local wins
const above = typeof value.above === 'number' && value.above >= 0 ? value.above : 0;
const below = typeof value.below === 'number' && value.below >= 0 ? value.below : 0;
if (above === 0 && below === 0) return;
window.localStorage.setItem(key, JSON.stringify({ above, below }));
});
} catch {
/* localStorage unavailable */
}
}

function isMixedAdjustMode(mode: AutoAdjustMode): boolean {
return mode === 'mixed' || mode === 'echo-mixed';
}
Expand Down Expand Up @@ -147,6 +219,7 @@ function saveCounts(
} catch {
/* localStorage quota or privacy error */
}
notifyAutoAdjustSync();
}

function removeLevelCounts(
Expand All @@ -161,6 +234,7 @@ function removeLevelCounts(
} catch {
/* no-op */
}
notifyAutoAdjustSync();
}

// ── Mode labels ─────────────────────────────────────────────────────────
Expand Down
33 changes: 29 additions & 4 deletions lib/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export const DEFAULT_SCORE_CONSTANTS: ScoreConstants = {
delta: 0.5,
};

/**
* Cap on the character-volume term. Without it the C^delta term is unbounded,
* so on a best-session leaderboard the longest good session always wins —
* rewarding stamina over skill. 200 characters (~2 sustained copy tests) is
* where extra volume stops proving anything new.
*/
export const MAX_SCORED_CHARS = 200;

function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
Expand Down Expand Up @@ -112,7 +120,7 @@ export function computeSessionScore(params: {
const N = Math.max(1, Number((params.effectiveAlphabetSize ?? params.alphabetSize) || 0));
const A = clampNumber(params.accuracy || 0, 0, 1);
const tAvg = Math.max(1, Math.round(params.avgResponseMs || 0));
const C = Math.max(1, Math.floor(params.totalChars || 0));
const C = Math.min(MAX_SCORED_CHARS, Math.max(1, Math.floor(params.totalChars || 0)));

const termAlphabet = Math.pow(N, c.alpha);
const termAccuracy = Math.pow(A, c.beta);
Expand All @@ -124,16 +132,33 @@ export function computeSessionScore(params: {
return Math.round(score * 100) / 100;
}

// Deterministic public numeric ID from Firebase UID (djb2 variant), 6 digits minimum
// Deterministic public numeric ID from a Firebase UID.
//
// New users get a 9-digit ID (FNV-1a over the UID): a 900M space keeps
// birthday-collision odds negligible until ~35k users, versus ~1.1k users for
// the legacy 6-digit space. Existing users keep their stored 6-digit IDs —
// ensurePublicId never rewrites an ID that already exists, so share URLs
// stay stable and the two ranges cannot collide with each other.
export function derivePublicIdFromUid(uid: string): number {
let hash = 0x811c9dc5; // FNV-1a offset basis
for (let i = 0; i < uid.length; i++) {
hash ^= uid.charCodeAt(i);
hash = Math.imul(hash, 0x01000193); // FNV prime
}
const positive = hash >>> 0;
const id = (positive % 900000000) + 100000000; // 100000000..999999999
return id;
}

/** @deprecated Legacy 6-digit derivation — kept only to document the historical range. */
export function deriveLegacyPublicIdFromUid(uid: string): number {
let hash = 5381;
for (let i = 0; i < uid.length; i++) {
hash = ((hash << 5) + hash) + uid.charCodeAt(i); // hash * 33 + char
hash |= 0; // 32-bit
}
const positive = Math.abs(hash);
const id = (positive % 900000) + 100000; // 100000..999999
return id;
return (positive % 900000) + 100000; // 100000..999999
}


40 changes: 38 additions & 2 deletions lib/sessionPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,24 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi
// Use group alignment for accurate letter-level accuracy calculation
return calculateGroupLetterAccuracy(groups);
})();
const hasRealTimestamp =
typeof rawObj['timestamp'] === 'number' || (opts?.docId !== undefined && /^\d+$/.test(opts.docId));
const ts =
typeof rawObj['timestamp'] === 'number'
? (rawObj['timestamp'] as number)
: opts?.docId && /^\d+$/.test(opts.docId)
? Number(opts.docId)
: Date.now();
const dateValue = rawObj['date'];
const date: string = typeof dateValue === 'string' ? dateValue : localDateForTimestamp(ts);
// Derive the calendar day from the timestamp in the viewer's timezone. This is
// the zero-write migration for legacy sessions whose stored date was the UTC
// day: streaks and the heatmap read consistent local days without rewriting
// any documents. The stored date only survives when there is no timestamp.
const date: string = hasRealTimestamp
? localDateForTimestamp(ts)
: typeof dateValue === 'string'
? dateValue
: localDateForTimestamp(ts);
const startedAt = typeof rawObj['startedAt'] === 'number' ? (rawObj['startedAt'] as number) : ts;
const finishedAt =
typeof rawObj['finishedAt'] === 'number' ? (rawObj['finishedAt'] as number) : ts;
Expand Down Expand Up @@ -261,6 +271,9 @@ async function recordDeletionTombstone(
}
}

/** Tombstones beyond this count are compacted away (oldest sessions first). */
const MAX_DELETION_TOMBSTONES = 500;

async function readDeletionTombstones(
services: FirebaseServicesLite,
user: { uid?: string } | null,
Expand All @@ -276,6 +289,16 @@ async function readDeletionTombstones(
if (typeof t === 'number' && Number.isFinite(t)) tombstones.add(t);
});
}
// Compact unbounded growth: old tombstones only matter while some device
// still holds the deleted session locally, and devices holding sessions
// that old have long since synced. Keep the newest N (fire-and-forget).
if (tombstones.size > MAX_DELETION_TOMBSTONES) {
const kept = [...tombstones].sort((a, b) => b - a).slice(0, MAX_DELETION_TOMBSTONES);
void setDoc(doc(services.db, 'users', user.uid, 'meta', 'deletedSessions'), {
timestamps: kept,
updatedAt: Date.now(),
}).catch(() => {});
}
}
} catch {
// Unavailable tombstones only mean a potential re-upload, never data loss.
Expand Down Expand Up @@ -323,15 +346,28 @@ export async function getUserCallSign(
}
}

/** Callsign-shaped: uppercase alphanumerics with optional portable suffix (matches firestore.rules). */
export const CALL_SIGN_PATTERN = /^[A-Z0-9/]{3,12}$/;

export function isValidCallSign(callSign: string): boolean {
return CALL_SIGN_PATTERN.test(callSign.trim().toUpperCase());
}

export async function setUserCallSign(
services: FirebaseServicesLite,
user: { uid: string } | null,
callSign: string | null,
): Promise<void> {
if (!(services && user && user.uid)) return;
const trimmed = callSign ? callSign.trim().toUpperCase() : null;
if (trimmed && trimmed.length > 0 && !CALL_SIGN_PATTERN.test(trimmed)) {
throw new Error(
'Call-sign must be 3-12 characters: letters, digits, and "/" only (e.g. SP9XYZ or K1ABC/P).',
);
}
try {
const profileDocRef = doc(services.db, 'users', user.uid, 'meta', 'profile');
const cleaned = callSign ? callSign.trim().toUpperCase() : null;
const cleaned = trimmed;
const update: any = { updatedAt: Date.now() };
if (cleaned && cleaned.length > 0) {
update.callSign = cleaned;
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/lib/score.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MAX_SCORED_CHARS, computeSessionScore } from '@/lib/score';

describe('computeSessionScore', () => {
const base = {
effectiveAlphabetSize: 10,
accuracy: 0.95,
avgResponseMs: 1000,
};

it('rewards more characters up to the volume cap', () => {
const short = computeSessionScore({ ...base, totalChars: 50 });
const longer = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS });
expect(longer).toBeGreaterThan(short);
});

it('stops rewarding volume beyond the cap so grinding cannot outscore skill', () => {
const atCap = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS });
const wayPastCap = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS * 50 });
expect(wayPastCap).toBe(atCap);
});

it('still ranks accuracy and speed above capped volume', () => {
const grinder = computeSessionScore({ ...base, accuracy: 0.8, totalChars: 100000 });
const sharpshooter = computeSessionScore({ ...base, accuracy: 1, totalChars: MAX_SCORED_CHARS });
expect(sharpshooter).toBeGreaterThan(grinder);
});
});
Loading
Loading