@vailix/mask 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
2
+ import { lt, gt, inArray } from 'drizzle-orm';
3
+ import { randomUUID } from 'react-native-quick-crypto';
4
+ import AsyncStorage from '@react-native-async-storage/async-storage';
5
+ import type { VailixDB } from './types';
6
+
7
+ const SCAN_HISTORY_KEY = 'vailix_scan_history';
8
+
9
+ export const scannedEvents = sqliteTable('scanned_events', {
10
+ id: text('id').primaryKey(),
11
+ rpi: text('rpi').notNull(),
12
+ metadataKey: text('metadata_key').notNull(),
13
+ timestamp: integer('timestamp').notNull(),
14
+ }, (t) => [index('rpi_idx').on(t.rpi)]);
15
+
16
+ /** Type for a scanned event row */
17
+ export type ScannedEvent = typeof scannedEvents.$inferSelect;
18
+
19
+ export class StorageService {
20
+ private rescanIntervalMs: number;
21
+ private lastScanByRpi = new Map<string, number>();
22
+
23
+ // Limit scan history to prevent unbounded memory growth
24
+ private static readonly MAX_SCAN_HISTORY_SIZE = 10000;
25
+
26
+ constructor(private db: VailixDB, config: { rescanIntervalMs?: number } = {}) {
27
+ this.rescanIntervalMs = config.rescanIntervalMs ?? 0;
28
+ }
29
+
30
+ // Load persisted scan history on init
31
+ async initialize(): Promise<void> {
32
+ const stored = await AsyncStorage.getItem(SCAN_HISTORY_KEY);
33
+ if (stored) {
34
+ const entries: [string, number][] = JSON.parse(stored);
35
+ this.lastScanByRpi = new Map(entries);
36
+ }
37
+ }
38
+
39
+ // Check if rescan is allowed for this RPI
40
+ canScan(rpi: string): boolean {
41
+ if (this.rescanIntervalMs === 0) return true;
42
+ const lastScan = this.lastScanByRpi.get(rpi);
43
+ if (!lastScan) return true;
44
+ return Date.now() - lastScan >= this.rescanIntervalMs;
45
+ }
46
+
47
+ async logScan(rpi: string, metadataKey: string, timestamp: number): Promise<void> {
48
+ await this.db.insert(scannedEvents).values({ id: randomUUID(), rpi, metadataKey, timestamp });
49
+ this.lastScanByRpi.set(rpi, Date.now());
50
+
51
+ // Prune oldest entries if exceeding max size (prevents unbounded growth)
52
+ if (this.lastScanByRpi.size > StorageService.MAX_SCAN_HISTORY_SIZE) {
53
+ const entries = Array.from(this.lastScanByRpi.entries());
54
+ entries.sort((a, b) => a[1] - b[1]); // Sort by timestamp ascending
55
+ const toRemove = entries.slice(0, entries.length - StorageService.MAX_SCAN_HISTORY_SIZE);
56
+ for (const [key] of toRemove) {
57
+ this.lastScanByRpi.delete(key);
58
+ }
59
+ }
60
+
61
+ // Persist scan history
62
+ const entries = Array.from(this.lastScanByRpi.entries());
63
+ await AsyncStorage.setItem(SCAN_HISTORY_KEY, JSON.stringify(entries));
64
+ }
65
+
66
+ async cleanupOldScans(): Promise<void> {
67
+ const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
68
+ await this.db.delete(scannedEvents).where(lt(scannedEvents.timestamp, cutoff));
69
+ // Also cleanup old RPI entries
70
+ if (this.rescanIntervalMs > 0) {
71
+ const now = Date.now();
72
+ for (const [rpi, lastScan] of this.lastScanByRpi) {
73
+ if (now - lastScan > this.rescanIntervalMs) {
74
+ this.lastScanByRpi.delete(rpi);
75
+ }
76
+ }
77
+ const entries = Array.from(this.lastScanByRpi.entries());
78
+ await AsyncStorage.setItem(SCAN_HISTORY_KEY, JSON.stringify(entries));
79
+ }
80
+ }
81
+
82
+ // Get only scans matching the given RPIs (efficient DB-level filtering)
83
+ // Batches queries to respect SQLite variable limits
84
+ async getMatchingScans(rpiList: string[]): Promise<ScannedEvent[]> {
85
+ if (rpiList.length === 0) return [];
86
+
87
+ // SQLite limit is often 999 or 32766 variables.
88
+ // We stick to a safe 500 batch size to be conservative across devices.
89
+ const BATCH_SIZE = 500;
90
+ const results: ScannedEvent[] = [];
91
+
92
+ for (let i = 0; i < rpiList.length; i += BATCH_SIZE) {
93
+ const batch = rpiList.slice(i, i + BATCH_SIZE);
94
+ const batchResults = await this.db.select().from(scannedEvents)
95
+ .where(inArray(scannedEvents.rpi, batch));
96
+ results.push(...batchResults);
97
+ }
98
+
99
+ return results;
100
+ }
101
+
102
+ async getRecentPairs(withinHours: number = 24): Promise<ScannedEvent[]> {
103
+ const cutoff = Date.now() - (withinHours * 60 * 60 * 1000);
104
+ const recent = await this.db.select()
105
+ .from(scannedEvents)
106
+ .where(gt(scannedEvents.timestamp, cutoff))
107
+ .orderBy(scannedEvents.timestamp);
108
+ return recent;
109
+ }
110
+ }
@@ -0,0 +1,20 @@
1
+ const VERSION = 'v1';
2
+
3
+ // Format: proto:v1:rpi:timestamp:metadataKey
4
+ export function formatQR(rpi: string, metadataKey: string): string {
5
+ return `proto:${VERSION}:${rpi}:${Date.now()}:${metadataKey}`;
6
+ }
7
+
8
+ export function parseQR(data: string): ParsedQR | null {
9
+ const p = data.split(':');
10
+ if (p.length !== 5 || p[0] !== 'proto' || p[1] !== VERSION) return null;
11
+ const ts = parseInt(p[3], 10);
12
+ if (isNaN(ts)) return null;
13
+ return { rpi: p[2], timestamp: ts, metadataKey: p[4] };
14
+ }
15
+
16
+ interface ParsedQR {
17
+ rpi: string;
18
+ timestamp: number;
19
+ metadataKey: string;
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite';
2
+
3
+ // Optional metadata apps can attach when reporting
4
+ export interface ReportMetadata {
5
+ [key: string]: string | number | boolean | (string | number | boolean)[] | undefined;
6
+ }
7
+
8
+ // Match result with optional reporter metadata
9
+ export interface Match {
10
+ rpi: string;
11
+ timestamp: number;
12
+ metadata?: ReportMetadata;
13
+ reportedAt?: number;
14
+ }
15
+
16
+ export interface ScanEvent {
17
+ id: string;
18
+ rpi: string;
19
+ timestamp: number;
20
+ }
21
+
22
+ // Key storage interface — allows apps to provide custom storage (e.g., iCloud sync)
23
+ export interface KeyStorage {
24
+ getKey(): Promise<string | null>;
25
+ setKey(key: string): Promise<void>;
26
+ }
27
+
28
+ export type MatchHandler = (matches: Match[]) => void;
29
+ export type VailixDB = ExpoSQLiteDatabase<Record<string, never>>;
30
+
31
+ // ============================================================================
32
+ // BLE Types
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Represents a nearby user discovered via BLE.
37
+ *
38
+ * Design: Internal details (RPI, metadataKey) are hidden from the public API.
39
+ * The app only needs: something to display, something to pair with, and status flags.
40
+ * RPIs and keys are managed internally by the package for contact tracing.
41
+ */
42
+ export interface NearbyUser {
43
+ /** Opaque identifier for pairing (pass to pairWithUser) */
44
+ id: string;
45
+ /** Generated emoji + number for UI display */
46
+ displayName: string;
47
+ /** Signal strength (for proximity filtering) */
48
+ rssi: number;
49
+ /** Timestamp of last advertisement received */
50
+ discoveredAt: number;
51
+ /** true if we have securely stored their data */
52
+ paired: boolean;
53
+ /** true if they sent us data but we haven't accepted yet (explicit mode) */
54
+ hasIncomingRequest: boolean;
55
+ /** Timestamp when pairing occurred (optional, populated after successful pair) */
56
+ pairedAt?: number;
57
+ }
58
+
59
+ /** Result of a pairing attempt */
60
+ export interface PairResult {
61
+ success: boolean;
62
+ partnerRpi?: string;
63
+ partnerMetadataKey?: string;
64
+ error?: string;
65
+ }
66
+
67
+ /**
68
+ * Unified SDK Configuration Interface
69
+ */
70
+ export interface VailixConfig {
71
+ // --- Backend & Auth ---
72
+ /** The live API endpoint for submitting reports */
73
+ reportUrl: string;
74
+ /** The endpoint for downloading keys (can be the same as reportUrl or a CDN) */
75
+ downloadUrl: string;
76
+ /** Application secret for API authentication */
77
+ appSecret: string;
78
+
79
+ // --- Storage ---
80
+ /** Custom key storage adapter (default: expo-secure-store) */
81
+ keyStorage?: KeyStorage;
82
+ /** Number of days of history to include in reports (default: 14) */
83
+ reportDays?: number;
84
+
85
+ // --- Contact Tracing Protocol ---
86
+ /** How long RPI persists in ms (default: 15min, can be 24h for STD apps) */
87
+ rpiDurationMs?: number;
88
+ /** Minimum time between scans of same RPI in ms (default: 0 = no limit) */
89
+ rescanIntervalMs?: number;
90
+
91
+ // --- BLE Proximity & Pairing ---
92
+ /** Timeout before removing a user from nearby list in ms (default: 15000) */
93
+ bleDiscoveryTimeoutMs?: number;
94
+ /** Minimum RSSI to consider a user "nearby" (default: -70) */
95
+ proximityThreshold?: number;
96
+ /** If true, automatically pair when receiving a write (default: true) */
97
+ autoAcceptIncomingPairs?: boolean;
98
+ /** Custom BLE Service UUID (default: a1b2c3d4-e5f6-7890-abcd-ef1234567890) */
99
+ serviceUUID?: string;
100
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared utilities for @vailix/mask
3
+ */
4
+
5
+ // Display name generation constants
6
+ export const EMOJIS = ['🐼', '🦊', '🐨', '🦁', '🐯', '🐸', '🦋', '🐙', '🦄', '🐳'];
7
+
8
+ /**
9
+ * Generate deterministic display name from truncated RPI (or full RPI).
10
+ * Ensures all scanners see the same name for the same user.
11
+ *
12
+ * @param rpiPrefix - RPI or RPI prefix (at least 8 chars recommended)
13
+ * @returns Emoji + Number string (e.g., "🐼 42")
14
+ */
15
+ export function generateDisplayName(rpiPrefix: string): string {
16
+ const hash = rpiPrefix.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
17
+ const emoji = EMOJIS[hash % EMOJIS.length];
18
+ const number = hash % 100;
19
+ return `${emoji} ${number}`;
20
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "jsx": "react-native",
7
+ "moduleResolution": "bundler",
8
+ "lib": [
9
+ "ESNext"
10
+ ]
11
+ },
12
+ "include": [
13
+ "src"
14
+ ]
15
+ }