diff --git a/app.json b/app.json
index ce4a510..c9a77d0 100644
--- a/app.json
+++ b/app.json
@@ -18,7 +18,11 @@
],
"ios": {
"supportsTablet": true,
- "bundleIdentifier": "com.beaux.ultraedge"
+ "bundleIdentifier": "com.beaux.ultraedge",
+ "privacyManifests": {
+ "NSPrivacyTracking": false,
+ "NSPrivacyCollectedDataTypes": []
+ }
},
"android": {
"adaptiveIcon": {
diff --git a/docs/release/COLLABORATION-SAFETY-OPERATIONS.md b/docs/release/COLLABORATION-SAFETY-OPERATIONS.md
new file mode 100644
index 0000000..677ab6d
--- /dev/null
+++ b/docs/release/COLLABORATION-SAFETY-OPERATIONS.md
@@ -0,0 +1,49 @@
+# Collaboration safeguards — implementation and release gates
+
+Status: source implementation only, September 6, 2026. No migration deployed, no live identities changed, no operational moderation response verified. **An implemented queue is not a monitored human response process. Do not submit on that assumption.**
+
+Confirmed by explicit user instruction: the legal operator is **Beaux Walton**. The approved private privacy/support/abuse email is **hello@heybeaux.dev**. The app provides a direct `mailto:` link with the same address shown if opening email fails. Mailbox deliverability, monitoring and operational response have not been tested; no email was sent. Operator identity and contact approval are settled, not outstanding confirmation gates.
+
+## App and backend contract
+
+`ue_live(action,args)` retains existing actions. Migration `supabase/collaboration/002_safety.sql` copies the original implementation to a client-inaccessible `ue_live_core` and adds a checked wrapper. No client table grants or permissive RLS policies are added. Existing optimistic timing revisions, request deduplication and owner permissions remain in the core.
+
+- `abuse_report`: `{roomId,userId,reason,details,requestId}` returns `{id}`. Both guests must currently belong to the room; self-reporting is rejected. Reasons are `harassment`, `hate`, `sexual_content`, `threats`, `other`; details are optional, max 1,000 characters. Request IDs deduplicate retries; ten new reports per reporting guest per rolling 24 hours. Failed submissions are not queued offline and UI never announces success without a receipt.
+- The private `ue_live_abuse_reports` table stores reporter/subject guest IDs, room reference, reason/details, timestamps/status, and a bounded evidence snapshot: event name, subject display name, subject timing observations and, only for reports about the owner, the shared plan. No invitation code or session token is copied. Clients cannot inspect reports, review them or change their status. The app includes neither moderator tooling nor notifications. Only an authorized backend operator can access the queue.
+- Evidence format version 1 preserves room/subject IDs and plan revision even if the live room is later removed. For owner reports the full plan is retained only if its serialized JSON is at most 262,144 bytes; otherwise `plan` is null and `planExcerpt` contains the first 16,000 characters of serialized plan text (an excerpt, **not valid complete JSON**). Observations are a deterministic latest-first sample (received time descending, checkpoint ID/kind tie-break), capped at 100 rows and 131,072 serialized bytes. `truncation` records original plan bytes, limits, total/included observation counts and separate plan/observation truncation flags. JSON escaping/UTF-8 overhead is bounded too; a large valid room cannot make reporting fail the 600,000-byte evidence constraint. Reason/details are never shortened by this sampling. Ordinary validation, membership and report quotas still apply.
+- Reviewers must inspect truncation metadata: a sample can omit the offending content. Use the preserved room/subject IDs to inspect current authorized server content where it still exists, and seek clarification through the verified private support process if needed. An excerpt is not proof that omitted content was safe, nor a complete historical record. No additional data collection or retention guarantee is implied.
+- `block_user`: `{roomId,userId}` returns `{removedRoomIds}`. Blocker leaves every common room they do not own. Where blocker owns the room, target is removed and invite rotates. Client purges departed-room cache and pending observations after server acknowledgement; retained rooms hide target observations. Server reads hide historical observations between blocked guests; server joins reject either guest entering a room containing the other. Blocks are across rooms, not merely a UI mute.
+- Block actions, joins, reporting and deletion use a shared transaction advisory lock to prevent interleaved join/block membership checks and reporting quota races. This intentionally serializes a bounded private-team workload; load-test before larger use. Existing room locks and owner checks remain. Do not bypass the public wrapper with new privileged endpoints.
+- Blocks last until either identity is deleted. There is no unblock UI in this iteration. Guest identities are not verified people: another identity can evade a block. Existing cached/exported copies on other devices cannot be recalled; offline devices cannot immediately receive access changes. Owner removals without blocking retain the original historical-report semantics.
+
+## Filtering is limited
+
+Server triggers reject new/updated display names and all string values in shared snapshots matching configured whole phrases after Unicode NFKC/case/punctuation normalization. `ue_live_content_terms` has just four starter entries: two English profanities and two explicit threat/self-harm harassment phrases. These are **limited baseline filtering, not full hate, sexual-content, threat, multilingual or semantic moderation**. Spelling obfuscation and other abusive language can evade it; innocent context can be rejected. In-app reporting and blocking are additional controls, not proof of comprehensive moderation.
+
+No retroactive scan occurs when a term changes. Before release, operators must review existing names/plans against the current rules, handle matches, curate broader proportionate terms (normalized lowercase plain words/phrases), and validate false-positive cases. Provide a private way to contest an incorrect filter. Do not edit the original migration to adjust a deployed term list; use an independently reviewed follow-up change.
+
+## Deletion and retention
+
+“Delete my collaboration data” is visible at the bottom of the live team panel, including inside an open room and when no room/actor is cached. Events → Join / open a live team race opens the panel; Settings → Privacy & Support explains the route. No guest is created solely to delete it.
+
+- A rejected/unacknowledged server deletion preserves rooms and pending observations so the user can retry. Deletion is idempotent for a still-valid guest token even after its user row is gone, allowing retry after a lost response. Expired/lost credentials remain a recovery limitation requiring the operator's verified private support process.
+- Once the server acknowledges deletion, local collaboration cache is cleared even if local sign-out throws or returns an error. UI distinguishes completed server deletion from local session/cache cleanup problems. Local race plans are untouched.
+- Deleting either guest cascades associated blocks and abuse reports/evidence. This intentionally prioritizes the current guest deletion contract; it is **not** a policy of retaining all abuse evidence for investigations. Closing a room instead nulls the queue's room reference and retains evidence for review.
+- There is no automatic queue aging/purge job. Before launch, the operator must choose and publish an evidence-retention limit, implement a reviewed purge schedule, determine appropriate deletion/legal handling, and validate it. Do not promise a duration yet. Provider auth logs, external logs, backups and previously downloaded copies are separate; this SQL does not erase those.
+
+## Migration / release procedure — requires approval
+
+1. Confirm the actual deployed `ue_live` body and schema match `001_live_rooms.sql`. Compare any production changes before replacing the RPC; the core is a copy, not an automatic merge. Take the operator's approved backup and review grants/definer ownership. Use a privileged database owner; no service key belongs in app configuration.
+2. On an isolated staging database, apply `001_live_rooms.sql` only if not already installed, then apply `002_safety.sql` once. The latter is a one-time transactional migration, not rerunnable. **Never rerun 001 over 002**: it would replace the safety wrapper. Do not run either against production without explicit approval.
+3. Run `bash scripts/test-live-safety-local.sh` for a disposable local PostgreSQL cluster, plus dedicated Jest tests. It binds only a temporary Unix socket, not a TCP listener; stops the cluster on exit and prints retained logs. It never uses project Supabase credentials. This validates real PostgreSQL behavior with minimal auth functions, not real Supabase JWT/network/device behavior.
+4. Verify staging permissions explicitly: `anon` cannot execute `ue_live`; `authenticated` can execute only the wrapper, cannot execute core/filter functions and cannot access safety tables directly; RLS remains enabled. Test deployed roles/default grants as well as those mocked locally.
+5. Two test devices: create/join, offensive name/plan rejection, submit report then block, target-owner / blocker-owner / third-party-owned common rooms, rotated invitations, blocked rejoin both directions, historical observations hidden, stale/offline state, observation conflicts, revoke/leave/close, and guest deletion including failure/retry. Use disposable identities with approval, never real team identities. Test the exact final binary.
+6. Deploy only in an approved maintenance/release window. Old apps can continue timing operations but lack reporting/block UI; server filtering/block enforcement still applies. Do not ship the new UI against a backend without migration: it will reject the new actions. Preserve the safety wrapper during rollback; prefer a reviewed forward fix instead of reopening unsafe direct access.
+
+## Operational gates — still open
+
+- Beaux Walton is the confirmed legal operator. Assign trained queue reviewers; verify restricted access, duty coverage, monitoring/alerts and escalation for threats or child-safety issues. The approved contact is hello@heybeaux.dev; mailbox deliverability and human response still need operational validation. Do not invent an emergency service or response time.
+- Assign someone to check `pending` reports, examine evidence with least-privilege access, record a decision using `reviewing` then `resolved`/`dismissed` and `reviewed_at`, and take proportionate action through an approved procedure (content correction/removal, invitation revocation, room closure, account measures where justified). Status alone does not remove content. Add secure tooling/audit records as needed; none is supplied by this migration.
+- Establish response targets, appeals handling through hello@heybeaux.dev, identity-evasion handling, retention/purge and escalation procedures. Validate receipt-to-human-triage and action end to end. The UI's receipt explicitly does not promise human review. No retention duration or SLA is approved by the contact confirmation.
+- Publish the confirmed Beaux Walton / hello@heybeaux.dev operator/contact information at the approved destinations and finalize provider/retention facts. The app targets `https://ultraedge.heybeaux.dev/privacy` and `/support`; these are intended destinations, not proof of deployment. Independently validate mailbox operation and monitoring before claiming a staffed support response.
+- Have the owner assess adequacy under [Apple App Review Guideline 1.2](https://developer.apple.com/app-store/review/guidelines/#user-generated-content). Invite-only sharing is not treated as exempt. **Safeguard implementation alone does not close App Review risk.**
diff --git a/docs/release/LOCAL-APP-STORE-RELEASE.md b/docs/release/LOCAL-APP-STORE-RELEASE.md
new file mode 100644
index 0000000..06ecfa9
--- /dev/null
+++ b/docs/release/LOCAL-APP-STORE-RELEASE.md
@@ -0,0 +1,9 @@
+# Free local-only App Store candidate
+
+September 6, 2026: Beaux authorized App Review submission and previously specified that this first release is free and local-only. Cloud backup, online sharing and in-app messaging are reserved for a future monetised release.
+
+This candidate includes the reviewed attachment lifecycle fixes and hosted privacy/support links. Both live-room UI entry points are removed. Online collaboration implementation remains dormant for future development; no collaboration backend migration is deployed for this release. App collection manifests are empty and tracking is disabled. App Privacy, screenshots and review metadata must match this candidate before submission.
+
+Validation: 36 Jest suites / 215 tests pass (3 TODO), TypeScript passes. Use Jest with --watchman=false on this host. For signed archives use EXPO_NO_DOTENV=1 and ULTRAEDGE_DISABLE_WATCHMAN=1. The installed release bundle is in the source repository vendor/bundle for Ruby 3.4.0; select Homebrew Ruby and the absolute BUNDLE_PATH when building from this worktree. Do not print or commit fastlane/.env.
+
+Website updated through heybeaux/heybeaux-web PR 19, merged through the configured GitHub deployment integration. Store privacy/support URLs, local-only description/review instructions and age-rating changes are saved. Submission and processed-build verification are tracked separately under workspace reports/UltraEdge-distribution.
diff --git a/docs/release/PRIVACY-DATA-MAPPING.md b/docs/release/PRIVACY-DATA-MAPPING.md
new file mode 100644
index 0000000..c65da58
--- /dev/null
+++ b/docs/release/PRIVACY-DATA-MAPPING.md
@@ -0,0 +1,33 @@
+# Privacy declaration evidence — September 6, 2026
+
+Source changes, not approved/published App Store Connect answers. Native `ios/UltraEdge/PrivacyInfo.xcprivacy` and Expo `app.json` declare matching categories; required-reason API entries in the native manifest are unchanged. Rebuild and review the aggregate privacy report before release. Do not reuse build 8's empty collection declaration.
+
+The user explicitly confirms **Beaux Walton** as legal operator and **hello@heybeaux.dev** as the approved private privacy/support/abuse email. These facts supersede earlier drafts awaiting operator/contact confirmation. Mailbox deliverability and operational response are untested; no email was sent. No phone number, address, copyright approval, retention duration or SLA is inferred from this confirmation.
+
+## Declared collection
+
+All entries are linked to the guest identity, not used for tracking, and have `NSPrivacyCollectedDataTypePurposeAppFunctionality` as their purpose.
+
+| Apple category / manifest suffix | Evidence and rationale |
+| --- | --- |
+| Name / `Name` | Team display names; optional real names can be supplied, including names in logistics labels. |
+| User ID / `UserID` | Supabase guest UUID in memberships, observations, blocks and reports. Pseudonymous is not automatically unlinked. |
+| Fitness / `Fitness` | Shared race distance/start/target timing and checkpoint exercise observations. This includes exercise-related data without HealthKit; not a declaration of local-only weight collection. |
+| Other User Content / `OtherUserContent` | Event/checkpoint names, stops/logistics, and retained relevant plan evidence. Client `makeLiveSnapshot` uses an explicit upload allowlist. |
+| Customer Support / `CustomerSupport` | In-app abuse report reason/details and associated evidence stored in the private queue. Declared rather than relying on the optional-feedback exemption. |
+| Other Data Types / `OtherDataTypes` | Connection/security metadata associated with authentication. Supabase documents retained auth event logs including user ID, IP and user-agent. This is a conservative mapping for documented provider behavior; operator must verify actual project/log settings and purposes, and revise this category if the confirmed use requires a more specific Apple category. No geographic inference from IP is implemented in the app. |
+
+`src/lib/liveRaceSync.ts`, `src/lib/liveRaceModel.ts`, and `supabase/collaboration/001_live_rooms.sql` / `002_safety.sql` are the implementation evidence. Data transmitted only to fulfill a request in real time is distinct from retained collection, but rooms, guest identity and reports persist. Ongoing optional collaboration is not treated as exempt optional feedback. These interpretations follow [Apple's App privacy details](https://developer.apple.com/app-store/app-privacy-details/) and [collection manifest guidance](https://developer.apple.com/documentation/bundleresources/describing-data-use-in-privacy-manifests).
+
+## Not inferred as collected by this feature
+
+Crew email/phone fields, personal notes, GPX files, gear photos and PDF guides are excluded from the collaboration allowlist; local profile/weight entries are not uploaded through it. User-selected exports and third-party map/file services need separate assessment, not an automatic blanket “no collection” claim. Free-form text can contain private data; users should avoid entering it in shared names, labels or reports. The app does not promise an automated detector will strip sensitive text.
+
+## Owner verification required
+
+- [Supabase Auth Audit Logs](https://supabase.com/docs/guides/auth/audit-logs) documents default authentication event logging and example IP/user-agent fields. Confirm the actual project configuration, region, externally retained logs, diagnostic/security uses, retention and backups before final policy and App Privacy answers. No exact duration or guaranteed Canada-only processing is asserted here; the in-app unverified region claim was removed.
+- Confirm network behavior and integrated SDK manifests on the release candidate. Review any additional diagnostics/usage data or provider IP-derived location behavior; do not hide it under Other Data Types if Apple's more specific categories apply.
+- Queue evidence survives room closure but cascades if either related guest identity is deleted. No automatic retention purge is implemented; see `COLLABORATION-SAFETY-OPERATIONS.md`. Publish only confirmed retention practices.
+- Evidence is bounded and may be partial: owner plans above 262,144 serialized bytes become a 16,000-character serialized-text excerpt; subject observations are capped at 100 rows / 131,072 bytes. Stable room/subject IDs, plan revision, reason/details and truncation counts/flags remain. Do not describe reports as storing a complete copy of every shared plan or observation history.
+- Publish and externally verify the intended privacy/support links with the confirmed Beaux Walton / hello@heybeaux.dev operator/contact details, validate mailbox delivery/monitoring separately, and update App Store Connect separately. Source URLs are not proof that policy/support pages are live. Identity/contact approval does not establish operational response.
+- In Xcode Organizer, generate the new archive's Privacy Report; inspect the app and SDK aggregate against actual traffic and these disclosures. Validate required-reason codes independently. Review submitted labels with the responsible operator, not as a substitute for their approval.
diff --git a/ios/UltraEdge.xcodeproj/project.pbxproj b/ios/UltraEdge.xcodeproj/project.pbxproj
index fe22c16..672d5e8 100644
--- a/ios/UltraEdge.xcodeproj/project.pbxproj
+++ b/ios/UltraEdge.xcodeproj/project.pbxproj
@@ -397,7 +397,7 @@
CODE_SIGN_ENTITLEMENTS = UltraEdge/UltraEdge.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 8;
+ CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = T348Z33H7K;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -437,7 +437,7 @@
CODE_SIGN_ENTITLEMENTS = UltraEdge/UltraEdge.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 8;
+ CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = T348Z33H7K;
INFOPLIST_FILE = UltraEdge/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
diff --git a/ios/UltraEdge/Info.plist b/ios/UltraEdge/Info.plist
index 2838963..e317d94 100644
--- a/ios/UltraEdge/Info.plist
+++ b/ios/UltraEdge/Info.plist
@@ -33,7 +33,7 @@
CFBundleVersion
- 8
+ 9ITSAppUsesNonExemptEncryptionLSMinimumSystemVersion
diff --git a/scripts/test-live-safety-local.sh b/scripts/test-live-safety-local.sh
new file mode 100644
index 0000000..28d8ae8
--- /dev/null
+++ b/scripts/test-live-safety-local.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PG_BIN="${PG_BIN:-$(pg_config --bindir)}"
+TEST_DIR="$(mktemp -d /tmp/ultraedge-safety.XXXXXX)"
+"$PG_BIN/initdb" -D "$TEST_DIR/data" -A trust --no-locale -E UTF8 > "$TEST_DIR/init.log"
+trap '"$PG_BIN/pg_ctl" -D "$TEST_DIR/data" -m fast -w stop > /dev/null; printf "Local test artifacts: %s\n" "$TEST_DIR"' EXIT
+"$PG_BIN/pg_ctl" -D "$TEST_DIR/data" -l "$TEST_DIR/server.log" -o "-k $TEST_DIR -p 55439 -h ''" -w start > /dev/null
+PSQL=("$PG_BIN/psql" -X -v ON_ERROR_STOP=1 -h "$TEST_DIR" -p 55439 -d postgres)
+"${PSQL[@]}" <<'SQL'
+create role anon;
+create role authenticated;
+create schema auth;
+create table auth.users(id uuid primary key);
+create function auth.uid() returns uuid language sql as $$ select nullif(current_setting('test.actor',true),'')::uuid $$;
+create function auth.jwt() returns jsonb language sql as $$ select jsonb_build_object('is_anonymous',coalesce(nullif(current_setting('test.guest',true),''),'true')::boolean) $$;
+grant usage on schema auth to authenticated;
+SQL
+"${PSQL[@]}" -f "$ROOT/supabase/collaboration/001_live_rooms.sql"
+"${PSQL[@]}" -f "$ROOT/supabase/collaboration/002_safety.sql"
+"${PSQL[@]}" -f "$ROOT/supabase/collaboration/tests/safety.sql"
+"${PSQL[@]}" -f "$ROOT/supabase/collaboration/tests/oversized_evidence.sql"
+printf 'PASS isolated PostgreSQL collaboration safeguards\n'
diff --git a/src/__tests__/dropBagGearPicker.test.tsx b/src/__tests__/dropBagGearPicker.test.tsx
index e7ab48e..bf51c74 100644
--- a/src/__tests__/dropBagGearPicker.test.tsx
+++ b/src/__tests__/dropBagGearPicker.test.tsx
@@ -1,5 +1,5 @@
jest.mock('expo-image-picker', () => ({ launchImageLibraryAsync: jest.fn() }));
-jest.mock('../lib/attachments', () => ({ attachmentUri: (v: string) => v, saveAttachment: jest.fn() }));
+jest.mock('../lib/attachments', () => ({ attachmentUri: (v: string) => v, saveAttachment: jest.fn(), retainAttachment: () => () => {}, releaseAttachmentImport: jest.fn() }));
import React from 'react';
import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity } from 'react-native';
const { act, create } = require('react-test-renderer');
diff --git a/src/__tests__/liveRaceSync.test.ts b/src/__tests__/liveRaceSync.test.ts
index 95761a8..3f56238 100644
--- a/src/__tests__/liveRaceSync.test.ts
+++ b/src/__tests__/liveRaceSync.test.ts
@@ -2,13 +2,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import {LiveRoom} from '../lib/liveRaceModel';
import {emptyOperations} from '../lib/raceOperations';
const mockRpc=jest.fn();
+const mockSignOut=jest.fn();
+const mockGetSession=jest.fn();
jest.mock('expo-crypto',()=>({randomUUID:()=>require('crypto').randomUUID()}));
jest.mock('../lib/supabase',()=>({createSecureStorageAdapter:()=>({})}));
-jest.mock('@supabase/supabase-js',()=>({createClient:()=>({auth:{getSession:async()=>({data:{session:{user:{id:'runner'}}},error:null})},rpc:(...args:unknown[])=>mockRpc(...args)})}));
-import {queueLiveReport,syncLiveRoom,readLiveCache} from '../lib/liveRaceSync';
+jest.mock('@supabase/supabase-js',()=>({createClient:()=>({auth:{getSession:()=>mockGetSession(),signOut:(...args:unknown[])=>mockSignOut(...args)},rpc:(...args:unknown[])=>mockRpc(...args)})}));
+import {queueLiveReport,syncLiveRoom,readLiveCache,deleteLiveIdentity,reportLiveAbuse,blockLiveUser} from '../lib/liveRaceSync';
const key='@ultraedge/live-rooms-v1';
const room:LiveRoom={id:'room',ownerId:'runner',revision:1,invite:null,serverTime:'2026-01-01T08:00:00Z',snapshot:{event:{id:'race'} as any,checkpoints:[{id:'half'} as any],startAt:'2026-01-01T08:00:00Z',operations:emptyOperations('race'),logistics:[]},members:[],reports:[]};
-beforeEach(async()=>{process.env.EXPO_PUBLIC_SUPABASE_URL='https://example.supabase.co';process.env.EXPO_PUBLIC_SUPABASE_KEY='test';jest.clearAllMocks();await AsyncStorage.clear();await AsyncStorage.setItem(key,JSON.stringify({rooms:[room],actorId:'runner',pending:[],lastSync:{}}));});
+beforeEach(async()=>{process.env.EXPO_PUBLIC_SUPABASE_URL='https://example.supabase.co';process.env.EXPO_PUBLIC_SUPABASE_KEY='test';jest.clearAllMocks();mockGetSession.mockResolvedValue({data:{session:{user:{id:'runner'}}},error:null});mockSignOut.mockResolvedValue({error:null});await AsyncStorage.clear();await AsyncStorage.setItem(key,JSON.stringify({rooms:[room],actorId:'runner',pending:[],lastSync:{}}));});
test('offline reports persist; reconnect sends each independently and acknowledges only successes',async()=>{
await queueLiveReport(room,'half','in','2026-01-01T13:00:00Z');await queueLiveReport(room,'half','in','2026-01-01T13:10:00Z');
expect((await readLiveCache()).pending.map(r=>r.revision)).toEqual([0,1]);
@@ -35,3 +37,50 @@ test('forget waits for an in-flight sync so it cannot resurrect the removed cach
mockRpc.mockImplementation(async()=>{if(first){first=false;entered();await gate;}return {data:room,error:null};});
const sync=syncLiveRoom('room');await started;const forgetting=forgetLiveRoom('room');release();await sync;await forgetting;expect((await readLiveCache()).rooms).toEqual([]);
});
+test('failed server guest deletion preserves cached rooms and pending observations',async()=>{
+ await queueLiveReport(room,'half','in','2026-01-01T13:00:00Z');const before=await readLiveCache();
+ mockRpc.mockResolvedValue({data:null,error:{message:'offline'}});
+ await expect(deleteLiveIdentity()).rejects.toMatchObject({message:'offline'});
+ expect(await readLiveCache()).toEqual(before);expect(mockSignOut).not.toHaveBeenCalled();
+});
+test('successful guest deletion clears collaboration cache only after server acknowledgement',async()=>{
+ mockRpc.mockResolvedValue({data:{},error:null});await deleteLiveIdentity();
+ expect(mockRpc).toHaveBeenCalledWith('ue_live',{action:'delete_identity',args:{}});
+ expect(mockSignOut).toHaveBeenCalledWith({scope:'local'});
+ expect(await readLiveCache()).toEqual({rooms:[],pending:[],actorId:null,lastSync:{}});
+});
+test('deletion without an active session never creates a fresh guest',async()=>{
+ mockGetSession.mockResolvedValue({data:{session:null},error:null});
+ await expect(deleteLiveIdentity()).rejects.toThrow('No active collaboration identity');expect(mockRpc).not.toHaveBeenCalled();
+});
+test('session cleanup error distinguishes completed server deletion',async()=>{
+ mockRpc.mockResolvedValue({data:{},error:null});mockSignOut.mockResolvedValue({error:new Error('storage')});
+ await expect(deleteLiveIdentity()).rejects.toThrow('Server collaboration data was deleted');expect((await readLiveCache()).rooms).toEqual([]);
+});
+test('thrown sign-out still clears cache after acknowledged server deletion',async()=>{
+ await queueLiveReport(room,'half','in','2026-01-01T13:00:00Z');
+ mockRpc.mockResolvedValue({data:{},error:null});mockSignOut.mockRejectedValue(new Error('storage'));
+ await expect(deleteLiveIdentity()).rejects.toThrow('Server collaboration data was deleted');
+ expect(await readLiveCache()).toEqual({rooms:[],pending:[],actorId:null,lastSync:{}});
+});
+test('cache cleanup failure explicitly reports already completed server deletion',async()=>{
+ mockRpc.mockResolvedValue({data:{},error:null});
+ const original=(AsyncStorage.setItem as jest.Mock).getMockImplementation()!;
+ jest.spyOn(AsyncStorage,'setItem').mockRejectedValueOnce(new Error('disk full'));
+ await expect(deleteLiveIdentity()).rejects.toThrow('Server collaboration data was deleted, but this phone could not clear its saved copy');
+ (AsyncStorage.setItem as jest.Mock).mockImplementation(original);
+});
+test('abuse reports submit only explicit bounded details and failures are not success receipts',async()=>{
+ await expect(reportLiveAbuse(room,'crew','hate','x'.repeat(1001))).rejects.toThrow('1,000');expect(mockRpc).not.toHaveBeenCalled();
+ mockRpc.mockResolvedValue({data:null,error:{message:'offline'}});
+ await expect(reportLiveAbuse(room,'crew','harassment',' Details ')).rejects.toMatchObject({message:'offline'});
+ expect(mockRpc).toHaveBeenCalledWith('ue_live',{action:'abuse_report',args:expect.objectContaining({roomId:'room',userId:'crew',reason:'harassment',details:'Details',requestId:expect.any(String)})});
+ expect((await readLiveCache()).pending).toEqual([]);
+});
+test('blocking purges departed rooms and their pending reports only after acknowledgement',async()=>{
+ await queueLiveReport(room,'half','in','2026-01-01T13:00:00Z');
+ mockRpc.mockResolvedValue({data:null,error:{message:'offline'}});
+ await expect(blockLiveUser(room,'crew')).rejects.toMatchObject({message:'offline'});expect((await readLiveCache()).pending).toHaveLength(1);
+ mockRpc.mockResolvedValue({data:{removedRoomIds:['room']},error:null});await blockLiveUser(room,'crew');
+ expect((await readLiveCache()).rooms).toEqual([]);expect((await readLiveCache()).pending).toEqual([]);
+});
diff --git a/src/__tests__/liveSafetyPanel.test.tsx b/src/__tests__/liveSafetyPanel.test.tsx
new file mode 100644
index 0000000..0dcb93b
--- /dev/null
+++ b/src/__tests__/liveSafetyPanel.test.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import {Alert,TouchableOpacity,Text} from 'react-native';
+import LiveRacePanel from '../components/LiveRacePanel';
+import {readLiveCache,deleteLiveIdentity,reportLiveAbuse,blockLiveUser} from '../lib/liveRaceSync';
+const {act,create}=require('react-test-renderer');
+jest.mock('../theme',()=>({useTheme:()=>({theme:{colors:{}}})}));
+jest.mock('../components/ui',()=>{
+ const {Text,TouchableOpacity}=require('react-native');
+ return {Body:Text,BodySmall:Text,H2:Text,H3:Text,Button:({children,...props}:any)=>{children}};
+});
+jest.mock('../components/StructuredTimeInput',()=>({DateTimeInput:()=>null}));
+jest.mock('../lib/liveRaceModel',()=>({liveProjection:()=>null}));
+jest.mock('../lib/liveRaceSync',()=>({
+ readLiveCache:jest.fn(),deleteLiveIdentity:jest.fn(),syncLiveRoom:jest.fn(),
+ reportLiveAbuse:jest.fn(),blockLiveUser:jest.fn(),abuseReasons:['harassment','hate','sexual_content','threats','other'],
+}));
+const room={id:'room',ownerId:'owner',revision:1,snapshot:{event:{name:'Fictional race'},checkpoints:[],logistics:[]},members:[{id:'owner',name:'Runner'},{id:'crew',name:'Crew'}],reports:[]};
+let tree:any;
+const button=(label:string)=>tree.root.findAllByType(TouchableOpacity).find((node:any)=>[node.findByType(Text).props.children].flat().join('')===label);
+const press=async(label:string)=>{await act(async()=>{await button(label).props.onPress();});};
+const confirm=async()=>{const calls=(Alert.alert as jest.Mock).mock.calls;await act(async()=>{await calls[calls.length-1][2][1].onPress();});};
+beforeEach(()=>{
+ jest.clearAllMocks();jest.spyOn(Alert,'alert').mockImplementation(()=>{});
+ (readLiveCache as jest.Mock).mockResolvedValue({rooms:[room],pending:[],actorId:'crew',lastSync:{}});
+});
+afterEach(async()=>{if(tree)await act(async()=>tree.unmount());tree=null;jest.restoreAllMocks();});
+test('deletion is discoverable without cached identity and within an open room',async()=>{
+ (readLiveCache as jest.Mock).mockResolvedValueOnce({rooms:[],pending:[],actorId:null,lastSync:{}});
+ await act(async()=>{tree=create();});await press('Join / open a live team race');
+ expect(button('Delete my collaboration data')).toBeTruthy();
+ await press('Done');await press('Join / open a live team race');await press('Fictional race');
+ expect(button('Delete my collaboration data')).toBeTruthy();
+ await press('Delete my collaboration data');expect(deleteLiveIdentity).not.toHaveBeenCalled();
+ expect((Alert.alert as jest.Mock).mock.calls.at(-1)[1]).toContain('If the server request fails');
+});
+test('failed deletion shows the error without claiming success',async()=>{
+ (deleteLiveIdentity as jest.Mock).mockRejectedValue(new Error('offline; retry'));
+ await act(async()=>{tree=create();});await press('Join / open a live team race');
+ await press('Delete my collaboration data');await confirm();
+ expect(Alert.alert).toHaveBeenLastCalledWith('Live race','offline; retry');
+ expect(button('Fictional race')).toBeTruthy();
+});
+test('report receipt is explicitly queue storage, not human review',async()=>{
+ (reportLiveAbuse as jest.Mock).mockResolvedValue({id:'receipt'});
+ await act(async()=>{tree=create();});await press('Join / open a live team race');await press('Fictional race');
+ await press('Report Runner');
+ const reportButton=tree.root.findAllByType(TouchableOpacity).find((node:any)=>JSON.stringify(node.findByType(Text).props.children).includes('harassment'));
+ await act(async()=>reportButton.props.onPress());expect(reportLiveAbuse).not.toHaveBeenCalled();await confirm();
+ expect(reportLiveAbuse).toHaveBeenCalledWith(room,'owner','harassment','');
+ expect(Alert.alert).toHaveBeenLastCalledWith('Report received',expect.stringContaining('not confirmation of human review'));
+});
+test('blocking confirms loss of unsent observations before invoking the server',async()=>{
+ (blockLiveUser as jest.Mock).mockResolvedValue(undefined);
+ await act(async()=>{tree=create();});await press('Join / open a live team race');await press('Fictional race');
+ await press('Block Runner');expect(blockLiveUser).not.toHaveBeenCalled();
+ expect((Alert.alert as jest.Mock).mock.calls.at(-1)[1]).toContain('Unsent observations');await confirm();
+ expect(blockLiveUser).toHaveBeenCalledWith(room,'owner');
+});
diff --git a/src/__tests__/photoFieldLifecycle.test.tsx b/src/__tests__/photoFieldLifecycle.test.tsx
new file mode 100644
index 0000000..0963613
--- /dev/null
+++ b/src/__tests__/photoFieldLifecycle.test.tsx
@@ -0,0 +1,92 @@
+jest.mock('expo-file-system/legacy', () => ({
+ documentDirectory: 'file:///documents/',
+ getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 100 }),
+ makeDirectoryAsync: jest.fn(), copyAsync: jest.fn(),
+}));
+jest.mock('expo-crypto', () => ({ randomUUID: () => 'photo' }));
+jest.mock('expo-image-picker', () => ({ launchImageLibraryAsync: jest.fn() }));
+jest.mock('../components/ui', () => {
+ const { Text, TouchableOpacity } = require('react-native');
+ return { BodySmall: Text, Button: TouchableOpacity };
+});
+
+import React, { useState } from 'react';
+import { TouchableOpacity } from 'react-native';
+import * as FS from 'expo-file-system/legacy';
+import * as ImagePicker from 'expo-image-picker';
+import PhotoField from '../components/PhotoField';
+import { retainedAttachments, unavailableAttachments, beginAttachmentDeletion } from '../lib/attachmentRetention';
+const { act, create } = require('react-test-renderer');
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+ jest.clearAllMocks();
+ retainedAttachments.clear();
+ unavailableAttachments.clear();
+ (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///picker/photo', mimeType: 'image/jpeg' }] });
+ (FS.copyAsync as jest.Mock).mockResolvedValue(undefined);
+});
+
+test('a selected draft remains protected until removed or the form is abandoned', async () => {
+ function Draft() {
+ const [value, setValue] = useState('old.jpg');
+ return ;
+ }
+ let tree: any;
+ await act(async () => { tree = create(); });
+ expect(retainedAttachments.has('old.jpg')).toBe(true);
+ await act(async () => { await tree.root.findAllByType(TouchableOpacity)[0].props.onPress(); });
+ expect(retainedAttachments.has('old.jpg')).toBe(false);
+ expect(retainedAttachments.get('photo.jpg')).toBe(1);
+ await act(async () => { await tree.root.findAllByType(TouchableOpacity)[1].props.onPress(); });
+ expect(retainedAttachments.has('photo.jpg')).toBe(false);
+ await act(async () => { await tree.root.findAllByType(TouchableOpacity)[0].props.onPress(); });
+ expect(retainedAttachments.has('photo.jpg')).toBe(true);
+ await act(async () => { tree.unmount(); });
+ expect(retainedAttachments.size).toBe(0);
+});
+
+test('failed immediate saves release the import while preserving the old photo', async () => {
+ let tree: any;
+ await act(async () => { tree = create( { throw new Error('Disk full'); }} />); });
+ await act(async () => { await tree.root.findAllByType(TouchableOpacity)[0].props.onPress(); });
+ expect(retainedAttachments.has('photo.jpg')).toBe(false);
+ expect(retainedAttachments.has('old.jpg')).toBe(true);
+ await act(async () => { tree.unmount(); });
+});
+
+test('mounting and updating a photo whose deletion has started does not throw from its layout effect', async () => {
+ expect(beginAttachmentDeletion('deleting.jpg')).toBe(true);
+ let tree: any;
+ await act(async () => { tree = create(); });
+ expect(retainedAttachments.has('deleting.jpg')).toBe(true);
+ expect(beginAttachmentDeletion('another.jpg')).toBe(true);
+ await act(async () => { tree.update(); });
+ expect(retainedAttachments.has('deleting.jpg')).toBe(false);
+ expect(retainedAttachments.has('another.jpg')).toBe(true);
+ await act(async () => { tree.unmount(); });
+ expect(retainedAttachments.size).toBe(0);
+});
+
+test('leaving during a copy releases its eventual import without updating the abandoned form', async () => {
+ let finishCopy!: () => void;
+ let copying!: () => void;
+ const started = new Promise(resolve => { copying = resolve; });
+ (FS.copyAsync as jest.Mock).mockImplementationOnce(async () => {
+ copying();
+ await new Promise(resolve => { finishCopy = resolve; });
+ });
+ const onChange = jest.fn();
+ let tree: any;
+ let picking!: Promise;
+ await act(async () => { tree = create(); });
+ await act(async () => {
+ picking = tree.root.findAllByType(TouchableOpacity)[0].props.onPress();
+ await started;
+ });
+ expect(retainedAttachments.has('photo.jpg')).toBe(true);
+ await act(async () => { tree.unmount(); });
+ await act(async () => { finishCopy(); await picking; });
+ expect(onChange).not.toHaveBeenCalled();
+ expect(retainedAttachments.size).toBe(0);
+});
diff --git a/src/__tests__/privacyManifest.test.ts b/src/__tests__/privacyManifest.test.ts
new file mode 100644
index 0000000..b694b94
--- /dev/null
+++ b/src/__tests__/privacyManifest.test.ts
@@ -0,0 +1,11 @@
+import fs from 'fs';
+import path from 'path';
+const root=path.resolve(__dirname,'../..');
+test('local-only release declares no app data collection or tracking',()=>{
+ const config=JSON.parse(fs.readFileSync(path.join(root,'app.json'),'utf8')).expo.ios.privacyManifests;
+ const native=fs.readFileSync(path.join(root,'ios/UltraEdge/PrivacyInfo.xcprivacy'),'utf8');
+ expect(config.NSPrivacyTracking).toBe(false);
+ expect(config.NSPrivacyCollectedDataTypes).toEqual([]);
+ expect(native).toMatch(/NSPrivacyCollectedDataTypes<\/key>\s*/);
+ expect(native).toContain('NSPrivacyAccessedAPICategoryUserDefaults');
+});
diff --git a/src/__tests__/privacySupport.test.tsx b/src/__tests__/privacySupport.test.tsx
index 24bf406..6cc4c07 100644
--- a/src/__tests__/privacySupport.test.tsx
+++ b/src/__tests__/privacySupport.test.tsx
@@ -8,19 +8,42 @@ jest.mock('../components/ui', () => {
const { Text } = require('react-native');
return { H2: Text, Body: Text, BodySmall: Text };
});
+beforeEach(() => { jest.clearAllMocks(); });
afterEach(() => { jest.restoreAllMocks(); });
-it('renders privacy and support offline and opens the public tracker only when explicitly pressed', async () => {
+it('renders offline and opens policy/support only when explicitly pressed', async () => {
const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
let tree: any;
await act(async () => { tree = create(); });
expect(open).not.toHaveBeenCalled();
const copy = tree.root.findAllByType(Text).map((node: any) => node.props.children).flat().join(' ');
expect(copy).toContain('Offload App keeps');
- expect(copy).toContain('Issues are public');
+ expect(copy).not.toContain('Live team races');
expect(copy).toContain('cannot restore an editable plan');
- await act(async () => { tree.root.findByType(TouchableOpacity).props.onPress(); });
- expect(open).toHaveBeenCalledWith('https://github.com/beaux-riel/UltraEdge/issues');
+ expect(copy).toContain('UltraEdge is operated by Beaux Walton');
+ expect(copy).toContain('contact hello@heybeaux.dev');
+ await act(async () => { tree.root.findAllByType(TouchableOpacity)[0].props.onPress(); });
+ expect(open).toHaveBeenLastCalledWith('https://ultraedge.heybeaux.dev/privacy');
+ await act(async () => { tree.root.findAllByType(TouchableOpacity)[1].props.onPress(); });
+ expect(open).toHaveBeenLastCalledWith('https://ultraedge.heybeaux.dev/support');
+ await act(async () => { tree.root.findAllByType(TouchableOpacity)[2].props.onPress(); });
+ expect(open).toHaveBeenLastCalledWith('mailto:hello@heybeaux.dev');
+ expect(open).toHaveBeenCalledTimes(3);
+ await act(async () => { tree.unmount(); });
+});
+
+it('offers the approved private address if no mail handler opens, without a public fallback', async () => {
+ const open = jest.spyOn(Linking, 'openURL').mockRejectedValue(new Error('No email app'));
+ const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
+ let tree: any;
+ await act(async () => { tree = create(); });
+ expect(open).not.toHaveBeenCalled();
+ const emailLink = tree.root.findAllByType(TouchableOpacity).find((node: any) => node.props.accessibilityLabel?.startsWith('Email Beaux Walton'));
+ await act(async () => { emailLink.props.onPress(); });
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(open).toHaveBeenCalledWith('mailto:hello@heybeaux.dev');
+ expect(alert).toHaveBeenCalledWith('Could not open email', expect.stringContaining('hello@heybeaux.dev'));
+ expect(alert).toHaveBeenCalledWith('Could not open email', expect.stringContaining('UltraEdge has not sent an email'));
await act(async () => { tree.unmount(); });
});
@@ -29,7 +52,9 @@ it('keeps offline help available when the external support link cannot open', as
const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
let tree: any;
await act(async () => { tree = create(); });
- await act(async () => { tree.root.findByType(TouchableOpacity).props.onPress(); });
- expect(alert).toHaveBeenCalledWith('Could not open support', expect.stringContaining('github.com/beaux-riel/UltraEdge/issues'));
+ await act(async () => { tree.root.findAllByType(TouchableOpacity)[1].props.onPress(); });
+ expect(alert).toHaveBeenCalledWith('Could not open support', expect.stringContaining('https://ultraedge.heybeaux.dev/support'));
+ await act(async () => { tree.root.findAllByType(TouchableOpacity)[0].props.onPress(); });
+ expect(alert).toHaveBeenCalledWith('Could not open privacy policy', expect.stringContaining('https://ultraedge.heybeaux.dev/privacy'));
await act(async () => { tree.unmount(); });
});
diff --git a/src/components/LiveRacePanel.tsx b/src/components/LiveRacePanel.tsx
index d65c35c..ade9b4b 100644
--- a/src/components/LiveRacePanel.tsx
+++ b/src/components/LiveRacePanel.tsx
@@ -4,13 +4,14 @@ import {View,Modal,ScrollView,TextInput,Alert,Share,AppState,Keyboard} from 'rea
import {Body,BodySmall,H2,H3,Button} from './ui';
import {useTheme} from '../theme';
import {LiveRoom,LiveSnapshot,liveProjection} from '../lib/liveRaceModel';
-import {readLiveCache,createLiveRoom,joinLiveRoom,syncLiveRoom,queueLiveReport,publishLiveRoom,removeLiveAccess,discardPendingReports,forgetLiveRoom,recoverLiveRooms,deleteLiveIdentity,LiveCache} from '../lib/liveRaceSync';
+import {readLiveCache,createLiveRoom,joinLiveRoom,syncLiveRoom,queueLiveReport,publishLiveRoom,removeLiveAccess,discardPendingReports,forgetLiveRoom,recoverLiveRooms,deleteLiveIdentity,reportLiveAbuse,blockLiveUser,abuseReasons,LiveCache} from '../lib/liveRaceSync';
import {clockLabel,parseLocalRaceTime,projectedDistance} from '../lib/raceOperations';
interface Props { snapshot?:()=>LiveSnapshot; }
export default function LiveRacePanel({snapshot}:Props){
const {theme:{colors}}=useTheme();const [open,setOpen]=useState(false);const [cache,setCache]=useState({rooms:[],pending:[],actorId:null,lastSync:{}});
const [roomId,setRoomId]=useState(null);const [name,setName]=useState('');const [code,setCode]=useState('');const [at,setAt]=useState('');const [busy,setBusy]=useState(false);const [status,setStatus]=useState('');const active=useRef(false);const polling=useRef(false);
const room=cache.rooms.find(r=>r.id===roomId);const projection=room?liveProjection(room):null;
+ const [reportTarget,setReportTarget]=useState(null);const [reportDetails,setReportDetails]=useState('');
const refreshCache=async()=>{const value=await readLiveCache();if(active.current)setCache(value);};
const sync=async(id:string)=>{if(polling.current)return;polling.current=true;try{await syncLiveRoom(id);if(active.current)setStatus('Connected — team updates checked every 10 seconds while open.');}catch(e){if(active.current)setStatus(`Not synced: ${e instanceof Error?e.message:(e as {message?:string})?.message??'Connection unavailable. Saved reports will retry.'}`);}finally{polling.current=false;await refreshCache();}};
useEffect(()=>{active.current=open;if(open)refreshCache();return()=>{active.current=false;};},[open]);
@@ -23,11 +24,11 @@ export default function LiveRacePanel({snapshot}:Props){
{!room?<>
Share a private race with your crew. Only joined team members can read it or record times.
Team access is saved securely on this phone. Keep the app installed to retain access; a new phone needs a new crew invitation. Race timing, crew names and vehicle/cargo labels are shared. Phone numbers, emails, personal notes and route files are excluded.
+ Keep team content respectful. No harassment, hate, threats or sexual content. Basic server filtering applies to shared text. Report a member or the plan owner from Team access; blocking also prevents joining teams together with the same guest identities.
{field('Your team display name',name,setName)}
{snapshot&&}
{field('Private invitation code',code,setCode)}
- {cache.actorId&&}
{cache.rooms.map(r=>)}
>:<>
{room.snapshot.event.name}
@@ -49,9 +50,22 @@ export default function LiveRacePanel({snapshot}:Props){
;})}
Team logistics
{room.snapshot.logistics.map((line,i)=>{line})}
Team access
{room.members.map(m=>{m.name}{m.id===room.ownerId?' • runner / owner':''}{room.ownerId===cache.actorId&&m.id!==cache.actorId&&})}
+ For abusive plan text, report the runner / owner. Reports send the selected reason, details, member identity and relevant shared content to a private server queue, not to the team. This is not an emergency service; submission does not guarantee a human response.
+ {room.members.filter(member=>member.id!==cache.actorId).map(member=>
+
+
+ )}
+ {reportTarget&&room.members.some(member=>member.id===reportTarget)&&
+
+ Do not include private contacts or health details. Choose a reason below to send. A connection is required; failed reports are not queued offline.
+ {field('Report details (optional, 1,000 characters maximum)',reportDetails,setReportDetails)}
+ {abuseReasons.map(reason=>)}
+
+ }
{!!cache.pending.filter(r=>r.roomId===room.id).length&&}
>}
+
>;
}
diff --git a/src/components/PhotoField.tsx b/src/components/PhotoField.tsx
index 2c62e57..ae4ff8e 100644
--- a/src/components/PhotoField.tsx
+++ b/src/components/PhotoField.tsx
@@ -1,8 +1,8 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { View, Image, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { BodySmall, Button } from './ui';
-import { attachmentUri, saveAttachment } from '../lib/attachments';
+import { attachmentUri, saveAttachment, retainAttachment, releaseAttachmentImport } from '../lib/attachments';
export default function PhotoField({
value,
onChange,
@@ -15,15 +15,36 @@ export default function PhotoField({
onBusyChange?: (busy: boolean) => void;
}) {
const [busy, setBusy] = useState(false);
+ const mounted = useRef(true);
+ const lock = useRef(false);
+ const imported = useRef(new Set());
+ useLayoutEffect(() => {
+ const release = retainAttachment(value);
+ if (value && imported.current.delete(value)) {
+ releaseAttachmentImport(value);
+ }
+ return release;
+ }, [value]);
+ useEffect(() => {
+ mounted.current = true;
+ const pendingImports = imported.current;
+ return () => {
+ mounted.current = false;
+ pendingImports.forEach(releaseAttachmentImport);
+ pendingImports.clear();
+ };
+ }, []);
useEffect(() => {
onBusyChange?.(busy);
return () => onBusyChange?.(false);
}, [busy, onBusyChange]);
const pick = async () => {
- if (busy) {
+ if (lock.current) {
return;
}
+ lock.current = true;
setBusy(true);
+ let path: string | undefined;
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
@@ -31,22 +52,35 @@ export default function PhotoField({
allowsEditing: false,
exif: false,
});
- if (!result.canceled) {
+ if (!result.canceled && mounted.current) {
const asset = result.assets[0];
const saved = await saveAttachment(
asset.uri,
asset.fileName || 'Gear photo',
asset.mimeType || 'image/jpeg',
);
- await onChange?.(saved.path);
+ path = saved.path;
+ if (!mounted.current) {
+ releaseAttachmentImport(path);
+ return;
+ }
+ imported.current.add(path);
+ await onChange?.(path);
}
} catch (e) {
+ if (path) {
+ imported.current.delete(path);
+ releaseAttachmentImport(path);
+ }
Alert.alert(
'Photo not saved',
e instanceof Error ? e.message : 'Please try again.',
);
} finally {
- setBusy(false);
+ lock.current = false;
+ if (mounted.current) {
+ setBusy(false);
+ }
}
};
let uri: string | undefined;
@@ -83,13 +117,20 @@ export default function PhotoField({
disabled={busy || disabled}
variant="tertiary"
onPress={async () => {
+ if (lock.current) {
+ return;
+ }
+ lock.current = true;
setBusy(true);
try {
await onChange(null);
} catch {
Alert.alert('Photo not removed', 'Please try again.');
} finally {
- setBusy(false);
+ lock.current = false;
+ if (mounted.current) {
+ setBusy(false);
+ }
}
}}
>
diff --git a/src/components/RaceGuidePanel.tsx b/src/components/RaceGuidePanel.tsx
index 3764e6b..8b20951 100644
--- a/src/components/RaceGuidePanel.tsx
+++ b/src/components/RaceGuidePanel.tsx
@@ -5,7 +5,7 @@ import * as Sharing from 'expo-sharing';
import * as FS from 'expo-file-system/legacy';
import { useEvents } from '../context/EventContext';
import { Event } from '../lib/database.types';
-import { saveAttachment, attachmentUri, attachmentShareUri } from '../lib/attachments';
+import { saveAttachment, attachmentUri, attachmentShareUri, releaseAttachmentImport } from '../lib/attachments';
import { Button, H3, BodySmall, Card, CardContent } from './ui';
export default function RaceGuidePanel({ event }: { event: Event }) {
const { updateEvent } = useEvents();
@@ -41,8 +41,12 @@ export default function RaceGuidePanel({ event }: { event: Event }) {
}
const a = result.assets[0];
const file = await saveAttachment(a.uri, a.name, 'application/pdf');
- if (!(await updateEvent(event.id, { raceGuide: file }))) {
- throw new Error('Race no longer exists.');
+ try {
+ if (!(await updateEvent(event.id, { raceGuide: file }))) {
+ throw new Error('Race no longer exists.');
+ }
+ } finally {
+ releaseAttachmentImport(file.path);
}
});
const share = () =>
diff --git a/src/components/RaceOperationsPanel.tsx b/src/components/RaceOperationsPanel.tsx
index f352f10..b6058e2 100644
--- a/src/components/RaceOperationsPanel.tsx
+++ b/src/components/RaceOperationsPanel.tsx
@@ -1,5 +1,4 @@
import { DateTimeInput, DurationInput } from './StructuredTimeInput';
-import LiveRacePanel from './LiveRacePanel';
import { makeLiveSnapshot } from '../lib/liveRaceModel';
import React, { useEffect, useState, useRef } from 'react';
import { View, TextInput, Alert, Modal, ScrollView, TouchableOpacity, Keyboard } from 'react-native';
@@ -62,7 +61,6 @@ export default function RaceOperationsPanel({ event, checkpoints, crew, bags, ge
return
Race operations
{projection ? `Projected finish ${clockLabel(projection.finish)} • ${projection.stopMinutes} min at stations` : 'Set a start date/time, target HH:MM, total distance and ordered checkpoint distances to see ETAs.'}
- makeLiveSnapshot(event, checkpoints, ops, [...ops.duties.map(d => `${checkpoints.find(cp=>cp.id===d.checkpointId)?.name}: ${crew.find(c=>c.id===d.crewMemberId)?.name} — ${d.role}`), ...ops.vehicles.map(v=>`${v.name} • Owner: ${crew.find(c=>c.id===v.ownerId)?.name??'Unassigned'} • Crew: ${v.crewIds.map(id=>crew.find(c=>c.id===id)?.name).join(', ')} • Cargo: ${ops.cargo.filter(c=>c.vehicleId===v.id).map(c=>c.label).join(', ')}`)])} />
setOpen(false)}>
diff --git a/src/lib/__tests__/attachmentLifecycle.test.ts b/src/lib/__tests__/attachmentLifecycle.test.ts
new file mode 100644
index 0000000..5bba4aa
--- /dev/null
+++ b/src/lib/__tests__/attachmentLifecycle.test.ts
@@ -0,0 +1,268 @@
+jest.mock('expo-file-system/legacy', () => ({
+ documentDirectory: 'file:///documents/',
+ cacheDirectory: 'file:///cache/',
+ EncodingType: { Base64: 'base64' },
+ getInfoAsync: jest.fn(), readDirectoryAsync: jest.fn(), deleteAsync: jest.fn(),
+ makeDirectoryAsync: jest.fn(), copyAsync: jest.fn(), readAsStringAsync: jest.fn(),
+}));
+jest.mock('expo-crypto', () => ({ randomUUID: jest.fn() }));
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as FS from 'expo-file-system/legacy';
+import * as Crypto from 'expo-crypto';
+import { attachmentUri, saveAttachment, releaseAttachmentImport, retainAttachment, attachmentShareUri } from '../attachments';
+import { retainedAttachments, unavailableAttachments } from '../attachmentRetention';
+import { deleteLocalEvent, deleteLocalGear, runLocalPlanOperation, saveArrayChanges } from '../localPlanStorage';
+
+const files = new Set();
+const originalSet = (AsyncStorage.setItem as jest.Mock).getMockImplementation()!;
+const put = (key: string, value: unknown) => AsyncStorage.setItem(key, JSON.stringify(value));
+const sweep = () => runLocalPlanOperation(async () => undefined);
+const gearKey = '@ultraedge/gear-items';
+const eventsKey = '@ultraedge/events';
+
+beforeEach(async () => {
+ await sweep();
+ jest.clearAllMocks();
+ retainedAttachments.clear();
+ unavailableAttachments.clear();
+ files.clear();
+ (AsyncStorage.setItem as jest.Mock).mockImplementation(originalSet);
+ await AsyncStorage.clear();
+ (FS.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true, size: 100 });
+ (FS.readDirectoryAsync as jest.Mock).mockImplementation(async () => [...files]);
+ (FS.deleteAsync as jest.Mock).mockImplementation(async (uri: string) => { files.delete(uri.split('/').pop()!); });
+ (FS.copyAsync as jest.Mock).mockImplementation(async ({ to }: { to: string }) => { files.add(to.split('/').pop()!); });
+ (FS.readAsStringAsync as jest.Mock).mockResolvedValue('JVBERi0=');
+ (Crypto.randomUUID as jest.Mock).mockReturnValue('imported');
+});
+
+test('replacement and removal preserve photos shared across gear, bags and templates', async () => {
+ files.add('shared.jpg');
+ files.add('replacement.jpg');
+ const initial = [{ id: 'gear', imageUrl: 'shared.jpg' }];
+ await put(gearKey, initial);
+ await put('@ultraedge/dropbags', [{ id: 'bag', imageUrl: attachmentUri('shared.jpg') }]);
+ await put('@ultraedge/dropbag-templates', [{ id: 'template', imageUrl: 'shared.jpg' }]);
+ const next = [{ id: 'gear', imageUrl: 'replacement.jpg' }];
+ await saveArrayChanges(gearKey, initial, next);
+ expect(files.has('shared.jpg')).toBe(true);
+ await saveArrayChanges('@ultraedge/dropbags', [{ id: 'bag', imageUrl: attachmentUri('shared.jpg') }], []);
+ expect(files.has('shared.jpg')).toBe(true);
+ await saveArrayChanges('@ultraedge/dropbag-templates', [{ id: 'template', imageUrl: 'shared.jpg' }], []);
+ expect(files.has('shared.jpg')).toBe(false);
+ await saveArrayChanges<{ id: string; imageUrl?: string }>(gearKey, next, [{ id: 'gear' }]);
+ expect(files.size).toBe(0);
+});
+
+test('event and gear deletion clean guides and child photos, preserving other references', async () => {
+ ['guide.pdf', 'bag.jpg', 'gear.jpg'].forEach(path => files.add(path));
+ await put(eventsKey, [{ id: 'race', raceGuide: { path: 'guide.pdf' } }, { id: 'other', raceGuide: { path: 'guide.pdf' } }]);
+ await put('@ultraedge/dropbags', [{ id: 'bag', eventId: 'race', imageUrl: 'bag.jpg', items: [] }]);
+ await put(gearKey, [{ id: 'gear', imageUrl: 'gear.jpg' }]);
+ await deleteLocalEvent('race');
+ expect([...files].sort()).toEqual(['gear.jpg', 'guide.pdf']);
+ await deleteLocalEvent('other');
+ await deleteLocalGear('gear');
+ expect(files.size).toBe(0);
+});
+
+test('startup removes abandoned imports but never deletes outside managed filenames', async () => {
+ ['abandoned.pdf', '../outside.jpg', 'unknown.txt', 'directory'].forEach(path => files.add(path));
+ await sweep();
+ expect([...files]).toEqual(['../outside.jpg', 'unknown.txt', 'directory']);
+});
+
+test('interrupted cascades preserve all files until journal recovery succeeds', async () => {
+ files.add('guide.pdf');
+ files.add('bag.jpg');
+ await put(eventsKey, [{ id: 'race', raceGuide: { path: 'guide.pdf' } }]);
+ await put('@ultraedge/dropbags', [{ id: 'bag', eventId: 'race', imageUrl: 'bag.jpg' }]);
+ (AsyncStorage.setItem as jest.Mock).mockImplementation(async (key, value) => {
+ if (key === eventsKey) throw new Error('Disk full');
+ return originalSet(key, value);
+ });
+ await expect(deleteLocalEvent('race')).rejects.toThrow('Disk full');
+ await expect(sweep()).rejects.toThrow('Disk full');
+ expect(files.size).toBe(2);
+ (AsyncStorage.setItem as jest.Mock).mockImplementation(originalSet);
+ await sweep();
+ expect(files.size).toBe(0);
+ expect(await AsyncStorage.getItem('@ultraedge/pending-plan-write')).toBeNull();
+});
+
+test.each(['broken JSON', '{}'])('unreadable reference storage disables cleanup: %s', async raw => {
+ files.add('keep.jpg');
+ await AsyncStorage.setItem(eventsKey, raw);
+ await sweep();
+ expect(files.has('keep.jpg')).toBe(true);
+});
+
+test('restart recovery commits a journaled replacement before collecting the old guide', async () => {
+ files.add('old.pdf');
+ files.add('new.pdf');
+ await put(eventsKey, [{ id: 'race', raceGuide: { path: 'old.pdf' } }]);
+ await put('@ultraedge/pending-plan-write', [[eventsKey, JSON.stringify([{ id: 'race', raceGuide: { path: 'new.pdf' } }])]]);
+ (AsyncStorage.setItem as jest.Mock).mockRejectedValueOnce(new Error('Recovery unavailable'));
+ await expect(sweep()).rejects.toThrow('Recovery unavailable');
+ expect(files.size).toBe(2);
+ await sweep();
+ expect([...files]).toEqual(['new.pdf']);
+ expect(JSON.parse((await AsyncStorage.getItem(eventsKey))!)[0].raceGuide.path).toBe('new.pdf');
+});
+
+test('directories are not treated as attachment files even when named like a PDF', async () => {
+ files.add('directory.pdf');
+ (FS.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true, isDirectory: true });
+ await sweep();
+ expect(FS.deleteAsync).not.toHaveBeenCalled();
+});
+
+test('cleanup errors do not fail durable saves, and failed deletions retry', async () => {
+ files.add('old.jpg');
+ const before = [{ id: 'gear', imageUrl: 'old.jpg' }];
+ await put(gearKey, before);
+ (FS.deleteAsync as jest.Mock).mockRejectedValueOnce(new Error('Busy file'));
+ await expect(saveArrayChanges(gearKey, before, [])).resolves.toEqual([]);
+ expect(files.has('old.jpg')).toBe(true);
+ await sweep();
+ expect(files.has('old.jpg')).toBe(false);
+});
+
+test('in-flight copies and unsaved drafts survive unrelated cleanup until released', async () => {
+ let finishCopy!: () => void;
+ let copying!: () => void;
+ const started = new Promise(resolve => { copying = resolve; });
+ (FS.copyAsync as jest.Mock).mockImplementationOnce(async () => {
+ files.add('imported.jpg');
+ copying();
+ await new Promise(resolve => { finishCopy = resolve; });
+ });
+ const saving = saveAttachment('file:///picker/photo', 'Photo', 'image/jpeg');
+ await started;
+ await sweep();
+ expect(files.has('imported.jpg')).toBe(true);
+ finishCopy();
+ const attachment = await saving;
+ const releaseDraft = retainAttachment(attachment.path);
+ releaseAttachmentImport(attachment.path);
+ await sweep();
+ expect(files.has(attachment.path)).toBe(true);
+ releaseDraft();
+ await sweep();
+ expect(files.has(attachment.path)).toBe(false);
+});
+
+test('failed saves retain disk references and drafts for retry; abandonment removes only the draft', async () => {
+ files.add('old.jpg');
+ files.add('draft.jpg');
+ const before = [{ id: 'gear', imageUrl: 'old.jpg' }];
+ await put(gearKey, before);
+ const release = retainAttachment('draft.jpg');
+ (AsyncStorage.setItem as jest.Mock).mockRejectedValueOnce(new Error('Disk full'));
+ await expect(saveArrayChanges(gearKey, before, [{ id: 'gear', imageUrl: 'draft.jpg' }])).rejects.toThrow('Disk full');
+ await sweep();
+ expect(files.size).toBe(2);
+ release();
+ await sweep();
+ expect([...files]).toEqual(['old.jpg']);
+});
+
+test('queued saves protect attachments even if the editing screen unmounts first', async () => {
+ files.add('queued.jpg');
+ const release = retainAttachment('queued.jpg');
+ const prior = sweep();
+ const saving = saveArrayChanges(gearKey, [], [{ id: 'gear', imageUrl: 'queued.jpg' }]);
+ release();
+ await prior;
+ await saving;
+ await sweep();
+ expect(files.has('queued.jpg')).toBe(true);
+});
+
+test.each([
+ [gearKey, { imageUrl: 'race.jpg' }],
+ [gearKey, { imageUrl: 'file:///documents/plan-attachments/race.jpg' }],
+ [eventsKey, { raceGuide: { path: 'race.jpg' } }],
+])('rejects a reference acquired after native deletion starts: %s %j', async (key, attachment) => {
+ files.add('race.jpg');
+ const before = [{ id: 'old', ...attachment }];
+ await put(key, before);
+ let finishDeletion!: () => void;
+ let deleting!: () => void;
+ const started = new Promise(resolve => { deleting = resolve; });
+ (FS.deleteAsync as jest.Mock).mockImplementationOnce(async () => {
+ deleting();
+ await new Promise(resolve => { finishDeletion = resolve; });
+ files.delete('race.jpg');
+ });
+ const removing = saveArrayChanges(key, before, []);
+ await started;
+ const saving = saveArrayChanges(key, [], [{ id: 'new', ...attachment }]);
+ const rejected = expect(saving).rejects.toThrow('attachment is no longer available');
+ finishDeletion();
+ await removing;
+ await rejected;
+ expect(files.has('race.jpg')).toBe(false);
+ expect(JSON.parse((await AsyncStorage.getItem(key))!)).toEqual([]);
+});
+
+test('failed copies release partial files for cleanup', async () => {
+ (FS.copyAsync as jest.Mock).mockImplementationOnce(async () => {
+ files.add('imported.jpg');
+ throw new Error('Copy failed');
+ });
+ await expect(saveAttachment('file:///picker/photo', 'Photo', 'image/jpeg')).rejects.toThrow('Copy failed');
+ await sweep();
+ expect(files.size).toBe(0);
+});
+
+test.each([true, false])('a failed deletion permits a queued reference only if the file still exists: %s', async stillExists => {
+ files.add('failed-delete.jpg');
+ let finishDeletion!: () => void;
+ let deleting!: () => void;
+ const started = new Promise(resolve => { deleting = resolve; });
+ (FS.getInfoAsync as jest.Mock).mockImplementation(async (uri: string) => ({
+ exists: uri.endsWith('plan-attachments/') || files.has(uri.split('/').pop()!),
+ isDirectory: uri.endsWith('plan-attachments/'),
+ }));
+ (FS.deleteAsync as jest.Mock).mockImplementationOnce(async () => {
+ deleting();
+ await new Promise(resolve => { finishDeletion = resolve; });
+ if (!stillExists) {
+ files.delete('failed-delete.jpg');
+ }
+ throw new Error('Native deletion failed');
+ });
+ const collecting = sweep();
+ await started;
+ const next = [{ id: 'new', imageUrl: 'failed-delete.jpg' }];
+ const saving = saveArrayChanges(gearKey, [], next);
+ const checked = stillExists
+ ? expect(saving).resolves.toEqual(next)
+ : expect(saving).rejects.toThrow('attachment is no longer available');
+ finishDeletion();
+ await collecting;
+ await checked;
+ expect(await AsyncStorage.getItem(gearKey)).toBe(stillExists ? JSON.stringify(next) : null);
+});
+
+test('a guide being copied for sharing stays protected after its event is removed', async () => {
+ files.add('guide.pdf');
+ await put(eventsKey, [{ id: 'race', raceGuide: { path: 'guide.pdf' } }]);
+ let finishCopy!: () => void;
+ let copying!: () => void;
+ const started = new Promise(resolve => { copying = resolve; });
+ (FS.copyAsync as jest.Mock).mockImplementationOnce(async () => {
+ copying();
+ await new Promise(resolve => { finishCopy = resolve; });
+ });
+ const sharing = attachmentShareUri({ path: 'guide.pdf', name: 'Guide.pdf', mimeType: 'application/pdf' });
+ await started;
+ await deleteLocalEvent('race');
+ expect(files.has('guide.pdf')).toBe(true);
+ finishCopy();
+ await sharing;
+ await sweep();
+ expect(files.has('guide.pdf')).toBe(false);
+});
diff --git a/src/lib/attachmentRetention.ts b/src/lib/attachmentRetention.ts
new file mode 100644
index 0000000..bbdb3a2
--- /dev/null
+++ b/src/lib/attachmentRetention.ts
@@ -0,0 +1,71 @@
+export const attachmentPathPattern = /^[a-zA-Z0-9-]+\.(jpg|png|webp|heic|pdf)$/;
+export const retainedAttachments = new Map();
+export const unavailableAttachments = new Set();
+let requestCleanup = () => {};
+
+function relativeAttachmentPath(path: string): string {
+ return path.startsWith('file:') && path.includes('/plan-attachments/') ? path.split('/').pop()! : path;
+}
+
+export function beginAttachmentDeletion(path: string): boolean {
+ if (retainedAttachments.has(path)) {
+ return false;
+ }
+ unavailableAttachments.add(path);
+ return true;
+}
+
+export function restoreAttachmentAvailability(path: string): void {
+ unavailableAttachments.delete(path);
+}
+
+export function assertAttachmentRecordsAvailable(records: unknown): void {
+ if (typeof records === 'string' && unavailableAttachments.has(relativeAttachmentPath(records))) {
+ throw new Error('This attachment is no longer available. Remove it or select it again before saving.');
+ }
+ if (records && typeof records === 'object') {
+ Object.values(records).forEach(assertAttachmentRecordsAvailable);
+ }
+}
+
+export function registerAttachmentCleanup(callback: () => void): void {
+ requestCleanup = callback;
+}
+
+export function retainAttachment(path?: string | null): () => void {
+ if (!path) {
+ return () => {};
+ }
+ const relative = relativeAttachmentPath(path);
+ if (!attachmentPathPattern.test(relative)) {
+ return () => {};
+ }
+ retainedAttachments.set(relative, (retainedAttachments.get(relative) ?? 0) + 1);
+ let released = false;
+ return () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ const count = (retainedAttachments.get(relative) ?? 1) - 1;
+ if (count) {
+ retainedAttachments.set(relative, count);
+ } else {
+ retainedAttachments.delete(relative);
+ }
+ requestCleanup();
+ };
+}
+
+export function retainAttachmentRecords(records: unknown): () => void {
+ const releases: (() => void)[] = [];
+ const visit = (value: unknown): void => {
+ if (typeof value === 'string') {
+ releases.push(retainAttachment(value));
+ } else if (value && typeof value === 'object') {
+ Object.values(value).forEach(visit);
+ }
+ };
+ visit(records);
+ return () => releases.forEach(release => release());
+}
diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts
index 30e690d..e4676a6 100644
--- a/src/lib/attachments.ts
+++ b/src/lib/attachments.ts
@@ -1,5 +1,7 @@
import * as FS from 'expo-file-system/legacy';
import * as Crypto from 'expo-crypto';
+import { attachmentPathPattern, retainAttachment, beginAttachmentDeletion, restoreAttachmentAvailability } from './attachmentRetention';
+export { retainAttachment } from './attachmentRetention';
export interface PlanAttachment {
path: string;
name: string;
@@ -11,6 +13,42 @@ const folder = () => {
}
return FS.documentDirectory + 'plan-attachments/';
};
+const imports = new Map void>();
+
+export function releaseAttachmentImport(path: string): void {
+ imports.get(path)?.();
+ imports.delete(path);
+}
+
+export async function cleanupAttachments(records: unknown[]): Promise {
+ const references = new Set();
+ const visit = (value: unknown): void => {
+ if (typeof value === 'string') {
+ references.add(value);
+ } else if (value && typeof value === 'object') {
+ Object.values(value).forEach(visit);
+ }
+ };
+ records.forEach(visit);
+ if (!(await FS.getInfoAsync(folder())).exists) return;
+ for (const path of await FS.readDirectoryAsync(folder())) {
+ if (!attachmentPathPattern.test(path) || references.has(path) || references.has(attachmentUri(path))) continue;
+ try {
+ if ((await FS.getInfoAsync(attachmentUri(path))).isDirectory || !beginAttachmentDeletion(path)) continue;
+ await FS.deleteAsync(attachmentUri(path), { idempotent: true });
+ } catch {
+ try {
+ const info = await FS.getInfoAsync(attachmentUri(path));
+ if (info.exists && !info.isDirectory) {
+ restoreAttachmentAvailability(path);
+ }
+ } catch {
+ continue;
+ }
+ continue;
+ }
+ }
+}
export function attachmentUri(path: string): string {
if (!/^[a-zA-Z0-9-]+\.(jpg|png|webp|heic|pdf)$/.test(path)) {
throw new Error('Invalid attachment path.');
@@ -53,17 +91,28 @@ export async function saveAttachment(
}
await FS.makeDirectoryAsync(folder(), { intermediates: true });
const path = `${Crypto.randomUUID()}.${extension}`;
- await FS.copyAsync({ from: uri, to: attachmentUri(path) });
+ imports.set(path, retainAttachment(path));
+ try {
+ await FS.copyAsync({ from: uri, to: attachmentUri(path) });
+ } catch (error) {
+ releaseAttachmentImport(path);
+ throw error;
+ }
return { path, name, mimeType };
}
/** Share a readable filename while keeping stored references stable and unique. */
export async function attachmentShareUri(attachment: PlanAttachment): Promise {
- if (!FS.cacheDirectory) throw new Error('File sharing storage is unavailable.');
- const name = attachment.name.replace(/[\\/\x00-\x1f]/g, '_').slice(0, 120).replace(/\.pdf$/i, '') || 'Race guide';
- const directory = `${FS.cacheDirectory}guide-share-${Crypto.randomUUID()}/`;
- await FS.makeDirectoryAsync(directory, { intermediates: true });
- const uri = `${directory}${name}.pdf`;
- await FS.copyAsync({ from: attachmentUri(attachment.path), to: uri });
- return uri;
+ const release = retainAttachment(attachment.path);
+ try {
+ if (!FS.cacheDirectory) throw new Error('File sharing storage is unavailable.');
+ const name = attachment.name.replace(/[\\/\x00-\x1f]/g, '_').slice(0, 120).replace(/\.pdf$/i, '') || 'Race guide';
+ const directory = `${FS.cacheDirectory}guide-share-${Crypto.randomUUID()}/`;
+ await FS.makeDirectoryAsync(directory, { intermediates: true });
+ const uri = `${directory}${name}.pdf`;
+ await FS.copyAsync({ from: attachmentUri(attachment.path), to: uri });
+ return uri;
+ } finally {
+ release();
+ }
}
diff --git a/src/lib/liveRaceSync.ts b/src/lib/liveRaceSync.ts
index 48e9e6b..69fce51 100644
--- a/src/lib/liveRaceSync.ts
+++ b/src/lib/liveRaceSync.ts
@@ -69,4 +69,28 @@ export async function forgetLiveRoom(id:string){await exclusive(()=>dropRoomCach
export async function discardPendingReports(id:string){await editCache(c=>({...c,pending:c.pending.filter(r=>r.roomId!==id)}));}
export async function recoverLiveRooms(){await identify();const list=await rpc<{id:string}[]>('list');for(const item of list)await syncLiveRoom(item.id);}
-export async function deleteLiveIdentity(){await exclusive(async()=>{await identify();await rpc('delete_identity');try{await connection().auth.signOut({scope:'local'});}finally{await editCache(()=>({rooms:[],pending:[],actorId:null,lastSync:{}}));}});}
+export const abuseReasons = ['harassment','hate','sexual_content','threats','other'] as const;
+export type AbuseReason = typeof abuseReasons[number];
+export async function reportLiveAbuse(room:LiveRoom,userId:string,reason:AbuseReason,details:string){
+ if(!abuseReasons.includes(reason)||details.trim().length>1000)throw new Error('Choose a reason and keep details under 1,000 characters.');
+ await identify();
+ return rpc<{id:string}>('abuse_report',{roomId:room.id,userId,reason,details:details.trim(),requestId:Crypto.randomUUID()});
+}
+export async function blockLiveUser(room:LiveRoom,userId:string){
+ await exclusive(async()=>{
+ await identify();
+ const {removedRoomIds}=await rpc<{removedRoomIds:string[]}>('block_user',{roomId:room.id,userId});
+ await editCache(c=>({...c,rooms:c.rooms.filter(saved=>!removedRoomIds.includes(saved.id)).map(saved=>({...saved,members:saved.members.filter(member=>member.id!==userId),reports:saved.reports.filter(report=>report.authorId!==userId)})),pending:c.pending.filter(report=>!removedRoomIds.includes(report.roomId)),lastSync:Object.fromEntries(Object.entries(c.lastSync).filter(([id])=>!removedRoomIds.includes(id)))}));
+ });
+}
+export async function deleteLiveIdentity(){await exclusive(async()=>{
+ const {data:{session},error}=await connection().auth.getSession();
+ if(error)throw error;
+ if(!session)throw new Error('No active collaboration identity was found. Keep the app installed and retry when connected.');
+ await rpc('delete_identity');
+ let sessionCleanupFailed=false;
+ try{const result=await connection().auth.signOut({scope:'local'});sessionCleanupFailed=!!result.error;}catch{sessionCleanupFailed=true;}
+ try{await editCache(()=>({rooms:[],pending:[],actorId:null,lastSync:{}}));}
+ catch{throw new Error('Server collaboration data was deleted, but this phone could not clear its saved copy. Check device storage and remove saved offline copies before sharing again.');}
+ if(sessionCleanupFailed)throw new Error('Server collaboration data was deleted, but this phone could not clear its saved session. Restart and retry before sharing again.');
+});}
diff --git a/src/lib/localPlanStorage.ts b/src/lib/localPlanStorage.ts
index 91163b5..c4f4947 100644
--- a/src/lib/localPlanStorage.ts
+++ b/src/lib/localPlanStorage.ts
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
+import { registerAttachmentCleanup, retainAttachmentRecords, assertAttachmentRecordsAvailable } from './attachmentRetention';
const JOURNAL_KEY = '@ultraedge/pending-plan-write';
type Entry = [string, string];
@@ -9,12 +10,33 @@ const listeners = new Set<(keys: string[]) => void>();
export function runLocalPlanOperation(operation: () => Promise): Promise {
const result = queue.then(async () => {
await recoverPendingPlanWrite();
- return operation();
+ const value = await operation();
+ await sweepAttachments();
+ return value;
});
queue = result.catch(() => undefined);
return result;
}
+async function sweepAttachments(): Promise {
+ try {
+ if (await AsyncStorage.getItem(JOURNAL_KEY)) return;
+ const records = [];
+ for (const key of ['@ultraedge/events', '@ultraedge/gear-items', '@ultraedge/dropbags', '@ultraedge/dropbag-templates', '@ultraedge/crew', '@ultraedge/event-crew', '@ultraedge/event-gear', '@ultraedge/race-operations']) {
+ records.push(await readArray(key));
+ }
+ records.push(await readCheckpointMap());
+ const { cleanupAttachments }: typeof import('./attachments') = require('./attachments');
+ await cleanupAttachments(records);
+ } catch {
+ return;
+ }
+}
+
+registerAttachmentCleanup(() => {
+ void runLocalPlanOperation(async () => undefined).catch(() => undefined);
+});
+
export function subscribePlanChanges(listener: (keys: string[]) => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
@@ -77,7 +99,9 @@ export async function deleteLocalEvent(eventId: string): Promise {
/** Merge this screen's changes into the latest disk state without erasing concurrent additions. */
export async function saveArrayChanges(key: string, previous: T[], next: T[]): Promise {
- return runLocalPlanOperation(async () => {
+ const release = retainAttachmentRecords(next);
+ try {
+ return await runLocalPlanOperation(async () => {
const current = await readArray(key);
const before = new Map(previous.map(row => [row.id, row]));
const after = new Map(next.map(row => [row.id, row]));
@@ -100,9 +124,13 @@ export async function saveArrayChanges(key: string, pr
merged.set(row.id, row);
}
const result = [...merged.values()];
+ assertAttachmentRecordsAvailable(result);
await AsyncStorage.setItem(key, JSON.stringify(result));
return result;
- });
+ });
+ } finally {
+ release();
+ }
}
/** Call within the operation lock before creating or changing a child record. */
diff --git a/src/screens/events/EventsListScreen.tsx b/src/screens/events/EventsListScreen.tsx
index 62d4fbc..36361d1 100644
--- a/src/screens/events/EventsListScreen.tsx
+++ b/src/screens/events/EventsListScreen.tsx
@@ -1,4 +1,3 @@
-import LiveRacePanel from '../../components/LiveRacePanel';
/**
* UltraEdge Events List Screen
* List all events with swipe to delete
@@ -284,7 +283,6 @@ export default function EventsListScreen({ navigation }: Props) {
-
{/* Events List */}
{loading || error ? (
diff --git a/src/screens/settings/PrivacySupportScreen.tsx b/src/screens/settings/PrivacySupportScreen.tsx
index 46cb950..34bea80 100644
--- a/src/screens/settings/PrivacySupportScreen.tsx
+++ b/src/screens/settings/PrivacySupportScreen.tsx
@@ -5,15 +5,23 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../theme';
import { H2, Body, BodySmall } from '../../components/ui';
-const SUPPORT_URL = 'https://github.com/beaux-riel/UltraEdge/issues';
+const SUPPORT_URL = 'https://ultraedge.heybeaux.dev/support';
+const PRIVACY_URL = 'https://ultraedge.heybeaux.dev/privacy';
+const PRIVATE_EMAIL = 'hello@heybeaux.dev';
export default function PrivacySupportScreen() {
const { theme } = useTheme();
const insets = useSafeAreaInsets();
- const openSupport = async () => {
- try { await Linking.openURL(SUPPORT_URL); }
+ const openLink = async (url:string, label:string) => {
+ try { await Linking.openURL(url); }
catch {
- Alert.alert('Could not open support', 'Connect to the internet and try again, or visit github.com/beaux-riel/UltraEdge/issues in your browser.');
+ Alert.alert(`Could not open ${label}`, `Connect to the internet and try again, or visit ${url} in your browser.`);
+ }
+ };
+ const openPrivateEmail = async () => {
+ try { await Linking.openURL(`mailto:${PRIVATE_EMAIL}`); }
+ catch {
+ Alert.alert('Could not open email', `Open your email app and write to ${PRIVATE_EMAIL} for private privacy, support or abuse concerns. UltraEdge has not sent an email.`);
}
};
@@ -23,10 +31,13 @@ export default function PrivacySupportScreen() {
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 32 }]}
>
UltraEdge 1.0 · Updated September 6, 2026
- This information is available offline. Opening the support link requires an internet connection.
+ This information is available offline. Opening policy or support links requires an internet connection.
+ { void openLink(PRIVACY_URL,'privacy policy'); }} style={styles.link}>
+ Privacy policy
+
-
Local plans and optional team sharing
+
Your plans stay on this device
UltraEdge stores your event plans, checkpoints, gear, drop bags, crew names and contact details,
roles, notes, profile, weight entries, preferences and imported GPX files locally.
@@ -39,26 +50,6 @@ export default function PrivacySupportScreen() {
-
-
Live team races
-
- Creating or joining a team race connects to Supabase in Canada and creates a guest identity
- whose session is stored securely on this phone. Shared race timing, checkpoint names,
- stop plans, crew display names, vehicle and cargo labels, and time observations are stored
- on the server and cached on joined phones. Crew phone numbers, emails, personal notes
- and route files are excluded from this upload. The service also processes connection
- information such as IP addresses for authentication and abuse prevention.
-
-
- Sharing is optional. Team members can read the plan and submit their own observations.
- The owner can remove members or stop sharing and delete the room. In Events, open the
- live team panel and choose Delete my collaboration data to delete your guest identity,
- owned rooms and reports. Removing a local race or deleting the app does not delete
- server copies. Previously downloaded or exported copies held by others remain with them.
- Pending reports stay on the device until synchronized. A new phone needs a new invitation;
- guest owner access has no email or password recovery.
-
-
Maps, files and sharing
@@ -101,22 +92,34 @@ export default function PrivacySupportScreen() {
Support and privacy questions
- Report problems through the UltraEdge GitHub issue tracker. Include your app version,
- device model, iOS version and the steps that led to the problem. GitHub may require an account
- to post and handles posts under its own privacy practices.
+ UltraEdge is operated by Beaux Walton. For private privacy, support or abuse concerns,
+ contact hello@heybeaux.dev. The email link opens your email app; you review and send
+ the message. It is not an emergency service.
+
+
+ Visit the support page for contact options and troubleshooting. Include your app version,
+ device model, iOS version and the steps that led to the problem. For collaboration abuse,
+ open a shared race and use Report or Block under Team access.
- Issues are public. Do not post private routes, crew contact details, health information or
- screenshots containing them. For a sensitive concern, first request a private contact method
- without including sensitive details.
+ Do not post private routes, crew contact details, health information or screenshots
+ containing them to a public issue tracker. Do not use team reports for emergencies.
{ void openSupport(); }}
+ accessibilityLabel="Open UltraEdge support"
+ onPress={() => { void openLink(SUPPORT_URL,'support'); }}
+ style={styles.link}
+ >
+ Support and contact options
+
+ { void openPrivateEmail(); }}
style={styles.link}
>
- Open public GitHub issue tracker
+ Email hello@heybeaux.dev privately
diff --git a/supabase/collaboration/002_safety.sql b/supabase/collaboration/002_safety.sql
new file mode 100644
index 0000000..ac1b2db
--- /dev/null
+++ b/supabase/collaboration/002_safety.sql
@@ -0,0 +1,228 @@
+begin;
+
+create table public.ue_live_blocks (
+ blocker_id uuid not null references auth.users(id) on delete cascade,
+ blocked_id uuid not null references auth.users(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key(blocker_id,blocked_id), check(blocker_id<>blocked_id)
+);
+create index ue_live_blocks_blocked on public.ue_live_blocks(blocked_id);
+
+create table public.ue_live_abuse_reports (
+ id uuid primary key default gen_random_uuid(),
+ reporter_id uuid not null references auth.users(id) on delete cascade,
+ subject_id uuid not null references auth.users(id) on delete cascade,
+ room_id uuid references public.ue_live_rooms(id) on delete set null,
+ request_id uuid not null,
+ reason text not null check(reason in ('harassment','hate','sexual_content','threats','other')),
+ details text not null check(length(details)<=1000),
+ evidence jsonb not null check(octet_length(evidence::text)<=600000),
+ status text not null default 'pending' check(status in ('pending','reviewing','resolved','dismissed')),
+ created_at timestamptz not null default now(), reviewed_at timestamptz,
+ unique(reporter_id,request_id), check(reporter_id<>subject_id)
+);
+create index ue_live_abuse_pending on public.ue_live_abuse_reports(created_at) where status='pending';
+create index ue_live_abuse_reporter on public.ue_live_abuse_reports(reporter_id,created_at);
+create table public.ue_live_content_terms (
+ phrase text primary key check(length(phrase) between 2 and 100)
+);
+insert into public.ue_live_content_terms values ('fuck'),('shit'),('kill yourself'),('i will kill you');
+
+alter table public.ue_live_blocks enable row level security;
+alter table public.ue_live_abuse_reports enable row level security;
+alter table public.ue_live_content_terms enable row level security;
+revoke all on public.ue_live_blocks,public.ue_live_abuse_reports,public.ue_live_content_terms from public,anon,authenticated;
+
+create function public.ue_live_check_content(content text) returns void
+language plpgsql security definer set search_path=public,pg_temp as $$
+declare normalized text;
+begin
+ normalized:=' '||regexp_replace(lower(normalize(content,NFKC)),'[^[:alnum:]]+',' ','g')||' ';
+ if exists(select 1 from ue_live_content_terms where strpos(normalized,' '||phrase||' ')>0)
+ then raise exception 'Shared text contains language that is not allowed. Edit the display name or plan before sharing.' using errcode='22023'; end if;
+end $$;
+revoke all on function public.ue_live_check_content(text) from public,anon,authenticated;
+
+create function public.ue_live_filter_content() returns trigger
+language plpgsql security definer set search_path=public,pg_temp as $$
+declare item jsonb;
+begin
+ if tg_table_name='ue_live_members' then perform ue_live_check_content(new.name);
+ else
+ for item in select jsonb_path_query(new.snapshot,'$.** ? (@.type() == "string")') loop
+ perform ue_live_check_content(item #>> '{}');
+ end loop;
+ end if;
+ return new;
+end $$;
+revoke all on function public.ue_live_filter_content() from public,anon,authenticated;
+create trigger ue_live_member_content before insert or update on public.ue_live_members for each row execute function public.ue_live_filter_content();
+create trigger ue_live_plan_content before insert or update of snapshot on public.ue_live_rooms for each row execute function public.ue_live_filter_content();
+
+create or replace function public.ue_live_core(action text, args jsonb default '{}'::jsonb) returns jsonb
+language plpgsql security definer set search_path = public, pg_temp as $$
+declare
+ actor uuid := auth.uid(); room public.ue_live_rooms; rid uuid; answer jsonb; checkpoint text; k text;
+ observed timestamptz; existing public.ue_live_reports; opposite timestamptz; expected integer; request uuid;
+ plan_snapshot jsonb; start_time timestamptz;
+begin
+ if actor is null then raise exception 'Sign in before sharing.' using errcode='42501'; end if;
+ if action='delete_identity' then
+ if coalesce(auth.jwt()->>'is_anonymous','false')<>'true' then raise exception 'Only collaboration guest identities can be removed here.'; end if;
+ delete from auth.users where id=actor; return '{}'::jsonb;
+ elsif action='create' then
+ plan_snapshot := args->'snapshot';
+ perform ue_live_validate(plan_snapshot);
+ if coalesce(jsonb_typeof(plan_snapshot),'null') <> 'object' or coalesce(jsonb_typeof(plan_snapshot->'checkpoints'),'null') <> 'array' or length(coalesce(args->>'localId','')) not between 1 and 200 then raise exception 'Invalid plan'; end if;
+ if (select count(*) from ue_live_rooms where owner_id=actor) >= 20 then raise exception 'Limit of 20 shared races reached.'; end if;
+ insert into ue_live_rooms(owner_id,local_id,snapshot) values(actor,args->>'localId',plan_snapshot)
+ on conflict(owner_id,local_id) do nothing;
+ select * into room from ue_live_rooms where owner_id=actor and local_id=args->>'localId';
+ insert into ue_live_members values(room.id,actor,left(coalesce(nullif(trim(args->>'name'),''),'Runner'),80)) on conflict do nothing;
+ return jsonb_build_object('id',room.id);
+ elsif action='join' then
+ select * into room from ue_live_rooms where invite=args->>'code' and created_at > now()-interval '1 year';
+ if room.id is null then raise exception 'Invite is invalid or expired.' using errcode='42501'; end if;
+ if (select count(*) from ue_live_members where room_id=room.id)>=50 then raise exception 'Team is full.'; end if;
+ insert into ue_live_members values(room.id,actor,left(coalesce(nullif(trim(args->>'name'),''),'Crew'),80)) on conflict do nothing;
+ return jsonb_build_object('id',room.id);
+ elsif action='list' then
+ return coalesce((select jsonb_agg(jsonb_build_object('id',r.id,'name',r.snapshot->'event'->>'name')) from ue_live_rooms r join ue_live_members m on m.room_id=r.id where m.user_id=actor),'[]'::jsonb);
+ end if;
+ rid := (args->>'roomId')::uuid;
+ select * into room from ue_live_rooms where id=rid for update;
+ if room.id is null or not exists(select 1 from ue_live_members where room_id=rid and user_id=actor) then raise exception 'Shared race is unavailable or access was removed.' using errcode='42501'; end if;
+ if action='read' then
+ return jsonb_build_object('id',rid,'ownerId',room.owner_id,'snapshot',room.snapshot,'revision',room.revision,
+ 'invite',case when actor=room.owner_id then room.invite else null end,'serverTime',now(),
+ 'members',coalesce((select jsonb_agg(jsonb_build_object('id',user_id,'name',name)) from ue_live_members where room_id=rid),'[]'::jsonb),
+ 'reports',coalesce((select jsonb_agg(jsonb_build_object('authorId',user_id,'checkpointId',checkpoint_id,'kind',kind,'at',observed_at,'revision',revision,'receivedAt',received_at)) from ue_live_reports where room_id=rid),'[]'::jsonb));
+ elsif action='report' then
+ request := (args->>'requestId')::uuid;
+ select result into answer from ue_live_requests where room_id=rid and user_id=actor and request_id=request;
+ if found then return answer; end if;
+ checkpoint:=args->>'checkpointId'; k:=args->>'kind'; observed:=(args->>'at')::timestamptz;
+ if k not in ('in','out') or not exists(select 1 from jsonb_array_elements(room.snapshot->'checkpoints') cp where cp->>'id'=checkpoint) then raise exception 'Invalid checkpoint or report type'; end if;
+ start_time := (room.snapshot->>'startAt')::timestamptz;
+ if start_time is null or observed < start_time or observed > now()+interval '1 minute' then raise exception 'Observation must be between race start and now.'; end if;
+ select * into existing from ue_live_reports where room_id=rid and user_id=actor and checkpoint_id=checkpoint and kind=k;
+ expected:=coalesce((args->>'revision')::integer,0);
+ if coalesce(existing.revision,0) <> expected then raise exception 'Report changed on another device. Refresh and correct it again.' using errcode='PT409'; end if;
+ select observed_at into opposite from ue_live_reports where room_id=rid and user_id=actor and checkpoint_id=checkpoint and kind=case when k='in' then 'out' else 'in' end;
+ if observed is not null and opposite is not null and ((k='in' and observed>opposite) or (k='out' and observedroom.owner_id then raise exception 'Only the owner can publish the plan.' using errcode='42501'; end if;
+ if coalesce((args->>'revision')::integer,-1)<>room.revision then raise exception 'Plan changed. Refresh before publishing.' using errcode='PT409'; end if;
+ plan_snapshot:=args->'snapshot';
+ perform ue_live_validate(plan_snapshot);
+ if coalesce(jsonb_typeof(plan_snapshot),'null')<>'object' or coalesce(jsonb_typeof(plan_snapshot->'checkpoints'),'null')<>'array' then raise exception 'Invalid plan'; end if;
+ update ue_live_rooms set snapshot=plan_snapshot,revision=revision+1 where id=rid;
+ return jsonb_build_object('revision',room.revision+1);
+ elsif action='revoke' then
+ if actor<>room.owner_id or (args->>'userId')::uuid=actor then raise exception 'Only the owner can remove another member.' using errcode='42501'; end if;
+ delete from ue_live_members where room_id=rid and user_id=(args->>'userId')::uuid;
+ -- Rotate invite so removed members cannot rejoin with the old code.
+ update ue_live_rooms set invite=replace(gen_random_uuid()::text,'-','') where id=rid;
+ return '{}'::jsonb;
+ elsif action='close' then
+ if actor<>room.owner_id then raise exception 'Only the owner can stop sharing.' using errcode='42501'; end if;
+ delete from ue_live_rooms where id=rid; return '{}'::jsonb;
+ elsif action='leave' then
+ if actor=room.owner_id then raise exception 'Stop sharing to remove the owner.'; end if;
+ delete from ue_live_members where room_id=rid and user_id=actor; return '{}'::jsonb;
+ end if;
+ raise exception 'Unknown action';
+end $$;
+
+revoke all on function public.ue_live_core(text,jsonb) from public,anon,authenticated;
+
+create or replace function public.ue_live(action text,args jsonb default '{}'::jsonb) returns jsonb
+language plpgsql security definer set search_path=public,pg_temp as $$
+declare
+ actor uuid:=auth.uid(); subject uuid; room public.ue_live_rooms; shared public.ue_live_rooms;
+ answer jsonb; removed jsonb:='[]'::jsonb; report_id uuid; requested uuid; reason text; details text;
+ report_evidence jsonb; plan_bytes integer; evidence_observations jsonb; next_observations jsonb;
+ subject_observation public.ue_live_reports; observation_total bigint;
+begin
+ if actor is null or (action<>'delete_identity' and not exists(select 1 from auth.users where id=actor))
+ then raise exception 'Sign in before sharing.' using errcode='42501'; end if;
+ if action in ('join','block_user','abuse_report','delete_identity') then
+ perform pg_advisory_xact_lock(827461,1);
+ end if;
+ if action='join' then
+ select * into room from ue_live_rooms where invite=args->>'code' for update;
+ if exists(select 1 from ue_live_members member join ue_live_blocks blocked
+ on (blocked.blocker_id=actor and blocked.blocked_id=member.user_id)
+ or (blocked.blocked_id=actor and blocked.blocker_id=member.user_id) where member.room_id=room.id)
+ then raise exception 'This team is unavailable because of a user block.' using errcode='42501'; end if;
+ elsif action in ('block_user','abuse_report') then
+ select * into room from ue_live_rooms where id=(args->>'roomId')::uuid for update;
+ subject:=(args->>'userId')::uuid;
+ if room.id is null or subject is null or subject=actor
+ or not exists(select 1 from ue_live_members where room_id=room.id and user_id=actor)
+ or not exists(select 1 from ue_live_members where room_id=room.id and user_id=subject)
+ then raise exception 'Choose another current member of your team.' using errcode='42501'; end if;
+ if action='abuse_report' then
+ requested:=(args->>'requestId')::uuid;
+ select id into report_id from ue_live_abuse_reports where reporter_id=actor and request_id=requested;
+ if found then return jsonb_build_object('id',report_id); end if;
+ reason:=args->>'reason'; details:=coalesce(args->>'details','');
+ if requested is null or reason is null or reason not in ('harassment','hate','sexual_content','threats','other') or length(details)>1000
+ then raise exception 'Choose a reason and keep details under 1,000 characters.'; end if;
+ if (select count(*) from ue_live_abuse_reports where reporter_id=actor and created_at>now()-interval '24 hours')>=10
+ then raise exception 'Report limit reached. Use the support page for further help.'; end if;
+ plan_bytes:=case when subject=room.owner_id then octet_length(room.snapshot::text) else null end;
+ evidence_observations:='[]'::jsonb;
+ select count(*) into observation_total from ue_live_reports where room_id=room.id and user_id=subject;
+ for subject_observation in select * from ue_live_reports where room_id=room.id and user_id=subject
+ order by received_at desc,checkpoint_id,kind limit 100 loop
+ next_observations:=evidence_observations||jsonb_build_array(to_jsonb(subject_observation)-'room_id');
+ exit when octet_length(next_observations::text)>131072;
+ evidence_observations:=next_observations;
+ end loop;
+ report_evidence:=jsonb_build_object(
+ 'version',1,'roomId',room.id,'subjectId',subject,'planRevision',room.revision,
+ 'eventName',room.snapshot->'event'->'name','subjectName',(select name from ue_live_members where room_id=room.id and user_id=subject),
+ 'plan',case when plan_bytes<=262144 then room.snapshot else null end,
+ 'planExcerpt',case when plan_bytes>262144 then left(room.snapshot::text,16000) else null end,
+ 'observations',evidence_observations,
+ 'truncation',jsonb_build_object('planTruncated',coalesce(plan_bytes>262144,false),'originalPlanBytes',plan_bytes,
+ 'planByteLimit',262144,'planExcerptCharacterLimit',16000,'observationsTotal',observation_total,
+ 'observationsIncluded',jsonb_array_length(evidence_observations),'observationsTruncated',observation_total>jsonb_array_length(evidence_observations),
+ 'observationCountLimit',100,'observationByteLimit',131072));
+ insert into ue_live_abuse_reports(reporter_id,subject_id,room_id,request_id,reason,details,evidence)
+ values(actor,subject,room.id,requested,reason,details,report_evidence)
+ returning id into report_id;
+ return jsonb_build_object('id',report_id);
+ end if;
+ insert into ue_live_blocks(blocker_id,blocked_id) values(actor,subject) on conflict do nothing;
+ for shared in select owned.* from ue_live_rooms owned
+ where exists(select 1 from ue_live_members where room_id=owned.id and user_id=actor)
+ and exists(select 1 from ue_live_members where room_id=owned.id and user_id=subject) order by owned.id for update loop
+ if shared.owner_id=actor then
+ delete from ue_live_members where room_id=shared.id and user_id=subject;
+ update ue_live_rooms set invite=replace(gen_random_uuid()::text,'-','') where id=shared.id;
+ else
+ delete from ue_live_members where room_id=shared.id and user_id=actor;
+ removed:=removed||jsonb_build_array(shared.id);
+ end if;
+ end loop;
+ return jsonb_build_object('removedRoomIds',removed);
+ end if;
+ answer:=ue_live_core(action,args);
+ if action='read' then
+ answer:=jsonb_set(answer,'{reports}',coalesce((select jsonb_agg(observation) from jsonb_array_elements(answer->'reports') observation
+ where not exists(select 1 from ue_live_blocks where
+ (blocker_id=actor and blocked_id=(observation->>'authorId')::uuid)
+ or (blocked_id=actor and blocker_id=(observation->>'authorId')::uuid))),'[]'::jsonb));
+ end if;
+ return answer;
+end $$;
+revoke all on function public.ue_live(text,jsonb) from public,anon;
+grant execute on function public.ue_live(text,jsonb) to authenticated;
+commit;
diff --git a/supabase/collaboration/tests/oversized_evidence.sql b/supabase/collaboration/tests/oversized_evidence.sql
new file mode 100644
index 0000000..ff1fc4b
--- /dev/null
+++ b/supabase/collaboration/tests/oversized_evidence.sql
@@ -0,0 +1,67 @@
+insert into auth.users values
+ ('00000000-0000-0000-0000-000000000004'),
+ ('00000000-0000-0000-0000-000000000005');
+set role authenticated;
+do $$
+declare
+ owner_id text:='00000000-0000-0000-0000-000000000004';
+ reporter_id text:='00000000-0000-0000-0000-000000000005';
+ plan jsonb; checkpoints jsonb; room jsonb; answer jsonb; report_args jsonb; observation_kind text;
+begin
+ select jsonb_agg(jsonb_build_object('id','cp-'||checkpoint_number,'name','Checkpoint '||checkpoint_number,
+ 'distance_from_start',checkpoint_number,'order_index',checkpoint_number)) into checkpoints from generate_series(1,500) checkpoint_number;
+ plan:=jsonb_build_object('event',jsonb_build_object('id','large-race','name','Large valid race','target_time','10:00','total_distance',500),
+ 'startAt',now()-interval '1 day','checkpoints',checkpoints,
+ 'operations',jsonb_build_object('variation',10,'stops','{}'::jsonb),'logistics',jsonb_build_array(repeat('a',440000)));
+ assert octet_length(plan::text)<=524288,'regression fixture must be a valid-size plan';
+ perform set_config('test.actor',owner_id,false);
+ answer:=public.ue_live('create',jsonb_build_object('snapshot',plan,'localId','large-race','name','Runner'));
+ room:=public.ue_live('read',jsonb_build_object('roomId',answer->>'id'));
+ perform set_config('test.large_room',room->>'id',false);
+ for checkpoint_number in 1..500 loop
+ foreach observation_kind in array array['in','out'] loop
+ perform public.ue_live('report',jsonb_build_object('roomId',room->>'id','checkpointId','cp-'||checkpoint_number,
+ 'kind',observation_kind,'at',now()-interval '12 hours','revision',0,'requestId',gen_random_uuid()));
+ end loop;
+ end loop;
+ perform set_config('test.actor',reporter_id,false);
+ perform public.ue_live('join',jsonb_build_object('code',room->>'invite','name','Reporter'));
+ report_args:=jsonb_build_object('roomId',room->>'id','userId',owner_id,'reason','harassment',
+ 'details','Offensive text may be beyond the excerpt; review the identified room.','requestId',gen_random_uuid());
+ answer:=public.ue_live('abuse_report',report_args);
+ assert answer=public.ue_live('abuse_report',report_args),'oversized evidence report retries are idempotent';
+ perform set_config('test.large_receipt',answer->>'id',false);
+ perform set_config('test.actor',owner_id,false);
+ plan:=jsonb_set(plan,'{logistics}',jsonb_build_array(repeat(chr(1)||'"'||chr(92)||'🚵',24000)));
+ assert octet_length(plan::text)<=524288,'escaped multibyte fixture must be valid-size';
+ perform public.ue_live('publish',jsonb_build_object('roomId',room->>'id','snapshot',plan,'revision',1));
+ perform set_config('test.actor',reporter_id,false);
+ answer:=public.ue_live('abuse_report',report_args||jsonb_build_object('requestId',gen_random_uuid()));
+ perform set_config('test.escaped_receipt',answer->>'id',false);
+end $$;
+reset role;
+do $$
+declare report public.ue_live_abuse_reports; legacy_bytes integer;
+begin
+ select * into report from public.ue_live_abuse_reports where id=current_setting('test.large_receipt')::uuid;
+ select octet_length(jsonb_build_object('plan',jsonb_set(snapshot,'{logistics}',jsonb_build_array(repeat('a',440000))),
+ 'observations',(select jsonb_agg(to_jsonb(observation)-'room_id') from public.ue_live_reports observation where room_id=rooms.id))::text)
+ into legacy_bytes from public.ue_live_rooms rooms where id=current_setting('test.large_room')::uuid;
+ assert legacy_bytes>600000,'fixture must exceed the previous evidence constraint';
+ assert octet_length(report.evidence::text)<600000,'report evidence stays below constraint';
+ assert report.reason='harassment' and length(report.details)>0,'report reason and details preserved';
+ assert report.evidence->>'roomId'=current_setting('test.large_room'),'stable room ID retained';
+ assert report.evidence->>'subjectId'=report.subject_id::text,'subject ID retained';
+ assert report.evidence->'plan'='null'::jsonb and length(report.evidence->>'planExcerpt')=16000,'large plan replaced with bounded excerpt';
+ assert (report.evidence->'truncation'->>'planTruncated')::boolean,'plan truncation explicit';
+ assert (report.evidence->'truncation'->>'originalPlanBytes')::integer>440000,'original plan size retained';
+ assert (report.evidence->'truncation'->>'observationsTotal')::integer=1000,'original observation count retained';
+ assert (report.evidence->'truncation'->>'observationsIncluded')::integer=100,'bounded observation sample';
+ assert (report.evidence->'truncation'->>'observationsTruncated')::boolean,'observation truncation explicit';
+ assert jsonb_array_length(report.evidence->'observations')=100,'latest observation sample retained';
+ assert (select count(*) from public.ue_live_abuse_reports where id=report.id)=1,'one report per retry ID';
+ select * into report from public.ue_live_abuse_reports where id=current_setting('test.escaped_receipt')::uuid;
+ assert octet_length(report.evidence::text)<600000,'JSON escaping and UTF-8 cannot exceed evidence limit';
+ assert length(report.evidence->>'planExcerpt')=16000 and (report.evidence->'truncation'->>'planTruncated')::boolean,'escaped plan explicitly excerpted';
+ raise notice 'Oversized evidence regression passed; prior evidence would require % bytes',legacy_bytes;
+end $$;
diff --git a/supabase/collaboration/tests/safety.sql b/supabase/collaboration/tests/safety.sql
new file mode 100644
index 0000000..966367a
--- /dev/null
+++ b/supabase/collaboration/tests/safety.sql
@@ -0,0 +1,119 @@
+insert into auth.users values
+ ('00000000-0000-0000-0000-000000000001'),
+ ('00000000-0000-0000-0000-000000000002'),
+ ('00000000-0000-0000-0000-000000000003');
+
+create function pg_temp.denied(action text,args jsonb,expected text) returns void language plpgsql as $$
+begin
+ begin
+ perform public.ue_live(action,args);
+ exception when others then
+ if strpos(sqlerrm,expected)=0 then raise exception 'Wrong rejection: % (expected %)',sqlerrm,expected; end if;
+ return;
+ end;
+ raise exception 'Expected rejection for %',action;
+end $$;
+
+set role authenticated;
+do $$
+declare
+ owner_id text:='00000000-0000-0000-0000-000000000001';
+ crew_id text:='00000000-0000-0000-0000-000000000002';
+ other_id text:='00000000-0000-0000-0000-000000000003';
+ plan jsonb; room jsonb; owned_room jsonb; other_room jsonb; answer jsonb; report_args jsonb; observation jsonb; old_invite text;
+begin
+ plan:=jsonb_build_object('event',jsonb_build_object('id','race','name','Quiet trail','target_time','10:00','total_distance',100),
+ 'startAt',now()-interval '1 day','checkpoints',jsonb_build_array(jsonb_build_object('id','half','name','Halfway','distance_from_start',50,'order_index',0)),
+ 'operations',jsonb_build_object('variation',10,'stops','{}'::jsonb),'logistics',jsonb_build_array('Blue van'));
+ perform set_config('test.actor',owner_id,false);
+ perform pg_temp.denied('create',jsonb_build_object('snapshot',plan,'localId','bad','name','FUCK'),'not allowed');
+ perform pg_temp.denied('create',jsonb_build_object('snapshot',jsonb_set(plan,'{event,name}','"kill yourself"'),'localId','bad','name','Runner'),'not allowed');
+ answer:=public.ue_live('create',jsonb_build_object('snapshot',plan,'localId','race','name','Runner'));
+ room:=public.ue_live('read',jsonb_build_object('roomId',answer->>'id'));
+ perform set_config('test.room',room->>'id',false);
+ old_invite:=room->>'invite';
+ perform set_config('test.actor',other_id,false);
+ perform pg_temp.denied('read',jsonb_build_object('roomId',room->>'id'),'unavailable');
+ perform pg_temp.denied('abuse_report',jsonb_build_object('roomId',room->>'id','userId',owner_id),'current member');
+ perform pg_temp.denied('block_user',jsonb_build_object('roomId',room->>'id','userId',owner_id),'current member');
+ perform set_config('test.actor',crew_id,false);
+ perform pg_temp.denied('join',jsonb_build_object('code',old_invite,'name','SHIT'),'not allowed');
+ perform public.ue_live('join',jsonb_build_object('code',old_invite,'name','Crew'));
+ perform pg_temp.denied('publish',jsonb_build_object('roomId',room->>'id','snapshot',plan,'revision',1),'Only the owner');
+ report_args:=jsonb_build_object('roomId',room->>'id','userId',owner_id,'reason','threats','details','Review the plan','requestId',gen_random_uuid());
+ perform pg_temp.denied('abuse_report',report_args||jsonb_build_object('userId',crew_id),'current member');
+ perform pg_temp.denied('abuse_report',report_args||jsonb_build_object('reason','unknown'),'Choose a reason');
+ perform pg_temp.denied('abuse_report',report_args||jsonb_build_object('details',repeat('a',1001)),'1,000');
+ answer:=public.ue_live('abuse_report',report_args);
+ assert answer=public.ue_live('abuse_report',report_args),'abuse retry must be idempotent';
+ for report_number in 2..10 loop
+ perform public.ue_live('abuse_report',report_args||jsonb_build_object('requestId',gen_random_uuid()));
+ end loop;
+ perform pg_temp.denied('abuse_report',report_args||jsonb_build_object('requestId',gen_random_uuid()),'limit reached');
+ assert answer=public.ue_live('abuse_report',report_args),'idempotency survives rate limit';
+ observation:=jsonb_build_object('roomId',room->>'id','requestId',gen_random_uuid(),'checkpointId','half','kind','in','at',now()-interval '12 hours','revision',0,'authorId',owner_id);
+ perform public.ue_live('report',observation);
+ perform public.ue_live('report',observation);
+ answer:=public.ue_live('read',jsonb_build_object('roomId',room->>'id'));
+ assert jsonb_array_length(answer->'reports')=1,'observation retry must remain idempotent';
+ assert answer->'reports'->0->>'authorId'=crew_id,'cannot forge observation author';
+ perform pg_temp.denied('report',observation||jsonb_build_object('requestId',gen_random_uuid()),'changed on another device');
+ answer:=public.ue_live('create',jsonb_build_object('snapshot',plan,'localId','crew-race','name','Crew'));
+ owned_room:=public.ue_live('read',jsonb_build_object('roomId',answer->>'id'));
+ perform set_config('test.actor',owner_id,false);
+ perform public.ue_live('join',jsonb_build_object('code',owned_room->>'invite','name','Runner'));
+ perform pg_temp.denied('publish',jsonb_build_object('roomId',room->>'id','snapshot',jsonb_set(plan,'{logistics}','["I will kill you"]'),'revision',1),'not allowed');
+ perform public.ue_live('publish',jsonb_build_object('roomId',room->>'id','snapshot',plan,'revision',1));
+ perform pg_temp.denied('publish',jsonb_build_object('roomId',room->>'id','snapshot',plan,'revision',1),'Plan changed');
+ perform set_config('test.actor',other_id,false);
+ answer:=public.ue_live('create',jsonb_build_object('snapshot',plan,'localId','third-race','name','Third'));
+ other_room:=public.ue_live('read',jsonb_build_object('roomId',answer->>'id'));
+ perform set_config('test.actor',owner_id,false);
+ perform public.ue_live('join',jsonb_build_object('code',other_room->>'invite','name','Runner'));
+ perform set_config('test.actor',crew_id,false);
+ perform public.ue_live('join',jsonb_build_object('code',other_room->>'invite','name','Crew'));
+ answer:=public.ue_live('block_user',jsonb_build_object('roomId',room->>'id','userId',owner_id));
+ assert answer->'removedRoomIds' @> jsonb_build_array(room->>'id',other_room->>'id'),'block leaves non-owned common rooms';
+ perform pg_temp.denied('read',jsonb_build_object('roomId',room->>'id'),'unavailable');
+ perform pg_temp.denied('report',observation||jsonb_build_object('requestId',gen_random_uuid()),'unavailable');
+ perform pg_temp.denied('join',jsonb_build_object('code',old_invite,'name','Crew'),'user block');
+ answer:=public.ue_live('read',jsonb_build_object('roomId',owned_room->>'id'));
+ assert jsonb_array_length(answer->'members')=1,'block removes target from blocker-owned room';
+ assert answer->>'invite'<>owned_room->>'invite','block rotates owned-room invitation';
+ perform set_config('test.actor',owner_id,false);
+ perform pg_temp.denied('join',jsonb_build_object('code',answer->>'invite','name','Runner'),'user block');
+ answer:=public.ue_live('read',jsonb_build_object('roomId',room->>'id'));
+ assert jsonb_array_length(answer->'reports')=0,'blocked historical observations hidden server-side';
+ perform public.ue_live('close',jsonb_build_object('roomId',room->>'id'));
+ perform set_config('test.guest','false',false);
+ perform pg_temp.denied('delete_identity','{}','Only collaboration guest');
+ perform set_config('test.guest','true',false);
+end $$;
+
+reset role;
+do $$
+begin
+ assert (select count(*) from public.ue_live_abuse_reports)=10,'private queue holds ten reports';
+ assert (select bool_and(room_id is null and evidence->'plan' is not null and not evidence ? 'invite') from public.ue_live_abuse_reports),'room closure retains bounded evidence without invite';
+ assert (select bool_and(not (evidence->'truncation'->>'planTruncated')::boolean and not (evidence->'truncation'->>'observationsTruncated')::boolean) from public.ue_live_abuse_reports),'small evidence remains complete';
+ assert not has_function_privilege('authenticated','public.ue_live_core(text,jsonb)','execute'),'core RPC inaccessible';
+ assert not has_function_privilege('anon','public.ue_live(text,jsonb)','execute'),'anonymous role cannot call RPC';
+ assert not has_table_privilege('authenticated','public.ue_live_abuse_reports','select'),'reports private';
+ assert not has_table_privilege('authenticated','public.ue_live_blocks','insert'),'blocks RPC-only';
+ assert not has_table_privilege('authenticated','public.ue_live_content_terms','update'),'filter configuration private';
+ assert (select bool_and(relrowsecurity) from pg_class where oid in ('public.ue_live_abuse_reports'::regclass,'public.ue_live_blocks'::regclass,'public.ue_live_content_terms'::regclass)),'RLS enabled';
+end $$;
+set role authenticated;
+select set_config('test.actor','00000000-0000-0000-0000-000000000002',false);
+select public.ue_live('delete_identity');
+select public.ue_live('delete_identity');
+select pg_temp.denied('list','{}','Sign in');
+reset role;
+do $$
+begin
+ assert not exists(select 1 from public.ue_live_abuse_reports),'guest deletion removes associated abuse evidence';
+ assert not exists(select 1 from public.ue_live_blocks),'guest deletion removes blocks';
+ assert not exists(select 1 from public.ue_live_rooms where owner_id='00000000-0000-0000-0000-000000000002'),'guest deletion removes owned rooms';
+ assert not exists(select 1 from public.ue_live_members where user_id='00000000-0000-0000-0000-000000000002'),'guest deletion removes memberships';
+ assert not exists(select 1 from public.ue_live_reports where user_id='00000000-0000-0000-0000-000000000002'),'guest deletion removes timing reports';
+end $$;