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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/SECURITY-INSIGHTS.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: ChainForge Security Insights

contact:
email: security@chainforge.app
disclosure: https://github.com/ChainForgee/ChainForge/security/advisories/new

policy:
url: https://github.com/ChainForgee/ChainForge/blob/main/SECURITY.md

supported_versions:
- version: latest
status: supported
- version: "1.0"
status: unsupported

languages:
- rust
- typescript
- python
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ For component-specific contribution guidelines, refer to the README in each `app

---

## Security

To report a vulnerability, email [security@chainforge.app](mailto:security@chainforge.app) or use [GitHub's private vulnerability reporting](https://github.com/ChainForgee/ChainForge/security/advisories/new). Please do not open public issues for security reports. See [SECURITY.md](SECURITY.md) for details.

---

## License

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
123 changes: 123 additions & 0 deletions app/mobile/src/__tests__/BulkScannerScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { parseQRCode } from '../screens/BulkScannerScreen';

describe('BulkScannerScreen QR parsing', () => {
it('extracts aidId from chainforge deep link', () => {
expect(parseQRCode('chainforge://package/aid-001')).toBe('aid-001');
});

it('returns null for non-chainforge URLs', () => {
expect(parseQRCode('https://example.com/qr')).toBeNull();
});

it('returns null for malformed input', () => {
expect(parseQRCode('')).toBeNull();
expect(parseQRCode('chainforge://')).toBeNull();
expect(parseQRCode('chainforge://package/')).toBeNull();
});
});

describe('BulkScannerScreen concurrency', () => {
const MAX_CONCURRENT = 4;

function createMockQueue(): {
queue: string[];
process: () => Promise<void>;
} {
const state = { queue: [] as string[], processing: 0, completed: 0 };
return {
queue: state.queue,
process: async () => {
state.queue.push('pending');
},
};
}

it('allows up to MAX_CONCURRENT in-flight scans', () => {
let inFlight = 0;
let maxObserved = 0;

for (let i = 0; i < 50; i++) {
if (inFlight < MAX_CONCURRENT) {
inFlight++;
maxObserved = Math.max(maxObserved, inFlight);
// Simulate async completion
inFlight--;
}
}

expect(maxObserved).toBe(MAX_CONCURRENT);
});

it('throughput with concurrency ≥ 2× sequential on 50 items', async () => {
const ITEM_COUNT = 50;
const SIMULATED_LATENCY_MS = 10;

async function processSequential(ids: string[]): Promise<number> {
const start = performance.now();
for (const id of ids) {
await new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS));
}
return performance.now() - start;
}

async function processConcurrent(ids: string[]): Promise<number> {
const start = performance.now();
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += MAX_CONCURRENT) {
chunks.push(ids.slice(i, i + MAX_CONCURRENT));
}
for (const chunk of chunks) {
await Promise.all(chunk.map(() => new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS))));
}
return performance.now() - start;
}

const ids = Array.from({ length: ITEM_COUNT }, (_, i) => `aid-${i}`);

const sequentialMs = await processSequential(ids);
const concurrentMs = await processConcurrent(ids);

const speedup = sequentialMs / concurrentMs;
expect(speedup).toBeGreaterThanOrEqual(2);
});

it('skips duplicate aidIds within dedup window', () => {
const seen = new Map<string, number>();
const DEDUP_TTL_MS = 5000;
const results: string[] = [];

const scans = ['aid-1', 'aid-2', 'aid-1', 'aid-3', 'aid-2'];

for (const aidId of scans) {
const now = Date.now();
const lastSeen = seen.get(aidId);
if (lastSeen && now - lastSeen < DEDUP_TTL_MS) {
results.push('skipped');
} else {
seen.set(aidId, now);
results.push('processed');
}
}

expect(results).toEqual(['processed', 'processed', 'skipped', 'processed', 'skipped']);
});

it('rate-limits scans within RATE_LIMIT_MS window', () => {
const RATE_LIMIT_MS = 300;
const seen = new Map<string, number>();
const results: string[] = [];

const now = Date.now();
seen.set('aid-1', now);

// Immediate re-scan of same aidId
const elapsed = Date.now() - (seen.get('aid-1') ?? 0);
if (elapsed < RATE_LIMIT_MS) {
results.push('rate-limited');
} else {
results.push('allowed');
}

expect(results).toEqual(['rate-limited']);
});
});
130 changes: 92 additions & 38 deletions app/mobile/src/screens/BulkScannerScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Text,
View,
StyleSheet,
Dimensions,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { BarCodeScanner } from 'expo-barcode-scanner';
Expand All @@ -27,17 +26,28 @@ interface SessionStats {
skipped: number;
}

const MAX_CONCURRENT = 4;
const RATE_LIMIT_MS = 300;
const DEDUP_TTL_MS = 5000;

export const parseQRCode = (data: string): string | null => {
const match = data.match(/^chainforge:\/\/package\/(.+)$/);
return match?.[1] ?? null;
};

export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error'; message: string } | null>(null);
const [inFlightCount, setInFlightCount] = useState(0);
const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error' | 'skipped'; message: string } | null>(null);
const [stats, setStats] = useState<SessionStats>({
scanned: 0,
verified: 0,
failed: 0,
skipped: 0,
});

const seenRef = useRef<Map<string, number>>(new Map());
const resultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { colors } = useTheme();
const { queueClaimConfirmation, isConnected } = useSync();

Expand All @@ -50,45 +60,76 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
getBarCodeScannerPermissions();
}, []);

const handleBarCodeScanned = async ({ type, data }: { type: string; data: string }) => {
if (isProcessing) return;
setIsProcessing(true);
useEffect(() => {
return () => {
if (resultTimerRef.current) clearTimeout(resultTimerRef.current);
};
}, []);

// Check if it's the correct format: chainforge://package/{id}
const regex = /^chainforge:\/\/package\/(.+)$/;
const match = data.match(regex);
const showResult = useCallback(
(result: { status: 'success' | 'error' | 'skipped'; message: string }) => {
if (resultTimerRef.current) clearTimeout(resultTimerRef.current);
setLastScanResult(result);
resultTimerRef.current = setTimeout(() => setLastScanResult(null), 2000);
},
[],
);

const handleBarCodeScanned = useCallback(
async ({ type, data }: { type: string; data: string }) => {
if (inFlightCount >= MAX_CONCURRENT) return;

setStats(prev => ({ ...prev, scanned: prev.scanned + 1 }));
const aidId = parseQRCode(data);

if (match && match[1]) {
const aidId = match[1];
const claimId = `claim-${aidId}`; // Assuming standard claimId format for bulk verify
if (!aidId) {
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, failed: prev.failed + 1 }));
showResult({ status: 'error', message: 'Invalid ChainForge QR code.' });
return;
}

const now = Date.now();
const lastSeen = seenRef.current.get(aidId);

if (lastSeen && now - lastSeen < DEDUP_TTL_MS) {
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 }));
showResult({ status: 'skipped', message: 'Duplicate scan — skipped.' });
return;
}

if (lastSeen && now - lastSeen < RATE_LIMIT_MS) {
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 }));
showResult({ status: 'skipped', message: 'Rate limited — slow down.' });
return;
}

seenRef.current.set(aidId, now);
setStats(prev => ({ ...prev, scanned: prev.scanned + 1 }));
setInFlightCount(prev => prev + 1);

const claimId = `claim-${aidId}`;

try {
const result = await queueClaimConfirmation(aidId, claimId);

if (result.status === 'completed' || result.status === 'queued') {
setStats(prev => ({ ...prev, verified: prev.verified + 1 }));
setLastScanResult({
status: 'success',
message: result.status === 'completed' ? 'Package verified successfully!' : 'Package queued for verification (offline).'
showResult({
status: 'success',
message:
result.status === 'completed'
? 'Package verified successfully!'
: 'Package queued for verification (offline).',
});
}
} catch (error) {
} catch {
setStats(prev => ({ ...prev, failed: prev.failed + 1 }));
setLastScanResult({ status: 'error', message: 'Verification failed. Please try again.' });
showResult({ status: 'error', message: 'Verification failed. Please try again.' });
} finally {
setInFlightCount(prev => prev - 1);
}
} else {
setStats(prev => ({ ...prev, failed: prev.failed + 1 }));
setLastScanResult({ status: 'error', message: 'Invalid ChainForge QR code.' });
}

// Short delay before allowing the next scan to provide feedback
setTimeout(() => {
setIsProcessing(false);
setLastScanResult(null);
}, 2000);
};
},
[inFlightCount, queueClaimConfirmation, showResult],
);

if (hasPermission === null) {
return (
Expand All @@ -112,10 +153,12 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
);
}

const isAtCapacity = inFlightCount >= MAX_CONCURRENT;

return (
<View style={styles.container}>
<BarCodeScanner
onBarCodeScanned={isProcessing ? undefined : handleBarCodeScanned}
onBarCodeScanned={isAtCapacity ? undefined : handleBarCodeScanned}
style={StyleSheet.absoluteFillObject}
/>

Expand All @@ -134,31 +177,42 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
<Text style={[styles.statValue, { color: colors.error }]}>{stats.failed}</Text>
<Text style={styles.statLabel}>Failed</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: colors.warning ?? '#FFD700' }]}>{inFlightCount}</Text>
<Text style={styles.statLabel}>In Flight</Text>
</View>
</View>

<View style={styles.viewfinderContainer}>
<View style={[styles.viewfinder, isProcessing && styles.viewfinderProcessing]} />
<View style={[styles.viewfinder, isAtCapacity && styles.viewfinderProcessing]} />
</View>

{/* Feedback Area */}
<View style={styles.feedbackContainer}>
{isProcessing && !lastScanResult && (
{isAtCapacity && !lastScanResult && (
<View style={styles.processingIndicator}>
<ActivityIndicator color="white" size="small" />
<Text style={styles.processingText}>Processing...</Text>
<Text style={styles.processingText}>Processing {inFlightCount}/{MAX_CONCURRENT}…</Text>
</View>
)}

{lastScanResult && (
<View style={[
styles.resultBadge,
{ backgroundColor: lastScanResult.status === 'success' ? colors.success : colors.error }
styles.resultBadge,
{
backgroundColor:
lastScanResult.status === 'success'
? colors.success
: lastScanResult.status === 'skipped'
? (colors.warning ?? '#FFD700')
: colors.error,
},
]}>
<Text style={styles.resultText}>{lastScanResult.message}</Text>
</View>
)}

{!isProcessing && !lastScanResult && (
{!isAtCapacity && !lastScanResult && (
<Text style={styles.instructionText}>Align QR code to scan</Text>
)}

Expand Down
Loading