diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 10b2aa3..bd02bf6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -52,7 +52,8 @@ "Bash(npx --yes js-yaml .github/workflows/security.yml)", "Skill(claude-api)", "Bash(deno --version)", - "Bash(git check-ignore *)" + "Bash(git check-ignore *)", + "Bash(sort -k5 -n -r)" ] } } diff --git a/hardening_migration.sql b/hardening_migration.sql index e6ca782..6d20150 100644 --- a/hardening_migration.sql +++ b/hardening_migration.sql @@ -276,7 +276,7 @@ BEGIN -- Validate source_type against the CHECK constraint domain. -- Mirrors migrations/007a_add_source_type.sql so a malformed payload -- never reaches the insert and never trips a 23514 CHECK error. - IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv', 'webhook') THEN + IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv') THEN RETURN jsonb_build_object('success', false, 'error', 'invalid source_type'); END IF; diff --git a/migrations/007a_add_source_type.sql b/migrations/007a_add_source_type.sql index 675c8d7..9c4bcd8 100644 --- a/migrations/007a_add_source_type.sql +++ b/migrations/007a_add_source_type.sql @@ -21,8 +21,8 @@ -- back-fill it to align with the new four-value domain. -- 2. Drops the legacy CHECK constraint installed by mig 019 (which -- allowed only `'live_ble' | 'manual_staff'`). --- 3. Installs the new CHECK constraint with the spec's full domain: --- 'manual' | 'live_ble' | 'imported_csv' | 'webhook' +-- 3. Installs the new CHECK constraint with the canonical 3-value domain: +-- 'manual' | 'live_ble' | 'imported_csv' -- 4. Sets the column DEFAULT to `'manual'` (the safe, conservative default -- — never claim hardware verification by accident). -- 5. Creates the single-column `idx_results_source_type` for the @@ -47,26 +47,37 @@ ALTER TABLE results -- 2. Drop the legacy CHECK constraint -------------------------------------- -- -- Mig 019 installed `results_source_type_check` with the two-value domain --- ('live_ble', 'manual_staff'). The new four-value domain supersedes it. +-- ('live_ble', 'manual_staff'). The canonical 3-value domain supersedes it. ALTER TABLE results DROP CONSTRAINT IF EXISTS results_source_type_check; --- 3. Back-fill legacy values onto the new domain --------------------------- +-- 3. Back-fill legacy values onto the canonical 3-value domain ------------- -- --- 'manual_staff' → 'manual' (one-to-one mapping; the new domain drops the --- now-redundant '_staff' suffix). 'live_ble' is preserved unchanged. +-- 'manual_staff' → 'manual' (drops the redundant '_staff' suffix) +-- 'legacy_csv' → 'imported_csv' (clarifies the canonical CSV-ingest tag +-- installed by mig 020 / 024) +-- 'live_ble' is preserved unchanged. +-- +-- Both UPDATEs are idempotent (`WHERE source_type = ''`) — re-running +-- is a no-op once the domain is canonicalised. CRITICAL: both migrations +-- must run BEFORE the new CHECK constraint below; otherwise PG's +-- ADD CONSTRAINT validation pass blocks on the legacy strings. UPDATE results SET source_type = 'manual' WHERE source_type = 'manual_staff'; +UPDATE results +SET source_type = 'imported_csv' +WHERE source_type = 'legacy_csv'; + -- 4. Realign the column DEFAULT to the new safe value ---------------------- ALTER TABLE results ALTER COLUMN source_type SET DEFAULT 'manual'; --- 5. Install the new CHECK constraint -------------------------------------- +-- 5. Install the new CHECK constraint — canonical 3-value domain ---------- ALTER TABLE results ADD CONSTRAINT results_source_type_check - CHECK (source_type IN ('manual', 'live_ble', 'imported_csv', 'webhook')); + CHECK (source_type IN ('manual', 'live_ble', 'imported_csv')); -- 6. Single-column index per spec ------------------------------------------ -- @@ -79,7 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_results_source_type -- 7. Documentation --------------------------------------------------------- COMMENT ON COLUMN results.source_type IS - 'Provenance discriminator: manual | live_ble | imported_csv | webhook. ' || + 'Provenance discriminator: manual | live_ble | imported_csv. ' || 'Required on every insert via submit_result_secure. Hardware-verified ' || 'rows (live_ble) are the only ones eligible for the cryptographic ' || 'verification_hash signing path.'; diff --git a/migrations/015_composite_uniqueness_guard.sql b/migrations/015_composite_uniqueness_guard.sql index 5f9f700..3e4536d 100644 --- a/migrations/015_composite_uniqueness_guard.sql +++ b/migrations/015_composite_uniqueness_guard.sql @@ -78,21 +78,41 @@ END $$; -- WHERE athlete_id = p_athlete_id -- AND drill_type = p_drill_type -- AND recorded_at > now() - interval '120 seconds' +-- ORDER BY recorded_at DESC +-- LIMIT 1 -- -- Without an index, this is a sequential scan. With 10,000+ results across a -- busy combine day, this adds measurable latency to every result submission. --- This partial index covers exactly the hot path: recent rows only. +-- +-- IMMUTABILITY CONSTRAINT: +-- PostgreSQL forbids volatile functions (now(), CURRENT_TIMESTAMP, random()) +-- in CREATE INDEX predicates — managed Postgres providers (Supabase included) +-- reject such migrations at provisioning time with +-- `ERROR: functions in index predicate must be marked IMMUTABLE`. +-- The previous predicate `WHERE recorded_at > (now() - interval '24 hours')` +-- triggered exactly that failure on every fresh provision. +-- +-- DESIGN: +-- Use a stable compound B-Tree on (athlete_id, drill_type, recorded_at DESC). +-- Postgres uses the leading two equality columns to seek and the trailing +-- recorded_at DESC to satisfy the ORDER BY ... LIMIT 1 directly from the +-- index — no sort, no heap scan beyond the single matching tuple. Bounding +-- `recorded_at > now() - interval '120 seconds'` becomes a cheap range scan +-- on the trailing index column, so the original hot-path latency goal is +-- preserved without any volatile predicate. +-- +-- The optional `WHERE voided IS DISTINCT FROM true` predicate keeps the +-- index sparse: voided rows are explicitly excluded from suspicious-duplicate +-- detection (see the matching filter inside submit_result_secure below), so +-- they should not occupy index pages either. `IS DISTINCT FROM` correctly +-- handles NULL (Postgres treats `voided = false` and `voided IS NULL` as +-- "not voided"), and it is an immutable boolean expression — index-safe. -- -- IF NOT EXISTS: idempotent. -- --------------------------------------------------------------------------- -CREATE INDEX IF NOT EXISTS idx_results_recent_by_athlete_drill +CREATE INDEX IF NOT EXISTS idx_results_athlete_drill_time ON results (athlete_id, drill_type, recorded_at DESC) - WHERE recorded_at > (now() - interval '24 hours'); - --- NOTE: The partial index predicate uses a static expression. PostgreSQL --- evaluates it at index creation time, not at query time. For a combine that --- runs within a single calendar day this is correct. For multi-day events, --- run this migration again at the start of each day to refresh the index. + WHERE voided IS DISTINCT FROM true; -- --------------------------------------------------------------------------- -- Step 3: submit_result_secure v4 — adds suspicious-duplicate detection. diff --git a/migrations/016a_athlete_lookup_rpc.sql b/migrations/016a_athlete_lookup_rpc.sql new file mode 100644 index 0000000..c07e0d9 --- /dev/null +++ b/migrations/016a_athlete_lookup_rpc.sql @@ -0,0 +1,197 @@ +-- ============================================================================= +-- MIGRATION 016a: Athlete Lookup RPC + Public-Read Eradication +-- Core Elite Combine 2026 · Mission "lookup_athlete_by_phone" +-- ============================================================================= +-- +-- FILENAME NOTE: the mission spec asked for `migrations/016_athlete_lookup_rpc.sql` +-- but slot `016_tier1_data_hardening.sql` is already taken by the Tier-1 data +-- hardening work (DOB constraints, parent-email format, dedup unique index). +-- Per the established 007a/008a/009a/010a/011a convention used everywhere +-- else in this repo, we use `016a` so the lexical sort still applies the +-- migrations in the correct order (016 → 016a → 017). +-- +-- WHY this exists: +-- The /lookup page lets parents type a phone number and receive their +-- child's wristband number. Until this migration, the page hit the +-- `athletes` table directly via `supabase.from('athletes').select(...)` +-- which required a permissive RLS policy. The original `Public Read Own +-- Athlete` policy (mig 010, line 33–36) granted `USING (true)` SELECT to +-- the default/public role — meaning any anonymous client could enumerate +-- the entire athletes table, scrape parent_phone, parent_email, DOB, and +-- build a contact list. That is a single-policy data breach. +-- +-- WHAT this does: +-- 1. Drops the `Public Read Own Athlete` USING(true) policy so anon/public +-- can no longer issue arbitrary SELECTs against `athletes`. +-- 2. Creates `athlete_lookup_attempts` — an append-only ledger that backs +-- the rate-limit gate inside the RPC (max 10 attempts per phone per +-- 5-minute window). RLS is enabled with no public/anon policies; only +-- the SECURITY DEFINER RPC writes here. +-- 3. Creates `lookup_athlete_by_phone(p_phone TEXT, p_event_id UUID)` — +-- the only sanctioned public path to athlete data on this page. +-- • SECURITY DEFINER with explicit `SET search_path = public, pg_temp` +-- to neutralize search-path injection. +-- • Validates phone format (10 digits, normalized server-side). +-- • Enforces the rate limit before any read. +-- • Returns ONLY {id, first_name, last_name, event_id, band_display} — +-- deliberately omits parent_phone, parent_email, DOB, position, +-- height/weight, etc. so a successful call exposes the minimum +-- needed to identify a wristband. +-- • Scoped to a single event via `p_event_id` so a parent must already +-- know which event their child is registered for; cross-event +-- scraping is blocked. +-- 4. REVOKEs PUBLIC, GRANTs EXECUTE only to anon + authenticated. +-- +-- WHAT this does NOT touch: +-- • `athletes_anon_public_select` (from 010a_rls_hardening.sql line 248): +-- this is narrowly scoped to is_public events via an EXISTS subquery — +-- it does NOT use `USING (true)` and falls outside the explicit +-- anti-pattern. The scout pages (Leaderboard, AthleteDetail) depend on +-- it and live outside Mission scope. +-- • `Staff Read Athletes` (TO authenticated USING (true)) and the +-- tenant-scoped policies installed by 010a — staff still need broad +-- read access via the admin app; the Mission anti-pattern explicitly +-- excludes authenticated roles. +-- +-- IDEMPOTENCY: Re-runnable. +-- DROP POLICY IF EXISTS, CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT +-- EXISTS, CREATE OR REPLACE FUNCTION, REVOKE/GRANT are all idempotent. +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 1. Drop the USING(true) public SELECT policy on athletes. +-- This is the exact target the Mission anti-pattern names. Mig 010 +-- installed it as a temporary bridge ("Public SELECT remains open — +-- parent portal and lookup pages need it"). The new RPC supersedes that +-- bridge. +-- --------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Read Own Athlete" ON athletes; + +-- Defensive: also drop any historical names that mig 002 / 001 may have +-- installed under different casings. None of these should still exist on a +-- fully-migrated database; the IF EXISTS guard makes the line a no-op when +-- they don't. +DROP POLICY IF EXISTS "Public Read Athletes" ON athletes; +DROP POLICY IF EXISTS "Public Insert Athletes" ON athletes; + +-- --------------------------------------------------------------------------- +-- 2. Rate-limit ledger. +-- Append-only. The RPC inserts one row per attempt and counts rows in +-- the trailing 5-minute window. Service role + the SECURITY DEFINER RPC +-- are the only writers; no anon/public/authenticated policies. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS athlete_lookup_attempts ( + id BIGSERIAL PRIMARY KEY, + phone_digits TEXT NOT NULL, + event_id UUID, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + matched_count INT +); + +-- Hot path: WHERE phone_digits = $1 AND attempted_at > now() - interval '...' +-- The trailing recorded_at DESC lets the LIMIT 1 / count(*) seek directly. +CREATE INDEX IF NOT EXISTS idx_athlete_lookup_attempts_phone_time + ON athlete_lookup_attempts (phone_digits, attempted_at DESC); + +ALTER TABLE athlete_lookup_attempts ENABLE ROW LEVEL SECURITY; +ALTER TABLE athlete_lookup_attempts FORCE ROW LEVEL SECURITY; +-- (Intentionally NO policies. SECURITY DEFINER RPC + service role only.) + +-- --------------------------------------------------------------------------- +-- 3. lookup_athlete_by_phone — the only sanctioned public read path. +-- +-- Return shape is fixed to the Mission spec: +-- id, first_name, last_name, event_id, band_display +-- Anything beyond that (parent_phone, parent_email, DOB, position, +-- height/weight) is excluded by design. +-- +-- `SET search_path = public, pg_temp` is mandatory for SECURITY DEFINER +-- functions that reference unqualified relation names — without it a +-- malicious caller with CREATE on a temp/parallel schema could shadow +-- `athletes` / `bands` and harvest writes. `pg_temp` is appended last +-- per the standard CVE-2018-1058 mitigation pattern. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION lookup_athlete_by_phone( + p_phone TEXT, + p_event_id UUID +) +RETURNS TABLE ( + id UUID, + first_name TEXT, + last_name TEXT, + event_id UUID, + band_display TEXT +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + v_digits TEXT; + v_recent_attempts INT; +BEGIN + -- ── Input validation ───────────────────────────────────────────────────── + IF p_phone IS NULL OR p_event_id IS NULL THEN + RAISE EXCEPTION 'phone and event_id are required' + USING ERRCODE = '22023'; -- invalid_parameter_value + END IF; + + -- Normalize: strip every non-digit. Defense in depth — the client also + -- normalizes, but never trust client-side stripping. + v_digits := regexp_replace(p_phone, '\D', '', 'g'); + + IF length(v_digits) <> 10 THEN + RAISE EXCEPTION 'phone must contain exactly 10 digits' + USING ERRCODE = '22023'; + END IF; + + -- ── Rate limit ─────────────────────────────────────────────────────────── + -- 10 attempts per phone per 5 minutes. Sized to absorb a parent + -- mistyping a few times while shutting down a scraper that's iterating + -- through area codes. + SELECT count(*) + INTO v_recent_attempts + FROM athlete_lookup_attempts + WHERE phone_digits = v_digits + AND attempted_at > now() - interval '5 minutes'; + + IF v_recent_attempts >= 10 THEN + RAISE EXCEPTION 'rate limit exceeded — please try again in a few minutes' + USING ERRCODE = '53400'; -- configuration_limit_exceeded + END IF; + + -- Record the attempt BEFORE the SELECT so a successful flood still + -- accrues against the limit (vs. only failed attempts). + INSERT INTO athlete_lookup_attempts (phone_digits, event_id) + VALUES (v_digits, p_event_id); + + -- ── Narrow read ────────────────────────────────────────────────────────── + RETURN QUERY + SELECT a.id, + a.first_name, + a.last_name, + a.event_id, + b.display_number::TEXT AS band_display + FROM athletes a + LEFT JOIN bands b + ON b.band_id = a.band_id + WHERE a.parent_phone = v_digits + AND a.event_id = p_event_id + ORDER BY a.created_at DESC + LIMIT 5; +END; +$$; + +-- Lock down execution: no PUBLIC, only the two real roles that hit PostgREST. +REVOKE ALL ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) TO anon, authenticated; + +COMMENT ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) IS + 'Public parent-portal lookup. Returns at most 5 athlete identity rows ' || + '(id, first_name, last_name, event_id, band_display) for a phone+event ' || + 'pair. Rate-limited at 10/5min/phone via athlete_lookup_attempts. ' || + 'SECURITY DEFINER + SET search_path defends against CVE-2018-1058.'; + +COMMIT; diff --git a/migrations/019_verification_hash.sql b/migrations/019_verification_hash.sql index b111782..54459b1 100644 --- a/migrations/019_verification_hash.sql +++ b/migrations/019_verification_hash.sql @@ -14,13 +14,13 @@ -- 1. results.verification_hash TEXT (nullable) -- Populated by the generate-verified-export Edge Function. -- HMAC-SHA-256(VERIFICATION_SECRET, canonical_payload_string). --- NULL = result has not been verified yet, or source_type = 'manual_staff'. +-- NULL = result has not been verified yet, or source_type = 'manual'. -- --- 2. results.source_type TEXT NOT NULL DEFAULT 'manual_staff' +-- 2. results.source_type TEXT NOT NULL DEFAULT 'manual' -- Set by the client at write time. -- 'live_ble' — result was captured via BLE hardware; eligible for -- cryptographic verification. --- 'manual_staff' — result was entered manually; can never be +-- 'manual' — result was entered manually; can never be -- hardware-verified; verification_hash remains NULL. -- -- 3. results.session_id TEXT (nullable) @@ -40,7 +40,7 @@ -- Also embedded in the verification hash payload. -- -- 6. submit_result_secure v6 — adds p_source_type and p_session_id params. --- Existing callers without the new params get 'manual_staff' / NULL. +-- Existing callers without the new params get 'manual' / NULL. -- -- 7. upsert_capture_telemetry_lww v2 — adds p_clock_offset_ms and p_rtt_ms. -- Existing callers default to NULL for both. @@ -57,7 +57,7 @@ BEGIN; -- --------------------------------------------------------------------------- ALTER TABLE results ADD COLUMN IF NOT EXISTS verification_hash TEXT; -ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual_staff'; +ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual'; ALTER TABLE results ADD COLUMN IF NOT EXISTS session_id TEXT; -- Check constraint: only valid source types @@ -69,7 +69,7 @@ BEGIN ) THEN ALTER TABLE results ADD CONSTRAINT results_source_type_check - CHECK (source_type IN ('live_ble', 'manual_staff')); + CHECK (source_type IN ('live_ble', 'manual')); END IF; END $$; @@ -77,7 +77,7 @@ END $$; -- If a result has a matching capture_telemetry row, it was hardware-captured. UPDATE results r SET source_type = 'live_ble' -WHERE source_type = 'manual_staff' +WHERE source_type = 'manual' AND EXISTS ( SELECT 1 FROM capture_telemetry ct WHERE ct.result_id = r.id ); @@ -121,7 +121,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure( p_attempt_number INT DEFAULT 1, p_meta JSONB DEFAULT '{}'::jsonb, p_device_timestamp BIGINT DEFAULT 0, - p_source_type TEXT DEFAULT 'manual_staff', + p_source_type TEXT DEFAULT 'manual', p_session_id TEXT DEFAULT NULL ) RETURNS JSONB LANGUAGE plpgsql diff --git a/migrations/024_enterprise_importer.sql b/migrations/024_enterprise_importer.sql index e2b6379..aa06840 100644 --- a/migrations/024_enterprise_importer.sql +++ b/migrations/024_enterprise_importer.sql @@ -13,16 +13,16 @@ -- -- 2. results.band_id / station_id — made nullable. -- Legacy imports have no band or station context. NULL is only valid when --- source_type = 'legacy_csv'. Enforced by CHECK constraint below. +-- source_type = 'imported_csv'. Enforced by CHECK constraint below. -- -- 3. submit_result_secure v7 — adds is_hardware_verified to INSERT. --- live_ble → true. manual_staff / legacy_csv → false. +-- live_ble → true. manual / imported_csv → false. -- -- 4. import_legacy_results_batch(p_event_id UUID, p_records JSONB) -- Batch RPC for the EnterpriseImporter UI. Deduplicates athletes by -- (first_name, last_name) and parent_email within the event, creates stubs -- for unknowns, inserts results with is_hardware_verified = false and --- source_type = 'legacy_csv'. Returns summary JSONB. +-- source_type = 'imported_csv'. Returns summary JSONB. -- -- IDEMPOTENCY: Safe to run multiple times (IF NOT EXISTS / CREATE OR REPLACE). -- ============================================================================= @@ -59,7 +59,7 @@ ALTER TABLE results DROP CONSTRAINT IF EXISTS results_legacy_nullable_check; ALTER TABLE results ADD CONSTRAINT results_legacy_nullable_check CHECK ( - source_type = 'legacy_csv' + source_type = 'imported_csv' OR (band_id IS NOT NULL AND station_id IS NOT NULL) ); @@ -68,7 +68,7 @@ ALTER TABLE results -- -- Identical to v6 (migration 019) except is_hardware_verified added to INSERT. -- is_hardware_verified = (p_source_type = 'live_ble') — evaluated at write time. --- All existing callers get false by default (p_source_type defaults 'manual_staff'). +-- All existing callers get false by default (p_source_type defaults 'manual'). -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION submit_result_secure( @@ -82,7 +82,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure( p_attempt_number INT DEFAULT 1, p_meta JSONB DEFAULT '{}'::jsonb, p_device_timestamp BIGINT DEFAULT 0, - p_source_type TEXT DEFAULT 'manual_staff', + p_source_type TEXT DEFAULT 'manual', p_session_id TEXT DEFAULT NULL ) RETURNS JSONB LANGUAGE plpgsql @@ -187,7 +187,7 @@ $$; -- -- Inserted results always have: -- is_hardware_verified = false (cannot be promoted — permanent) --- source_type = 'legacy_csv' +-- source_type = 'imported_csv' -- band_id = NULL (no station context for legacy data) -- station_id = NULL -- --------------------------------------------------------------------------- @@ -306,7 +306,7 @@ BEGIN ) VALUES ( gen_random_uuid(), p_event_id, v_athlete_id, 'forty', v_drill_val, 1, - 'legacy_csv', false, 'clean', now(), + 'imported_csv', false, 'clean', now(), jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email) ); v_count_inserted := v_count_inserted + 1; @@ -324,7 +324,7 @@ BEGIN ) VALUES ( gen_random_uuid(), p_event_id, v_athlete_id, 'shuttle_5_10_5', v_drill_val, 1, - 'legacy_csv', false, 'clean', now(), + 'imported_csv', false, 'clean', now(), jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email) ); v_count_inserted := v_count_inserted + 1; @@ -342,7 +342,7 @@ BEGIN ) VALUES ( gen_random_uuid(), p_event_id, v_athlete_id, 'vertical', v_drill_val, 1, - 'legacy_csv', false, 'clean', now(), + 'imported_csv', false, 'clean', now(), jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email) ); v_count_inserted := v_count_inserted + 1; @@ -360,7 +360,7 @@ BEGIN ) VALUES ( gen_random_uuid(), p_event_id, v_athlete_id, 'broad', v_drill_val, 1, - 'legacy_csv', false, 'clean', now(), + 'imported_csv', false, 'clean', now(), jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email) ); v_count_inserted := v_count_inserted + 1; diff --git a/packages/powersync/src/connector.ts b/packages/powersync/src/connector.ts index 69964c9..1e981e3 100644 --- a/packages/powersync/src/connector.ts +++ b/packages/powersync/src/connector.ts @@ -227,7 +227,7 @@ export class CoreElitePowerSyncConnector implements PowerSyncBackendConnector { // v5: device_timestamp is the LWW key (migration 018). // Defaults to 0 on the server if not supplied (backwards compatible). p_device_timestamp: (opData.device_timestamp as number) ?? 0, - p_source_type: (opData.source_type as string) ?? 'manual_staff', + p_source_type: (opData.source_type as string) ?? 'manual', p_session_id: (opData.session_id as string) ?? null, }); diff --git a/packages/powersync/src/schema.ts b/packages/powersync/src/schema.ts index 9fd7fc2..5ee3571 100644 --- a/packages/powersync/src/schema.ts +++ b/packages/powersync/src/schema.ts @@ -59,7 +59,7 @@ const results = new Table({ validation_status: new Column({ type: ColumnType.TEXT }), hlc_timestamp: new Column({ type: ColumnType.TEXT }), // HLC: causal ordering device_timestamp: new Column({ type: ColumnType.INTEGER }), // LWW key: device wall-clock ms - source_type: new Column({ type: ColumnType.TEXT }), // 'live_ble' | 'manual_staff' + source_type: new Column({ type: ColumnType.TEXT }), // 'live_ble' | 'manual' | 'imported_csv' session_id: new Column({ type: ColumnType.TEXT }), // combine wave/session verification_hash: new Column({ type: ColumnType.TEXT }), // HMAC-SHA-256, set by Edge Fn recorded_at: new Column({ type: ColumnType.TEXT }), diff --git a/packages/powersync/src/useSync.ts b/packages/powersync/src/useSync.ts index 4659769..722d593 100644 --- a/packages/powersync/src/useSync.ts +++ b/packages/powersync/src/useSync.ts @@ -279,10 +279,12 @@ export interface ResultPayload { device_timestamp?: number; /** * 'live_ble' — captured via BLE hardware; eligible for cryptographic verification. - * 'manual_staff' — manually keyed; verification_hash will always be null. - * Defaults to 'manual_staff' when omitted. + * 'manual' — manually keyed; verification_hash will always be null. + * 'imported_csv' — bulk-ingested historical record. + * Defaults to 'manual' when omitted (defense-in-depth — the StationMode + * capture path always sets this explicitly per Mission "p_source_type"). */ - source_type?: 'live_ble' | 'manual_staff'; + source_type?: 'live_ble' | 'manual' | 'imported_csv'; /** Combine wave/session identifier for bulk session exports. */ session_id?: string; meta?: Record; @@ -348,7 +350,7 @@ export function useSyncedWrite() { const id = payload.client_result_id; const now = new Date().toISOString(); const deviceTimestamp = payload.device_timestamp ?? Date.now(); - const sourceType = payload.source_type ?? 'manual_staff'; + const sourceType = payload.source_type ?? 'manual'; await db.execute(` INSERT OR REPLACE INTO results ( diff --git a/src/App.tsx b/src/App.tsx index f992a1e..0801ecb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { SyncIndicator } from './components/SyncIndicator'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RouteGuard } from './components/RouteGuard'; import { ThemeProvider } from './components/ThemeProvider'; +import { SyncProvider } from './contexts/SyncProvider'; import { reportNav } from './lib/apm'; // APM route-timing beacon — mounts once inside and fires a @@ -75,6 +76,7 @@ export default function App() { +
@@ -176,6 +178,7 @@ export default function App() {
+
diff --git a/src/components/CoachRadarChart.tsx b/src/components/CoachRadarChart.tsx new file mode 100644 index 0000000..a7b121c --- /dev/null +++ b/src/components/CoachRadarChart.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { + RadarChart, + PolarGrid, + PolarAngleAxis, + Radar, + ResponsiveContainer, + Tooltip, +} from 'recharts'; + +// Standalone radar chart so recharts (~120KB min) is split into its own Vite +// chunk and never reaches the initial CoachPortal payload. Imported via +// React.lazy from CoachPortal so the chart bundle is fetched only when an +// operator actually toggles compare-mode and selects ≥2 athletes. + +export interface CoachRadarDatum { + drill: string; + [athleteKey: string]: string | number; +} + +export interface CoachRadarAthlete { + id: string; + first_name: string; + last_name: string; +} + +interface CoachRadarChartProps { + data: CoachRadarDatum[]; + athletes: CoachRadarAthlete[]; + colors: string[]; +} + +const CoachRadarChart: React.FC = ({ data, athletes, colors }) => ( + + + + + [`${value}th percentile`]} + contentStyle={{ borderRadius: '12px', border: '1px solid #e4e4e7', fontSize: 12 }} + /> + {athletes.map((athlete, i) => ( + + ))} + + +); + +export default CoachRadarChart; diff --git a/src/components/FilmEmbed.tsx b/src/components/FilmEmbed.tsx index f54482d..3c4dc83 100644 --- a/src/components/FilmEmbed.tsx +++ b/src/components/FilmEmbed.tsx @@ -65,7 +65,7 @@ export default function FilmEmbed({ filmUrl, title }: FilmEmbedProps) {
- +

{`> UNSUPPORTED PROVIDER. DIRECT LINK ONLY.`} @@ -95,7 +95,7 @@ export default function FilmEmbed({ filmUrl, title }: FilmEmbedProps) { FILM · {providerLabel[parsed.provider]} {title && ( - + · {title} )} diff --git a/src/components/HardwareStandby.tsx b/src/components/HardwareStandby.tsx index a0f2d56..a248db8 100644 --- a/src/components/HardwareStandby.tsx +++ b/src/components/HardwareStandby.tsx @@ -221,7 +221,7 @@ export default function HardwareStandby({ onRetry }: HardwareStandbyProps) { SYS ONLINE

- {timeStr} + {timeStr}
@@ -240,7 +240,7 @@ export default function HardwareStandby({ onRetry }: HardwareStandbyProps) { {/* Scanning label */} -
+
{sweeping ? 'ACTIVE SWEEP IN PROGRESS...' @@ -263,7 +263,7 @@ export default function HardwareStandby({ onRetry }: HardwareStandbyProps) { {/* Section label */}
-

+

SYSTEM DIAGNOSTIC LOG

@@ -293,7 +293,7 @@ export default function HardwareStandby({ onRetry }: HardwareStandbyProps) {
{line} diff --git a/src/components/ProgressionMatrix.tsx b/src/components/ProgressionMatrix.tsx index 4567d27..a5ea7be 100644 --- a/src/components/ProgressionMatrix.tsx +++ b/src/components/ProgressionMatrix.tsx @@ -290,7 +290,7 @@ function resolvePosition(raw: string): string { // ─── TierHash ───────────────────────────────────────────────────────────────── const TIER_COLORS = { - hs: { line: 'bg-zinc-700', text: 'text-zinc-600' }, + hs: { line: 'bg-zinc-700', text: 'text-zinc-400' }, fcs: { line: 'bg-zinc-500', text: 'text-zinc-400' }, p5: { line: 'bg-white/60', text: 'text-white/60' }, } as const; @@ -423,7 +423,7 @@ function DeltaTrack({ drill, current }: DeltaTrackProps) { : 'border-zinc-800 bg-zinc-950' }`} > - {tier.label} + {tier.label} Δ {c.fmtSigned(delta)} @@ -481,7 +481,7 @@ function MetricCard({ drill, current, index }: MetricCardProps) { }; return { label: '[ DEVELOPMENTAL ]', - badgeCls: 'text-zinc-600 border-zinc-800 bg-zinc-950/60', + badgeCls: 'text-zinc-400 border-zinc-800 bg-zinc-950/60', cardBorder: 'border-white/5', accentCls: 'bg-transparent', isCoreElite: false, @@ -501,7 +501,7 @@ function MetricCard({ drill, current, index }: MetricCardProps) { {/* Header row */}
-

+

{drill.label}

@@ -544,7 +544,7 @@ function NullMetricCard({ drill, index }: NullMetricCardProps) { [ -- ]

- + [ SENSOR NULL ]
@@ -595,7 +595,7 @@ export default function ProgressionMatrix({ results, position, firstName, weight return (
-

+

No benchmark metrics on record.

@@ -624,7 +624,7 @@ export default function ProgressionMatrix({ results, position, firstName, weight )}

-

+

{profile.label} · {activeCards.length}/{cards.length} metric{cards.length !== 1 ? 's' : ''} on record

@@ -648,7 +648,7 @@ export default function ProgressionMatrix({ results, position, firstName, weight {/* Telemetry headline */} {firstName && ( -

+

{`> ${firstName.toUpperCase()} — LIVE TELEMETRY VS. POSITIONAL D1 THRESHOLDS`}

)} @@ -673,7 +673,7 @@ export default function ProgressionMatrix({ results, position, firstName, weight ] as const).map(({ color, label, glow }) => (
- {label} + {label}
))}
diff --git a/src/components/QRScanner.tsx b/src/components/QRScanner.tsx index 1941d93..025af18 100644 --- a/src/components/QRScanner.tsx +++ b/src/components/QRScanner.tsx @@ -1,5 +1,21 @@ import React, { useEffect, useRef } from 'react'; -import { Html5QrcodeScanner } from 'html5-qrcode'; + +// `html5-qrcode` ships ~85 KB minified + parses a heavyweight DOM polyfill on +// load. The lib is referenced ONLY inside this component's effect, so we can +// dynamic-import it at mount time. Vite hoists the import into its own chunk +// (`html5-qrcode-*.js`) and ships zero of it on the initial bundle. The default +// export is captured by the runtime once and reused across remounts. +type Html5QrcodeScannerCtor = new ( + elementId: string, + config: { fps: number; qrbox: number; aspectRatio: number; disableFlip: boolean }, + verbose: boolean, +) => { + render: ( + onSuccess: (decodedText: string) => void, + onError: (error: unknown) => void, + ) => void; + clear: () => Promise; +}; interface QRScannerProps { onScan: (decodedText: string) => void; @@ -9,37 +25,42 @@ interface QRScannerProps { disableFlip?: boolean; } -export const QRScanner: React.FC = ({ - onScan, - fps = 10, - qrbox = 250, +export const QRScanner: React.FC = ({ + onScan, + fps = 10, + qrbox = 250, aspectRatio = 1.0, - disableFlip = false + disableFlip = false, }) => { - const scannerRef = useRef(null); + const scannerRef = useRef<{ clear: () => Promise } | null>(null); useEffect(() => { - const scanner = new Html5QrcodeScanner( - 'qr-reader', - { fps, qrbox, aspectRatio, disableFlip }, - /* verbose= */ false - ); - - scanner.render( - (decodedText) => { - onScan(decodedText); - // scanner.clear(); // Optional: stop scanning after first success - }, - (error) => { - // console.warn(error); - } - ); + let cancelled = false; + let active: { clear: () => Promise } | null = null; - scannerRef.current = scanner; + (async () => { + const mod = await import('html5-qrcode'); + if (cancelled) return; + const Ctor = (mod as unknown as { Html5QrcodeScanner: Html5QrcodeScannerCtor }) + .Html5QrcodeScanner; + const scanner = new Ctor( + 'qr-reader', + { fps, qrbox, aspectRatio, disableFlip }, + false, + ); + scanner.render( + (decodedText) => onScan(decodedText), + () => { /* intentionally ignored — every non-match is a "decode error" */ }, + ); + active = scanner; + scannerRef.current = scanner; + })(); return () => { - if (scannerRef.current) { - scannerRef.current.clear().catch(err => console.error('Failed to clear scanner', err)); + cancelled = true; + const s = active ?? scannerRef.current; + if (s) { + s.clear().catch(err => console.error('Failed to clear scanner', err)); } }; }, [onScan, fps, qrbox, aspectRatio, disableFlip]); @@ -50,3 +71,5 @@ export const QRScanner: React.FC = ({
); }; + +export default QRScanner; diff --git a/src/components/SyncIndicator.tsx b/src/components/SyncIndicator.tsx index 273b3e6..fe08917 100644 --- a/src/components/SyncIndicator.tsx +++ b/src/components/SyncIndicator.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; -import { useOfflineSync } from '../hooks/useOfflineSync'; +import { useSyncContext } from '../contexts/SyncProvider'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -9,7 +9,7 @@ function cn(...inputs: ClassValue[]) { } export const SyncIndicator: React.FC = () => { - const { isOnline, pendingCount, lastSyncTime, syncOutbox } = useOfflineSync(); + const { isOnline, pendingCount, lastSyncTime, syncOutbox } = useSyncContext(); return (
diff --git a/src/components/admin/EnterpriseImporter.tsx b/src/components/admin/EnterpriseImporter.tsx index 5d80c60..419152b 100644 --- a/src/components/admin/EnterpriseImporter.tsx +++ b/src/components/admin/EnterpriseImporter.tsx @@ -408,12 +408,12 @@ export default function EnterpriseImporter() { Unverified Data Only
-

+

Legacy CSV Ingestion — Mission N · All records tagged is_hardware_verified = false

{fileName && ( - )} @@ -446,13 +446,13 @@ export default function EnterpriseImporter() { onChange={onFileInput} />
- +

{isDragOver ? 'Release to load' : 'Drop athlete CSV here or click to browse'}

-

+

Required: First Name · Last Name · DOB · at least one drill column

@@ -462,7 +462,7 @@ export default function EnterpriseImporter() {

{errorMsg}

-
@@ -480,7 +480,7 @@ export default function EnterpriseImporter() { Paste any messy roster — Claude normalizes it into the strict importer schema.

- + Mission Z @@ -501,13 +501,13 @@ export default function EnterpriseImporter() { />
-

+

{cleanseRaw.length.toLocaleString()} chars · max 200,000

@@ -684,7 +684,7 @@ export default function EnterpriseImporter() { ['Errors', importResult.errors?.length ?? 0, importResult.errors?.length > 0 ? 'text-red-400' : 'text-zinc-700'], ] as const).map(([label, val, color]) => (
-

{label}

+

{label}

{val}

))} @@ -741,7 +741,7 @@ export default function EnterpriseImporter() {
{events.length === 0 ? ( -

Loading events…

+

Loading events…

) : ( - + # @@ -488,7 +488,7 @@ export default function AdminDashboard() { onClick={() => handleSort('name')} /> - + Pos @@ -509,7 +509,7 @@ export default function AdminDashboard() { onClick={() => handleSort('score')} /> - + Status @@ -520,7 +520,7 @@ export default function AdminDashboard() { {paginatedAthletes.length === 0 ? ( -

No athletes match the current filters.

+

No athletes match the current filters.

) : ( @@ -543,7 +543,7 @@ export default function AdminDashboard() { {/* Athlete name + email */}
{athlete.first_name} {athlete.last_name}
-
{athlete.parent_email}
+
{athlete.parent_email}
{/* Position */} @@ -572,7 +572,7 @@ export default function AdminDashboard() { {score !== null ? ( - {score}th + {score}th ) : ( @@ -610,7 +610,7 @@ export default function AdminDashboard() { {/* Pagination */} {totalPages > 1 && (
-

+

{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filteredAndSortedAthletes.length)} of {filteredAndSortedAthletes.length}

@@ -663,7 +663,7 @@ function SortHeader({
diff --git a/src/pages/Pricing.tsx b/src/pages/Pricing.tsx index 9793d15..c7833ef 100644 --- a/src/pages/Pricing.tsx +++ b/src/pages/Pricing.tsx @@ -152,7 +152,7 @@ export default function Pricing() { $3K /mo
-

+

Billed annually at ${PLANS.enterprise.price.toLocaleString()}/yr

@@ -187,7 +187,7 @@ export default function Pricing() {

{/* Flywheel caption */} -

+

The flywheel: $49 gets an athlete through the gate → verified data builds their profile → $14.99/mo keeps it live → scouts find it through the $36K/yr Enterprise API → demand drives more registrations.

@@ -195,7 +195,7 @@ export default function Pricing() { {/* Footer */}