diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..75f44f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/) and [Keep a Changelog](http://keepachangelog.com/). + + +## [Unreleased] - Date + +### Added + +### Changed + +### Fixed + +### Removed + +### Security + + +## [0.1.0] - 2026-07-17 + +### Added + +- **"Encontrado" (Found) reports** — end users can mark an SOS victim as found by + target ID + their own location, end to end: + - New signed 118-byte *found frame* (`22 B payload + 32 B finder pubkey + 64 B + Ed25519 signature`, domain `guacamaya.found-frame.v1`), byte-identical between + `packages/shared/src/mesh/foundFrame.ts` and the Android + `net.guacamaya.proto.FoundPayload`. + - `POST /ingest` accepts a new `foundFrames` array. Every frame is re-verified + zero-trust (CRC16 → GPS-fix sanity → domain-separated Ed25519) before anything + is persisted; reports whose target SOS is unknown are rejected. + - **Proximity validation against the SOS's last reference point**: the backend + computes the haversine distance from the finder's signed GPS position to the + target SOS's last reported point. Within `FOUND_PROXIMITY_METERS` (default + 100 m) → `near` / `likely_found`; further → `far` / `possible_false_positive` + (⚠️ flags trolls and mistaken reports instead of hiding them). Verdicts are + returned in the ingest response (`foundAccepted`, `foundDuplicate`, + `foundRejected`, `foundReasons`, `foundResults[]`). + - New community channel **`encontrado`** (`GET /channels/encontrado/records`, + WebSocket broadcasts) holding the reports as `verified:false` records. + - Android: "**Marcar como encontrado**" action on the Radar screen with a local + near/far pre-check dialog, a new Room queue (`outbound_found`, DB v10), and + data-mule upload of queued reports through the existing `/ingest` worker. + - New backend env var `FOUND_PROXIMITY_METERS` (default 100). diff --git a/CLAUDE.md b/CLAUDE.md index 1c3a14b..5911fe8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,24 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this branch is -The **`develop` branch is the consolidated GuacaMalla monorepo** holding both halves of the product: +The **`develop` branch is the consolidated GuacaMalla monorepo** holding every half of the product: -- **`backend/` + `packages/`** — the GuacaMalla Red backend (Bun + TypeScript). It is the **data-mule - ingestion point** for the mesh (`POST /ingest`), serves official signed channels, and feeds the - moving-map location history. The mesh works without it; the backend is never a hard dependency. +- **`backend/` + `packages/`** — the GuacaMalla Red backend (Bun + TypeScript + Fastify). It is the + **data-mule ingestion point** for the mesh (`POST /ingest`), serves official signed channels, runs + the resolve/ledger flow, and feeds the moving-map dashboard. The mesh works without it; the backend + is never a hard dependency. - **`android/`** — the native Kotlin + Compose **GuacaMalla mesh app**, the connectionless BLE/Wi-Fi-Aware mesh. A **self-contained Gradle project** (its own `gradlew`, `build.gradle.kts`, `docs/`, `CLAUDE.md`); open `android/` directly in Android Studio. See **`android/CLAUDE.md`** for - the mesh side. - -The abandoned Expo app that used to live in `app/` has been removed. `android/` is **not** a Bun -workspace — keep it out of the root `package.json` workspaces. + the mesh side. A Cursor rule (`.cursor/rules/device-test-first.mdc`) requires Android BLE/UI changes + to be verified on connected adb devices (`cd android && ./scripts/demo.sh install` / + `device-test` / `logcat`) before claiming they work. +- **`guacamalla-landing/landing/`** — the public marketing site (Vite + React 19 + Tailwind 4 + + shadcn/Radix). It has its **own `bun.lock`** and is **not** a root Bun workspace; run it via + `bun run dev:landing`. It is **connected to Lovable** (see its `AGENTS.md`): never force-push, + rebase, or amend commits already pushed on its branch, and keep the branch in a working state. + +`android/` is **not** a Bun workspace — keep it (and the landing) out of the root `package.json` +workspaces (`packages/*`, `backend`). The `app/` directory is a leftover stub (a lone +`expo-env.d.ts`) from the removed Expo app — nothing lives there. Working language of comments/docs is Spanish in places; code identifiers are English. ## Brand rules -- The product is **GuacaMalla Net** (mesh app + optional backend). Use *GuacaMalla* in prose, docs, +- The product is **GuacaMalla** (mesh app + optional backend). Use *GuacaMalla* in prose, docs, comments, and UI. **SOSNet** is a retired name — do not use it. - **Code identifiers intentionally stay `guacamaya`** — the brand fix (Guacamaya→GuacaMalla) was *visible + docs only*. Do **not** "fix" these to `guacamalla`; doing so changes the app identity, @@ -29,37 +37,41 @@ Working language of comments/docs is Spanish in places; code identifiers are Eng - Android package + `namespace`/`applicationId` (`net.guacamaya`) and action strings (`net.guacamaya.action.*`). - npm/workspace packages (`@guacamaya/*`, `guacamaya-net`) and env vars (`GUACAMAYA_ADMIN_KEY`, `GUACAMAYA_*_KEY`). - Runtime keys: Keystore alias `guacamaya_master`, Room DB `guacamaya.db`, WorkManager `guacamaya-ingest-upload`, - notification channel/log tags (`guacamaya.*`), and the WS subprotocol prefix `guacamaya.` (matched on both sides). + notification channel/log tags (`guacamaya.*`), signing domains (`guacamaya.rescuer-cert.v1`, + `CONTROL_SIGN_DOMAIN`, …), and the WS subprotocol prefix (matched on both sides). - Wire-format constants live in `packages/shared/src/mesh/` and must stay byte-identical with `net.guacamaya.proto.*` on Android. - Exception: the git branch name **`init-sosnet`** is a literal remote branch name until renamed. > **Context rule:** when a request touches anything under `backend/` or the shared user flow (API > contracts, data shapes shared between mesh and backend, WebSocket events, channel/location -> ingestion), read `backend/CLAUDE.md` first — it has the three-layer stack pattern, the dedup -> invariant, environment setup, and per-domain commands. +> ingestion, auth keys), read `backend/CLAUDE.md` first — it has the three-layer stack pattern, the +> dedup invariant, the security/key model, environment setup, and per-domain commands. > **Mobile integration:** `backend_final.md` (repo root) is the authoritative per-endpoint reference > for wiring the Android app to the backend — auth model (which keys the app must/must not hold), the > 118-byte upload-frame layout for the `IngestClient`, every endpoint's request/response, the resolve > witness-signing format, and the recommended mule-upload flow. Read it before touching the -> mesh↔backend HTTP/WS contract or building the Android `IngestClient`. +> mesh↔backend HTTP/WS contract or building against the Android `IngestClient`. ## Run / test (Bun-first — never npm/node) ```bash bun install bun run dev:backend # bun --watch backend/src/index.ts (HTTP + WS on :3000) -bun test # shared-package unit tests (bun:test) -bun run build # typecheck/build shared + backend +bun run dev:landing # Vite dev server for the landing site +bun test # all unit tests (packages/shared + backend, bun:test) +bun test packages/shared/src/ledger.test.ts # single test file +bun run build # typecheck/build shared + backend + landing +bun run keygen # generate BACKEND_PRIVATE_KEY_HEX + GUACAMAYA_ADMIN_KEY ``` Bun installs to `~/.bun/bin`. Runs out of the box with **no database**: if `SUPABASE_URL` / -`SUPABASE_SERVICE_ROLE_KEY` are unset, the data layer falls back to in-memory stores -(`backend/src/channels/store.ts`, `backend/src/locations/store.ts`). Configure `backend/.env` from -`backend/.env.example`; set `BACKEND_PRIVATE_KEY_HEX` so the server's Ed25519 identity (and public -key) stays stable across reboots — otherwise a fresh ephemeral keypair is generated each boot (it -warns on startup). +`SUPABASE_SERVICE_ROLE_KEY` are unset, the data layer falls back to in-memory stores (`store.ts` in +each domain). Configure `backend/.env` from `backend/.env.example`; set `BACKEND_PRIVATE_KEY_HEX` so +the server's Ed25519 identity stays stable across reboots (otherwise an ephemeral keypair is +generated each boot, with a startup warning). `NODE_ENV=production` refuses to start without +`GUACAMAYA_ADMIN_KEY`. ## Architecture @@ -75,26 +87,52 @@ trajectory for the moving-map dashboard. Like records, `id` = SHA-256 of canonic ### Layout -- `packages/shared/` — `ChannelRecord` + `LocationPoint` types, `getRecordId`, `getLocationId`, - `verifyRecordSignature`, `mergeLogs` (union + dedupe + signature check). Pure, `bun:test` coverage. - The contract both sides share. Keep it framework-agnostic. -- `backend/src/index.ts` — Fastify bootstrap (CORS, rate-limit 100/min), registers route plugins, WS, graceful shutdown. -- `backend/src/channels/routes.ts` — channel HTTP API + `/ingest` (see below). `store.ts` — in-memory fallback. -- `backend/src/locations/routes.ts` — location HTTP API (see below). `store.ts` — in-memory fallback. +- `packages/shared/` — the contract both sides share. Pure, framework-agnostic, `bun:test` coverage: + - `types.ts`, `crypto.ts`, `merge.ts` — `ChannelRecord`/`LocationPoint`, `getRecordId`/`getLocationId`, + `verifyRecordSignature`, `mergeLogs` (union + dedupe + signature check). + - `canonical.ts` — canonical JSON serialization + `SosId`/`EventId` formats; every signed structure + hashes through it. + - `control.ts` — `ControlReceipt` verification, **rescuer certificates** (7-day max, domain + `guacamaya.rescuer-cert.v1`) and the signed **CRL** the backend issues. + - `ledger.ts` — the SOS lifecycle event chain: `SOS_CREATED | SOS_RESOLVED | SOS_CANCELLED | + SOS_EXPIRED | KEY_REVOKED`, event hashing/chaining, replay guard. + - `resolve.ts` — witness envelope + quorum verification for finder co-signed disarms. + - `mesh/` — binary wire format: `constants.ts` (offsets/sizes/domains), `crc16.ts` (CRC16-CCITT), + `controlFrame.ts` (22-byte control payload, byte-identical with Android `ControlPayload.kt`, + domain-separated signature so SOS and control signatures are never interchangeable). +- `backend/src/index.ts` — Fastify bootstrap (helmet+CSP, CORS, rate limits), registers all route + plugins, WS, periodic sweep, graceful shutdown. +- `backend/src//` — one directory per domain, each following the same three-layer stack + (`routes.ts` → `*Repo.ts` Supabase-or-fallback → `store.ts` in-memory): `channels/` (+ `/ingest`), + `locations/`, `resolve/` (witness quorum + evidence upload), `control/` (NAN control-receipt + ingress + replay guard), `ledger/` (SOS lifecycle chain), `waitlist/` (landing signups → Resend + email), `dashboard/` (self-contained Leaflet map page, public, coordinates degraded ~1 km). - `backend/src/crypto/` — `keys.ts` (server identity), `signer.ts` (signs official records). -- `backend/src/mesh/frame.ts` — **decodes + zero-trust-verifies GuacaMalla binary mesh frames** for `/ingest`. -- `backend/src/db/` — `channelsRepo.ts`, `locationsRepo.ts` (Supabase with in-memory fallback), `supabase.ts`. Schema: `backend/supabase/schema.sql`. -- `backend/src/ws/server.ts` — WebSocket `/ws`, subscribe/unsubscribe per channel, `broadcastRecord`, `broadcastLocation`. - -### HTTP API - -- `GET /pubkey` — backend public key (clients verify official records against it). -- `GET /channels` — channel list. -- `GET /channels/:id/records?since=` — records since a timestamp. -- `POST /channels/:id/records` — create an **official** record: backend signs it (`verified:true`), persists, broadcasts over WS. -- `POST /ingest` — **data-mule upload of signed binary mesh frames** (see below). Also populates the - location history: each verified frame's lat/lon becomes a `LocationPoint`. -- `GET /locations?since=&deviceId=` — location points for the moving-map dashboard (read-only). +- `backend/src/mesh/frame.ts` — **decodes + zero-trust-verifies GuacaMalla binary mesh frames** for + `/ingest`; `controlFrame.ts` for control frames. +- `backend/src/security/` — API-key auth (timing-safe), env-driven config, per-route rate limits, + input validation, `keygen.ts`. Key model (admin/read/WS keys) is detailed in `backend/CLAUDE.md`. +- `backend/src/db/` — Supabase repos with in-memory fallback. Schema: `backend/supabase/schema.sql`. +- `backend/src/ws/server.ts` — WebSocket `/ws` (key-gated), subscribe/unsubscribe per channel, + `broadcastRecord`, `broadcastLocation`, `broadcastResolve`. + +### HTTP API (details + auth per endpoint in `backend_final.md`) + +- `GET /pubkey` — backend public key (clients verify official records/receipts against it). +- `GET /channels`, `GET /channels/:id/records?since=` — channel reads. +- `POST /channels/:id/records` — create an **official** record (admin key; backend signs it + `verified:true`, persists, broadcasts over WS). +- `POST /ingest` — **data-mule upload of signed binary mesh frames** (see below). +- `GET /locations?since=&deviceId=` — location points (read key), moving-map dashboard. +- `POST /resolve/evidence` + `POST /resolve` — finder co-signed disarm of an active SOS: photo + evidence upload, then witness-quorum envelope; backend re-verifies every witness signature. +- `POST /control/receipts` — NAN control-receipt ingress (backend-resolve / self-cancel / delivery); + verified receipts derive ledger events (`SOS_RESOLVED` / `SOS_CANCELLED`). +- `GET /ledger/state/:sosId`, `GET /ledger/events/:sosId`, `GET /ledger/events` (cursor-paginated) — + SOS lifecycle ledger reads (`POST /ledger/events` is a 501 stub — p2p event transport not enabled). +- `POST /operator/rescuer-certificates` — admin-key issuance of short-lived rescuer certificates. +- `POST /waitlist` — landing-page signup (validated, rate-limited, Resend confirmation email). +- `GET /dashboard` — public self-contained alert map (privacy-degraded coordinates). ### `POST /ingest` — the data-mule bridge (most important flow) @@ -119,16 +157,20 @@ broadcasts it to `"locations"` WS subscribers — same zero-trust gate, no separ ## Conventions worth keeping -- **Bun only** for package management and scripts (project preference). `.env.example` mentioning `npm` is stale. -- `/ingest` must **never** persist a frame whose signature it hasn't re-verified. Verification is the gate. +- **Bun only** for package management and scripts (project preference). +- `/ingest` must **never** persist a frame whose signature it hasn't re-verified. Verification is the + gate — the same holds for `/control/receipts`, `/resolve`, and `/ledger/events`: every signature is + re-verified server-side and receipts go through the replay guard. - Keep `packages/shared` framework-agnostic — it's the contract; don't pull backend-only deps into it. -- The binary frame layout in `frame.ts` mirrors `proto/Payload.kt` on the mesh branch; if the wire - format changes there (offsets, sizes, CRC), update both or `/ingest` silently rejects valid frames. +- The binary frame layouts in `backend/src/mesh/` and `packages/shared/src/mesh/` mirror + `proto/Payload.kt` / `proto/ControlPayload.kt` on the Android side; if the wire format changes + there (offsets, sizes, CRC, signing domains), update both or `/ingest` silently rejects valid frames. +- All signatures are **domain-separated** (e.g. `guacamaya.rescuer-cert.v1`, `CONTROL_SIGN_DOMAIN`) — + never sign or verify bare payload bytes for a new structure; add a new domain constant. - **Always recompute `id` server-side** (`getRecordId` / `getLocationId`) on ingest — never trust a client-supplied id. - **Locations are frame-derived, never client-trusted.** The canonical, authenticated source of a device's position is the **lat/lon inside a verified mesh frame** — `decodeAndVerifyFrame` emits the `LocationPoint` and `/ingest` persists it. There is intentionally **no** trusted-JSON location - ingest endpoint (the original `POST /ingest/locations` was removed): the same Ed25519 gate that - guards records guards positions. `deviceId` is derived from the verified pubkey, never supplied by - the client. `GET /locations` stays read-only. + ingest endpoint: the same Ed25519 gate that guards records guards positions. `deviceId` is derived + from the verified pubkey, never supplied by the client. `GET /locations` stays read-only. diff --git a/Dockerfile b/Dockerfile index 409e04a..5470a4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # GuacaMalla Net — backend image (Railway / Docker). Also builds and bundles -# the marketing/waitlist page (web/landing), which the backend serves as -# static files at GET / (see backend/src/index.ts) — one image, one service. +# the marketing/waitlist page (guacamalla-landing/landing), which the backend +# serves as static files at GET / (see backend/src/index.ts) — one image, one +# service. # # IMPORTANT: build context is the REPO ROOT, not backend/. The backend imports # @guacamaya/shared (a Bun `workspace:*` package) and backend/tsconfig.json @@ -19,18 +20,23 @@ WORKDIR /app COPY package.json bun.lock ./ COPY backend/package.json ./backend/package.json COPY packages/shared/package.json ./packages/shared/package.json -COPY web/landing/package.json ./web/landing/package.json RUN bun install --frozen-lockfile +# The landing is NOT a root workspace — it ships its own bun.lock/bunfig +# (self-contained Vite app), so it gets its own cached install layer. +COPY guacamalla-landing/landing/package.json guacamalla-landing/landing/bun.lock guacamalla-landing/landing/bunfig.toml ./guacamalla-landing/landing/ +RUN bun install --frozen-lockfile --cwd guacamalla-landing/landing + # 2) Copy the source. node_modules is excluded via .dockerignore so this does -# not clobber the installed deps from the layer above. +# not clobber the installed deps from the layers above. COPY packages/ ./packages/ COPY backend/ ./backend/ -COPY web/landing/ ./web/landing/ +COPY guacamalla-landing/landing/ ./guacamalla-landing/landing/ -# 3) Build the landing page to static files (web/landing/dist) — no SSR, no -# server needed at runtime, the backend just serves the compiled output. -RUN bun run --cwd web/landing build +# 3) Build the landing page to static files (guacamalla-landing/landing/dist) — +# no SSR, no server needed at runtime, the backend just serves the compiled +# output. +RUN bun run --cwd guacamalla-landing/landing build # Production by design: the server refuses to boot without GUACAMAYA_ADMIN_KEY # (set it, and the rest, as Railway Variables — see backend/.env.example). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 43cb486..fd28f88 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,6 +22,9 @@ fun prop(name: String, fallback: String): String = val backendDebugUrl: String = prop("BACKEND_BASE_URL", "http://10.0.2.2:3000") val backendReleaseUrl: String = prop("BACKEND_RELEASE_URL", "https://guacamaya.invalid") +// Trust anchor, supplied only at build time from deployment configuration. Runtime +// GET /pubkey is diagnostic; it is never allowed to replace this key (no TOFU). +val backendPublicKeyHex: String = prop("BACKEND_PUBLIC_KEY_HEX", "") android { namespace = "net.guacamaya" @@ -40,7 +43,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -48,12 +52,14 @@ android { ) // Deployed HTTPS backend (placeholder until one exists — see backendReleaseUrl). buildConfigField("String", "BACKEND_BASE_URL", "\"$backendReleaseUrl\"") + buildConfigField("String", "BACKEND_PUBLIC_KEY_HEX", "\"$backendPublicKeyHex\"") } debug { isMinifyEnabled = false // Emulator loopback by default; override with -PBACKEND_BASE_URL for a LAN backend. // Cleartext allowed broadly by the debug network-security-config. buildConfigField("String", "BACKEND_BASE_URL", "\"$backendDebugUrl\"") + buildConfigField("String", "BACKEND_PUBLIC_KEY_HEX", "\"$backendPublicKeyHex\"") manifestPlaceholders["firebase_crashlytics_collection_enabled"] = "true" firebaseAppDistribution { groups = "internal-testers" diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 0f47ea3..ff7e7cc 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -6,6 +6,19 @@ + + + + + + + + + + + + + + android:exported="true" + android:permission="android.permission.DUMP"> diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4e1ee5c..664aa55 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -63,16 +63,6 @@ - - - - - - - - - - (null) private val publishRef = AtomicReference(null) private val subscribeRef = AtomicReference(null) + private var dutyOpen: Runnable? = null + private var dutyClose: Runnable? = null fun interface Listener { fun onFrame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, peer: PeerHandle) } + fun interface DigestListener { fun onDigest(digest: ByteArray, peer: PeerHandle) } private var listener: Listener? = null + private var digestListener: DigestListener? = null fun setListener(l: Listener) { listener = l } + fun setDigestListener(l: DigestListener) { digestListener = l } + + fun publishDigest(ids: Collection) = publish(byteArrayOf(DIGEST_TYPE) + NanDigestCodec.encode(ids)) /** True when Wi-Fi Aware is attached and usable. */ val isAttached: Boolean get() = sessionRef.get() != null @@ -161,6 +168,10 @@ class NanMessenger private constructor( matchFilter: MutableList, ) { val ssi = serviceSpecificInfo + if (ssi.firstOrNull() == DIGEST_TYPE) { + digestListener?.onDigest(ssi.copyOfRange(1, ssi.size), peerHandle) + return + } if (ssi.size != AwareConfig.SSI_SIZE) { Log.w(tag, "discovered but malformed ssi size=${ssi.size}") return @@ -177,13 +188,40 @@ class NanMessenger private constructor( subscribeRef.getAndSet(null)?.close() } + /** Listen in bounded discovery bursts instead of pinning Wi-Fi Aware on. */ + fun startDutyCycled(cycle: NanDutyCycle) { + stopDutyCycled() + lateinit var open: Runnable + lateinit var close: Runnable + open = Runnable { + subscribe() + handler.postDelayed(close, cycle.listenMs) + } + close = Runnable { + stopSubscribe() + handler.postDelayed(open, cycle.sleepMs) + } + dutyOpen = open + dutyClose = close + handler.post(open) + } + + fun stopDutyCycled() { + dutyOpen?.let(handler::removeCallbacks) + dutyClose?.let(handler::removeCallbacks) + dutyOpen = null + dutyClose = null + stopSubscribe() + } + fun detach() { stopPublish() - stopSubscribe() + stopDutyCycled() sessionRef.getAndSet(null)?.close() } companion object { + private const val DIGEST_TYPE: Byte = 0x7e fun create(context: Context): NanMessenger? { val mgr = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? WifiAwareManager ?: run { diff --git a/android/app/src/main/kotlin/net/guacamaya/aware/NanSync.kt b/android/app/src/main/kotlin/net/guacamaya/aware/NanSync.kt new file mode 100644 index 0000000..1071960 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/aware/NanSync.kt @@ -0,0 +1,47 @@ +package net.guacamaya.aware + +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** Battery-aware NAN cadence. Urgent backlog gets denser discovery windows. */ +data class NanDutyCycle(val listenMs: Long, val sleepMs: Long) { + init { require(listenMs > 0 && sleepMs >= 0) } + companion object { + val IDLE = NanDutyCycle(5_000, 55_000) + val ACTIVE = NanDutyCycle(8_000, 12_000) + val URGENT = NanDutyCycle(12_000, 3_000) + fun forBacklog(pending: Int, critical: Boolean = false) = when { + critical -> URGENT + pending > 0 -> ACTIVE + else -> IDLE + } + } +} + +/** Compact anti-entropy digest. IDs are represented by 64-bit SHA-256 prefixes; + * a collision only causes a missed reconciliation round, never false acceptance. */ +object NanDigestCodec { + private const val VERSION: Byte = 1 + private const val HEADER = 2 + private const val HASH_BYTES = 8 + + fun encode(ids: Collection): ByteArray { + val hashes = ids.asSequence().map(::prefix).distinct().sorted().take((AwareConfig.SSI_MAX - HEADER) / HASH_BYTES).toList() + return ByteBuffer.allocate(HEADER + hashes.size * HASH_BYTES).put(VERSION).put(hashes.size.toByte()).also { b -> hashes.forEach(b::putLong) }.array() + } + + fun missing(localIds: Collection, remoteDigest: ByteArray): List { + val remote = decode(remoteDigest) ?: return emptyList() + return localIds.filter { prefix(it) !in remote } + } + + private fun decode(bytes: ByteArray): Set? { + if (bytes.size < HEADER || bytes[0] != VERSION) return null + val count = bytes[1].toInt() and 0xff + if (bytes.size != HEADER + count * HASH_BYTES) return null + val buffer = ByteBuffer.wrap(bytes, HEADER, bytes.size - HEADER) + return buildSet(count) { repeat(count) { add(buffer.long) } } + } + + private fun prefix(id: String): Long = ByteBuffer.wrap(MessageDigest.getInstance("SHA-256").digest(id.toByteArray())).long +} diff --git a/android/app/src/main/kotlin/net/guacamaya/backend/AlertsRepository.kt b/android/app/src/main/kotlin/net/guacamaya/backend/AlertsRepository.kt index b3f57c8..72eafed 100644 --- a/android/app/src/main/kotlin/net/guacamaya/backend/AlertsRepository.kt +++ b/android/app/src/main/kotlin/net/guacamaya/backend/AlertsRepository.kt @@ -1,6 +1,7 @@ package net.guacamaya.backend import android.util.Log +import net.guacamaya.BuildConfig /** A verified official alert, ready for the UI. [payloadRaw] is the verbatim JSON. */ data class OfficialAlert( @@ -21,13 +22,15 @@ data class OfficialAlert( */ class AlertsRepository( private val client: BackendClient, + private val backendPubkeyHex: String = BuildConfig.BACKEND_PUBLIC_KEY_HEX, private val officialChannels: List = OFFICIAL_CHANNELS, ) { private val tag = "guacamaya.backend" suspend fun fetchVerifiedAlerts(sinceMs: Long = 0): Result> { - val pubkey = client.pubkey() - ?: return Result.failure(IllegalStateException("backend pubkey unavailable")) + if (!backendPubkeyHex.matches(Regex("[0-9a-fA-F]{64}"))) { + return Result.failure(IllegalStateException("backend public-key trust anchor missing from build")) + } val verified = mutableListOf() var dropped = 0 @@ -39,7 +42,7 @@ class AlertsRepository( continue } for (rec in records) { - if (OfficialRecordVerifier.verify(rec, pubkey)) { + if (OfficialRecordVerifier.verify(rec, backendPubkeyHex)) { verified += OfficialAlert(rec.id, rec.channel, rec.timestamp, rec.payloadRaw) } else { dropped++ diff --git a/android/app/src/main/kotlin/net/guacamaya/backend/OfficialRecordVerifier.kt b/android/app/src/main/kotlin/net/guacamaya/backend/OfficialRecordVerifier.kt index caa5565..ab9e95d 100644 --- a/android/app/src/main/kotlin/net/guacamaya/backend/OfficialRecordVerifier.kt +++ b/android/app/src/main/kotlin/net/guacamaya/backend/OfficialRecordVerifier.kt @@ -17,8 +17,8 @@ import java.security.MessageDigest * that reuses a validly-signed (id, sig) pair. * 2. Ed25519.verify(sig, hash, backendPubkey) — authenticity. * - * `payloadJson` is [OfficialRecord.payloadRaw], the verbatim wire bytes — see - * [RecordJson] for why it must not be re-serialized. + * `payloadJson` is [OfficialRecord.payloadRaw]. The backend emits canonical JSON + * (object keys sorted recursively), matching the representation stored by jsonb. * * This is a DISTINCT crypto path from the 22-byte mesh-frame signature * (`Signer.verify`): different message, different layout. Keep them separate. diff --git a/android/app/src/main/kotlin/net/guacamaya/backend/RecordJson.kt b/android/app/src/main/kotlin/net/guacamaya/backend/RecordJson.kt index f13e7ee..eadc7e6 100644 --- a/android/app/src/main/kotlin/net/guacamaya/backend/RecordJson.kt +++ b/android/app/src/main/kotlin/net/guacamaya/backend/RecordJson.kt @@ -3,12 +3,9 @@ package net.guacamaya.backend /** * One official `ChannelRecord` as served by `GET /channels/:id/records`. * - * [payloadRaw] is the **exact JSON text of the `payload` value as it arrived on the - * wire** — never re-serialized. The backend signs `sha256("channel:timestamp:ttl: - * author:verified:" + JSON.stringify(payload))`, so verification must hash the same - * bytes the server produced; re-encoding the payload (e.g. via org.json, which does - * not preserve key order) would change those bytes and break the signature. - * See [OfficialRecordVerifier]. + * [payloadRaw] preserves the canonical JSON value emitted by the backend. The + * backend sorts keys before signing and Supabase jsonb returns the same logical + * ordering, so verification hashes these exact wire bytes. */ data class OfficialRecord( val channel: String, @@ -22,9 +19,8 @@ data class OfficialRecord( ) /** - * Minimal, order-preserving JSON reader for the records endpoint. Hand-rolled (no - * org.json) precisely so it can return each record's `payload` as the verbatim source - * substring. Tolerant of unknown keys; assumes the compact output of the backend. + * Minimal JSON reader for the records endpoint. It returns each record's `payload` + * as a source substring for later canonicalization and is tolerant of unknown keys. */ object RecordJson { diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/BleConfig.kt b/android/app/src/main/kotlin/net/guacamaya/ble/BleConfig.kt index 48001c0..bbaed36 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ble/BleConfig.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ble/BleConfig.kt @@ -38,6 +38,10 @@ import java.util.UUID */ object BleConfig { + /** Reserved service UUID for a future incompatible frame v2. Old v1 apps do + * not scan it; new apps will dual-scan v1 + v2 during a disaster rollout. */ + val V2_SERVICE_UUID: UUID = UUID.fromString("bb34a92c-3b5f-4c13-9fa5-599a2ed26b3e") + /** * 128-bit production Service UUID an origin broadcasts under. A random v4 (not a * registry-assigned value) — the 128-bit space makes collision negligible and no @@ -46,22 +50,70 @@ object BleConfig { */ val SERVICE_UUID: UUID = UUID.fromString("a613421e-f059-4233-a4de-8b08de13fb7e") + /** + * Dedicated Service UUID for **control frames** (delivery / backend-resolve / + * self-cancel). A separate UUID — NOT [SERVICE_UUID] and NOT [V2_SERVICE_UUID] — so + * the Observer can tell a control PDU from an SOS PDU on the hot path by the UUID it + * arrived under, with zero ambiguity and no payload-byte heuristics. A random v4 + * (collision-negligible, no registry). The 119 B on-wire envelope is identical to + * the SOS frame; only the Service UUID and the 22 B payload content differ. + * + * Backend `/ingest` never sees this UUID — mules strip the leading TTL byte and + * upload the bare 118 B frame, so the control UUID is purely an Android BLE + * transport concern and has no mirror in `packages/shared`. + */ + val CONTROL_SERVICE_UUID: UUID = UUID.fromString("a5f1f968-bf85-40b3-b84b-c0770d7d4afa") + + /** Versioned public group-chat transport. Variable-length frames; never parsed as SOS. */ + val CHAT_SERVICE_UUID: UUID = UUID.fromString("f3e1d6a2-7b49-4a93-93c1-64b4750cb8ad") + /** * Retired UUIDs the Observer still matches so devices on an older build stay reachable * during a rollout. Broadcast is always [SERVICE_UUID]; only scanning accepts these. + * Legacy UUIDs carry SOS frames only. */ val LEGACY_UUIDS: List = listOf( UUID.fromString("8d3d0001-2a1b-4c8e-9c0f-1234567890ab"), ) - /** Every UUID the Observer accepts (current + legacy), newest first. */ - val MATCH_UUIDS: List = listOf(SERVICE_UUID) + LEGACY_UUIDS + /** SOS UUIDs the Observer accepts: current production + legacy rollout values. */ + val SOS_MATCH_UUIDS: List = listOf(SERVICE_UUID) + LEGACY_UUIDS + + /** Control UUIDs the Observer accepts (single value, room to grow like SOS). */ + val CONTROL_MATCH_UUIDS: List = listOf(CONTROL_SERVICE_UUID) + + /** Every UUID the Observer accepts, SOS first then control. */ + val MATCH_UUIDS: List = SOS_MATCH_UUIDS + CONTROL_MATCH_UUIDS + CHAT_SERVICE_UUID val SERVICE_PARCEL_UUID: ParcelUuid = ParcelUuid(SERVICE_UUID) + val CONTROL_PARCEL_UUID: ParcelUuid = ParcelUuid(CONTROL_SERVICE_UUID) + val CHAT_PARCEL_UUID: ParcelUuid = ParcelUuid(CHAT_SERVICE_UUID) + /** ParcelUuids for software matching of [MATCH_UUIDS]. */ val MATCH_PARCEL_UUIDS: List = MATCH_UUIDS.map { ParcelUuid(it) } + /** What a service-data blob under a given UUID carries. */ + enum class FrameKind { SOS, CONTROL, CHAT } + + private val SOS_MATCH_SET: Set = SOS_MATCH_UUIDS.map { ParcelUuid(it) }.toSet() + + /** Classify a parsed service-data UUID, or null if it is not a GuacaMalla UUID. */ + fun kindOf(parcel: ParcelUuid): FrameKind? = when { + parcel == CONTROL_PARCEL_UUID -> FrameKind.CONTROL + parcel == CHAT_PARCEL_UUID -> FrameKind.CHAT + parcel in SOS_MATCH_SET -> FrameKind.SOS + else -> null + } + + /** Classify a raw-AD parse UUID, or null if it is not a GuacaMalla UUID. */ + fun kindOfUuid(uuid: UUID): FrameKind? = when { + uuid == CONTROL_SERVICE_UUID -> FrameKind.CONTROL + uuid == CHAT_SERVICE_UUID -> FrameKind.CHAT + uuid in SOS_MATCH_UUIDS -> FrameKind.SOS + else -> null + } + /** Byte offsets within the service-data blob. */ const val TTL_OFFSET = 0 const val PAYLOAD_OFFSET = 1 @@ -114,18 +166,29 @@ object BleConfig { .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_MAX) .build() + fun normalParameters(txPower: Int, active: Boolean = false): AdvertisingSetParameters = + AdvertisingSetParameters.Builder() + .setConnectable(false).setScannable(false) + .setPrimaryPhy(BluetoothDevice.PHY_LE_1M).setSecondaryPhy(BluetoothDevice.PHY_LE_1M) + .setInterval(if (active) AdvertisingSetParameters.INTERVAL_LOW else AdvertisingSetParameters.INTERVAL_MEDIUM) + .setTxPowerLevel(txPower).build() + /** * Scan profiles for the Observer. Extended ADV (119 B service-data) requires * `legacy=false`; hardware ScanFilter is avoided (see Observer.kt). * - * [AGGRESSIVE] — default: low-latency, immediate callbacks (`reportDelay=0`), - * all PHYs. Best on Pixel / recent Qualcomm. + * [BALANCED] — normal mesh observation with all matches but without the + * highest scan duty cycle. The active rescue radar promotes this to + * [AGGRESSIVE]. + * + * [AGGRESSIVE] — active rescue search: low-latency, immediate callbacks + * (`reportDelay=0`), all PHYs. Best on Pixel / recent Qualcomm. * * [LEGACY_STACK] — for older MTK / Xiaomi-class stacks (Android 8–11) that * fail aggressive match or stop delivering callbacks: sticky match, fewer * concurrent matches, CODED PHY when supported (Broadcaster secondary PHY). */ - enum class ScanProfile { AGGRESSIVE, LEGACY_STACK } + enum class ScanProfile { BALANCED, AGGRESSIVE, LEGACY_STACK } /** Build scan settings tuned for [profile] and this chip's PHY support. */ fun scanSettings(adapter: BluetoothAdapter, profile: ScanProfile): ScanSettings { @@ -137,6 +200,18 @@ object BleConfig { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { builder.setLegacy(false) when (profile) { + ScanProfile.BALANCED -> { + builder.setScanMode(ScanSettings.SCAN_MODE_BALANCED) + builder.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + builder.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) + builder.setPhy( + if (adapter.isLeCodedPhySupported) { + ScanSettings.PHY_LE_ALL_SUPPORTED + } else { + BluetoothDevice.PHY_LE_1M + }, + ) + } ScanProfile.AGGRESSIVE -> { builder.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) builder.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt b/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt index 9ce6ae6..4e4f6a5 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ble/BleMeshRuntime.kt @@ -6,8 +6,12 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import net.guacamaya.BuildConfig import net.guacamaya.mesh.FloodRouter import net.guacamaya.mesh.GuacamayaDatabase +import net.guacamaya.mesh.NeighborObservationRecorder +import net.guacamaya.time.MeshClock +import java.util.Base64 /** * Shared BLE observer + mesh router. Started from FGS and from MainActivity.onResume @@ -19,9 +23,19 @@ object BleMeshRuntime { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var observer: Observer? = null private var router: FloodRouter? = null + private var controlBroadcaster: ControlBroadcaster? = null + @Volatile private var radarActive = false + @Volatile private var controlSink: ControlSink? = null + + /** + * Plug in the control-frame consumer. [ensureObserving] registers the [FloodRouter] + * (it implements [ControlSink]); null here would drop control frames on arrival. + */ + fun setControlSink(sink: ControlSink?) { controlSink = sink } fun ensureObserving(ctx: Context): Boolean { val app = ctx.applicationContext + ChatRuntime.initialize(app) if (observer == null || router == null) { val obs = Observer.create(app) ?: run { Log.i(PROBE, "observe fail Observer.create=null") @@ -29,10 +43,62 @@ object BleMeshRuntime { } val dao = GuacamayaDatabase.get(app).messageDao() val bcast = Broadcaster.create(app) - val r = FloodRouter(dao = dao, broadcaster = bcast, scope = scope) - obs.setListener { p22, pub32, sig64, ttl, rssi -> - r.onFrame(p22, pub32, sig64, ttl, rssi) + // Control relay on its own transport UUID (best-effort; null if the chip + // cannot advertise — verify + trust state still apply, only relay is lost). + val ctrlBcast = ControlBroadcaster.create(app) + val recorder = NeighborObservationRecorder(app, dao, scope) + val r = FloodRouter( + dao = dao, + broadcaster = bcast, + observationRecorder = recorder, + scope = scope, + now = { MeshClock.now(app) }, + // Build-anchored backend pin (never GET /pubkey — see + // docs/PROTOCOL_CONTROL.md). "" when unset at build → + // verifyControlFrame treats it as null → only self-cancel (0x03) + // verifiable. Delivery/backend-resolve require the real pin. + backendPublicKeyProvider = { BuildConfig.BACKEND_PUBLIC_KEY_HEX }, + controlBroadcaster = ctrlBcast, + ) + // FloodRouter is the ControlSink: a control PDU routed by UUID from the + // Observer is enqueued into the same single-consumer coroutine (verify → + // persistent replay gate → trust state → relay on CONTROL_SERVICE_UUID). + setControlSink(r) + controlBroadcaster = ctrlBcast + obs.setListener { kind, p22, pub32, sig64, ttl, radio -> + when (kind) { + BleConfig.FrameKind.SOS -> r.onFrame( + p22, + pub32, + sig64, + ttl, + radio.rssi, + radio.txPower, + radio.primaryPhy, + radio.secondaryPhy, + ) + BleConfig.FrameKind.CONTROL -> { + // Reassemble the 119 B on-wire blob so the sink can both re-verify + // (verifyControlFrame strips the leading TTL) and re-relay it. The + // sink is FloodRouter itself (set above); null only if ensure was + // never completed → drop, do not relay. + val sink = controlSink + if (sink == null) { + Log.d(PROBE, "control frame dropped (no sink) ttl=$ttl rssi=${radio.rssi}") + return@setListener + } + val onWire = ByteArray(BleConfig.SERVICE_DATA_SIZE) + onWire[BleConfig.TTL_OFFSET] = ttl.toByte() + System.arraycopy(p22, 0, onWire, BleConfig.PAYLOAD_OFFSET, BleConfig.PUBKEY_OFFSET - BleConfig.PAYLOAD_OFFSET) + System.arraycopy(pub32, 0, onWire, BleConfig.PUBKEY_OFFSET, BleConfig.SIG_OFFSET - BleConfig.PUBKEY_OFFSET) + System.arraycopy(sig64, 0, onWire, BleConfig.SIG_OFFSET, BleConfig.SERVICE_DATA_SIZE - BleConfig.SIG_OFFSET) + sink.onControlFrame(onWire, radio) + } + BleConfig.FrameKind.CHAT -> Unit // delivered through the raw chat listener + } } + obs.setChatListener { frame, _ -> ChatRuntime.onFrame(frame) } + obs.setRadarActive(radarActive) observer = obs router = r } @@ -48,8 +114,66 @@ object BleMeshRuntime { fun stopObserving() { observer?.stop() + controlBroadcaster?.stop() Log.i(PROBE, "BleMeshRuntime stopped") } + /** + * Feed a backend-minted control frame (delivery 0x01 / backend-resolve 0x02) back into the + * mesh after a data-mule upload round-trip. [frameB64] is the canonical 118 B upload blob + * (TTL-stripped) the backend returned in `controlFrames`. We re-prepend the origin hop TTL + * to rebuild the 119 B on-wire form, then hand it to the router as if the chip had just + * scanned it — same verify cascade + persistent replay gate + trust-state effect + relay as + * any other control receipt. RSSI 0 / no txPower / PHY 0 is fine: control frames are not + * victim-origin RF sources, so they never feed the Sonar observation recorder, and the + * weak-first relay delay collapses to jitter-only. + * + * Best-effort: if the router is null (BLE never came up) the frame is dropped with a log — + * a later upload re-mints it, and the victim still has the SOS re-flood path. + */ + fun injectControlFrame(frameB64: String) { + val router = this.router + if (router == null) { + Log.d(PROBE, "control inject dropped (no router)") + return + } + val stripped = try { + Base64.getDecoder().decode(frameB64) + } catch (e: IllegalArgumentException) { + Log.w(PROBE, "control inject dropped (bad base64): ${e.message}") + return + } + // 118 B upload frame → 119 B on-wire (prepend origin hop TTL). Tolerate a full 119 B + // blob the same way /ingest does by using it as-is. + val onWire = if (stripped.size == BleConfig.SERVICE_DATA_SIZE) { + stripped + } else { + ByteArray(BleConfig.SERVICE_DATA_SIZE).also { out -> + out[BleConfig.TTL_OFFSET] = BleConfig.ORIGIN_HOP_TTL.toByte() + stripped.copyInto(out, BleConfig.PAYLOAD_OFFSET) + } + } + router.onControlFrame(onWire, Observer.RadioMetadata(0, null, 0, 0)) + } + + fun setRadarActive(active: Boolean) { + radarActive = active + observer?.setRadarActive(active) + } + fun isScanning(): Boolean = observer?.isScanning == true } + +/** + * Consumes a control frame (delivery / backend-resolve / self-cancel) that the Observer + * surfaced under [BleConfig.CONTROL_SERVICE_UUID]. [onWire119] is the bare 119 B on-wire + * blob (leading mutable hop-TTL included); the sink owns the full zero-trust verify + + * persistent replay gate + trust-state effects. [FloodRouter] implements this. + * + * The sink is nullable in [BleMeshRuntime] so the transport compiles standalone; in the + * live runtime [ensureObserving] sets it to the [FloodRouter], so control PDUs are verified, + * persisted, and relayed on [BleConfig.CONTROL_SERVICE_UUID]. + */ +fun interface ControlSink { + fun onControlFrame(onWire119: ByteArray, radio: Observer.RadioMetadata) +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/Broadcaster.kt b/android/app/src/main/kotlin/net/guacamaya/ble/Broadcaster.kt index 62cf847..06f9a6e 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ble/Broadcaster.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ble/Broadcaster.kt @@ -42,9 +42,15 @@ class Broadcaster private constructor( * signature, concatenated into a single service-data blob keyed by the GuacaMalla * Service UUID. [ttl] is the unsigned hop budget (0..15) written at offset 0. */ - fun start(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int = BleConfig.ORIGIN_HOP_TTL) { + fun start( + payload22: ByteArray, + pub32: ByteArray, + sig64: ByteArray, + ttl: Int = BleConfig.ORIGIN_HOP_TTL, + params: android.bluetooth.le.AdvertisingSetParameters = BleConfig.parametersCompat, + ) { pendingPayload = Quad(payload22, pub32, sig64, ttl) - beginAdvertising(BleConfig.parametersCompat, "1M/1M") + beginAdvertising(params, "1M/1M") } private fun beginAdvertising(params: android.bluetooth.le.AdvertisingSetParameters, label: String) { diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/ChatBroadcaster.kt b/android/app/src/main/kotlin/net/guacamaya/ble/ChatBroadcaster.kt new file mode 100644 index 0000000..3537492 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ble/ChatBroadcaster.kt @@ -0,0 +1,40 @@ +package net.guacamaya.ble + +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertisingSet +import android.bluetooth.le.AdvertisingSetCallback +import android.bluetooth.le.AdvertisingSetParameters +import android.content.Context +import android.util.Log + +class ChatBroadcaster private constructor(private val context: Context) { + private val advertiser get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter.bluetoothLeAdvertiser + private var callback: AdvertisingSetCallback? = null + + fun advertise(frame: ByteArray, txPower: Int, active: Boolean) { + stop() + val params = AdvertisingSetParameters.Builder() + .setConnectable(false).setScannable(false) + .setPrimaryPhy(android.bluetooth.BluetoothDevice.PHY_LE_1M) + .setSecondaryPhy(android.bluetooth.BluetoothDevice.PHY_LE_1M) + .setInterval(if (active) AdvertisingSetParameters.INTERVAL_LOW else AdvertisingSetParameters.INTERVAL_MEDIUM) + .setTxPowerLevel(txPower).build() + val data = AdvertiseData.Builder().setIncludeDeviceName(false).setIncludeTxPowerLevel(false) + .addServiceUuid(BleConfig.CHAT_PARCEL_UUID).addServiceData(BleConfig.CHAT_PARCEL_UUID, frame).build() + val cb = object : AdvertisingSetCallback() { + override fun onAdvertisingSetStarted(set: AdvertisingSet?, power: Int, status: Int) { + if (status != ADVERTISE_SUCCESS) Log.w(TAG, "chat advertise failed status=$status") + } + } + callback = cb + advertiser?.startAdvertisingSet(params, data, null, null, null, cb) + } + + fun stop() { callback?.let { advertiser?.stopAdvertisingSet(it) }; callback = null } + + companion object { + private const val TAG = "guacamaya.ble.Chat" + fun create(context: Context) = ChatBroadcaster(context.applicationContext) + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/ChatRuntime.kt b/android/app/src/main/kotlin/net/guacamaya/ble/ChatRuntime.kt new file mode 100644 index 0000000..6a245d9 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ble/ChatRuntime.kt @@ -0,0 +1,128 @@ +package net.guacamaya.ble + +import android.content.Context +import java.security.SecureRandom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import net.guacamaya.crypto.Identity +import net.guacamaya.crypto.Signer +import net.guacamaya.mesh.ChatFragmentEntity +import net.guacamaya.mesh.ChatMessageEntity +import net.guacamaya.mesh.GuacamayaDatabase +import net.guacamaya.proto.ChatCodec +import net.guacamaya.proto.ChatMessage +import net.guacamaya.time.MeshClock + +object ChatRuntime { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val rng = SecureRandom() + private var app: Context? = null + private var broadcaster: ChatBroadcaster? = null + private var identity: Identity? = null + private val relayed = LinkedHashSet() + private val _policy = MutableStateFlow(RadioPolicyEngine.forTier(RadioTier.NORMAL)) + val policy: StateFlow = _policy + private val _active = MutableStateFlow(false) + val active: StateFlow = _active + @Volatile private var windowClosed: (() -> Unit)? = null + + fun initialize(context: Context) { + if (app != null) return + app = context.applicationContext + broadcaster = ChatBroadcaster.create(context) + scope.launch { identity = Identity.loadOrCreate(context) } + } + + fun setPolicy(policy: RadioPolicy) { _policy.value = policy } + fun setWindowClosedListener(listener: (() -> Unit)?) { windowClosed = listener } + + fun openActiveWindow(explicitSend: Boolean = false) { + if (_policy.value.tier == RadioTier.CRITICAL && !explicitSend) return + _active.value = true + scope.launch { delay(30_000L); _active.value = false; broadcaster?.stop(); windowClosed?.invoke() } + } + + fun send(text: String): Result { + val bytes = text.trim().toByteArray(Charsets.UTF_8) + if (bytes.isEmpty()) return Result.failure(IllegalArgumentException("Message is empty")) + if (bytes.size > ChatCodec.MAX_TEXT_BYTES) return Result.failure(IllegalArgumentException("Message exceeds 160 UTF-8 bytes")) + val context = app ?: return Result.failure(IllegalStateException("Chat is not initialized")) + scope.launch { + val id = identity ?: Identity.loadOrCreate(context).also { identity = it } + val messageId = rng.nextLong() + val timestamp = MeshClock.now(context) / 1000 + val canonical = ChatCodec.canonical(id.nodeId, messageId, timestamp, bytes) + val message = ChatMessage(id.nodeId, messageId, timestamp, bytes, id.publicKey, Signer.signMessage(id.privateKeySeed, canonical)) + val dao = GuacamayaDatabase.get(context).chatDao() + dao.insertChatMessage(message.toEntity(own = true, state = 0, receivedAt = System.currentTimeMillis())) + openActiveWindow(explicitSend = true) + air(message, ttl = 3) + dao.updateChatDelivery(id.nodeId.hex(), messageId, 1) + prune(dao) + } + return Result.success(Unit) + } + + fun onFrame(frame: ByteArray) { + val context = app ?: return + val fragment = ChatCodec.decode(frame) ?: return + scope.launch { + val dao = GuacamayaDatabase.get(context).chatDao() + val sender = fragment.senderNodeId.hex() + val ownId = identity?.nodeId?.hex() + if (sender == ownId) { + dao.updateChatDelivery(sender, fragment.messageId, 2) + return@launch + } + dao.insertChatFragment( + ChatFragmentEntity(sender, fragment.messageId, fragment.index, fragment.count, fragment.totalLength, + fragment.timestampSeconds, fragment.chunk, fragment.publicKey, fragment.signature, fragment.ttl, System.currentTimeMillis()), + ) + val parts = dao.chatFragments(sender, fragment.messageId) + if (parts.size != fragment.count || parts.map { it.fragmentIndex }.distinct().size != fragment.count) return@launch + val text = parts.sortedBy { it.fragmentIndex }.flatMap { it.chunk.asIterable() }.toByteArray() + if (text.size != fragment.totalLength) return@launch + val message = ChatMessage(fragment.senderNodeId, fragment.messageId, fragment.timestampSeconds, text, fragment.publicKey, fragment.signature) + if (!ChatCodec.verify(message, MeshClock.now(context) / 1000)) { + dao.deleteChatFragments(sender, fragment.messageId) + return@launch + } + dao.insertChatMessage(message.toEntity(false, 2, System.currentTimeMillis())) + dao.deleteChatFragments(sender, fragment.messageId) + val key = "$sender:${fragment.messageId}" + if (fragment.ttl > 0 && _policy.value.relayEnabled && synchronized(relayed) { relayed.add(key) }) { + delay(300L + rng.nextInt(901)) + air(message, fragment.ttl - 1) + } + prune(dao) + } + } + + private suspend fun air(message: ChatMessage, ttl: Int) { + val frames = ChatCodec.fragment(message, ttl) + repeat(3) { + for (frame in frames) { + broadcaster?.advertise(frame, _policy.value.txPower, active = _active.value) + delay(650L) + } + } + broadcaster?.stop() + } + + private suspend fun prune(dao: net.guacamaya.mesh.ChatDao) { + val now = System.currentTimeMillis() + dao.pruneChatFragments(now - 15 * 60_000L) + dao.pruneChatMessages(now - 24 * 60 * 60_000L) + } + + private fun ChatMessage.toEntity(own: Boolean, state: Int, receivedAt: Long) = ChatMessageEntity( + senderNodeId.hex(), messageId, timestampSeconds * 1000, text.toString(Charsets.UTF_8), publicKey, signature, own, state, receivedAt, + ) + + private fun ByteArray.hex() = joinToString("") { "%02x".format(it.toInt() and 0xff) } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/ControlBroadcaster.kt b/android/app/src/main/kotlin/net/guacamaya/ble/ControlBroadcaster.kt new file mode 100644 index 0000000..c25ea14 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ble/ControlBroadcaster.kt @@ -0,0 +1,153 @@ +package net.guacamaya.ble + +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertisingSet +import android.bluetooth.le.AdvertisingSetCallback +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.os.Build +import android.util.Log +import java.util.concurrent.atomic.AtomicReference + +/** + * BLE GAP Broadcaster for **control frames** (delivery / backend-resolve / self-cancel), + * advertised under [BleConfig.CONTROL_SERVICE_UUID] — a separate UUID from the SOS + * [BleConfig.SERVICE_UUID] so the Observer routes a control PDU by UUID, never by + * payload-byte inspection. + * + * Deliberately lighter than the SOS [Broadcaster]: + * - **Best-effort, no PHY fallback.** The SOS Broadcaster retries 1M/1M → 1M/CODED on a + * start failure; control relay is not life-critical broadcast (a missed relay still + * reaches the victim through other hops / the backend `/ingest` mule path), so a single + * [BleConfig.parametersCompat] attempt is enough and avoids the start/stop cycles + * Android throttles. + * - **Relay TTL is caller-supplied** ([net.guacamaya.mesh.FloodRouter] passes + * `ttl - 1`; a node never re-originates a control frame, only forwards one that already + * passed its own zero-trust verify). TTL 0 is not advertised — relay dies at the edge. + * - Same 119 B on-wire envelope as SOS: `[ttl][22 B payload][32 B pubkey][64 B sig]`. + */ +class ControlBroadcaster private constructor( + private val advertiser: BluetoothLeAdvertiser, +) : ControlRelay { + private val tag = "guacamaya.ble.ControlBroadcaster" + + private val activeSet = AtomicReference(null) + @Volatile private var activeCallback: AdvertisingSetCallback? = null + + private data class Quad(val p22: ByteArray, val pub: ByteArray, val sig: ByteArray, val ttl: Int) + + /** True if a control frame is currently on the air. */ + val isAdvertising: Boolean get() = activeSet.get() != null + + /** + * Put (or replace) a control frame on the air under [BleConfig.CONTROL_SERVICE_UUID]. + * [ttl] is the unsigned hop budget the caller already decremented; 0 means "do not + * relay" and is a no-op (the frame reached its hop edge). Best-effort: a start failure + * is logged, not retried with a different PHY. + */ + override fun relay(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int) { + if (ttl <= 0) { + Log.d(tag, "relay suppressed ttl=$ttl (hop edge)") + return + } + beginAdvertising(Quad(payload22, pub32, sig64, ttl)) + } + + private fun beginAdvertising(quad: Quad) { + val combined = frame(quad.p22, quad.pub, quad.sig, quad.ttl) + + val data = AdvertiseData.Builder() + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .addServiceUuid(BleConfig.CONTROL_PARCEL_UUID) + .addServiceData(BleConfig.CONTROL_PARCEL_UUID, combined) + .build() + + // Stop any prior control set before starting a fresh one. Control relay is + // low-frequency, so a stop+start per relay is acceptable and simpler than a swap. + activeCallback?.let { advertiser.stopAdvertisingSet(it) } + activeSet.set(null) + + val callback = object : AdvertisingSetCallback() { + override fun onAdvertisingSetStarted(set: AdvertisingSet?, txPower: Int, status: Int) { + if (status == ADVERTISE_SUCCESS) { + activeSet.set(set) + Log.i(tag, "control relay started ttl=${quad.ttl} txPower=$txPower dBm frame=${combined.size} B") + } else { + Log.w(tag, "control relay start failed status=$status (best-effort, not retried)") + } + } + + override fun onAdvertisingSetStopped(set: AdvertisingSet?) { + activeSet.set(null) + activeCallback = null + Log.d(tag, "control relay stopped") + } + } + activeCallback = callback + advertiser.startAdvertisingSet(BleConfig.parametersCompat, data, null, null, null, callback) + } + + /** Assemble the 119-byte service-data blob: [ttl][payload][pubkey][sig]. */ + private fun frame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int): ByteArray { + require(payload22.size == 22) { "payload must be 22 bytes, got ${payload22.size}" } + require(pub32.size == 32) { "pubkey must be 32 bytes, got ${pub32.size}" } + require(sig64.size == 64) { "signature must be 64 bytes, got ${sig64.size}" } + require(ttl in 0..255) { "ttl out of byte range: $ttl" } + return ByteArray(BleConfig.SERVICE_DATA_SIZE).also { + it[BleConfig.TTL_OFFSET] = ttl.toByte() + System.arraycopy(payload22, 0, it, BleConfig.PAYLOAD_OFFSET, 22) + System.arraycopy(pub32, 0, it, BleConfig.PUBKEY_OFFSET, 32) + System.arraycopy(sig64, 0, it, BleConfig.SIG_OFFSET, 64) + } + } + + fun stop() { + val cb = activeCallback + if (cb != null) { + advertiser.stopAdvertisingSet(cb) + } + activeSet.set(null) + activeCallback = null + } + + companion object { + fun create(context: Context): ControlBroadcaster? { + val bm = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val adapter = bm?.adapter ?: run { + Log.w("guacamaya.ble.ControlBroadcaster", "no BluetoothManager") + return null + } + if (!adapter.isMultipleAdvertisementSupported) { + Log.w("guacamaya.ble.ControlBroadcaster", "chip does not advertise; BLE extended ADV required") + return null + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !adapter.isLeExtendedAdvertisingSupported + ) { + Log.w("guacamaya.ble.ControlBroadcaster", "extended advertising not supported on this chip") + return null + } + val advertiser = adapter.bluetoothLeAdvertiser ?: run { + Log.w("guacamaya.ble.ControlBroadcaster", "null BluetoothLeAdvertiser") + return null + } + return ControlBroadcaster(advertiser) + } + } +} + +/** + * Minimal dependency of [net.guacamaya.mesh.FloodRouter] on the control relay: "put this + * signed control frame on the air." Concrete [ControlBroadcaster] implements it; tests + * inject a recording SAM. Kept separate from [ControlBroadcaster] so the router depends + * on the one method it uses, not on the Android advertiser plumbing. + */ +fun interface ControlRelay { + /** + * Advertise a control frame under [BleConfig.CONTROL_SERVICE_UUID]. [ttl] is the + * unsigned hop budget the caller already decremented; ≤ 0 means stop (hop edge). + */ + fun relay(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int) +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/Observer.kt b/android/app/src/main/kotlin/net/guacamaya/ble/Observer.kt index 05abea6..ced9426 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ble/Observer.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ble/Observer.kt @@ -30,20 +30,50 @@ class Observer private constructor( private val mainHandler = Handler(Looper.getMainLooper()) @Volatile private var scanning = false - @Volatile private var scanProfile = BleConfig.ScanProfile.AGGRESSIVE + @Volatile private var scanProfile = BleConfig.ScanProfile.BALANCED + @Volatile private var radarActive = false private var lastCallbackAt = 0L @Volatile private var lastGuacamayaFrameAt = 0L private var scanCallbackCount = 0 private val activeCallback = AtomicReference(null) + private val exitRadarRunnable = Runnable { + radarActive = false + switchProfile(defaultScanProfile(), "radar debounce elapsed") + } - /** Called on every Guacamaya-shaped frame. Implementations must be thread-safe. */ + /** + * Called on every Guacamaya-shaped frame. [kind] tells the listener whether the + * frame arrived under the SOS Service UUID (an SOS payload) or the control Service + * UUID (a delivery / backend-resolve / self-cancel payload) — the routing decision + * is made from the UUID, never from payload-byte inspection on the hot path. + * Implementations must be thread-safe. + */ fun interface FrameListener { - fun onFrame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int, rssi: Int) + fun onFrame( + kind: BleConfig.FrameKind, + payload22: ByteArray, + pub32: ByteArray, + sig64: ByteArray, + ttl: Int, + radio: RadioMetadata, + ) } + /** Per-PDU radio metadata. `ALL_MATCHES` is intentional: RSSI filtering needs + * repeated advertising samples, not a deduplicated device callback. */ + data class RadioMetadata( + val rssi: Int, + val txPower: Int?, + val primaryPhy: Int, + val secondaryPhy: Int, + ) + private var listener: FrameListener? = null + fun interface ChatFrameListener { fun onFrame(frame: ByteArray, radio: RadioMetadata) } + private var chatListener: ChatFrameListener? = null fun setListener(l: FrameListener) { listener = l } + fun setChatListener(l: ChatFrameListener) { chatListener = l } private fun newCallback(): ScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { @@ -65,9 +95,12 @@ class Observer private constructor( override fun onScanFailed(errorCode: Int) { Log.e(tag, "scan failed code=$errorCode profile=$scanProfile") + if (errorCode == ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED) { + Log.e(tag, "scan registration rejected; Android may be rate-limiting start/stop cycles") + } scanning = false activeCallback.set(null) - scheduleScanRestart(fallback = scanProfile == BleConfig.ScanProfile.AGGRESSIVE) + scheduleScanRestart(fallback = scanProfile != BleConfig.ScanProfile.LEGACY_STACK) } } @@ -78,39 +111,77 @@ class Observer private constructor( Log.i(tag, "saw UUID rssi=${result.rssi} legacy=${result.isLegacy} len=${rec.bytes?.size ?: 0}") Log.i("guacamaya.probe", "saw_uuid rssi=${result.rssi} legacy=${result.isLegacy}") } - val blob = extractServiceData(rec) - if (blob == null || blob.size != BleConfig.SERVICE_DATA_SIZE) { - if (sawUuid || blob != null) { - Log.w( - tag, - "GuacaMalla frame incomplete size=${blob?.size ?: -1} " + - "(need ${BleConfig.SERVICE_DATA_SIZE}) rssi=${result.rssi} legacy=${result.isLegacy}", + val blobs = extractServiceData(rec) + if (blobs.isEmpty()) return + // A scan record can carry service-data under several UUIDs at once — e.g. an SOS + // origin under SERVICE_UUID and a control relay under CONTROL_SERVICE_UUID in the + // same advertisement. Surface each blob tagged with the kind its UUID implies. + for ((blob, kind) in blobs) { + if (kind == BleConfig.FrameKind.CHAT) { + lastGuacamayaFrameAt = SystemClock.elapsedRealtime() + chatListener?.onFrame( + blob, + RadioMetadata(result.rssi, result.txPower.takeUnless { it == ScanResult.TX_POWER_NOT_PRESENT }, result.primaryPhy, result.secondaryPhy), ) + continue } - return + if (blob.size != BleConfig.SERVICE_DATA_SIZE) { + if (sawUuid) { + Log.w( + tag, + "$kind frame incomplete size=${blob.size} " + + "(need ${BleConfig.SERVICE_DATA_SIZE}) rssi=${result.rssi} legacy=${result.isLegacy}", + ) + } + continue + } + val ttl = blob[BleConfig.TTL_OFFSET].toInt() and 0xFF + val payload22 = blob.copyOfRange(BleConfig.PAYLOAD_OFFSET, BleConfig.PUBKEY_OFFSET) + val pub32 = blob.copyOfRange(BleConfig.PUBKEY_OFFSET, BleConfig.SIG_OFFSET) + val sig64 = blob.copyOfRange(BleConfig.SIG_OFFSET, BleConfig.SERVICE_DATA_SIZE) + Log.d(tag, "$kind frame ttl=$ttl rssi=${result.rssi} src=${result.device.address}") + lastGuacamayaFrameAt = SystemClock.elapsedRealtime() + listener?.onFrame( + kind, + payload22, + pub32, + sig64, + ttl, + RadioMetadata( + rssi = result.rssi, + txPower = result.txPower.takeUnless { it == ScanResult.TX_POWER_NOT_PRESENT }, + primaryPhy = result.primaryPhy, + secondaryPhy = result.secondaryPhy, + ), + ) } - val ttl = blob[BleConfig.TTL_OFFSET].toInt() and 0xFF - val payload22 = blob.copyOfRange(BleConfig.PAYLOAD_OFFSET, BleConfig.PUBKEY_OFFSET) - val pub32 = blob.copyOfRange(BleConfig.PUBKEY_OFFSET, BleConfig.SIG_OFFSET) - val sig64 = blob.copyOfRange(BleConfig.SIG_OFFSET, BleConfig.SERVICE_DATA_SIZE) - Log.d(tag, "frame ttl=$ttl rssi=${result.rssi} src=${result.device.address}") - lastGuacamayaFrameAt = SystemClock.elapsedRealtime() - listener?.onFrame(payload22, pub32, sig64, ttl, result.rssi) } /** * Prefer the parsed map; fall back to raw AD bytes. Some older stacks populate * [ScanRecord.getServiceUuids] but leave [ScanRecord.getServiceData] empty on - * extended ADV secondary payloads. + * extended ADV secondary payloads. Each blob is tagged with the [BleConfig.FrameKind] + * of the UUID it was found under, so the caller routes SOS vs control by UUID and + * never inspects payload bytes on the hot path. Within a kind, the first blob wins + * (a broadcaster advertises under exactly one UUID of a kind, so duplicates only + * arise when the parsed map and the raw-AD parse both surface the same UUID). */ - private fun extractServiceData(rec: ScanRecord): ByteArray? { - for (parcel in BleConfig.MATCH_PARCEL_UUIDS) { - rec.serviceData?.get(parcel)?.let { return it } + private fun extractServiceData(rec: ScanRecord): List> { + val out = ArrayList>() + val seenKinds = mutableSetOf() + rec.serviceData?.forEach { (parcel, blob) -> + val kind = BleConfig.kindOf(parcel) ?: return@forEach + if (seenKinds.add(kind)) out.add(blob to kind) } for (uuid in BleConfig.MATCH_UUIDS) { - parseServiceData128(rec.bytes, uuid)?.let { return it } + val kind = BleConfig.kindOfUuid(uuid) ?: continue + if (kind in seenKinds) continue + parseServiceData128(rec.bytes, uuid)?.let { + seenKinds.add(kind) + out.add(it to kind) + } } - return null + return out } /** Begin scanning. Idempotent. */ @@ -121,7 +192,11 @@ class Observer private constructor( ) { Log.w(tag, "chip lacks LE extended advertising — extended SOS frames may not arrive") } - scanProfile = defaultScanProfile() + scanProfile = if (radarActive && !isXiaomiLegacyStack()) { + BleConfig.ScanProfile.AGGRESSIVE + } else { + defaultScanProfile() + } Log.i(tag, "device=${Build.DEVICE} mfr=${Build.MANUFACTURER} api=${Build.VERSION.SDK_INT} profile=$scanProfile") startInternal() } @@ -129,7 +204,7 @@ class Observer private constructor( /** Xiaomi/MTK API ≤31: start LEGACY_STACK — AGGRESSIVE often misses extended ADV. */ private fun defaultScanProfile(): BleConfig.ScanProfile = if (isXiaomiLegacyStack()) BleConfig.ScanProfile.LEGACY_STACK - else BleConfig.ScanProfile.AGGRESSIVE + else BleConfig.ScanProfile.BALANCED private fun isXiaomiLegacyStack(): Boolean { val mfr = Build.MANUFACTURER.lowercase() @@ -149,10 +224,39 @@ class Observer private constructor( stopInternal() mainHandler.removeCallbacks(watchdogRunnable) mainHandler.removeCallbacks(restartRunnable) + mainHandler.removeCallbacks(exitRadarRunnable) + radarActive = false } val isScanning: Boolean get() = scanning + /** Active rescue search needs a low-latency RSSI stream. Normal mesh mode + * returns to balanced scanning as soon as the radar closes. */ + fun setRadarActive(active: Boolean) { + if (active) { + mainHandler.removeCallbacks(exitRadarRunnable) + if (radarActive) return + radarActive = true + val wanted = if (!isXiaomiLegacyStack()) BleConfig.ScanProfile.AGGRESSIVE else defaultScanProfile() + switchProfile(wanted, "radar opened") + } else if (radarActive) { + // Android can throttle apps that repeatedly stop/start scanning. Keep + // low latency briefly while a rescuer switches between radar and map. + mainHandler.removeCallbacks(exitRadarRunnable) + mainHandler.postDelayed(exitRadarRunnable, RADAR_EXIT_DEBOUNCE_MS) + Log.d(tag, "radar closed; keeping low-latency scan for ${RADAR_EXIT_DEBOUNCE_MS}ms") + } + } + + private fun switchProfile(wanted: BleConfig.ScanProfile, reason: String) { + if (scanProfile == wanted) return + scanProfile = wanted + if (scanning) { + Log.i(tag, "$reason; switching scan profile=$scanProfile") + restart() + } + } + private fun startInternal() { val callback = newCallback() activeCallback.set(callback) @@ -223,10 +327,10 @@ class Observer private constructor( "scan stalled callbacks=${idleCallbacks}ms guacamalla=${idleGuacamaya}ms " + "profile=$scanProfile — restarting", ) - if (isXiaomiLegacyStack() && scanProfile == BleConfig.ScanProfile.AGGRESSIVE) { + if (isXiaomiLegacyStack() && scanProfile != BleConfig.ScanProfile.LEGACY_STACK) { scanProfile = BleConfig.ScanProfile.LEGACY_STACK } else if (scanProfile == BleConfig.ScanProfile.LEGACY_STACK) { - scanProfile = BleConfig.ScanProfile.AGGRESSIVE + scanProfile = if (radarActive) BleConfig.ScanProfile.AGGRESSIVE else BleConfig.ScanProfile.BALANCED } restart() } @@ -236,6 +340,7 @@ class Observer private constructor( companion object { private const val RESTART_DELAY_MS = 1_500L + private const val RADAR_EXIT_DEBOUNCE_MS = 10_000L private const val WATCHDOG_INTERVAL_MS = 60_000L /** Legacy Xiaomi/sweet: check every 30 s, restart if no GuacaMalla frame in 60 s. */ private const val LEGACY_WATCHDOG_INTERVAL_MS = 30_000L diff --git a/android/app/src/main/kotlin/net/guacamaya/ble/RadioPolicy.kt b/android/app/src/main/kotlin/net/guacamaya/ble/RadioPolicy.kt new file mode 100644 index 0000000..d780e7e --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ble/RadioPolicy.kt @@ -0,0 +1,44 @@ +package net.guacamaya.ble + +import android.bluetooth.le.AdvertisingSetParameters + +enum class RadioTier { CHARGING, NORMAL, LOW, CRITICAL } + +data class RadioPolicy( + val tier: RadioTier, + val txPower: Int, + val windowMs: Long, + val cadenceMs: Long, + val relayEnabled: Boolean, +) { + val reducedRange: Boolean get() = tier == RadioTier.LOW || tier == RadioTier.CRITICAL +} + +/** Pure hysteretic state machine; Android battery broadcasts are adapted by the service. */ +object RadioPolicyEngine { + fun next(previous: RadioTier, percent: Int, charging: Boolean): RadioPolicy { + val tier = when { + charging -> RadioTier.CHARGING + previous == RadioTier.CRITICAL && percent < 15 -> RadioTier.CRITICAL + percent <= 10 -> RadioTier.CRITICAL + previous == RadioTier.LOW && percent < 25 -> RadioTier.LOW + percent <= 20 -> RadioTier.LOW + else -> RadioTier.NORMAL + } + return forTier(tier) + } + + fun forTier(tier: RadioTier): RadioPolicy = when (tier) { + RadioTier.CHARGING -> RadioPolicy(tier, AdvertisingSetParameters.TX_POWER_HIGH, 45_000L, 300_000L, true) + RadioTier.NORMAL -> RadioPolicy(tier, AdvertisingSetParameters.TX_POWER_HIGH, 30_000L, 300_000L, true) + RadioTier.LOW -> RadioPolicy(tier, AdvertisingSetParameters.TX_POWER_MEDIUM, 20_000L, 300_000L, true) + RadioTier.CRITICAL -> RadioPolicy(tier, AdvertisingSetParameters.TX_POWER_ULTRA_LOW, 10_000L, 600_000L, false) + } + + /** Stable ±3 s offset derived from the node id; equal-length windows still overlap. */ + fun jitterMs(nodeId: ByteArray): Long { + var hash = 0 + nodeId.forEach { hash = hash * 31 + (it.toInt() and 0xff) } + return ((hash.toLong() and 0x7fffffffL) % 6_001L) - 3_000L + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/crypto/Signer.kt b/android/app/src/main/kotlin/net/guacamaya/crypto/Signer.kt index 538f0cb..50bcc94 100644 --- a/android/app/src/main/kotlin/net/guacamaya/crypto/Signer.kt +++ b/android/app/src/main/kotlin/net/guacamaya/crypto/Signer.kt @@ -16,6 +16,14 @@ object Signer { const val PUBLIC_KEY_SIZE = 32 const val PRIVATE_KEY_SIZE = 32 + /** Derive the 32-byte Ed25519 public key from a 32-byte private key. */ + fun derivePublicKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == PRIVATE_KEY_SIZE) { + "private key must be $PRIVATE_KEY_SIZE bytes, got ${privateKey.size}" + } + return Ed25519PrivateKeyParameters(privateKey, 0).generatePublicKey().encoded + } + /** Sign the full 22-byte payload (CRC included). Returns 64-byte signature. */ fun sign(privateKey: ByteArray, payload: ByteArray): ByteArray { require(privateKey.size == PRIVATE_KEY_SIZE) { @@ -37,6 +45,23 @@ object Signer { return verifyMessage(publicKey, payload, signature) } + /** + * Sign a 64-byte signature over an **arbitrary-length** message. The control-frame + * scheme signs `UTF8(domain) || payload22` (47 B for control v1), not a bare 22-byte + * payload, so [sign] (which requires exactly PAYLOAD_SIZE) cannot sign it. Mirrors + * [verifyMessage] on the sign side. See net.guacamaya.proto.ControlPayload. + */ + fun signMessage(privateKey: ByteArray, message: ByteArray): ByteArray { + require(privateKey.size == PRIVATE_KEY_SIZE) { + "private key must be $PRIVATE_KEY_SIZE bytes, got ${privateKey.size}" + } + val signer = Ed25519Signer().apply { + init(true, Ed25519PrivateKeyParameters(privateKey, 0)) + update(message, 0, message.size) + } + return signer.generateSignature() + } + /** * Verify a 64-byte signature over an **arbitrary-length** message. Used for the * backend's official-record scheme, where the signed message is the 32-byte diff --git a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt index 969bca6..3648545 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt @@ -7,21 +7,56 @@ import java.net.HttpURLConnection import java.net.URL /** - * Result of one `POST /ingest` call. The backend returns aggregate counts only, - * not per-frame outcomes (`backend/src/channels/routes.ts`): + * Result of one `POST /ingest` call. The backend returns aggregate counts, not + * per-frame outcomes (`backend/src/channels/routes.ts`): * - * { success, ingested, duplicate, rejected, locationsIngested, reasons } + * { success, ingested, duplicate, rejected, locationsIngested, reasons, + * controlAccepted, controlDuplicate, controlRejected, controlReasons, + * controlFrames: [, ...] } * * `ok` is true on any HTTP 2xx. Both `ingested` and `duplicate` mean the backend * now holds the frame; `rejected` should be ~0 for frames we send (we only store * frames that already passed our own identical reject cascade), so a non-zero * count is a data-integrity signal worth logging. + * + * [controlFrames] are backend-minted 118 B control frames (base64, TTL-stripped) the + * server produced in response to the SOSes we uploaded — delivery (0x01) when an SOS + * is still fresh, backend-resolve (0x02) once it is terminal. The uploader re-injects + * them into the mesh so they reach the victim (and relay onward). Never self-cancel. */ data class IngestResult( val ok: Boolean, val ingested: Int = 0, val duplicate: Int = 0, val rejected: Int = 0, + val controlAccepted: Int = 0, + val controlDuplicate: Int = 0, + val controlRejected: Int = 0, + val controlFrames: List = emptyList(), + val foundAccepted: Int = 0, + val foundDuplicate: Int = 0, + val foundRejected: Int = 0, + val foundResults: List = emptyList(), + val certificateRevocationList: MuleCrl? = null, +) + +data class MuleCrl( + val sequence: Long, + val issuedAt: Long, + val nextUpdate: Long, + val revokedPublicKeys: List, + val backendSignature: String, +) + +/** Backend proximity verdict for one uploaded "Encontrado" frame. */ +data class FoundVerdict( + val id: String, + val targetRecordId: String, + val distanceMeters: Int, + /** "near" | "far" */ + val proximity: String, + /** "likely_found" | "possible_false_positive" */ + val verdict: String, ) /** @@ -29,8 +64,17 @@ data class IngestResult( * so [IngestRepository] stays free of Android/HTTP dependencies. */ interface IngestApi { - /** POST a batch of base64 frames. Throws [IOException] on transport failure. */ - suspend fun upload(frames: List): IngestResult + /** + * POST a batch of base64 SOS [frames], (optional) base64 [controlFrames] + * (self-cancels) and (optional) base64 [foundFrames] ("Encontrado" reports). + * Any list may be empty; the backend accepts a batch with at least one + * non-empty side. Throws [IOException] on transport failure. + */ + suspend fun upload( + frames: List, + controlFrames: List, + foundFrames: List, + ): IngestResult } /** @@ -47,7 +91,11 @@ class IngestClient( private val readTimeoutMs: Int = 30_000, ) : IngestApi { - override suspend fun upload(frames: List): IngestResult { + override suspend fun upload( + frames: List, + controlFrames: List, + foundFrames: List, + ): IngestResult { val url = URL(baseUrl.trimEnd('/') + "/ingest") val conn = (url.openConnection() as HttpURLConnection).apply { requestMethod = "POST" @@ -58,7 +106,9 @@ class IngestClient( setRequestProperty("Accept", "application/json") } try { - conn.outputStream.use { it.write(buildBody(frames).toByteArray(Charsets.UTF_8)) } + conn.outputStream.use { + it.write(buildBody(frames, controlFrames, foundFrames).toByteArray(Charsets.UTF_8)) + } val code = conn.responseCode if (code !in 200..299) { // Drain the error stream so the connection can be pooled/closed cleanly. @@ -72,12 +122,58 @@ class IngestClient( ingested = json.optInt("ingested", 0), duplicate = json.optInt("duplicate", 0), rejected = json.optInt("rejected", 0), + controlAccepted = json.optInt("controlAccepted", 0), + controlDuplicate = json.optInt("controlDuplicate", 0), + controlRejected = json.optInt("controlRejected", 0), + controlFrames = json.optJSONArray("controlFrames")?.let { arr -> + List(arr.length()) { arr.getString(it) } + } ?: emptyList(), + foundAccepted = json.optInt("foundAccepted", 0), + foundDuplicate = json.optInt("foundDuplicate", 0), + foundRejected = json.optInt("foundRejected", 0), + foundResults = json.optJSONArray("foundResults")?.let { arr -> + List(arr.length()) { i -> + val o = arr.getJSONObject(i) + FoundVerdict( + id = o.optString("id"), + targetRecordId = o.optString("targetRecordId"), + distanceMeters = o.optInt("distanceMeters"), + proximity = o.optString("proximity"), + verdict = o.optString("verdict"), + ) + } + } ?: emptyList(), + certificateRevocationList = json.optJSONObject("certificateRevocationList")?.let { crl -> + val keys = crl.optJSONArray("revokedPublicKeys") ?: JSONArray() + MuleCrl( + sequence = crl.getLong("sequence"), + issuedAt = crl.getLong("issuedAt"), + nextUpdate = crl.getLong("nextUpdate"), + revokedPublicKeys = List(keys.length()) { keys.getString(it) }, + backendSignature = crl.getString("backendSignature"), + ) + }, ) } finally { conn.disconnect() } } - private fun buildBody(frames: List): String = - JSONObject().put("frames", JSONArray(frames)).toString() + /** + * Include only non-empty arrays. The backend requires at least one valid batch, so + * a request carrying only SOS, only control, or both is always accepted — but a + * key absent vs `[]` keeps the body minimal and sidesteps any empty-array edge in + * `validateIngestBatch`. + */ + private fun buildBody( + frames: List, + controlFrames: List, + foundFrames: List, + ): String { + val obj = JSONObject() + if (frames.isNotEmpty()) obj.put("frames", JSONArray(frames)) + if (controlFrames.isNotEmpty()) obj.put("controlFrames", JSONArray(controlFrames)) + if (foundFrames.isNotEmpty()) obj.put("foundFrames", JSONArray(foundFrames)) + return obj.toString() + } } diff --git a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt index 871349c..98c1df9 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt @@ -2,10 +2,12 @@ package net.guacamaya.ingest import net.guacamaya.mesh.MessageDao import java.io.IOException +import java.util.Base64 /** - * Drives the data-mule upload loop: pull un-uploaded verified frames in batches, - * rebuild the 118 B `/ingest` frame, POST them, and mark the batch uploaded. + * Drives the data-mule upload loop: pull un-uploaded verified frames (SOS, self-cancel + * control, and Encontrado reports) in batches, rebuild the 118 B `/ingest` frame, POST + * them, and mark the batch uploaded. * * Pure of Android/WorkManager deps so it is unit-testable with a fake [MessageDao] * and [IngestApi]. [IngestUploadWorker] is the thin Android wrapper. @@ -33,49 +35,125 @@ class IngestRepository( val outcome: Outcome, val batches: Int = 0, val framesSent: Int = 0, + val controlFramesSent: Int = 0, val ingested: Int = 0, val duplicate: Int = 0, val rejected: Int = 0, + val controlAccepted: Int = 0, + val controlDuplicate: Int = 0, + val controlRejected: Int = 0, + val foundFramesSent: Int = 0, + val foundAccepted: Int = 0, + val foundDuplicate: Int = 0, + val foundRejected: Int = 0, + /** Backend proximity verdicts for the "Encontrado" frames this run uploaded. */ + val foundResults: List = emptyList(), + /** + * Backend-minted 118 B control frames (base64, TTL-stripped) returned across all + * batches this run — delivery (0x01) / backend-resolve (0x02) the server produced in + * response to the SOSes we uploaded. The worker re-injects them into the mesh so they + * reach the victim. Never self-cancel (0x03), so re-injection cannot loop back into the + * outbound queue. Accumulated across batches so one worker run drains them in one pass. + */ + val mintedControlFrames: List = emptyList(), + /** Newest signed CRL carried home by this mule run. */ + val certificateRevocationList: MuleCrl? = null, ) /** - * Upload all pending frames, oldest first, in batches of [batchSize]. Stops early - * (returning [Outcome.RETRY]) on the first transport failure; the marked batches - * persist, so the retry resumes where it left off. [maxBatches] bounds a single - * run so one worker invocation can't spin unbounded during a BLE storm. + * Upload all pending SOS frames AND queued self-cancel control frames, oldest first, in + * batches of [batchSize]. Each POST carries whichever side has work (SOS only, control + * only, or both); the backend requires at least one non-empty batch, and this loop only + * POSTs when at least one side is non-empty. Stops early (returning [Outcome.RETRY]) on + * the first transport failure; the marked batches persist, so the retry resumes where it + * left off. [maxBatches] bounds a single run so one worker invocation can't spin + * unbounded during a BLE storm. */ suspend fun uploadPending(maxBatches: Int = DEFAULT_MAX_BATCHES): UploadSummary { var batches = 0 var framesSent = 0 + var controlFramesSent = 0 + var foundFramesSent = 0 var ingested = 0 var duplicate = 0 var rejected = 0 + var controlAccepted = 0 + var controlDuplicate = 0 + var controlRejected = 0 + var foundAccepted = 0 + var foundDuplicate = 0 + var foundRejected = 0 + val foundResults = mutableListOf() + val minted = mutableListOf() + var crl: MuleCrl? = null + + fun summary(outcome: Outcome) = UploadSummary( + outcome = outcome, + batches = batches, + framesSent = framesSent, + controlFramesSent = controlFramesSent, + foundFramesSent = foundFramesSent, + ingested = ingested, + duplicate = duplicate, + rejected = rejected, + controlAccepted = controlAccepted, + controlDuplicate = controlDuplicate, + controlRejected = controlRejected, + foundAccepted = foundAccepted, + foundDuplicate = foundDuplicate, + foundRejected = foundRejected, + foundResults = foundResults.toList(), + mintedControlFrames = minted.toList(), + certificateRevocationList = crl, + ) repeat(maxBatches) { val batch = dao.selectUploadable(batchSize) - if (batch.isEmpty()) { - return UploadSummary(Outcome.SUCCESS, batches, framesSent, ingested, duplicate, rejected) + val controlBatch = dao.selectUploadableControl(batchSize) + val foundBatch = dao.selectUploadableFound(batchSize) + if (batch.isEmpty() && controlBatch.isEmpty() && foundBatch.isEmpty()) { + return summary(Outcome.SUCCESS) } val frames = batch.map { IngestFrame.toBase64(it.payloadRaw, it.pubkey, it.sig) } + // Control/found frames are stored as their canonical 118 B blob, so they + // only need base64 — same std encoder IngestFrame uses, keeping this + // class Android-free. + val controlFrames = controlBatch.map { Base64.getEncoder().encodeToString(it.frame) } + val foundFrames = foundBatch.map { Base64.getEncoder().encodeToString(it.frame) } val result = try { - api.upload(frames) + api.upload(frames, controlFrames, foundFrames) } catch (_: IOException) { - return UploadSummary(Outcome.RETRY, batches, framesSent, ingested, duplicate, rejected) + return summary(Outcome.RETRY) } if (!result.ok) { - return UploadSummary(Outcome.RETRY, batches, framesSent, ingested, duplicate, rejected) + return summary(Outcome.RETRY) } dao.markUploaded(batch.map { it.id }) + dao.markControlUploaded(controlBatch.map { it.signatureHex }) + dao.markFoundUploaded(foundBatch.map { it.signatureHex }) batches++ framesSent += frames.size + controlFramesSent += controlFrames.size + foundFramesSent += foundFrames.size ingested += result.ingested duplicate += result.duplicate rejected += result.rejected + controlAccepted += result.controlAccepted + controlDuplicate += result.controlDuplicate + controlRejected += result.controlRejected + foundAccepted += result.foundAccepted + foundDuplicate += result.foundDuplicate + foundRejected += result.foundRejected + foundResults += result.foundResults + minted += result.controlFrames + result.certificateRevocationList?.let { candidate -> + if (crl == null || candidate.sequence > crl!!.sequence) crl = candidate + } } - return UploadSummary(Outcome.SUCCESS, batches, framesSent, ingested, duplicate, rejected) + return summary(Outcome.SUCCESS) } companion object { diff --git a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt index 4d8fb73..80626bc 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt @@ -11,6 +11,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import net.guacamaya.BuildConfig +import net.guacamaya.ble.BleMeshRuntime import net.guacamaya.mesh.GuacamayaDatabase import java.util.concurrent.TimeUnit @@ -36,11 +37,42 @@ class IngestUploadWorker( TAG, "ingest run outcome=${summary.outcome} batches=${summary.batches} " + "sent=${summary.framesSent} ingested=${summary.ingested} " + - "duplicate=${summary.duplicate} rejected=${summary.rejected}", + "duplicate=${summary.duplicate} rejected=${summary.rejected} " + + "ctrlSent=${summary.controlFramesSent} ctrlOk=${summary.controlAccepted} " + + "ctrlDup=${summary.controlDuplicate} ctrlRej=${summary.controlRejected}" + + " foundSent=${summary.foundFramesSent} foundOk=${summary.foundAccepted} foundRej=${summary.foundRejected}", ) if (summary.rejected > 0) { Log.w(TAG, "backend rejected ${summary.rejected} locally-verified frames — check wire-format sync") } + if (summary.controlRejected > 0) { + Log.w(TAG, "backend rejected ${summary.controlRejected} self-cancel frames — check control wire-format sync") + } + for (v in summary.foundResults) { + Log.i(TAG, "found verdict target=${v.targetRecordId.take(12)} d=${v.distanceMeters}m ${v.proximity}/${v.verdict}") + } + + // Close the round-trip: re-inject any backend-minted control frames (delivery / + // backend-resolve) back into the mesh so they reach the victim and relay onward. + // Best-effort — never fails the run. Minted frames are never self-cancel, so this + // cannot loop back into the outbound queue. + var injected = 0 + for (frameB64 in summary.mintedControlFrames) { + try { + BleMeshRuntime.injectControlFrame(frameB64) + injected++ + } catch (t: Throwable) { + Log.w(TAG, "control inject failed: ${t.message}") + } + } + if (summary.mintedControlFrames.isNotEmpty()) { + Log.i(TAG, "control inject minted=${summary.mintedControlFrames.size} injected=$injected") + } + summary.certificateRevocationList?.let { crl -> + if (!MuleCrlStore.accept(applicationContext, crl, BuildConfig.BACKEND_PUBLIC_KEY_HEX)) { + Log.w(TAG, "discarded invalid/stale mule CRL sequence=${crl.sequence}") + } + } return when (summary.outcome) { IngestRepository.Outcome.SUCCESS -> Result.success() diff --git a/android/app/src/main/kotlin/net/guacamaya/ingest/MuleCrlStore.kt b/android/app/src/main/kotlin/net/guacamaya/ingest/MuleCrlStore.kt new file mode 100644 index 0000000..222841d --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ingest/MuleCrlStore.kt @@ -0,0 +1,33 @@ +package net.guacamaya.ingest + +import android.content.Context +import net.guacamaya.crypto.Signer +import java.security.MessageDigest + +/** Verifies and monotonically installs backend CRLs received through /ingest. */ +object MuleCrlStore { + fun accept(context: Context, crl: MuleCrl, backendPublicKeyHex: String, now: Long = System.currentTimeMillis()): Boolean { + if (crl.sequence < 0 || crl.issuedAt > now || crl.nextUpdate < now) return false + if (crl.revokedPublicKeys.any { !it.matches(Regex("[0-9a-f]{64}")) }) return false + val pub = hex(backendPublicKeyHex) ?: return false + val sig = hex(crl.backendSignature) ?: return false + val keys = crl.revokedPublicKeys.distinct().sorted().joinToString(",") { "\"$it\"" } + val canonical = "{\"issuedAt\":${crl.issuedAt},\"nextUpdate\":${crl.nextUpdate},\"revokedPublicKeys\":[$keys],\"sequence\":${crl.sequence},\"version\":1}" + val hash = MessageDigest.getInstance("SHA-256").digest("guacamaya.rescuer-crl.v1\n$canonical".toByteArray()) + if (!Signer.verifyMessage(pub, hash, sig)) return false + val prefs = context.getSharedPreferences("guacamaya_crl", Context.MODE_PRIVATE) + if (crl.sequence < prefs.getLong("sequence", -1)) return false + prefs.edit().putLong("sequence", crl.sequence).putLong("next_update", crl.nextUpdate) + .putStringSet("revoked", crl.revokedPublicKeys.toSet()).apply() + return true + } + + fun isRevoked(context: Context, publicKeyHex: String): Boolean = + context.getSharedPreferences("guacamaya_crl", Context.MODE_PRIVATE) + .getStringSet("revoked", emptySet())?.contains(publicKeyHex) == true + + private fun hex(value: String): ByteArray? { + if (value.length % 2 != 0) return null + return runCatching { ByteArray(value.length / 2) { i -> value.substring(i * 2, i * 2 + 2).toInt(16).toByte() } }.getOrNull() + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/mesh/FloodRouter.kt b/android/app/src/main/kotlin/net/guacamaya/mesh/FloodRouter.kt index 1d4091e..156dc9c 100644 --- a/android/app/src/main/kotlin/net/guacamaya/mesh/FloodRouter.kt +++ b/android/app/src/main/kotlin/net/guacamaya/mesh/FloodRouter.kt @@ -5,12 +5,21 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import net.guacamaya.ble.BleConfig import net.guacamaya.ble.Broadcaster +import net.guacamaya.ble.ControlRelay +import net.guacamaya.ble.ControlSink +import net.guacamaya.ble.Observer import net.guacamaya.crypto.Signer +import net.guacamaya.proto.ControlFrameResult +import net.guacamaya.proto.ControlType import net.guacamaya.proto.Flags import net.guacamaya.proto.Payload +import net.guacamaya.proto.VerifiedControlFrame +import net.guacamaya.proto.verifyControlFrame import java.security.MessageDigest /** @@ -36,31 +45,109 @@ class FloodRouter( private val dedupe: DedupeCache = DedupeCache(), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val broadcaster: Broadcaster? = null, + private val observationRecorder: NeighborObservationSink? = null, private val now: () -> Long = System::currentTimeMillis, -) { + /** + * Pinned backend Ed25519 public key (lowercase hex), or null when none is pinned. + * Required to accept a delivery (0x01) or backend-resolve (0x02) control frame; + * null leaves only self-cancel (0x03) verifiable. TOFU-anchored at build time, never + * from `GET /pubkey` (see docs/PROTOCOL_CONTROL.md). Lazy so the pin can arrive late + * (e.g. fetched on first ingest) without rebuilding the router. + */ + private val backendPublicKeyProvider: () -> String? = { null }, + /** + * Relay path for control frames, advertised under [BleConfig.CONTROL_SERVICE_UUID] — + * NOT the SOS [Broadcaster]. A control frame relayed on the SOS UUID would be + * classified as SOS by the Observer and dropped on signature; control must leave on + * its own transport UUID. Nullable so the router degrades to verify-only (persist + + * trust state still apply) when the chip cannot advertise. + */ + private val controlBroadcaster: ControlRelay? = null, +) : ControlSink { private val tag = "guacamaya.mesh.FloodRouter" private val nodeIdDigest = MessageDigest.getInstance("SHA-256") - private val frames = Channel( + private val frames = Channel( capacity = FRAME_QUEUE_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) private var insertsSincePrune = 0 + private val copiesByMessage = mutableMapOf() + private val relayScheduled = mutableSetOf() + private val originBuckets = mutableMapOf() init { scope.launch { - for (frame in frames) { - handle(frame.payload22, frame.pub32, frame.sig64, frame.ttl, frame.rssi) + for (item in frames) { + when (item) { + is Inbox.Sos -> handle( + item.payload22, + item.pub32, + item.sig64, + item.ttl, + item.rssi, + item.txPower, + item.primaryPhy, + item.secondaryPhy, + ) + is Inbox.Control -> handleControlEnvelope(item.onWire119, item.rssi) + } } } } /** Callback for the BLE Observer. Cheap and bounded; drops oldest under storms. */ - fun onFrame(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int, rssi: Int) { - val accepted = frames.trySend(Frame(payload22, pub32, sig64, ttl, rssi)).isSuccess + fun onFrame( + payload22: ByteArray, + pub32: ByteArray, + sig64: ByteArray, + ttl: Int, + rssi: Int, + txPower: Int? = null, + primaryPhy: Int = 0, + secondaryPhy: Int = 0, + ) { + val accepted = frames.trySend( + Inbox.Sos(payload22, pub32, sig64, ttl, rssi, txPower, primaryPhy, secondaryPhy), + ).isSuccess if (!accepted) Log.w(tag, "DROP: frame queue saturated") } - private suspend fun handle(payload22: ByteArray, pub32: ByteArray, sig64: ByteArray, ttl: Int, rssi: Int) { + /** + * [ControlSink] — entry for a control PDU the Observer surfaced under + * [BleConfig.CONTROL_SERVICE_UUID]. Cheap and bounded; the full verify cascade + + * persistent replay gate run on the single-consumer coroutine (same thread as SOS, + * so the mutable relay maps and dao stay race-free). Null sink ⇒ dropped upstream. + */ + override fun onControlFrame(onWire119: ByteArray, radio: Observer.RadioMetadata) { + val accepted = frames.trySend(Inbox.Control(onWire119, radio.rssi)).isSuccess + if (!accepted) Log.w(tag, "DROP: control frame queue saturated") + } + + /** + * Verify a control envelope on the consumer thread. The on-wire 119 B blob is fed + * straight to [verifyControlFrame] (it strips the leading mutable hop TTL itself). + * Only a fully verified control frame is acted on; any rejection is dropped — there + * is NO SOS fall-through, because UUID routing already classified this PDU as control + * (a control UUID never carries an SOS payload). + */ + private suspend fun handleControlEnvelope(onWire119: ByteArray, rssi: Int) { + val ttl = onWire119[BleConfig.TTL_OFFSET].toInt() and 0xFF + when (val result = verifyControlFrame(onWire119, now(), backendPublicKeyProvider())) { + is ControlFrameResult.Ok -> handleControl(result.control, ttl, rssi) + is ControlFrameResult.Err -> Log.w(tag, "DROP: control reject (${result.reason})") + } + } + + private suspend fun handle( + payload22: ByteArray, + pub32: ByteArray, + sig64: ByteArray, + ttl: Int, + rssi: Int, + txPower: Int?, + primaryPhy: Int, + secondaryPhy: Int, + ) { // 1. Pubkey binding (reuse digest — single consumer thread). val digest = nodeIdDigest.digest(pub32) val expectedNodeId = digest.copyOfRange(0, 4) @@ -92,6 +179,27 @@ class FloodRouter( return } + // An unchanged TTL is only an untrusted proximity hint: the mutable byte + // can be copied by a relay/wormhole and never proves physical origin. + if (ttl == payload.flags.hopTtl) { + observationRecorder?.record( + DirectRadioObservation( + nodeId = payload.nodeId, + rssi = rssi, + source = ObservationSource.BLE_RSSI, + txPower = txPower, + primaryPhy = primaryPhy, + secondaryPhy = secondaryPhy, + proximityTrusted = false, + ), + ) + } + + // Count every valid copy before dedupe. A relay is suppressed when enough + // neighbours already cover it; far/weak receptions get the shortest delay. + val messageKey = "${payload.nodeId.toHex()}:${payload.msgId}:${payload.tsUnix}" + copiesByMessage[messageKey] = (copiesByMessage[messageKey] ?: 0) + 1 + // 6. Dedupe + persist. val fresh = dedupe.admit(payload.nodeId, payload.msgId) val entity = MessageEntity( @@ -132,19 +240,176 @@ class FloodRouter( Log.d(tag, "no relay: hop TTL exhausted (ttl_in=$ttl)") return } - broadcaster?.swap(payload22, pub32, sig64, nextTtl) + val bucket = originBuckets.getOrPut(pub32.toHex()) { TokenBucket(now()) } + if (!bucket.tryTake(now())) { + Log.w(tag, "no relay: origin rate limit") + return + } + if (!relayScheduled.add(messageKey)) return + val weakFirstDelay = ((rssi.coerceIn(-100, -35) + 35) * RSSI_DELAY_SPAN_MS / 65).toLong() + scope.launch { + delay(weakFirstDelay + (0..RELAY_JITTER_MS).random()) + val copies = copiesByMessage[messageKey] ?: 0 + relayScheduled.remove(messageKey) + copiesByMessage.remove(messageKey) + if (copies >= COUNTER_SUPPRESSION_K) { + Log.d(tag, "no relay: counter suppression copies=$copies") + } else { + broadcaster?.swap(payload22, pub32, sig64, nextTtl) + } + } + } + + /** + * A verified control receipt (delivery 0x01 / backend-resolve 0x02 / self-cancel + * 0x03). Act on it against this device's own SOS state, then relay — never persist + * it as an SOS frame. Replay is gated by the persistent [MessageDao.insertControlReplay] + * (survives a reboot, unlike [DedupeCache]); trust state is pinned per own SOS in + * [MessageDao.upsertControlTrust]. Control frames are not victim-origin RF sources, + * so they are excluded from the Sonar observation recorder. Relay fairness reuses the + * same token-bucket / counter-suppression / weak-first delay as SOS, keyed by issuer. + */ + private suspend fun handleControl( + control: VerifiedControlFrame, + ttl: Int, + rssi: Int, + ) { + val nowMs = now() + + // Persistent replay guard — the cross-reboot gate. -1 (IGNORE) ⇒ a signature we + // have already acted on: do not re-act, do not re-flood. + val admitted = dao.insertControlReplay( + ControlReplayEntity( + signatureHex = control.signatureHex, + frameType = control.type.code, + issuerNodeId = control.issuerNodeIdHex, + ctrlMsgId = control.ctrlMsgId, + expiresAt = control.expiresAt * 1_000L, + receivedAt = nowMs, + ), + ) + if (admitted == -1L) { + Log.d(tag, "control replay sig=${control.signatureHex.take(16)}") + return + } + dao.pruneControlReplay(nowMs) // opportunistic: bounded table, expired rows first + + // Queue verified self-cancels (0x03) for data-mule upload: self-cancel is the + // only mesh-originated control the backend needs (processControlFrames → + // SOS_CANCELLED ledger event). Delivery (0x01) / backend-resolve (0x02) are + // minted server-side, so they are not re-uploaded. insertControlReplay above + // already rejected a replay, so a given self-cancel is queued at most once; + // IGNORE on the outbound PK is the belt-and-braces. + if (control.type == ControlType.SELF_CANCEL) { + dao.insertOutboundControl( + OutboundControlEntity( + signatureHex = control.signatureHex, + frameType = control.type.code, + frame = control.frame, + queuedAt = nowMs, + ), + ) + } + + applyControlTrust(control, nowMs) + + Log.i( + tag, + "OK control type=${control.type} target=${control.targetNodeIdHex} msg=${control.targetMsgId} " + + "issuer=${control.issuerNodeIdHex} ctrl=${control.ctrlMsgId} ttl_in=$ttl", + ) + + // Relay. The signed 22 B payload (+ issuer pubkey + sig) is forwarded unchanged, + // only the unsigned hop TTL shrinks — same invariant as SOS relay. + val nextTtl = ttl - 1 + if (nextTtl <= 0) { + Log.d(tag, "no control relay: hop TTL exhausted (ttl_in=$ttl)") + return + } + val bucket = originBuckets.getOrPut(control.issuerPublicKeyHex) { TokenBucket(nowMs) } + if (!bucket.tryTake(nowMs)) { + Log.w(tag, "no control relay: issuer rate limit") + return + } + val relayKey = "ctrl:${control.issuerNodeIdHex}:${control.ctrlMsgId}" + copiesByMessage[relayKey] = (copiesByMessage[relayKey] ?: 0) + 1 + if (!relayScheduled.add(relayKey)) return + val weakFirstDelay = ((rssi.coerceIn(-100, -35) + 35) * RSSI_DELAY_SPAN_MS / 65).toLong() + scope.launch { + delay(weakFirstDelay + (0..RELAY_JITTER_MS).random()) + val copies = copiesByMessage[relayKey] ?: 0 + relayScheduled.remove(relayKey) + copiesByMessage.remove(relayKey) + if (copies >= COUNTER_SUPPRESSION_K) { + Log.d(tag, "no control relay: counter suppression copies=$copies") + } else { + // Relay under CONTROL_SERVICE_UUID — NOT the SOS broadcaster. The signed + // 22 B payload + issuer pubkey + sig forward unchanged; only hop TTL shrinks. + controlBroadcaster?.relay(control.payload, control.issuerPublicKey, control.signature, nextTtl) + } + } + } + + /** + * Pin the trust state for one of this device's own SOS frames. A terminal receipt + * (backend-resolve 0x02 / self-cancel 0x03) is the local authority to STOP + * advertising that SOS, sticky across reboots. A delivery (0x01) is informational + * and must never overwrite an existing terminal row. + */ + private suspend fun applyControlTrust(control: VerifiedControlFrame, nowMs: Long) { + val existing = dao.getControlTrust(control.targetNodeIdHex, control.targetMsgId) + val terminal = control.type == ControlType.BACKEND_RESOLVE || control.type == ControlType.SELF_CANCEL + if (!terminal && existing != null && existing.terminal) { + Log.d(tag, "delivery on terminal SOS ${control.targetNodeIdHex}:${control.targetMsgId}; no state change") + return + } + dao.upsertControlTrust( + ControlTrustEntity( + nodeId = control.targetNodeIdHex, + msgId = control.targetMsgId, + terminal = terminal, + resolveType = control.type.code, + issuerNodeId = control.issuerNodeIdHex, + authorityPubkeyHex = control.issuerPublicKeyHex, + authoritySignatureHex = control.signatureHex, + expiresAt = control.expiresAt * 1_000L, + establishedAt = nowMs, + ), + ) } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } - private data class Frame( - val payload22: ByteArray, - val pub32: ByteArray, - val sig64: ByteArray, - val ttl: Int, - val rssi: Int, - ) + /** + * Work items on the single consumer thread. [Sos] is a victim distress frame; the + * [Control] variant carries the bare 119 B on-wire control blob (leading TTL intact) + * so the consumer can both re-verify (verifyControlFrame strips the TTL) and re-relay. + */ + private sealed interface Inbox { + data class Sos( + val payload22: ByteArray, + val pub32: ByteArray, + val sig64: ByteArray, + val ttl: Int, + val rssi: Int, + val txPower: Int?, + val primaryPhy: Int, + val secondaryPhy: Int, + ) : Inbox + + data class Control(val onWire119: ByteArray, val rssi: Int) : Inbox + } + + private class TokenBucket(private var lastMs: Long, private var tokens: Double = ORIGIN_BURST.toDouble()) { + fun tryTake(nowMs: Long): Boolean { + tokens = minOf(ORIGIN_BURST.toDouble(), tokens + (nowMs - lastMs).coerceAtLeast(0) * ORIGIN_PER_MINUTE / 60_000.0) + lastMs = nowMs + if (tokens < 1) return false + tokens -= 1 + return true + } + } companion object { const val MAX_TS_SKEW_SECONDS = 300L @@ -154,5 +419,12 @@ class FloodRouter( /** Batch pruning keeps SQLite cheap during dense BLE storms. */ const val PRUNE_EVERY_INSERTS = 128 + /** Do not re-broadcast after hearing this many valid copies during the timer. */ + const val COUNTER_SUPPRESSION_K = 3 + /** Weak RSSI wins geographically: -100 dBm starts near 0 ms, -35 near 1 s. */ + const val RSSI_DELAY_SPAN_MS = 1_000 + const val RELAY_JITTER_MS = 120 + const val ORIGIN_PER_MINUTE = 30 + const val ORIGIN_BURST = 8 } } diff --git a/android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt b/android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt index f89c722..15d8c99 100644 --- a/android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt +++ b/android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow const val DEFAULT_RECENT_LIMIT = 2_000 const val MAX_STORED_MESSAGES = 25_000 +const val MAX_NEIGHBOR_OBSERVATIONS_PER_NODE = 50 /** * Persistent record of every verified SOS frame received. See docs/protocol-flows.md @@ -66,6 +67,210 @@ data class MessageEntity( override fun hashCode(): Int = System.identityHashCode(this) } +/** + * Bounded history of authenticated, direct-neighbour radio samples. The observer + * position belongs to the receiver at the instant of measurement; it is retained + * for a future cooperative multilateration exchange, never embedded in the SOS. + */ +@Entity( + tableName = "neighbor_observations", + indices = [ + Index(value = ["node_id", "observed_at"]), + Index(value = ["observed_at"]), + ], +) +data class NeighborObservationEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "node_id") val nodeId: String, + @ColumnInfo(name = "observed_at") val observedAt: Long, + @ColumnInfo(name = "rssi") val rssi: Int, + @ColumnInfo(name = "source") val source: Int, + @ColumnInfo(name = "tx_power") val txPower: Int?, + @ColumnInfo(name = "primary_phy") val primaryPhy: Int, + @ColumnInfo(name = "secondary_phy") val secondaryPhy: Int, + @ColumnInfo(name = "observer_lat_e7") val observerLatE7: Int?, + @ColumnInfo(name = "observer_lon_e7") val observerLonE7: Int?, + @ColumnInfo(name = "observer_accuracy_m") val observerAccuracyMeters: Float?, +) + +/** Latest filtered state. One row per directly heard node makes the radar cheap + * to observe and preserves liveness across a process restart. */ +@Entity( + tableName = "neighbor_estimates", + indices = [Index(value = ["last_seen_at"]), Index(value = ["state"])], +) +data class NeighborEstimateEntity( + @PrimaryKey + @ColumnInfo(name = "node_id") val nodeId: String, + @ColumnInfo(name = "updated_at") val updatedAt: Long, + @ColumnInfo(name = "last_seen_at") val lastSeenAt: Long, + @ColumnInfo(name = "filtered_rssi") val filteredRssi: Float, + @ColumnInfo(name = "rssi_variance") val rssiVariance: Float, + @ColumnInfo(name = "distance_m") val distanceMeters: Float?, + @ColumnInfo(name = "uncertainty_m") val uncertaintyMeters: Float?, + @ColumnInfo(name = "source") val source: Int, + @ColumnInfo(name = "tx_power") val txPower: Int?, + @ColumnInfo(name = "calibrated") val calibrated: Boolean, + @ColumnInfo(name = "stable") val stable: Boolean, + @ColumnInfo(name = "sample_count") val sampleCount: Int, + @ColumnInfo(name = "state") val state: Int, +) + +/** + * Persistent anti-replay for control frames (delivery 0x01 / backend-resolve 0x02 + * / self-cancel 0x03). Keyed by the 64-byte Ed25519 signature hex — a replay is + * the exact same frame, so the signature alone is the dedup key. Survives reboot, + * which the in-memory [DedupeCache] does not: a control frame re-relayed after a + * restart must not be re-acted-upon. Pruned by [expiresAt] so the table stays + * bounded under churn. Mirrors `control_replay` on the backend. + */ +@Entity( + tableName = "control_replay", + indices = [Index(value = ["expires_at"])], +) +data class ControlReplayEntity( + /** 64-byte Ed25519 signature hex (128 chars). The dedup key. */ + @PrimaryKey @ColumnInfo(name = "signature_hex") val signatureHex: String, + /** 0x01 delivery | 0x02 backend-resolve | 0x03 self-cancel. */ + @ColumnInfo(name = "frame_type") val frameType: Int, + /** sha256(issuer pubkey)[0..3] hex — dedupe namespace / debugging. */ + @ColumnInfo(name = "issuer_node_id") val issuerNodeId: String, + /** u16 CSPRNG control message id. */ + @ColumnInfo(name = "ctrl_msg_id") val ctrlMsgId: Int, + /** Unix ms the underlying control frame expires at. Drives pruning. */ + @ColumnInfo(name = "expires_at") val expiresAt: Long, + @ColumnInfo(name = "received_at") val receivedAt: Long, +) + +/** + * A control receipt this device has verified and acted upon, one row per own SOS + * `(node_id, msg_id)`. A terminal row (backend-resolve 0x02 or self-cancel 0x03) + * is the *local* authority to STOP advertising that SOS — sticky across reboots so + * a resolved SOS does not resume TX. Delivery (0x01) is informational only and is + * never terminal. The authority is pinned by signature + pubkey so a forged + * "stop" from a non-backend, non-self issuer cannot flip state. + */ +@Entity( + tableName = "control_trust", + primaryKeys = ["node_id", "msg_id"], + indices = [Index(value = ["terminal"]), Index(value = ["established_at"])], +) +data class ControlTrustEntity( + /** Self node id (sha256(pubkey)[0..3] hex) — the SOS originator. */ + @ColumnInfo(name = "node_id") val nodeId: String, + @ColumnInfo(name = "msg_id") val msgId: Int, + /** True once a terminal receipt (0x02 / 0x03) was verified → stop TX. */ + @ColumnInfo(name = "terminal") val terminal: Boolean, + /** Control type that established the current state (0x01/0x02/0x03). */ + @ColumnInfo(name = "resolve_type") val resolveType: Int, + /** sha256(issuer pubkey)[0..3] hex — backend for 0x02, self for 0x03. */ + @ColumnInfo(name = "issuer_node_id") val issuerNodeId: String, + /** Issuer pubkey hex (backend or self). Re-verifiable against the pinned key. */ + @ColumnInfo(name = "authority_pubkey_hex") val authorityPubkeyHex: String, + /** Signature hex of the control frame that established this trust. */ + @ColumnInfo(name = "authority_signature_hex") val authoritySignatureHex: String, + /** Unix ms the underlying control frame expires at. */ + @ColumnInfo(name = "expires_at") val expiresAt: Long, + /** Unix ms this device acted on the receipt. */ + @ColumnInfo(name = "established_at") val establishedAt: Long, +) + +/** + * A verified self-cancel (0x03) control frame queued for data-mule upload to the + * backend. Only self-cancel is persisted here: it is the sole mesh-originated + * control the backend acts on (`processControlFrames` → `SOS_CANCELLED` ledger + * event). Delivery (0x01) and backend-resolve (0x02) are minted server-side, so + * re-uploading them would be pure noise. + * + * Keyed by the 64-byte Ed25519 signature hex — the same dedup key as + * [ControlReplayEntity] — so a frame already admitted is inserted exactly once (a + * replay is rejected by [MessageDao.insertControlReplay] before this insert is ever + * reached; IGNORE on the PK is the belt-and-braces). [frame] is the canonical 118 B + * upload blob (payload‖pubkey‖sig, hop-TTL stripped) the backend's `controlFrames[]` + * field expects — identical layout to a 118 B SOS ingest frame. + */ +@Entity( + tableName = "outbound_control", + indices = [Index(value = ["queued_at"])], +) +data class OutboundControlEntity( + /** 64-byte Ed25519 signature hex (128 chars). The dedup key. */ + @PrimaryKey @ColumnInfo(name = "signature_hex") val signatureHex: String, + /** 0x03 self-cancel (the only type persisted for upload). */ + @ColumnInfo(name = "frame_type") val frameType: Int, + /** Canonical 118 B upload frame (payload22 + pubkey32 + sig64, TTL stripped). */ + @ColumnInfo(name = "frame") val frame: ByteArray, + /** Set once the backend accepted/duplicated the frame in `controlFrames[]`. */ + @ColumnInfo(name = "uploaded", defaultValue = "0") val uploaded: Boolean = false, + @ColumnInfo(name = "queued_at") val queuedAt: Long, +) { + // Room needs equals/hashCode for ByteArray fields. + override fun equals(other: Any?): Boolean = this === other + override fun hashCode(): Int = System.identityHashCode(this) +} + +/** + * A locally-authored "Encontrado" (found) frame queued for data-mule upload to + * the backend (`foundFrames[]` in POST /ingest). Keyed by the 64-byte Ed25519 + * signature hex so the same report is queued exactly once. [frame] is the + * canonical 118 B upload blob (payload22 ‖ pubkey32 ‖ sig64) built by + * net.guacamaya.proto.buildFoundFrame. + */ +@Entity( + tableName = "outbound_found", + indices = [Index(value = ["queued_at"])], +) +data class OutboundFoundEntity( + /** 64-byte Ed25519 signature hex (128 chars). The dedup key. */ + @PrimaryKey @ColumnInfo(name = "signature_hex") val signatureHex: String, + /** Canonical 118 B upload frame (payload22 + pubkey32 + sig64). */ + @ColumnInfo(name = "frame") val frame: ByteArray, + /** Set once the backend accepted/duplicated the frame in `foundFrames[]`. */ + @ColumnInfo(name = "uploaded", defaultValue = "0") val uploaded: Boolean = false, + @ColumnInfo(name = "queued_at") val queuedAt: Long, +) { + // Room needs equals/hashCode for ByteArray fields. + override fun equals(other: Any?): Boolean = this === other + override fun hashCode(): Int = System.identityHashCode(this) +} + +@Entity( + tableName = "chat_messages", + primaryKeys = ["sender_node_id", "message_id"], + indices = [Index(value = ["timestamp_ms"]), Index(value = ["received_at"])], +) +data class ChatMessageEntity( + @ColumnInfo(name = "sender_node_id") val senderNodeId: String, + @ColumnInfo(name = "message_id") val messageId: Long, + @ColumnInfo(name = "timestamp_ms") val timestampMs: Long, + @ColumnInfo(name = "text") val text: String, + @ColumnInfo(name = "public_key") val publicKey: ByteArray, + @ColumnInfo(name = "signature") val signature: ByteArray, + @ColumnInfo(name = "own") val own: Boolean, + /** 0 queued, 1 advertised, 2 relayed. */ + @ColumnInfo(name = "delivery_state") val deliveryState: Int, + @ColumnInfo(name = "received_at") val receivedAt: Long, +) + +@Entity( + tableName = "chat_fragments", + primaryKeys = ["sender_node_id", "message_id", "fragment_index"], + indices = [Index(value = ["received_at"])], +) +data class ChatFragmentEntity( + @ColumnInfo(name = "sender_node_id") val senderNodeId: String, + @ColumnInfo(name = "message_id") val messageId: Long, + @ColumnInfo(name = "fragment_index") val fragmentIndex: Int, + @ColumnInfo(name = "fragment_count") val fragmentCount: Int, + @ColumnInfo(name = "total_length") val totalLength: Int, + @ColumnInfo(name = "timestamp_seconds") val timestampSeconds: Long, + @ColumnInfo(name = "chunk") val chunk: ByteArray, + @ColumnInfo(name = "public_key") val publicKey: ByteArray, + @ColumnInfo(name = "signature") val signature: ByteArray, + @ColumnInfo(name = "ttl") val ttl: Int, + @ColumnInfo(name = "received_at") val receivedAt: Long, +) + @Dao interface MessageDao { @@ -149,6 +354,133 @@ interface MessageDao { @Query("SELECT COUNT(*) FROM messages WHERE uploaded = 0 AND length(sig) = 64") suspend fun countUploadable(): Int + + @Insert + suspend fun insertNeighborObservation(entity: NeighborObservationEntity): Long + + @Query( + "DELETE FROM neighbor_observations WHERE node_id = :nodeId AND id NOT IN " + + "(SELECT id FROM neighbor_observations WHERE node_id = :nodeId " + + "ORDER BY observed_at DESC, id DESC LIMIT :keep)", + ) + suspend fun pruneNeighborObservations(nodeId: String, keep: Int = MAX_NEIGHBOR_OBSERVATIONS_PER_NODE) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertNeighborEstimate(entity: NeighborEstimateEntity) + + @Query("SELECT * FROM neighbor_estimates ORDER BY state ASC, filtered_rssi DESC LIMIT :limit") + fun observeNeighborEstimates(limit: Int = 500): Flow> + + @Query( + "UPDATE neighbor_estimates SET state = CASE " + + "WHEN :now - last_seen_at >= :lostAfter THEN 2 " + + "WHEN :now - last_seen_at >= :degradedAfter THEN 1 " + + "ELSE 0 END", + ) + suspend fun refreshNeighborStates(now: Long, degradedAfter: Long, lostAfter: Long) + + // ── Control frames: persistent replay guard + acted-on trust state ───────── + + /** + * Admit a freshly-verified control frame exactly once per signature. Returns + * the row id (>0) iff newly inserted; -1 (IGNORE conflict) on a replay. This + * is the persistent complement to [DedupeCache] — the gate for acting on / + * relaying a control frame across reboots. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertControlReplay(entity: ControlReplayEntity): Long + + @Query("SELECT EXISTS(SELECT 1 FROM control_replay WHERE signature_hex = :signatureHex)") + suspend fun hasControlReplay(signatureHex: String): Boolean + + @Query("DELETE FROM control_replay WHERE expires_at < :now") + suspend fun pruneControlReplay(now: Long): Int + + @Query("SELECT COUNT(*) FROM control_replay") + suspend fun countControlReplay(): Int + + /** + * Upsert the trust state for one of this device's own SOS frames. One row per + * `(node_id, msg_id)`; a terminal upsert is the local authority to stop TX. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertControlTrust(entity: ControlTrustEntity) + + @Query("SELECT * FROM control_trust WHERE node_id = :nodeId AND msg_id = :msgId") + suspend fun getControlTrust(nodeId: String, msgId: Int): ControlTrustEntity? + + @Query("SELECT * FROM control_trust WHERE node_id = :nodeId AND msg_id = :msgId") + fun observeControlTrust(nodeId: String, msgId: Int): Flow + + /** Every own SOS currently suppressed by a terminal receipt — consulted on boot. */ + @Query("SELECT * FROM control_trust WHERE terminal = 1") + suspend fun terminalControlTrusts(): List + + @Query("SELECT COUNT(*) FROM control_trust") + suspend fun countControlTrust(): Int + + @Query("DELETE FROM control_trust") + suspend fun clearControlTrust() + + // ── Outbound control frames: mule-upload queue (self-cancel → backend) ─────── + + /** + * Queue a verified self-cancel (0x03) for mule upload. IGNORE on the PK means the + * same frame is queued exactly once even if the caller re-persists it. Returns + * the row id (>0) iff newly inserted; -1 on a duplicate signature. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOutboundControl(entity: OutboundControlEntity): Long + + /** + * Oldest-first batch of control frames not yet uploaded, drained alongside SOS + * frames in the same `/ingest` call (the backend's `controlFrames[]` field). + */ + @Query("SELECT * FROM outbound_control WHERE uploaded = 0 ORDER BY queued_at ASC LIMIT :limit") + suspend fun selectUploadableControl(limit: Int): List + + @Query("UPDATE outbound_control SET uploaded = 1 WHERE signature_hex IN (:sigs)") + suspend fun markControlUploaded(sigs: List) + + @Query("SELECT COUNT(*) FROM outbound_control WHERE uploaded = 0") + suspend fun countUploadableControl(): Int + + // ── Outbound found frames: mule-upload queue ("Encontrado" → backend) ──────── + + /** Queue a locally-authored found frame. IGNORE on the PK dedupes by signature. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOutboundFound(entity: OutboundFoundEntity): Long + + /** Oldest-first batch of found frames not yet uploaded, drained alongside SOS + * frames in the same `/ingest` call (the backend's `foundFrames[]` field). */ + @Query("SELECT * FROM outbound_found WHERE uploaded = 0 ORDER BY queued_at ASC LIMIT :limit") + suspend fun selectUploadableFound(limit: Int): List + + @Query("UPDATE outbound_found SET uploaded = 1 WHERE signature_hex IN (:sigs)") + suspend fun markFoundUploaded(sigs: List) + + @Query("SELECT COUNT(*) FROM outbound_found WHERE uploaded = 0") + suspend fun countUploadableFound(): Int +} + +@Dao +interface ChatDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertChatMessage(entity: ChatMessageEntity): Long + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertChatFragment(entity: ChatFragmentEntity): Long + @Query("SELECT * FROM chat_messages ORDER BY timestamp_ms ASC LIMIT :limit") + fun observeChatMessages(limit: Int = 500): Flow> + @Query("SELECT * FROM chat_fragments WHERE sender_node_id = :sender AND message_id = :messageId ORDER BY fragment_index ASC") + suspend fun chatFragments(sender: String, messageId: Long): List + @Query("DELETE FROM chat_fragments WHERE sender_node_id = :sender AND message_id = :messageId") + suspend fun deleteChatFragments(sender: String, messageId: Long) + @Query("UPDATE chat_messages SET delivery_state = :state WHERE sender_node_id = :sender AND message_id = :messageId") + suspend fun updateChatDelivery(sender: String, messageId: Long, state: Int) + @Query("DELETE FROM chat_fragments WHERE received_at < :cutoff") + suspend fun pruneChatFragments(cutoff: Long) + @Query("DELETE FROM chat_messages WHERE received_at < :cutoff") + suspend fun pruneChatMessages(cutoff: Long) } /** @@ -174,9 +506,138 @@ val MIGRATION_3_4 = object : Migration(3, 4) { } } -@Database(entities = [MessageEntity::class], version = 4, exportSchema = false) +/** v4 → v5: persist a small, per-neighbour radio history and its filtered state. + * Keeping this separate from signed SOS frames prevents radio telemetry from + * changing the protocol's upload or relay semantics. */ +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS neighbor_observations (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "node_id TEXT NOT NULL, observed_at INTEGER NOT NULL, rssi INTEGER NOT NULL, " + + "source INTEGER NOT NULL, tx_power INTEGER, primary_phy INTEGER NOT NULL, " + + "secondary_phy INTEGER NOT NULL, observer_lat_e7 INTEGER, observer_lon_e7 INTEGER, " + + "observer_accuracy_m REAL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_neighbor_observations_node_id_observed_at ON neighbor_observations (node_id, observed_at)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_neighbor_observations_observed_at ON neighbor_observations (observed_at)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS neighbor_estimates (" + + "node_id TEXT NOT NULL PRIMARY KEY, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, " + + "filtered_rssi REAL NOT NULL, rssi_variance REAL NOT NULL, distance_m REAL, uncertainty_m REAL, " + + "source INTEGER NOT NULL, tx_power INTEGER, calibrated INTEGER NOT NULL, sample_count INTEGER NOT NULL, " + + "state INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_neighbor_estimates_last_seen_at ON neighbor_estimates (last_seen_at)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_neighbor_estimates_state ON neighbor_estimates (state)") + } +} + +/** v5 → v6: preserve whether the current time-bounded RSSI median has enough + * samples. A process restart must not render a fresh one-sample estimate as stable. */ +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE neighbor_estimates ADD COLUMN stable INTEGER NOT NULL DEFAULT 0") + } +} + +/** + * v6 → v7: persistent control-frame replay guard + acted-on trust state. + * `control_replay` dedupes verified control frames by signature across reboots + * (the in-memory DedupeCache is lost on restart); `control_trust` pins, per own + * SOS, the receipt that authorised stopping TX — sticky so a resolved SOS does + * not resume advertising. Both tables are independent of signed SOS storage, so + * the protocol's upload/relay semantics are unchanged. + */ +val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS control_replay (" + + "signature_hex TEXT NOT NULL PRIMARY KEY, " + + "frame_type INTEGER NOT NULL, issuer_node_id TEXT NOT NULL, " + + "ctrl_msg_id INTEGER NOT NULL, expires_at INTEGER NOT NULL, " + + "received_at INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_control_replay_expires_at ON control_replay (expires_at)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS control_trust (" + + "node_id TEXT NOT NULL, msg_id INTEGER NOT NULL, " + + "terminal INTEGER NOT NULL, resolve_type INTEGER NOT NULL, " + + "issuer_node_id TEXT NOT NULL, authority_pubkey_hex TEXT NOT NULL, " + + "authority_signature_hex TEXT NOT NULL, expires_at INTEGER NOT NULL, " + + "established_at INTEGER NOT NULL, PRIMARY KEY(node_id, msg_id))" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_control_trust_terminal ON control_trust (terminal)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_control_trust_established_at ON control_trust (established_at)") + } +} + +/** + * v7 → v8: outbound queue for self-cancel (0x03) control frames awaiting data-mule + * upload to the backend's `SOS_CANCELLED` path. `outbound_control` holds verified + * self-cancels — the only mesh-originated control that mutates the ledger; delivery + * (0x01) and backend-resolve (0x02) are minted server-side and are never queued here. + * Independent of signed SOS storage and of the replay/trust tables, so the protocol's + * upload/relay semantics are unchanged. + */ +val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS outbound_control (" + + "signature_hex TEXT NOT NULL PRIMARY KEY, " + + "frame_type INTEGER NOT NULL, frame BLOB NOT NULL, " + + "uploaded INTEGER NOT NULL DEFAULT 0, queued_at INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_outbound_control_queued_at ON outbound_control (queued_at)") + } +} + +val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS chat_messages (sender_node_id TEXT NOT NULL, message_id INTEGER NOT NULL, timestamp_ms INTEGER NOT NULL, text TEXT NOT NULL, public_key BLOB NOT NULL, signature BLOB NOT NULL, own INTEGER NOT NULL, delivery_state INTEGER NOT NULL, received_at INTEGER NOT NULL, PRIMARY KEY(sender_node_id, message_id))") + db.execSQL("CREATE INDEX IF NOT EXISTS index_chat_messages_timestamp_ms ON chat_messages (timestamp_ms)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_chat_messages_received_at ON chat_messages (received_at)") + db.execSQL("CREATE TABLE IF NOT EXISTS chat_fragments (sender_node_id TEXT NOT NULL, message_id INTEGER NOT NULL, fragment_index INTEGER NOT NULL, fragment_count INTEGER NOT NULL, total_length INTEGER NOT NULL, timestamp_seconds INTEGER NOT NULL, chunk BLOB NOT NULL, public_key BLOB NOT NULL, signature BLOB NOT NULL, ttl INTEGER NOT NULL, received_at INTEGER NOT NULL, PRIMARY KEY(sender_node_id, message_id, fragment_index))") + db.execSQL("CREATE INDEX IF NOT EXISTS index_chat_fragments_received_at ON chat_fragments (received_at)") + } +} + +/** + * v9 → v10: outbound queue for "Encontrado" (found) frames awaiting data-mule + * upload to the backend's `foundFrames[]` ingest path. Independent of signed SOS + * storage and of the control tables, so the protocol's upload/relay semantics + * are unchanged. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS outbound_found (" + + "signature_hex TEXT NOT NULL PRIMARY KEY, " + + "frame BLOB NOT NULL, " + + "uploaded INTEGER NOT NULL DEFAULT 0, queued_at INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_outbound_found_queued_at ON outbound_found (queued_at)") + } +} + +@Database( + entities = [ + MessageEntity::class, + NeighborObservationEntity::class, + NeighborEstimateEntity::class, + ControlReplayEntity::class, + ControlTrustEntity::class, + OutboundControlEntity::class, + OutboundFoundEntity::class, + ChatMessageEntity::class, + ChatFragmentEntity::class, + ], + version = 10, + exportSchema = false, +) abstract class GuacamayaDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao + abstract fun chatDao(): ChatDao companion object { @Volatile private var instance: GuacamayaDatabase? = null @@ -188,7 +649,7 @@ abstract class GuacamayaDatabase : RoomDatabase() { GuacamayaDatabase::class.java, "guacamaya.db" ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) .fallbackToDestructiveMigration() // safety net for any unhandled jump .build().also { instance = it } } diff --git a/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborObservationRecorder.kt b/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborObservationRecorder.kt new file mode 100644 index 0000000..469888d --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborObservationRecorder.kt @@ -0,0 +1,195 @@ +package net.guacamaya.mesh + +import android.content.Context +import android.location.Location +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import net.guacamaya.loc.PlatformLocation +import net.guacamaya.time.MeshClock +import kotlin.math.abs + +/** Metadata attached by a transport after a frame has been authenticated. */ +data class DirectRadioObservation( + val nodeId: ByteArray, + val rssi: Int, + val source: ObservationSource, + val txPower: Int? = null, + val primaryPhy: Int = 0, + val secondaryPhy: Int = 0, + val distanceMeters: Float? = null, + val distanceStdDevMeters: Float? = null, + val proximityTrusted: Boolean = false, +) + +fun interface NeighborObservationSink { + suspend fun record(observation: DirectRadioObservation) +} + +/** + * Records direct radio evidence independently from [FloodRouter]. It is called + * only after the signed origin frame passes validation and retains no relay data: + * RSSI of a relay says where the relay is, not where the original victim is. + */ +class NeighborObservationRecorder( + private val context: Context, + private val dao: MessageDao, + scope: CoroutineScope, + private val now: () -> Long = { MeshClock.now(context) }, +) : NeighborObservationSink { + private val filters = HashMap() + private val samplesSincePrune = HashMap() + private val lastSeenByNode = HashMap() + private val lastPersistAt = HashMap() + private var cachedPosition: PositionSnapshot? = null + private var lastPositionReadAt = 0L + + init { + // State is derived at query time in the UI as well, but this persisted + // refresh keeps a process restart from making every old entry look live. + scope.launch { + while (isActive) { + delay(NEIGHBOR_STATE_REFRESH_MS) + dao.refreshNeighborStates( + now = now(), + degradedAfter = NeighborState.DEGRADED_AFTER_MS, + lostAfter = NeighborState.LOST_AFTER_MS, + ) + } + } + } + + override suspend fun record(observation: DirectRadioObservation) { + // Admission gate: a wildly non-physical RSSI (noise-floor underflow, impossible + // positive spike) is garbage from a malformed scan or an attacker. Drop it before + // it refreshes liveness, feeds the filter, or hits the audit trail — the estimate + // must never reflect it. The filter still clamps residual edge values defensively. + if (!RssiRange.isPlausible(observation.rssi)) return + val nodeId = observation.nodeId.toHex() + val observedAt = now() + lastSeenByNode[nodeId] = observedAt + trimTrackedNodes() + val estimate = filters.getOrPut(nodeId) { NeighborSignalFilter() }.add( + NeighborSignalSample( + rssi = observation.rssi, + txPower = observation.txPower, + source = observation.source, + observedAtMs = observedAt, + primaryPhy = observation.primaryPhy, + secondaryPhy = observation.secondaryPhy, + distanceMeters = observation.distanceMeters, + distanceStdDevMeters = observation.distanceStdDevMeters, + proximityTrusted = observation.proximityTrusted, + ), + ) + // The filter sees every PDU. Persist a representative sample at 5 Hz so + // a low-latency scan does not turn SQLite/Compose into the bottleneck. + // RTT measurements are sparse and are kept in full. + val lastPersisted = lastPersistAt[nodeId] + if (observation.source != ObservationSource.BLE_RSSI || + lastPersisted == null || observedAt - lastPersisted >= PERSIST_INTERVAL_MS + ) { + persist(nodeId, observedAt, observation, estimate) + lastPersistAt[nodeId] = observedAt + } + } + + private suspend fun persist( + nodeId: String, + observedAt: Long, + observation: DirectRadioObservation, + estimate: NeighborSignalEstimate, + ) { + val position = positionAt(observedAt) + dao.insertNeighborObservation( + NeighborObservationEntity( + nodeId = nodeId, + observedAt = observedAt, + rssi = observation.rssi, + source = observation.source.code, + txPower = observation.txPower, + primaryPhy = observation.primaryPhy, + secondaryPhy = observation.secondaryPhy, + observerLatE7 = position?.latE7, + observerLonE7 = position?.lonE7, + observerAccuracyMeters = position?.accuracyMeters, + ), + ) + val state = NeighborState.at(observedAt, observedAt) + dao.upsertNeighborEstimate( + NeighborEstimateEntity( + nodeId = nodeId, + updatedAt = observedAt, + lastSeenAt = observedAt, + filteredRssi = estimate.filteredRssi, + rssiVariance = estimate.rssiVariance, + distanceMeters = estimate.estimatedDistanceMeters, + uncertaintyMeters = estimate.uncertaintyMeters, + source = estimate.source.code, + txPower = estimate.txPower, + calibrated = estimate.calibrated, + stable = estimate.stable, + sampleCount = estimate.sampleCount, + state = state.code, + ), + ) + + // Avoid a DELETE for every controller callback while keeping the persisted + // ring very close to 50 entries even during a low-latency sweep. + val sincePrune = (samplesSincePrune[nodeId] ?: 0) + 1 + if (sincePrune >= PRUNE_EVERY_SAMPLES) { + dao.pruneNeighborObservations(nodeId) + samplesSincePrune[nodeId] = 0 + } else { + samplesSincePrune[nodeId] = sincePrune + } + } + + private fun trimTrackedNodes() { + while (filters.size > MAX_TRACKED_NEIGHBORS) { + val oldest = lastSeenByNode.minByOrNull { it.value }?.key ?: return + filters.remove(oldest) + samplesSincePrune.remove(oldest) + lastSeenByNode.remove(oldest) + lastPersistAt.remove(oldest) + } + } + + private fun positionAt(observedAt: Long): PositionSnapshot? { + if (observedAt - lastPositionReadAt < POSITION_CACHE_MS) return cachedPosition + lastPositionReadAt = observedAt + val location = PlatformLocation.lastKnown(context) ?: return cachedPosition + // A last-known fix older than two minutes is misleading to a future + // multilateration solver, so retain no coordinates for this sample. + if (location.time <= 0L || abs(observedAt - location.time) > MAX_POSITION_AGE_MS) { + cachedPosition = null + return null + } + cachedPosition = location.toSnapshot() + return cachedPosition + } + + private fun Location.toSnapshot(): PositionSnapshot = PositionSnapshot( + latE7 = (latitude * 1e7).toInt().coerceIn(-900_000_000, 900_000_000), + lonE7 = (longitude * 1e7).toInt().coerceIn(-1_800_000_000, 1_800_000_000), + accuracyMeters = if (hasAccuracy()) accuracy else null, + ) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private data class PositionSnapshot( + val latE7: Int, + val lonE7: Int, + val accuracyMeters: Float?, + ) + + companion object { + private const val PRUNE_EVERY_SAMPLES = 8 + private const val PERSIST_INTERVAL_MS = 200L + private const val NEIGHBOR_STATE_REFRESH_MS = 5_000L + private const val POSITION_CACHE_MS = 5_000L + private const val MAX_POSITION_AGE_MS = 2 * 60_000L + private const val MAX_TRACKED_NEIGHBORS = 500 + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborSignal.kt b/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborSignal.kt new file mode 100644 index 0000000..753d560 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/mesh/NeighborSignal.kt @@ -0,0 +1,310 @@ +package net.guacamaya.mesh + +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Radio observations are a search aid only. They never participate in frame + * acceptance, relay policy, or receipt authorization: authenticated identity is + * not proof of physical proximity. + */ +enum class ObservationSource(val code: Int) { + BLE_RSSI(0), + NAN_RSSI(1), + NAN_RTT(2), + ANDROID_RANGING(3), + ; + + companion object { + fun fromCode(code: Int): ObservationSource = entries.firstOrNull { it.code == code } ?: BLE_RSSI + } +} + +/** Direct-neighbour liveness. The values are intentionally conservative because + * phone advertising cadence varies with relay rotation and vendor controllers. */ +enum class NeighborState(val code: Int) { + HEARD(0), + DEGRADED(1), + LOST(2), + ; + + companion object { + fun fromCode(code: Int): NeighborState = entries.firstOrNull { it.code == code } ?: HEARD + + fun at(lastSeenAt: Long, now: Long): NeighborState = when { + now - lastSeenAt >= LOST_AFTER_MS -> LOST + now - lastSeenAt >= DEGRADED_AFTER_MS -> DEGRADED + else -> HEARD + } + + /** A node can rotate its own frame around held relays, so use 5 s rather + * than the controller's advertising interval as the expected cadence. */ + const val EXPECTED_DIRECT_FRAME_MS = 5_000L + const val DEGRADED_AFTER_MS = 3 * EXPECTED_DIRECT_FRAME_MS + const val LOST_AFTER_MS = 10 * EXPECTED_DIRECT_FRAME_MS + } +} + +data class NeighborSignalSample( + val rssi: Int, + val txPower: Int?, + val source: ObservationSource, + val observedAtMs: Long, + val primaryPhy: Int = 0, + val secondaryPhy: Int = 0, + /** RTT / Android Ranging can provide a direct distance in a future phase. */ + val distanceMeters: Float? = null, + val distanceStdDevMeters: Float? = null, + /** BLE hop TTL is unsigned, so even an unchanged value is only a hint. */ + val proximityTrusted: Boolean = false, +) + +data class PathLossProfile( + val referencePowerDbm: Float = -59f, + val pathLossExponent: Float, + val residualSigmaDb: Float, +) { + init { + require(referencePowerDbm in -100f..20f) + require(pathLossExponent in 1f..6f) + require(residualSigmaDb in 0f..40f) + } +} + +/** Runtime-swappable field calibration, keyed by Android PHY. */ +class PathLossProfiles(overrides: Map = emptyMap()) { + private val profiles = DEFAULTS + overrides + fun forPhy(primaryPhy: Int, secondaryPhy: Int): PathLossProfile = + profiles[secondaryPhy.takeIf { it != 0 } ?: primaryPhy] ?: profiles.getValue(1) + + companion object { + val DEFAULTS = mapOf( + 1 to PathLossProfile(-59f, 2.7f, 8.5f), + 2 to PathLossProfile(-58f, 2.9f, 9.0f), + 3 to PathLossProfile(-61f, 2.45f, 8.0f), + ) + } +} + +data class NeighborSignalEstimate( + val filteredRssi: Float, + val rssiVariance: Float, + val estimatedDistanceMeters: Float?, + val uncertaintyMeters: Float?, + val txPower: Int?, + val calibrated: Boolean, + val source: ObservationSource, + val primaryPhy: Int, + val secondaryPhy: Int, + /** True after at least three temporally recent samples contribute. */ + val stable: Boolean, + val sampleCount: Int, +) + +/** + * Physical bounds for BLE RSSI. The Core Spec carries RSSI as an 8-bit signed int, + * but consumer radios report ~-100..-30 dBm in practice; outside [-100, +20] a value + * is noise-floor or a malformed / attacker-injected sample. The sonar is a search + * aid only and never protocol authority, but a non-physical sample could still pin + * the log-distance model to a bogus distance or snap proximity, so every admission + * point clamps to this band. Shared by [NeighborSignalFilter], + * [NeighborObservationRecorder], and [net.guacamaya.ui.BodyBearingSweep]. + */ +object RssiRange { + const val MIN_DBM = -100 + const val MAX_DBM = 20 + + fun clamp(rssi: Int): Int = rssi.coerceIn(MIN_DBM, MAX_DBM) + fun clampFloat(rssi: Float): Float = rssi.coerceIn(MIN_DBM.toFloat(), MAX_DBM.toFloat()) + fun isPlausible(rssi: Int): Boolean = rssi in MIN_DBM..MAX_DBM +} + +/** + * Tolerance for monotonic-clock admission. Controller/scan callbacks can report + * timestamps a few tens of ms out of order without it meaning the clock regressed; + * beyond that, an out-of-order PDU is rejected so the time window and slew rate stay + * consistent. + */ +const val OBSERVATION_CLOCK_TOLERANCE_MS = 50L + +/** + * A small per-neighbour ring: median rejects body/multipath spikes, then EWMA is + * applied in dBm (not metres) where the noise is closer to Gaussian. The raw ring + * is deliberately kept in memory; Room stores a bounded audit trail separately. + */ +class NeighborSignalFilter( + private val medianWindowMs: Long = DEFAULT_MEDIAN_WINDOW_MS, + private val maxMedianSamples: Int = MAX_MEDIAN_SAMPLES, + private val alpha: Float = DEFAULT_EWMA_ALPHA, +) { + init { + require(medianWindowMs > 0) { "medianWindowMs must be positive" } + require(maxMedianSamples >= MIN_MEDIAN_SAMPLES) { "maxMedianSamples must be >= $MIN_MEDIAN_SAMPLES" } + require(alpha in 0f..1f) { "alpha must be in 0..1" } + } + + private val raw = ArrayDeque(maxMedianSamples) + private var filtered: Float? = null + private var variance = 0f + private var totalSamples = 0 + + fun add(sample: NeighborSignalSample): NeighborSignalEstimate { + // 1. Bounded RSSI: clamp a non-physical sample into the band so it cannot pin + // the log-distance model to a bogus distance or starve the ring. The + // recorder hard-rejects the worst of these upstream; this clamp covers + // direct/test use and residual edge values. + val sanitized = if (RssiRange.isPlausible(sample.rssi)) sample + else sample.copy(rssi = RssiRange.clamp(sample.rssi)) + + val lastAt = raw.lastOrNull()?.observedAtMs + if (lastAt != null) { + // 2. Observation-time: a non-monotonic clock (out-of-order PDU, MeshClock + // regression) would corrupt both the time window below and the slew + // rate. Reject it — hold the prior estimate, leave the ring untouched. + // A small tolerance absorbs controller report jitter. + if (sanitized.observedAtMs + OBSERVATION_CLOCK_TOLERANCE_MS < lastAt) { + return estimateFor(sanitized) + } + } + + if (lastAt?.let { sanitized.observedAtMs - it > medianWindowMs } == true) { + // A new advertising epoch after a long silence must not be averaged + // against an old physical position or RF environment. + raw.clear() + filtered = null + variance = 0f + } + raw.addLast(sanitized) + while (raw.firstOrNull()?.let { sanitized.observedAtMs - it.observedAtMs > medianWindowMs } == true) { + raw.removeFirst() + } + while (raw.size > maxMedianSamples) raw.removeFirst() + val median = raw.map { it.rssi.toFloat() }.sorted()[raw.size / 2] + val prior = filtered + var next = if (prior == null) median else prior + alpha * (median - prior) + if (prior != null) { + // Exponentially weighted residual variance, stable with a short ring. + val residual = median - prior + variance = (1f - alpha) * (variance + alpha * residual * residual) + } + // 3. Reject impossible jumps: RSSI cannot slew arbitrarily fast. Capping the + // filtered value's rate of change stops a TX-power blip or an injected + // sample from snapping proximity from "far" to "close" faster than physics + // allows — the UI shows a real closure ramp, not an RF artifact. + if (prior != null && lastAt != null) { + val dtSec = ((sanitized.observedAtMs - lastAt).coerceAtLeast(0)) / 1000f + if (dtSec > 0f) { + val maxStep = MAX_SLEW_DB_PER_SEC * dtSec + next = next.coerceIn(prior - maxStep, prior + maxStep) + } + } + filtered = next + totalSamples++ + return estimateFor(sanitized) + } + + private fun estimateFor(sample: NeighborSignalSample): NeighborSignalEstimate = + SignalDistance.estimate( + filteredRssi = filtered ?: sample.rssi.toFloat(), + rssiVariance = variance, + sample = sample, + stable = raw.size >= MIN_MEDIAN_SAMPLES, + sampleCount = totalSamples, + ) + + fun rawCount(): Int = raw.size + + companion object { + /** Time-bounded median avoids 7 slow advertisements becoming 20 s old. */ + const val DEFAULT_MEDIAN_WINDOW_MS = 8_000L + const val MIN_MEDIAN_SAMPLES = 3 + /** Cap CPU work during 31 ms advertising while retaining a very fresh median. */ + const val MAX_MEDIAN_SAMPLES = 9 + const val DEFAULT_EWMA_ALPHA = 0.28f + /** + * Maximum filtered-RSSI rate of change (dB/s). Sustained body-carried RSSI rarely + * exceeds ~25 dB/s; 40 dB/s still kills a physics-impossible snap (e.g. +50 dB in a + * 31 ms advertising interval) while leaving room for genuine rapid closure. A backstop + * on top of the median spike rejection, not a primary smoother. + */ + const val MAX_SLEW_DB_PER_SEC = 40f + } +} + +/** BLE log-distance model with deliberately broad uncertainty. v1 does not carry + * a transmitter's measured power, therefore its fallback must never be shown as a + * precise location. v2 reserves a signed measured-power byte for this purpose. */ +object SignalDistance { + private const val DEFAULT_MEASURED_POWER_DBM = -59 + /** A direct RTT distance outside this band is below resolution or malformed. */ + private const val RTT_MIN_METERS = 0.5f + private const val RTT_MAX_METERS = 1_000f + + @Volatile var profiles: PathLossProfiles = PathLossProfiles() + + fun installProfiles(value: PathLossProfiles) { profiles = value } + + fun estimate( + filteredRssi: Float, + rssiVariance: Float, + sample: NeighborSignalSample, + stable: Boolean, + sampleCount: Int, + ): NeighborSignalEstimate { + // RTT / Android Ranging distance: trust it only inside a physically sane band. + // A negative/zero/gargantuan value is below resolution or a malformed ranging + // result — fall through to the log-distance model rather than surfacing a bogus + // "direct distance" the UI would treat as ground truth. + val rtt = sample.distanceMeters?.takeIf { it in RTT_MIN_METERS..RTT_MAX_METERS } + if (rtt != null) { + val stdDev = (sample.distanceStdDevMeters ?: rtt * 0.15f).coerceIn(0f, rtt) + return NeighborSignalEstimate( + filteredRssi = filteredRssi, + rssiVariance = rssiVariance, + estimatedDistanceMeters = rtt, + uncertaintyMeters = stdDev, + txPower = sample.txPower, + calibrated = true, + source = sample.source, + primaryPhy = sample.primaryPhy, + secondaryPhy = sample.secondaryPhy, + stable = stable, + sampleCount = sampleCount, + ) + } + + val advertisedTx = sample.txPower?.takeIf { it in -100..20 } + val model = profiles.forPhy(sample.primaryPhy, sample.secondaryPhy) + val measuredPower = advertisedTx?.toFloat() ?: model.referencePowerDbm + val distance = 10.0.pow(((measuredPower - filteredRssi) / (10f * model.pathLossExponent)).toDouble()).toFloat() + val sigmaDb = sqrt(rssiVariance.coerceAtLeast(0f)) + val derivative = distance * (ln(10.0) / (10f * model.pathLossExponent)).toFloat() + // Model error dominates the mathematics on consumer phones. Keep the band + // honest even if a short sample window happens to be very stable. + val calibratedSigma = maxOf(sigmaDb, model.residualSigmaDb) + val uncertainty = maxOf(distance * if (advertisedTx != null) 0.45f else 0.70f, derivative * calibratedSigma) + return NeighborSignalEstimate( + filteredRssi = filteredRssi, + rssiVariance = rssiVariance, + estimatedDistanceMeters = distance, + uncertaintyMeters = uncertainty, + txPower = advertisedTx, + calibrated = sample.proximityTrusted && advertisedTx != null, + source = sample.source, + primaryPhy = sample.primaryPhy, + secondaryPhy = sample.secondaryPhy, + stable = stable, + sampleCount = sampleCount, + ) + } + + fun temperature(filteredRssi: Float): String = when { + filteredRssi >= -55f -> "muy cerca" + filteredRssi >= -67f -> "cerca" + filteredRssi >= -78f -> "media" + else -> "débil" + } + + /** Android BluetoothDevice PHY constants kept numeric to make this model JVM-only. */ +} diff --git a/android/app/src/main/kotlin/net/guacamaya/proto/ChatFrame.kt b/android/app/src/main/kotlin/net/guacamaya/proto/ChatFrame.kt new file mode 100644 index 0000000..f7988d4 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/proto/ChatFrame.kt @@ -0,0 +1,93 @@ +package net.guacamaya.proto + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import net.guacamaya.crypto.Signer + +data class ChatMessage( + val senderNodeId: ByteArray, + val messageId: Long, + val timestampSeconds: Long, + val text: ByteArray, + val publicKey: ByteArray, + val signature: ByteArray, +) + +data class ChatFragment( + val ttl: Int, + val senderNodeId: ByteArray, + val messageId: Long, + val timestampSeconds: Long, + val index: Int, + val count: Int, + val totalLength: Int, + val chunk: ByteArray, + val publicKey: ByteArray, + val signature: ByteArray, +) + +object ChatCodec { + const val VERSION = 1 + const val TYPE_MESSAGE = 1 + const val MAX_TEXT_BYTES = 160 + const val CHUNK_BYTES = 40 + const val MAX_FRAGMENTS = 4 + const val HEADER_BYTES = 23 + const val MAX_FRAME_BYTES = HEADER_BYTES + CHUNK_BYTES + 32 + 64 + private val DOMAIN = "guacamaya-chat-v1\u0000".toByteArray(Charsets.UTF_8) + + fun canonical(nodeId: ByteArray, messageId: Long, timestampSeconds: Long, text: ByteArray): ByteArray { + require(nodeId.size == 4) + require(text.isNotEmpty() && text.size <= MAX_TEXT_BYTES) + return ByteBuffer.allocate(DOMAIN.size + 1 + 4 + 8 + 4 + 2 + text.size) + .order(ByteOrder.BIG_ENDIAN) + .put(DOMAIN).put(VERSION.toByte()).put(nodeId).putLong(messageId) + .putInt(timestampSeconds.toInt()).putShort(text.size.toShort()).put(text).array() + } + + fun fragment(message: ChatMessage, ttl: Int = 3): List { + require(ttl in 0..15) + val count = (message.text.size + CHUNK_BYTES - 1) / CHUNK_BYTES + require(count in 1..MAX_FRAGMENTS) + return (0 until count).map { index -> + val from = index * CHUNK_BYTES + val chunk = message.text.copyOfRange(from, minOf(from + CHUNK_BYTES, message.text.size)) + ByteBuffer.allocate(HEADER_BYTES + chunk.size + 32 + 64).order(ByteOrder.BIG_ENDIAN) + .put(ttl.toByte()).put(VERSION.toByte()).put(TYPE_MESSAGE.toByte()) + .put(message.senderNodeId).putLong(message.messageId) + .putInt(message.timestampSeconds.toInt()).put(index.toByte()).put(count.toByte()) + .put(message.text.size.toByte()).put(chunk.size.toByte()).put(chunk) + .put(message.publicKey).put(message.signature).array() + } + } + + fun decode(frame: ByteArray): ChatFragment? { + return try { + if (frame.size !in (HEADER_BYTES + 1 + 96)..MAX_FRAME_BYTES) return null + val b = ByteBuffer.wrap(frame).order(ByteOrder.BIG_ENDIAN) + val ttl = b.get().toInt() and 0xff + if ((b.get().toInt() and 0xff) != VERSION || (b.get().toInt() and 0xff) != TYPE_MESSAGE) return null + val node = ByteArray(4).also(b::get) + val id = b.long + val ts = b.int.toLong() and 0xffffffffL + val index = b.get().toInt() and 0xff + val count = b.get().toInt() and 0xff + val total = b.get().toInt() and 0xff + val chunkSize = b.get().toInt() and 0xff + if (ttl > 15 || count !in 1..MAX_FRAGMENTS || index >= count || total !in 1..MAX_TEXT_BYTES || chunkSize !in 1..CHUNK_BYTES) return null + if (frame.size != HEADER_BYTES + chunkSize + 96) return null + val chunk = ByteArray(chunkSize).also(b::get) + val pub = ByteArray(32).also(b::get) + val sig = ByteArray(64).also(b::get) + ChatFragment(ttl, node, id, ts, index, count, total, chunk, pub, sig) + } catch (_: RuntimeException) { null } + } + + fun verify(message: ChatMessage, nowSeconds: Long): Boolean { + if (kotlin.math.abs(nowSeconds - message.timestampSeconds) > 24 * 60 * 60) return false + val bound = MessageDigest.getInstance("SHA-256").digest(message.publicKey).copyOfRange(0, 4) + if (!bound.contentEquals(message.senderNodeId)) return false + return Signer.verifyMessage(message.publicKey, canonical(message.senderNodeId, message.messageId, message.timestampSeconds, message.text), message.signature) + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/proto/ControlPayload.kt b/android/app/src/main/kotlin/net/guacamaya/proto/ControlPayload.kt new file mode 100644 index 0000000..365b465 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/proto/ControlPayload.kt @@ -0,0 +1,347 @@ +package net.guacamaya.proto + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import net.guacamaya.crypto.Signer + +/** + * Control frame v1 — delivery / backend-resolve / self-cancel carried over BLE. + * + * Same envelope as the SOS frame: 22 B payload + 32 B pubkey + 64 B signature, with + * a leading mutable hop-TTL byte on the wire (stripped here). Byte-identical with + * `packages/shared/src/mesh/controlFrame.ts` and `constants.ts` on the backend — + * the golden vectors in [ControlPayloadTest] lock the two sides together. + * + * off 0 type 0x01 delivery | 0x02 backend-resolve | 0x03 self-cancel + * off 1..4 targetNodeId SHA-256(victim pubkey)[0..3] + * off 5..6 targetMsgId u16 — msgId of the SOS session being addressed + * off 7..10 issuedAt u32 unix seconds + * off 11..14 expiresAt u32 unix seconds + * off 15..16 ctrlMsgId u16 — CSPRNG per control message (dedupe key) + * off 17..19 reserved must be zero (reject otherwise; no silent v2) + * off 20..21 crc CRC16-CCITT over bytes 0..19 + * + * Signature is domain-separated: Ed25519 over UTF8([CONTROL_SIGN_DOMAIN]) || payload22 + * — never over the bare payload, so SOS and control signatures are not interchangeable. + */ +object ControlFrame { + const val PAYLOAD_LEN = 22 + const val PUBKEY_LEN = 32 + const val SIG_LEN = 64 + const val FRAME_LEN = PAYLOAD_LEN + PUBKEY_LEN + SIG_LEN // 118 (upload, TTL stripped) + const val ON_WIRE_LEN = FRAME_LEN + 1 // 119 (leading hop TTL) + + const val PAYLOAD_OFF = 0 + const val PUBKEY_OFF = PAYLOAD_OFF + PAYLOAD_LEN // 22 + const val SIG_OFF = PUBKEY_OFF + PUBKEY_LEN // 54 + + /** + * Domain separation for the control-frame Ed25519 signature: + * sig = Ed25519(sk, UTF8(CONTROL_SIGN_DOMAIN) || payload22). An SOS payload + * signature can never be replayed as a control frame or vice versa. + * Mirrored verbatim in packages/shared/src/mesh/constants.ts. + */ + const val CONTROL_SIGN_DOMAIN = "guacamaya.control-frame.v1\n" + + /** The exact bytes the Ed25519 signature covers: UTF8 domain prefix + payload. */ + fun signBytes(payload22: ByteArray): ByteArray { + val domain = CONTROL_SIGN_DOMAIN.toByteArray(Charsets.UTF_8) + val out = ByteArray(domain.size + payload22.size) + System.arraycopy(domain, 0, out, 0, domain.size) + System.arraycopy(payload22, 0, out, domain.size, payload22.size) + return out + } + + private val HEX_CHARS = "0123456789abcdef".toCharArray() + + /** Lowercase hex — matches noble's `bytesToHex` used on the backend. */ + fun toHex(bytes: ByteArray): String { + val out = CharArray(bytes.size * 2) + for (i in bytes.indices) { + val v = bytes[i].toInt() and 0xFF + out[i * 2] = HEX_CHARS[v ushr 4] + out[i * 2 + 1] = HEX_CHARS[v and 0x0F] + } + return String(out) + } + + /** Parse lowercase/uppercase hex into bytes. Throws on odd length / bad char. */ + fun fromHex(hex: String): ByteArray { + require(hex.length % 2 == 0) { "hex must have even length, got ${hex.length}" } + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + val hi = parseHexNibble(hex[i * 2]) + val lo = parseHexNibble(hex[i * 2 + 1]) + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } + + private fun parseHexNibble(c: Char): Int = when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> throw IllegalArgumentException("invalid hex char: $c") + } + + private fun sha256(b: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(b) + + /** First 4 bytes of SHA-256(pubkey) — the mesh node id. */ + fun nodeIdOf(pubkey: ByteArray): ByteArray = sha256(pubkey).copyOfRange(0, 4) + + /** + * Cheap SOS-vs-control discriminator run *before* the expensive signature check + * (the FloodRouter hot path). True only if [payload22] could plausibly be a + * control payload: a control type byte, the reserved bytes 17..19 zero, and a + * valid control CRC over 0..19. + * + * This is deliberately NOT conclusive. An SOS payload's high byte is the high + * byte of latE7, which is 0x01..0x03 for plausible latitudes (~16°/33°/50°), + * and both frames compute CRC16-CCITT over 0..19 — so type + CRC alone do not + * separate them. The reserved-zero bytes are the structural tell, but a + * contrived SOS frame could still satisfy all four. That is harmless: a false + * positive simply routes into [verifyControlFrame], whose domain-separated + * signature check fails, and the caller falls back through to the SOS cascade. + * A genuine control payload always passes this. + */ + fun isCandidate(payload22: ByteArray): Boolean { + if (payload22.size != PAYLOAD_LEN) return false + val type = payload22[0].toInt() and 0xFF + if (type != 0x01 && type != 0x02 && type != 0x03) return false + if (payload22[17] != 0.toByte() || payload22[18] != 0.toByte() || payload22[19] != 0.toByte()) return false + val crcStored = ((payload22[20].toInt() and 0xFF) shl 8) or (payload22[21].toInt() and 0xFF) + return crcStored == (Crc16.ccitt(payload22, 0, 20) and 0xFFFF) + } +} + +/** Control payload type codes — mirrors `CONTROL_TYPE_*` in constants.ts. */ +enum class ControlType(val code: Int, val maxLifetimeSec: Long) { + DELIVERY(0x01, 30L * 24 * 3600), + BACKEND_RESOLVE(0x02, 30L * 24 * 3600), + SELF_CANCEL(0x03, 24L * 3600); + + companion object { + fun fromCode(code: Int): ControlType? = values().firstOrNull { it.code == code } + } +} + +/** + * Encodable control payload. [encode] throws on invalid fields — mirrors + * `encodeControlPayload` on the backend. The caller appends pubkey + signature to + * build the 118-byte frame (see [buildControlFrame]). + */ +data class ControlPayload( + val type: ControlType, + /** SHA-256(victim pubkey)[0..3], exactly 4 bytes. */ + val targetNodeId: ByteArray, + val targetMsgId: Int, // u16 + val issuedAt: Long, // u32 unix seconds + val expiresAt: Long, // u32 unix seconds + val ctrlMsgId: Int, // u16 +) { + init { + require(targetNodeId.size == 4) { "targetNodeId must be 4 bytes" } + require(targetMsgId in 0..0xFFFF) { "targetMsgId out of u16 range" } + require(ctrlMsgId in 0..0xFFFF) { "ctrlMsgId out of u16 range" } + require(issuedAt in 0..0xFFFFFFFFL) { "issuedAt out of u32 range" } + require(expiresAt in 0..0xFFFFFFFFL) { "expiresAt out of u32 range" } + require(expiresAt > issuedAt) { "expiresAt must be after issuedAt" } + require(expiresAt - issuedAt <= type.maxLifetimeSec) { "lifetime exceeds per-type maximum" } + } + + /** Encode to a fresh 22-byte big-endian array with the trailing CRC16 filled in. */ + fun encode(): ByteArray { + val buf = ByteBuffer.allocate(ControlFrame.PAYLOAD_LEN).order(ByteOrder.BIG_ENDIAN) + buf.put(type.code.toByte()) + buf.put(targetNodeId) + buf.putShort(targetMsgId.toShort()) + buf.putInt(issuedAt.toInt()) + buf.putInt(expiresAt.toInt()) + buf.putShort(ctrlMsgId.toShort()) + // bytes 17..19 stay zero (reserved) + buf.putShort(0) + val withoutCrc = buf.array().copyOfRange(0, ControlFrame.PAYLOAD_LEN - 2) + val crc = Crc16.ccitt(withoutCrc) + buf.putShort(ControlFrame.PAYLOAD_LEN - 2, crc.toShort()) + return buf.array() + } + + companion object { + /** Decode a 22-byte array; throws on size or CRC failure. Does not verify the signature. */ + fun decode(bytes: ByteArray): ControlPayload { + require(bytes.size == ControlFrame.PAYLOAD_LEN) { + "control payload must be ${ControlFrame.PAYLOAD_LEN} bytes, got ${bytes.size}" + } + val crcStored = ((bytes[ControlFrame.PAYLOAD_LEN - 2].toInt() and 0xFF) shl 8) or + (bytes[ControlFrame.PAYLOAD_LEN - 1].toInt() and 0xFF) + val crcComputed = Crc16.ccitt(bytes, 0, ControlFrame.PAYLOAD_LEN - 2) and 0xFFFF + require(crcStored == crcComputed) { "crc mismatch" } + val type = ControlType.fromCode(bytes[0].toInt() and 0xFF) + ?: throw IllegalArgumentException("unknown control type 0x${(bytes[0].toInt() and 0xFF).toString(16)}") + require(bytes[17] == 0.toByte() && bytes[18] == 0.toByte() && bytes[19] == 0.toByte()) { + "reserved bytes not zero" + } + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + val targetNodeId = ByteArray(4).also { buf.position(1); buf.get(it) } + buf.position(5) + val targetMsgId = buf.short.toInt() and 0xFFFF + val issuedAt = (buf.int.toLong() and 0xFFFFFFFFL) + val expiresAt = (buf.int.toLong() and 0xFFFFFFFFL) + val ctrlMsgId = buf.short.toInt() and 0xFFFF + return ControlPayload(type, targetNodeId, targetMsgId, issuedAt, expiresAt, ctrlMsgId) + } + } +} + +/** + * A control frame that passed the full verification cascade. The signature and the + * per-frame pubkey are retained so the caller can dedupe by signature and pin the + * issuer for trust state. + */ +data class VerifiedControlFrame( + val type: ControlType, + val targetNodeId: ByteArray, + val targetNodeIdHex: String, + val targetMsgId: Int, + val issuedAt: Long, + val expiresAt: Long, + val ctrlMsgId: Int, + val issuerPublicKey: ByteArray, + val issuerPublicKeyHex: String, + /** SHA-256(issuer pubkey)[0..3] hex — dedupe namespace with ctrlMsgId. */ + val issuerNodeIdHex: String, + val signature: ByteArray, + val signatureHex: String, + val payload: ByteArray, + /** Canonical 118-byte upload frame (TTL stripped). */ + val frame: ByteArray, +) + +sealed interface ControlFrameResult { + data class Ok(val control: VerifiedControlFrame) : ControlFrameResult + data class Err(val reason: String) : ControlFrameResult +} + +/** + * Assemble the canonical 118-byte upload frame: payload22 + pubkey32 + sig64. The + * signature covers [ControlFrame.signBytes] of the encoded payload (domain-prefixed). + * Used by the Android self-cancel path and by tests; the backend owns delivery / + * backend-resolve minting. + */ +fun buildControlFrame(payload: ControlPayload, privateKey: ByteArray): ByteArray { + require(privateKey.size == ControlFrame.PUBKEY_LEN) { + "private key must be ${ControlFrame.PUBKEY_LEN} bytes, got ${privateKey.size}" + } + val payloadBytes = payload.encode() + val pubkey = Signer.derivePublicKey(privateKey) + val sig = Signer.signMessage(privateKey, ControlFrame.signBytes(payloadBytes)) + val frame = ByteArray(ControlFrame.FRAME_LEN) + System.arraycopy(payloadBytes, 0, frame, ControlFrame.PAYLOAD_OFF, ControlFrame.PAYLOAD_LEN) + System.arraycopy(pubkey, 0, frame, ControlFrame.PUBKEY_OFF, ControlFrame.PUBKEY_LEN) + System.arraycopy(sig, 0, frame, ControlFrame.SIG_OFF, ControlFrame.SIG_LEN) + return frame +} + +/** + * Full zero-trust verification cascade, cheapest first — mirrors + * `verifyControlFrame` on the backend and the FloodRouter control branch: + * size → type/reserved → CRC → time window → Ed25519 (domain-prefixed) → + * authorization (backend pin for 0x01/0x02, self-binding for 0x03). + * Dedupe (issuerNodeId+ctrlMsgId) and replay-by-signature stay with the caller, + * which owns the persistent stores. + * + * @param bytes 118-byte upload frame, or a 119-byte on-wire frame (leading TTL byte + * stripped when present). + * @param nowMs wall clock (System.currentTimeMillis()). + * @param backendPublicKeyHex pinned backend Ed25519 public key (lowercase hex). + * Required to accept delivery / backend-resolve frames; null rejects them. + */ +fun verifyControlFrame( + bytes: ByteArray, + nowMs: Long, + backendPublicKeyHex: String?, +): ControlFrameResult { + val frame = if (bytes.size == ControlFrame.ON_WIRE_LEN) bytes.copyOfRange(1, bytes.size) else bytes + if (frame.size != ControlFrame.FRAME_LEN) { + return ControlFrameResult.Err( + "bad control frame length ${bytes.size} (need ${ControlFrame.FRAME_LEN})" + ) + } + + val payload = frame.copyOfRange(ControlFrame.PAYLOAD_OFF, ControlFrame.PAYLOAD_OFF + ControlFrame.PAYLOAD_LEN) + val pubkey = frame.copyOfRange(ControlFrame.PUBKEY_OFF, ControlFrame.PUBKEY_OFF + ControlFrame.PUBKEY_LEN) + val sig = frame.copyOfRange(ControlFrame.SIG_OFF, ControlFrame.SIG_OFF + ControlFrame.SIG_LEN) + + val typeCode = payload[0].toInt() and 0xFF + val type = ControlType.fromCode(typeCode) + if (type == null) return ControlFrameResult.Err("unknown control type 0x${typeCode.toString(16)}") + if (payload[17] != 0.toByte() || payload[18] != 0.toByte() || payload[19] != 0.toByte()) { + return ControlFrameResult.Err("reserved bytes not zero") + } + + val crcStored = ((payload[20].toInt() and 0xFF) shl 8) or (payload[21].toInt() and 0xFF) + if (crcStored != (Crc16.ccitt(payload, 0, 20) and 0xFFFF)) { + return ControlFrameResult.Err("crc mismatch") + } + + val issuedAt = readU32(payload, 7) + val expiresAt = readU32(payload, 11) + val nowSec = nowMs / 1000 + if (expiresAt <= issuedAt) return ControlFrameResult.Err("expiresAt not after issuedAt") + if (expiresAt - issuedAt > type.maxLifetimeSec) { + return ControlFrameResult.Err("lifetime exceeds per-type maximum") + } + if (expiresAt < nowSec) return ControlFrameResult.Err("control frame expired") + + if (!Signer.verifyMessage(pubkey, ControlFrame.signBytes(payload), sig)) { + return ControlFrameResult.Err("signature invalid") + } + + val targetNodeId = payload.copyOfRange(1, 5) + val issuerNodeId = ControlFrame.nodeIdOf(pubkey) + val pubkeyHex = ControlFrame.toHex(pubkey) + + if (type == ControlType.SELF_CANCEL) { + // Only the victim's own key may cancel its SOS: issuer node id must be the target. + if (!issuerNodeId.contentEquals(targetNodeId)) { + return ControlFrameResult.Err("self-cancel issuer is not the target") + } + } else { + if (backendPublicKeyHex.isNullOrEmpty()) { + return ControlFrameResult.Err("backend key not pinned") + } + if (pubkeyHex != backendPublicKeyHex.lowercase()) { + return ControlFrameResult.Err("issuer is not the pinned backend") + } + } + + return ControlFrameResult.Ok( + VerifiedControlFrame( + type = type, + targetNodeId = targetNodeId, + targetNodeIdHex = ControlFrame.toHex(targetNodeId), + targetMsgId = readU16(payload, 5), + issuedAt = issuedAt, + expiresAt = expiresAt, + ctrlMsgId = readU16(payload, 15), + issuerPublicKey = pubkey, + issuerPublicKeyHex = pubkeyHex, + issuerNodeIdHex = ControlFrame.toHex(issuerNodeId), + signature = sig, + signatureHex = ControlFrame.toHex(sig), + payload = payload, + frame = frame, + ) + ) +} + +private fun readU16(b: ByteArray, off: Int): Int = + (((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)) + +private fun readU32(b: ByteArray, off: Int): Long = + ((b[off].toInt() and 0xFF).toLong() shl 24) or + ((b[off + 1].toInt() and 0xFF).toLong() shl 16) or + ((b[off + 2].toInt() and 0xFF).toLong() shl 8) or + (b[off + 3].toInt() and 0xFF).toLong() diff --git a/android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt b/android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt new file mode 100644 index 0000000..97fee09 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt @@ -0,0 +1,145 @@ +package net.guacamaya.proto + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import net.guacamaya.crypto.Signer + +/** + * Found frame v1 ("Encontrado") — a finder marks an SOS victim as found, carried + * to the backend as `foundFrames[]` in POST /ingest. + * + * Same envelope as the SOS frame: 22 B payload + 32 B pubkey + 64 B signature. + * Byte-identical with `packages/shared/src/mesh/foundFrame.ts` on the backend. + * + * off 0..3 latE7 int32 — finder latitude × 1e7 + * off 4..7 lonE7 int32 — finder longitude × 1e7 + * off 8..11 tsUnix u32 unix seconds + * off 12..15 targetNodeId SHA-256(victim pubkey)[0..3] + * off 16..17 targetMsgId u16 — msgId of the SOS session being marked + * off 18..19 foundMsgId u16 — CSPRNG per found report (dedupe entropy) + * off 20..21 crc CRC16-CCITT over bytes 0..19 + * + * Signature is domain-separated: Ed25519 over UTF8(FOUND_SIGN_DOMAIN) || payload22 + * — never over the bare payload, so SOS/control/found signatures are never + * interchangeable. + */ +object FoundFrame { + const val PAYLOAD_LEN = 22 + const val PUBKEY_LEN = 32 + const val SIG_LEN = 64 + const val FRAME_LEN = PAYLOAD_LEN + PUBKEY_LEN + SIG_LEN // 118 (upload, TTL stripped) + const val ON_WIRE_LEN = FRAME_LEN + 1 // 119 (leading hop TTL) + + const val PAYLOAD_OFF = 0 + const val PUBKEY_OFF = PAYLOAD_OFF + PAYLOAD_LEN // 22 + const val SIG_OFF = PUBKEY_OFF + PUBKEY_LEN // 54 + + /** Mirrored verbatim in packages/shared/src/mesh/constants.ts. */ + const val FOUND_SIGN_DOMAIN = "guacamaya.found-frame.v1\n" + + /** The exact bytes the Ed25519 signature covers: UTF8 domain prefix + payload. */ + fun signBytes(payload22: ByteArray): ByteArray { + val domain = FOUND_SIGN_DOMAIN.toByteArray(Charsets.UTF_8) + val out = ByteArray(domain.size + payload22.size) + System.arraycopy(domain, 0, out, 0, domain.size) + System.arraycopy(payload22, 0, out, domain.size, payload22.size) + return out + } +} + +/** + * Encodable found payload. [encode] throws on invalid fields — mirrors + * `encodeFoundPayload` on the backend. The caller appends pubkey + signature to + * build the 118-byte frame (see [buildFoundFrame]). + */ +data class FoundPayload( + val latE7: Int, + val lonE7: Int, + val tsUnix: Long, // u32 unix seconds + /** SHA-256(victim pubkey)[0..3], exactly 4 bytes. */ + val targetNodeId: ByteArray, + val targetMsgId: Int, // u16 + val foundMsgId: Int, // u16 +) { + init { + require(targetNodeId.size == 4) { "targetNodeId must be 4 bytes" } + require(tsUnix in 0..0xFFFFFFFFL) { "tsUnix out of u32 range" } + require(targetMsgId in 0..0xFFFF) { "targetMsgId out of u16 range" } + require(foundMsgId in 0..0xFFFF) { "foundMsgId out of u16 range" } + } + + /** Encode to a fresh 22-byte big-endian array with the trailing CRC16 filled in. */ + fun encode(): ByteArray { + val buf = ByteBuffer.allocate(FoundFrame.PAYLOAD_LEN).order(ByteOrder.BIG_ENDIAN) + buf.putInt(latE7) + buf.putInt(lonE7) + buf.putInt(tsUnix.toInt()) + buf.put(targetNodeId) + buf.putShort(targetMsgId.toShort()) + buf.putShort(foundMsgId.toShort()) + val withoutCrc = buf.array().copyOfRange(0, FoundFrame.PAYLOAD_LEN - 2) + val crc = Crc16.ccitt(withoutCrc) + buf.putShort(FoundFrame.PAYLOAD_LEN - 2, crc.toShort()) + return buf.array() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FoundPayload) return false + return latE7 == other.latE7 && + lonE7 == other.lonE7 && + tsUnix == other.tsUnix && + targetNodeId.contentEquals(other.targetNodeId) && + targetMsgId == other.targetMsgId && + foundMsgId == other.foundMsgId + } + + override fun hashCode(): Int { + var r = latE7 + r = 31 * r + lonE7 + r = 31 * r + tsUnix.hashCode() + r = 31 * r + targetNodeId.contentHashCode() + r = 31 * r + targetMsgId + r = 31 * r + foundMsgId + return r + } + + companion object { + /** Decode a 22-byte array; throws on size or CRC failure. Does not verify the signature. */ + fun decode(bytes: ByteArray): FoundPayload { + require(bytes.size == FoundFrame.PAYLOAD_LEN) { + "found payload must be ${FoundFrame.PAYLOAD_LEN} bytes, got ${bytes.size}" + } + val crcStored = ((bytes[FoundFrame.PAYLOAD_LEN - 2].toInt() and 0xFF) shl 8) or + (bytes[FoundFrame.PAYLOAD_LEN - 1].toInt() and 0xFF) + val crcComputed = Crc16.ccitt(bytes, 0, FoundFrame.PAYLOAD_LEN - 2) and 0xFFFF + require(crcStored == crcComputed) { "crc mismatch" } + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + val latE7 = buf.getInt(0) + val lonE7 = buf.getInt(4) + val tsUnix = buf.getInt(8).toLong() and 0xFFFFFFFFL + val targetNodeId = bytes.copyOfRange(12, 16) + val targetMsgId = buf.getShort(16).toInt() and 0xFFFF + val foundMsgId = buf.getShort(18).toInt() and 0xFFFF + return FoundPayload(latE7, lonE7, tsUnix, targetNodeId, targetMsgId, foundMsgId) + } + } +} + +/** + * Assemble the canonical 118-byte upload frame: payload22 + pubkey32 + sig64. The + * signature covers [FoundFrame.signBytes] of the encoded payload (domain-prefixed). + */ +fun buildFoundFrame(payload: FoundPayload, privateKey: ByteArray): ByteArray { + require(privateKey.size == FoundFrame.PUBKEY_LEN) { + "private key must be ${FoundFrame.PUBKEY_LEN} bytes, got ${privateKey.size}" + } + val payloadBytes = payload.encode() + val pubkey = Signer.derivePublicKey(privateKey) + val sig = Signer.signMessage(privateKey, FoundFrame.signBytes(payloadBytes)) + val frame = ByteArray(FoundFrame.FRAME_LEN) + System.arraycopy(payloadBytes, 0, frame, FoundFrame.PAYLOAD_OFF, FoundFrame.PAYLOAD_LEN) + System.arraycopy(pubkey, 0, frame, FoundFrame.PUBKEY_OFF, FoundFrame.PUBKEY_LEN) + System.arraycopy(sig, 0, frame, FoundFrame.SIG_OFF, FoundFrame.SIG_LEN) + return frame +} diff --git a/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt b/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt index 922d5d6..fdac4be 100644 --- a/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt +++ b/android/app/src/main/kotlin/net/guacamaya/service/GuacamayaForegroundService.kt @@ -27,6 +27,10 @@ import kotlinx.coroutines.isActive import net.guacamaya.R import net.guacamaya.ble.BleMeshRuntime import net.guacamaya.ble.Broadcaster +import net.guacamaya.ble.ChatRuntime +import net.guacamaya.ble.RadioPolicy +import net.guacamaya.ble.RadioPolicyEngine +import net.guacamaya.ble.RadioTier import net.guacamaya.crypto.Identity import net.guacamaya.ingest.IngestUploadWorker import net.guacamaya.ingest.OwnUploadPolicy @@ -38,12 +42,14 @@ import net.guacamaya.mesh.MessageEntity import net.guacamaya.proto.Flags import net.guacamaya.proto.Payload import net.guacamaya.proto.SosType +import net.guacamaya.time.MeshClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.security.SecureRandom /** * Foreground service that keeps BLE (and, when added, Wi-Fi Aware) alive while @@ -74,6 +80,10 @@ class GuacamayaForegroundService : Service() { // fixed-point. Reset per broadcast session so the first frame always uploads. private var lastOwnUploadLatE7: Int? = null private var lastOwnUploadLonE7: Int? = null + private val sessionRng = SecureRandom() + private var distressStartedAtMs = 0L + private var radioTier = RadioTier.NORMAL + @Volatile private var presenceSleeping = false override fun onBind(intent: Intent?): IBinder? = null @@ -169,7 +179,7 @@ class GuacamayaForegroundService : Service() { while (isActive && wantObserving) { delay(OBSERVE_HEALTH_MS) if (!wantObserving) break - if (!BleMeshRuntime.isScanning()) { + if (!presenceSleeping && !BleMeshRuntime.isScanning()) { Log.w(tag, "observe health tick — scan inactive, restarting") BleMeshRuntime.ensureObserving(this@GuacamayaForegroundService) } @@ -182,7 +192,7 @@ class GuacamayaForegroundService : Service() { /** SOS mode: this device's own frame is a critical distress request. */ private fun startDistressBroadcast() = - startBroadcast(SosType.DISTRESS, ownCritical = true, ownTtl = 15) + startBroadcast(SosType.DISTRESS, ownCritical = true, ownTtl = 15).also { distressStartedAtMs = System.currentTimeMillis() } /** * "Ambos" mode: own frame is a low-priority presence beacon — but the rotation still @@ -190,7 +200,36 @@ class GuacamayaForegroundService : Service() { */ private fun startPresenceHeartbeat() { if (broadcastJob?.isActive == true) return - startBroadcast(SosType.OTHER, ownCritical = false, ownTtl = PRESENCE_HOP_TTL) + broadcastJob = scope.launch { + val id = identity ?: Identity.loadOrCreate(this@GuacamayaForegroundService).also { identity = it } + val bcast = broadcaster ?: Broadcaster.create(this@GuacamayaForegroundService)?.also { broadcaster = it } + ?: return@launch + while (isActive) { + val policy = currentRadioPolicy() + ChatRuntime.initialize(this@GuacamayaForegroundService) + ChatRuntime.setPolicy(policy) + ChatRuntime.setWindowClosedListener { + if (presenceSleeping) BleMeshRuntime.stopObserving() + } + presenceSleeping = false + startObserving() + val payload = signedPayload(id, SosType.OTHER, false, PRESENCE_HOP_TTL) + bcast.start( + payload, id.publicKey, id.sign(payload), PRESENCE_HOP_TTL, + net.guacamaya.ble.BleConfig.normalParameters(policy.txPower), + ) + delay(policy.windowMs) + bcast.stop() + if (!ChatRuntime.active.value) { + presenceSleeping = true + BleMeshRuntime.stopObserving() + } + val meshNow = MeshClock.now(this@GuacamayaForegroundService) + val jitter = RadioPolicyEngine.jitterMs(id.nodeId) + val phase = Math.floorMod(meshNow - jitter, policy.cadenceMs) + delay((policy.cadenceMs - phase).coerceAtLeast(1_000L)) + } + } } /** @@ -227,6 +266,19 @@ class GuacamayaForegroundService : Service() { } val dao = GuacamayaDatabase.get(this@GuacamayaForegroundService).messageDao() + // A resolved / self-cancelled SOS must not resume TX on reboot. A terminal + // control trust (backend-resolve 0x02 or self-cancel 0x03) for THIS device, + // still inside its signed lifetime, is the local authority to stay silent as + // a distress beacon. The victim gates by nodeId, NOT by msgId: its own SOS + // msgId rotates every dwell for flood dedup (DedupeCache keys on nodeId+msgId), + // so a resolve that targeted one dwell's msgId would never match the live frame. + // The mule/relay role lives in FloodRouter and is unaffected — only OWN distress + // TX is suppressed. See docs/PROTOCOL_CONTROL.md. + if (ownType == SosType.DISTRESS && isDistressSuppressed(dao, id)) { + Log.i(tag, "distress TX suppressed — terminal control trust active for this node") + return@launch + } + // Bootstrap own ADV via start() so the compat→coded PHY negotiation runs once. signedPayload(id, ownType, ownCritical, ownTtl).let { p -> val sig = id.sign(p) @@ -238,7 +290,7 @@ class GuacamayaForegroundService : Service() { var idx = 0 var ownTurn = false // own frame just went out above while (isActive) { - delay(FORWARD_DWELL_MS) + delay(nextDwellMs(ownCritical)) if (ownTurn) { val p = signedPayload(id, ownType, ownCritical, ownTtl) val sig = id.sign(p) @@ -306,6 +358,20 @@ class GuacamayaForegroundService : Service() { } } + /** + * True if THIS device has a non-expired terminal control receipt (backend-resolve + * 0x02 or self-cancel 0x03) — the local authority to keep off the air as a distress + * beacon. Matched by nodeId (SHA-256(pubkey)[0..3] hex), never by msgId: the SOS + * msgId rotates per dwell for flood dedup, so it cannot match a resolve that pinned + * one specific dwell. Uses MeshClock for the lifetime check so the gate agrees with + * the same clock that verified the control frame in [net.guacamaya.mesh.FloodRouter]. + */ + private suspend fun isDistressSuppressed(dao: MessageDao, id: Identity): Boolean { + val myNodeIdHex = id.nodeId.joinToString("") { "%02x".format(it.toInt() and 0xFF) } + val nowMs = MeshClock.now(this) + return dao.terminalControlTrusts().any { it.nodeId == myNodeIdHex && it.expiresAt > nowMs } + } + private fun stopBroadcasting() { broadcastJob?.cancel() broadcastJob = null @@ -315,6 +381,19 @@ class GuacamayaForegroundService : Service() { private fun stopPresenceHeartbeat() { broadcastJob?.cancel() broadcastJob = null + broadcaster?.stop() + presenceSleeping = false + } + + private fun currentRadioPolicy(): RadioPolicy { + val battery = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED)) + val level = battery?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, 100) ?: 100 + val scale = battery?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, 100)?.coerceAtLeast(1) ?: 100 + val percent = (level * 100 / scale).coerceIn(0, 100) + val status = battery?.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1) ?: -1 + val charging = status == android.os.BatteryManager.BATTERY_STATUS_CHARGING || + status == android.os.BatteryManager.BATTERY_STATUS_FULL + return RadioPolicyEngine.next(radioTier, percent, charging).also { radioTier = it.tier } } private suspend fun signedPayload( @@ -327,14 +406,29 @@ class GuacamayaForegroundService : Service() { return Payload( latE7 = latE7, lonE7 = lonE7, - tsUnix = System.currentTimeMillis() / 1000, + tsUnix = MeshClock.now(this) / 1000, nodeId = id.nodeId, flags = Flags(hasHeavy = false, critical = critical, batteryBucket = 3, hopTtl = hopTtl), sosType = type, - msgId = (System.currentTimeMillis() and 0xFFFF).toInt(), + // Entropy per session prevents a captured control receipt from matching + // a later SOS that happens to share a wall-clock low word. + msgId = sessionRng.nextInt(0x1_0000), ).encode() } + /** Graceful degradation: fast initially, slower after an hour and on low battery. */ + private fun nextDwellMs(distress: Boolean): Long { + if (!distress) return FORWARD_DWELL_MS + val age = System.currentTimeMillis() - distressStartedAtMs + val battery = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED)) + ?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, 100) ?: 100 + return when { + battery < 15 -> LOW_BATTERY_DWELL_MS + age >= ONE_HOUR_MS -> AGED_DISTRESS_DWELL_MS + else -> FORWARD_DWELL_MS + } + } + /** * Fetch the device's current position as (latE7, lonE7), or null if location is * unavailable (permission denied, services off, or no Google Play Services). @@ -372,6 +466,7 @@ class GuacamayaForegroundService : Service() { Log.w(tag, "no last known location available (Fused + platform)") return null } + MeshClock.updateFromGps(this, loc) val latE7 = (loc.latitude * 1e7).toInt().coerceIn(-900_000_000, 900_000_000) val lonE7 = (loc.longitude * 1e7).toInt().coerceIn(-1_800_000_000, 1_800_000_000) return latE7 to lonE7 @@ -444,6 +539,9 @@ class GuacamayaForegroundService : Service() { * is small — chained holders carry SOS across the mesh. Tune for reach vs battery. */ private const val REBROADCAST_NODES = 20 private const val FORWARD_DWELL_MS = 2_000L + private const val AGED_DISTRESS_DWELL_MS = 30_000L + private const val LOW_BATTERY_DWELL_MS = 100_000L + private const val ONE_HOUR_MS = 60 * 60_000L private const val REBROADCAST_TTL = 2 private const val PREFS = "guacamaya_service" diff --git a/android/app/src/main/kotlin/net/guacamaya/time/MeshClock.kt b/android/app/src/main/kotlin/net/guacamaya/time/MeshClock.kt new file mode 100644 index 0000000..75b7541 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/time/MeshClock.kt @@ -0,0 +1,32 @@ +package net.guacamaya.time + +import android.content.Context +import android.location.Location + +/** + * Small, persisted GPS clock correction. It never changes wall-clock settings; + * callers use [now] for protocol timestamps and record whether time is trusted. + */ +object MeshClock { + private const val PREFS = "guacamaya_mesh_clock" + private const val OFFSET_MS = "offset_ms" + private const val TRUSTED_AT_MS = "trusted_at_ms" + private const val MAX_GPS_AGE_MS = 24 * 60 * 60_000L + + fun updateFromGps(context: Context, location: Location) { + if (location.time <= 0L) return + val offset = location.time - System.currentTimeMillis() + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putLong(OFFSET_MS, offset).putLong(TRUSTED_AT_MS, System.currentTimeMillis()).apply() + } + + fun now(context: Context): Long { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return System.currentTimeMillis() + p.getLong(OFFSET_MS, 0L) + } + + fun isTrusted(context: Context): Boolean { + val at = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(TRUSTED_AT_MS, 0L) + return at > 0 && System.currentTimeMillis() - at <= MAX_GPS_AGE_MS + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ui/BodyBearingSweep.kt b/android/app/src/main/kotlin/net/guacamaya/ui/BodyBearingSweep.kt new file mode 100644 index 0000000..9dfa1a5 --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ui/BodyBearingSweep.kt @@ -0,0 +1,61 @@ +package net.guacamaya.ui + +import net.guacamaya.mesh.RssiRange +import kotlin.math.floor + +/** + * Practical bearing aid for phones without AoA antennas. A rescuer slowly turns + * with the phone at chest level; body shadow creates a broad RSSI maximum. It is + * deliberately labelled low-confidence unless the scan covers several headings + * and observes meaningful signal variation. + */ +class BodyBearingSweep { + data class Result( + val bearing: Float, + val rssiSpanDb: Float, + val headingBins: Int, + val confident: Boolean, + ) + + private val samples = ArrayDeque() + private var lastRecordedAt: Long? = null + + fun record(heading: Float, rssi: Float, at: Long): Result? { + val last = lastRecordedAt + // Too soon → ignore. This also subsumes any backward clock jump (at < last ⇒ + // at - last < 0 < MIN_SAMPLE_INTERVAL_MS), so no separate monotonic guard. + if (last != null && at - last < MIN_SAMPLE_INTERVAL_MS) return result() + lastRecordedAt = at + // Bounded RSSI: an out-of-band value (or a UI-side NaN/sentinel) must not skew + // the max/min span or pick a bogus bearing. Clamp to the physical BLE band. + samples.addLast(Sample(CompassMath.normalizeDegrees(heading), RssiRange.clampFloat(rssi), at)) + while (samples.isNotEmpty() && at - samples.first().at > WINDOW_MS) samples.removeFirst() + return result() + } + + fun result(): Result? { + if (samples.size < MIN_SAMPLES) return null + val max = samples.maxByOrNull { it.rssi } ?: return null + val min = samples.minOf { it.rssi } + val bins = samples.map { floor(it.heading / BIN_DEGREES).toInt() }.toSet().size + val span = max.rssi - min + return Result( + bearing = max.heading, + rssiSpanDb = span, + headingBins = bins, + confident = bins >= MIN_HEADING_BINS && span >= MIN_RSSI_SPAN_DB, + ) + } + + private data class Sample(val heading: Float, val rssi: Float, val at: Long) + + companion object { + /** Enough time for one slow 360-degree turn or two deliberate passes. */ + private const val WINDOW_MS = 45_000L + private const val MIN_SAMPLE_INTERVAL_MS = 300L + private const val MIN_SAMPLES = 8 + private const val BIN_DEGREES = 45f + private const val MIN_HEADING_BINS = 4 + private const val MIN_RSSI_SPAN_DB = 6f + } +} diff --git a/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt b/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt index a0a0da4..933a0b7 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt @@ -43,7 +43,9 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import net.guacamaya.BuildConfig import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -55,8 +57,10 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -89,16 +93,24 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.location.LocationServices import net.guacamaya.loc.PlatformLocation +import net.guacamaya.ble.BleMeshRuntime import net.guacamaya.mesh.MessageEntity +import net.guacamaya.mesh.NeighborEstimateEntity +import net.guacamaya.mesh.ChatMessageEntity +import net.guacamaya.ble.RadioPolicy +import net.guacamaya.ble.RadioTier import net.guacamaya.mesh.NodeCatalog import net.guacamaya.backend.OfficialAlert import net.guacamaya.service.GuacamayaForegroundService import org.json.JSONObject import net.guacamaya.util.BatteryHelper import kotlin.math.PI +import kotlin.math.asin import kotlin.math.cos +import kotlin.math.pow import kotlin.math.roundToInt import kotlin.math.sin +import kotlin.math.sqrt /** * VPN-style single screen. One big power button toggles mesh participation @@ -193,6 +205,7 @@ class MainActivity : ComponentActivity() { /** MIUI/singleTop often drops intent.action; extra + prefs survive adb redispatch. */ private fun resolveAdbAction(source: Intent?): String? { + if (!BuildConfig.DEBUG) return null source?.getStringExtra(EXTRA_ADB_ACTION)?.let { extra -> if (extra.startsWith("net.guacamaya.action.")) { getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(KEY_ADB_ACTION, extra).apply() @@ -228,6 +241,7 @@ class MainActivity : ComponentActivity() { /** Route adb `am start -a …` into the foreground service (shell cannot start FGS on API 30+). */ private fun dispatchServiceAction(action: String?) { + if (!BuildConfig.DEBUG) return if (action.isNullOrBlank() || action == Intent.ACTION_MAIN || !action.startsWith("net.guacamaya.action.") @@ -312,6 +326,7 @@ class MainActivity : ComponentActivity() { } private fun isDirectAdbLaunch(source: Intent?): Boolean { + if (!BuildConfig.DEBUG) return false val extra = source?.getStringExtra(EXTRA_ADB_ACTION) val action = source?.action return extra?.startsWith("net.guacamaya.action.") == true || @@ -518,16 +533,21 @@ private fun Screen(vm: MapViewModel = viewModel()) { val mode by vm.mode.collectAsState() val messages by vm.messages.collectAsState() val latestNodes by vm.latestNodes.collectAsState() + val neighborEstimates by vm.neighborEstimates.collectAsState() val devicesReceived by vm.devicesReceived.collectAsState() val totalFrames by vm.totalFrames.collectAsState() val alerts by vm.alerts.collectAsState() val liveSos by vm.liveSos.collectAsState() val broadcastSupported by vm.broadcastSupported.collectAsState() + val chatMessages by vm.chatMessages.collectAsState() + val radioPolicy by vm.radioPolicy.collectAsState() + val chatActive by vm.chatActive.collectAsState() val ctx = LocalContext.current val probeCompass = rememberCompassState() val probeLocation = rememberLiveLocation(ctx, highAccuracy = false) var showRadar by remember { mutableStateOf(false) } + var showChat by remember { mutableStateOf(false) } val running = broadcasting || observing val locationGranted = rememberLocationPermission(ctx) FunctionalProbe(compass = probeCompass, location = probeLocation, nodes = latestNodes, totalFrames = totalFrames) @@ -543,6 +563,13 @@ private fun Screen(vm: MapViewModel = viewModel()) { onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // The foreground rescue view is the only place that asks the radio for the + // high-duty RSSI stream. Background mesh participation stays balanced. + DisposableEffect(showRadar) { + BleMeshRuntime.setRadarActive(showRadar) + onDispose { BleMeshRuntime.setRadarActive(false) } + } + fun send(action: String) { val intent = Intent(ctx, GuacamayaForegroundService::class.java).apply { this.action = action } ContextCompat.startForegroundService(ctx, intent) @@ -597,14 +624,29 @@ private fun Screen(vm: MapViewModel = viewModel()) { .background(Canvas0) .windowInsetsPadding(WindowInsets.safeDrawing), ) { - if (showRadar) { - RadarScreen(latestNodes = latestNodes, onBack = { showRadar = false }) + if (showChat) { + ChatScreen( + messages = chatMessages, + policy = radioPolicy, + active = chatActive, + peerCount = devicesReceived, + onBack = { showChat = false }, + onSend = vm::sendChat, + ) + } else if (showRadar) { + RadarScreen( + latestNodes = latestNodes, + neighborEstimates = neighborEstimates, + onBack = { showRadar = false }, + onMarkFound = { node, lat, lon -> vm.markFound(node, lat, lon) }, + ) } else { HomeScreen( nodeIdHex = identity?.nodeId?.toHex(), running = running, mode = mode, latestNodes = latestNodes, + neighborEstimates = neighborEstimates, devicesReceived = devicesReceived, alerts = alerts, liveSosCount = liveSos.size, @@ -613,6 +655,11 @@ private fun Screen(vm: MapViewModel = viewModel()) { broadcastSupported = broadcastSupported, locationGranted = locationGranted, onOpenRadar = { showRadar = true }, + onOpenChat = { + vm.openChat() + BleMeshRuntime.ensureObserving(ctx) + showChat = true + }, ) } } @@ -644,6 +691,7 @@ private fun HomeScreen( running: Boolean, mode: MeshMode, latestNodes: List, + neighborEstimates: List, devicesReceived: Int, alerts: List, liveSosCount: Int, @@ -652,6 +700,7 @@ private fun HomeScreen( broadcastSupported: Boolean, locationGranted: Boolean, onOpenRadar: () -> Unit, + onOpenChat: () -> Unit, ) { val lastNode = latestNodes.firstOrNull() val lastRssi = lastNode?.rssi @@ -738,7 +787,72 @@ private fun HomeScreen( Spacer(Modifier.height(Space.sm)) - RadarEntry(latestNodes = latestNodes, onClick = onOpenRadar) + RadarEntry( + latestNodes = latestNodes, + neighborEstimates = neighborEstimates, + onClick = onOpenRadar, + ) + Spacer(Modifier.height(Space.sm)) + FilledTonalButton(onClick = onOpenChat, modifier = Modifier.fillMaxWidth()) { + Text("Chat BLE público") + } + } +} + +@Composable +private fun ChatScreen( + messages: List, + policy: RadioPolicy, + active: Boolean, + peerCount: Int, + onBack: () -> Unit, + onSend: (String) -> Result, +) { + var draft by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val used = draft.toByteArray(Charsets.UTF_8).size + Column(Modifier.fillMaxSize().padding(Space.md)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text("Atrás") } + Text("Chat cercano", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f), textAlign = TextAlign.Center) + } + Text("Sala pública firmada · $peerCount nodos · ${policy.tier.name.lowercase()}", color = TextLo, style = MaterialTheme.typography.bodySmall) + Text(if (active) "Radio activo" else "Próxima ventana programada", color = if (active) SuccessC else TextLo) + if (policy.reducedRange) Text("Ahorro de batería: alcance reducido", color = GuacamayaPalette.Warning) + Text("Objetivo directo 10–20 m; no es una medición exacta.", color = TextLo, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(Space.md)) + Column(Modifier.weight(1f).fillMaxWidth()) { + if (messages.isEmpty()) Text("No hay mensajes todavía. Se enviarán cuando aparezca otro nodo.", color = TextLo) + messages.takeLast(12).forEach { message -> + Surface( + color = if (message.own) GuacamayaPalette.PrimaryDisabled else GuacamayaPalette.SurfaceCard, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxs), + ) { + Column(Modifier.padding(Space.sm)) { + Text(message.text, color = TextHi) + val state = when (message.deliveryState) { 0 -> "en cola"; 1 -> "anunciado"; else -> "retransmitido" } + Text("${message.senderNodeId.take(8)} · $state", color = TextLo, style = MaterialTheme.typography.labelSmall) + } + } + } + } + OutlinedTextField( + value = draft, + onValueChange = { candidate -> if (candidate.toByteArray(Charsets.UTF_8).size <= 160) draft = candidate }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Mensaje") }, + supportingText = { Text("$used/160 bytes") }, + isError = error != null, + ) + error?.let { Text(it, color = DangerC, style = MaterialTheme.typography.bodySmall) } + Button( + onClick = { + onSend(draft).onSuccess { draft = ""; error = null }.onFailure { error = it.message } + }, + enabled = used in 1..160, + modifier = Modifier.fillMaxWidth(), + ) { Text("Enviar por BLE") } } } @@ -1102,16 +1216,22 @@ private fun StatCell(label: String, value: String, modifier: Modifier = Modifier } @Composable -private fun RadarEntry(latestNodes: List, onClick: () -> Unit) { +private fun RadarEntry( + latestNodes: List, + neighborEstimates: List, + onClick: () -> Unit, +) { val ctx = LocalContext.current val heading = rememberCompassHeading() val location = rememberLiveLocation(ctx, highAccuracy = false) - val target = location?.let { GeoProximity.nearest(it, latestNodes) } - val relative = target?.let { + val gpsTarget = location?.let { GeoProximity.nearest(it, latestNodes) } + val criticalByNode = remember(latestNodes) { latestNodes.associate { it.nodeId.toHex() to it.critical } } + val signalTarget = SignalRadar.targetFor(gpsTarget?.nodeId, neighborEstimates, criticalByNode) + val consistency = SignalRadar.gpsConsistency(gpsTarget, signalTarget) + val relative = gpsTarget?.let { if (it.coLocated) 0f else CompassMath.relativeBearing(it.bearing, heading) } ?: 0f - // Compact entry — small compass + one status line + chevron. Row( Modifier .fillMaxWidth() @@ -1123,35 +1243,64 @@ private fun RadarEntry(latestNodes: List, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.sm), ) { - RadarCompass(sizeDp = 40, heading = heading, relative = relative, target = target) - + RadarCompass( + sizeDp = 40, + heading = heading, + relative = relative, + target = gpsTarget, + signal = signalTarget, + hasDirection = gpsTarget?.coLocated == false, + showSignalRing = consistency != SignalRadar.GpsConsistency.INCONSISTENT, + ) Column(Modifier.weight(1f)) { Text("Radar", color = TextHi, style = MaterialTheme.typography.titleSmall) Text( - if (target == null) "Sin objetivo con GPS aún" - else "Nodo ${target.nodeId.take(8)} · ${GeoProximity.formatDistance(target)}", + when { + gpsTarget != null -> "Nodo ${gpsTarget.nodeId.take(8)} · ${GeoProximity.formatDistance(gpsTarget)}" + signalTarget != null -> "Nodo ${signalTarget.nodeId.take(8)} · ${SignalRadar.rangeLabel(signalTarget)}" + else -> "Sin objetivo directo aún" + }, color = TextLo, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - Text("›", color = TextLo, style = MaterialTheme.typography.titleMedium) } } @Composable -private fun RadarScreen(latestNodes: List, onBack: () -> Unit) { +private fun RadarScreen( + latestNodes: List, + neighborEstimates: List, + onBack: () -> Unit, + onMarkFound: (MessageEntity, Double, Double) -> Unit, +) { val ctx = LocalContext.current var compassKey by remember { mutableIntStateOf(0) } val heading = rememberCompassHeading(reloadKey = compassKey) val location = rememberLiveLocation(ctx, highAccuracy = true) - val target = location?.let { GeoProximity.nearest(it, latestNodes) } - val targetNode = target?.let { t -> latestNodes.firstOrNull { it.nodeId.toHex() == t.nodeId } } - val relative = target?.let { + val gpsTarget = location?.let { GeoProximity.nearest(it, latestNodes) } + val criticalByNode = remember(latestNodes) { latestNodes.associate { it.nodeId.toHex() to it.critical } } + val signalTarget = SignalRadar.targetFor(gpsTarget?.nodeId, neighborEstimates, criticalByNode) + val consistency = SignalRadar.gpsConsistency(gpsTarget, signalTarget) + val targetNodeId = gpsTarget?.nodeId ?: signalTarget?.nodeId + val targetNode = targetNodeId?.let { id -> latestNodes.firstOrNull { it.nodeId.toHex() == id } } + + val sweep = remember(signalTarget?.nodeId) { BodyBearingSweep() } + var sweepResult by remember(signalTarget?.nodeId) { mutableStateOf(null) } + LaunchedEffect(signalTarget?.nodeId, signalTarget?.estimate?.updatedAt, heading) { + if (gpsTarget == null && signalTarget?.estimate?.stable == true) { + sweepResult = sweep.record(heading, signalTarget.estimate.filteredRssi, System.currentTimeMillis()) + } + } + val bodyBearing = sweepResult?.takeIf { it.confident && signalTarget?.estimate?.stable == true }?.bearing + val relative = gpsTarget?.let { if (it.coLocated) 0f else CompassMath.relativeBearing(it.bearing, heading) - } ?: 0f + } ?: bodyBearing?.let { CompassMath.relativeBearing(it, heading) } ?: 0f + val hasDirection = gpsTarget?.coLocated == false || bodyBearing != null + val critical = gpsTarget?.critical ?: signalTarget?.critical ?: false Column( Modifier.fillMaxSize().padding(Space.md), @@ -1167,47 +1316,92 @@ private fun RadarScreen(latestNodes: List, onBack: () -> Unit) { } Spacer(Modifier.weight(0.7f)) - - RadarCompass(sizeDp = 280, heading = heading, relative = relative, target = target) - + RadarCompass( + sizeDp = 280, + heading = heading, + relative = relative, + target = gpsTarget, + signal = signalTarget, + hasDirection = hasDirection, + showSignalRing = consistency != SignalRadar.GpsConsistency.INCONSISTENT, + ) Spacer(Modifier.height(Space.xl)) - if (target == null) { - Text("Sin objetivo con GPS", color = TextHi, style = MaterialTheme.typography.headlineSmall) + when { + gpsTarget != null -> { + Text( + GeoProximity.formatDistance(gpsTarget), + color = if (critical) DangerC else InfoC, + style = MaterialTheme.typography.displaySmall, + ) + Text("hacia nodo ${gpsTarget.nodeId.take(8)}", color = TextHi, style = MaterialTheme.typography.titleMedium) + Text("posición GPS firmada por la mesh", color = TextLo, style = MaterialTheme.typography.labelMedium) + if (gpsTarget.coLocated) { + Text("GPS sin resolución para esta distancia", color = TextLo, style = MaterialTheme.typography.bodySmall) + } else { + Text( + "${relative.roundToInt()}° relativo · bearing ${gpsTarget.bearing.roundToInt()}° · brújula ${heading.roundToInt()}°", + color = TextLo, + style = MaterialTheme.typography.labelMedium, + ) + } + when { + signalTarget == null -> Text("sin señal directa; solo posición reportada", color = TextLo, style = MaterialTheme.typography.labelMedium) + consistency == SignalRadar.GpsConsistency.INCONSISTENT -> Text("señal directa ${SignalRadar.rangeLabel(signalTarget)}: inconsistente con GPS firmado", color = DangerC, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) + } + } + signalTarget != null -> { + Text( + SignalRadar.rangeLabel(signalTarget), + color = if (critical) DangerC else InfoC, + style = MaterialTheme.typography.displaySmall, + ) + Text("señal de nodo ${signalTarget.nodeId.take(8)}", color = TextHi, style = MaterialTheme.typography.titleMedium) + if (bodyBearing != null) { + Text( + "${relative.roundToInt()}° por barrido corporal · confianza baja", + color = TextLo, + style = MaterialTheme.typography.labelMedium, + ) + } else { + Text("Haz un giro lento de 15-20 s, o dos vueltas, con el teléfono al pecho", color = TextLo, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) + } + } + else -> { + Text("Sin objetivo directo", color = TextHi, style = MaterialTheme.typography.headlineSmall) + Text("El radar mostrará GPS o una señal BLE autenticada al recibirla.", color = TextLo, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + } + } + + targetNode?.let { node -> Spacer(Modifier.height(Space.xxs)) Text( - "Enciende SOS o Ambos en el otro teléfono y espera su señal con GPS.", - color = TextLo, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, + "Último ${NodeCatalog.signalKind(node)}: ${NodeCatalog.formatLastHeartbeat(node.receivedAt)}", + color = InfoC, + style = MaterialTheme.typography.titleSmall, ) - } else { + } + signalTarget?.let { signal -> + Spacer(Modifier.height(Space.xxs)) Text( - GeoProximity.formatDistance(target), - color = if (target.critical) DangerC else InfoC, - style = MaterialTheme.typography.displaySmall, + "${SignalRadar.sourceLabel(signal.estimate.source)} filtrado ${signal.estimate.filteredRssi.roundToInt()} dBm · ${SignalRadar.statusLabel(signal.state)}", + color = TextLo, + style = MaterialTheme.typography.labelMedium, ) - Text("hacia nodo ${target.nodeId.take(8)}", color = TextHi, style = MaterialTheme.typography.titleMedium) - targetNode?.let { node -> - Spacer(Modifier.height(Space.xxs)) + if (gpsTarget != null) { Text( - "Último ${NodeCatalog.signalKind(node)}: ${NodeCatalog.formatLastHeartbeat(node.receivedAt)}", - color = InfoC, - style = MaterialTheme.typography.titleSmall, + SignalRadar.consistencyLabel(consistency), + color = if (consistency == SignalRadar.GpsConsistency.INCONSISTENT) DangerC else TextLo, + style = MaterialTheme.typography.labelMedium, ) } - Spacer(Modifier.height(Space.xs)) - if (target.coLocated) { - Text("Dispositivos juntos — GPS no distingue cm", color = TextLo, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center) - Text("Usa la brújula; calibra apuntando al norte", color = TextLo, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) - } else { - Text("${relative.roundToInt()}° relativo · bearing ${target.bearing.roundToInt()}° · brújula ${heading.roundToInt()}°", color = TextLo, style = MaterialTheme.typography.labelMedium) - } - Text("rssi ${target.rssi} dBm · ±${target.uncertaintyMeters.roundToInt()} m GPS · ${if (target.critical) "SOS" else "presencia"}", color = TextLo, style = MaterialTheme.typography.labelMedium) } - Spacer(Modifier.height(Space.md)) + targetNode?.let { node -> + FoundReportSection(targetNode = node, location = location, onMarkFound = onMarkFound) + } + Spacer(Modifier.height(Space.md)) Column( Modifier .fillMaxWidth() @@ -1220,7 +1414,8 @@ private fun RadarScreen(latestNodes: List, onBack: () -> Unit) { Spacer(Modifier.height(Space.xxs)) Text("GPS local: ${location?.accuracy?.roundToInt()?.let { "±$it m" } ?: "sin fix"}", color = TextLo, style = MaterialTheme.typography.bodySmall) Text("Objetivos con GPS: ${latestNodes.count { it.latE7 != 0 || it.lonE7 != 0 }}", color = TextLo, style = MaterialTheme.typography.bodySmall) - Text("Nota: a <15 m la distancia GPS se muestra como «junto».", color = TextLo, style = MaterialTheme.typography.labelMedium) + Text("Muestras BLE directas: ${neighborEstimates.count { net.guacamaya.mesh.NeighborState.at(it.lastSeenAt, System.currentTimeMillis()) != net.guacamaya.mesh.NeighborState.LOST }}", color = TextLo, style = MaterialTheme.typography.bodySmall) + Text("BLE da un anillo de incertidumbre, no una posición ni autoridad de protocolo.", color = TextLo, style = MaterialTheme.typography.labelMedium) Spacer(Modifier.height(Space.xs)) FilledTonalButton( onClick = { @@ -1234,16 +1429,24 @@ private fun RadarScreen(latestNodes: List, onBack: () -> Unit) { shape = MaterialTheme.shapes.medium, ) { Text("Calibrar norte (top del teléfono → N)", style = MaterialTheme.typography.labelLarge) } } - Spacer(Modifier.weight(1f)) } } @Composable -private fun RadarCompass(sizeDp: Int, heading: Float, relative: Float, target: GeoProximity.Result?) { +private fun RadarCompass( + sizeDp: Int, + heading: Float, + relative: Float, + target: GeoProximity.Result?, + signal: SignalRadar.Target?, + hasDirection: Boolean, + showSignalRing: Boolean, +) { val arrowAlpha = if (target?.coLocated == true) 0.35f else 1f + val signalColor = if (target?.critical == true || signal?.critical == true) DangerC else InfoC + val signalEstimate = signal?.estimate Box(Modifier.size(sizeDp.dp), contentAlignment = Alignment.Center) { - // Rose rotates so N aligns with true north; arrow shows target relative to phone top. Box(Modifier.fillMaxSize().rotate(-heading), contentAlignment = Alignment.Center) { Canvas(Modifier.fillMaxSize()) { val r = size.minDimension / 2 @@ -1252,6 +1455,15 @@ private fun RadarCompass(sizeDp: Int, heading: Float, relative: Float, target: G drawCircle(color = CardLine.copy(alpha = 0.35f), radius = r * 0.33f, style = Stroke(width = 1.dp.toPx())) drawLine(TextLo.copy(alpha = 0.35f), Offset(r, 0f), Offset(r, size.height), strokeWidth = 1.dp.toPx()) drawLine(TextLo.copy(alpha = 0.35f), Offset(0f, r), Offset(size.width, r), strokeWidth = 1.dp.toPx()) + signalEstimate?.takeIf { it.stable && showSignalRing }?.distanceMeters?.let { distance -> + val uncertainty = signalEstimate.uncertaintyMeters ?: distance * 0.7f + val band = (uncertainty / distance.coerceAtLeast(0.5f)).coerceIn(0.14f, 0.7f) + drawCircle( + color = signalColor.copy(alpha = 0.55f), + radius = r * 0.54f, + style = Stroke(width = r * band * 0.34f), + ) + } } Text( "N", @@ -1260,13 +1472,15 @@ private fun RadarCompass(sizeDp: Int, heading: Float, relative: Float, target: G modifier = Modifier.align(Alignment.TopCenter).padding(top = 6.dp), ) } - Text( - "▲", - color = target?.let { if (it.critical) DangerC else InfoC }?.copy(alpha = arrowAlpha) ?: TextLo.copy(alpha = arrowAlpha), - fontSize = (sizeDp / 3).sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.rotate(relative), - ) + if (hasDirection) { + Text( + "▲", + color = signalColor.copy(alpha = arrowAlpha), + fontSize = (sizeDp / 3).sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.rotate(relative), + ) + } } } @@ -1317,4 +1531,89 @@ private fun BackButton(onBack: () -> Unit) { ) { Text("‹ Volver", style = MaterialTheme.typography.labelLarge) } } +/** Local mirror of the backend's FOUND_PROXIMITY_METERS default (100 m). The + * backend computes the authoritative verdict; this only powers the pre-check UI. */ +private const val FOUND_NEAR_METERS = 100.0 + +/** + * "Marcar como encontrado": queues a signed Encontrado report for the radar's + * current target. Before queueing, shows a proximity pre-check against the + * target's last reported point — near (≤100 m) reads as "probable encontrado", + * far warns "posible falso positivo" so trolls/mistakes are flagged, not hidden. + */ +@Composable +private fun FoundReportSection( + targetNode: MessageEntity, + location: Location?, + onMarkFound: (MessageEntity, Double, Double) -> Unit, +) { + // Without the target's fix there is no reference point to validate against. + if (targetNode.latE7 == 0 && targetNode.lonE7 == 0) return + val nodeKey = "${targetNode.nodeId.toHex()}-${targetNode.msgId}" + var showDialog by remember(nodeKey) { mutableStateOf(false) } + var queued by remember(nodeKey) { mutableStateOf(false) } + + Spacer(Modifier.height(Space.sm)) + if (queued) { + Text( + "Reporte \"Encontrado\" en cola — se subirá al backend con conexión", + color = InfoC, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + ) + return + } + FilledTonalButton( + onClick = { showDialog = true }, + enabled = location != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (location != null) "Marcar como encontrado" else "Marcar como encontrado (esperando GPS)") + } + + if (showDialog && location != null) { + val distance = haversineMeters( + location.latitude, location.longitude, + targetNode.latE7 / 1e7, targetNode.lonE7 / 1e7, + ) + val near = distance <= FOUND_NEAR_METERS + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text("¿Marcar nodo ${targetNode.nodeId.toHex().take(8)} como encontrado?") }, + text = { + Text( + if (near) { + "✅ Estás a ~${distance.roundToInt()} m del último punto reportado. " + + "Probable encontrado." + } else { + "⚠️ Estás a ~${distance.roundToInt()} m del último punto reportado " + + "(más de ${FOUND_NEAR_METERS.roundToInt()} m). El reporte se marcará " + + "como posible falso positivo." + } + ) + }, + confirmButton = { + TextButton(onClick = { + onMarkFound(targetNode, location.latitude, location.longitude) + queued = true + showDialog = false + }) { Text("Confirmar") } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { Text("Cancelar") } + }, + ) + } +} + +/** Great-circle distance in meters — mirror of `haversineMeters` in @guacamaya/shared. */ +private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2) + return 2 * r * asin(sqrt(a)) +} + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt b/android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt index b857e06..0c0d289 100644 --- a/android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt +++ b/android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt @@ -24,9 +24,19 @@ import net.guacamaya.backend.ws.LiveSos import net.guacamaya.backend.ws.LiveSosClient import net.guacamaya.ble.Broadcaster import net.guacamaya.crypto.Identity +import net.guacamaya.ingest.IngestUploadWorker import net.guacamaya.mesh.MessageDao import net.guacamaya.mesh.MessageEntity import net.guacamaya.mesh.GuacamayaDatabase +import net.guacamaya.mesh.NeighborEstimateEntity +import net.guacamaya.mesh.ChatMessageEntity +import net.guacamaya.mesh.OutboundFoundEntity +import net.guacamaya.ble.ChatRuntime +import net.guacamaya.proto.ControlFrame +import net.guacamaya.proto.FoundFrame +import net.guacamaya.proto.FoundPayload +import net.guacamaya.proto.buildFoundFrame +import java.security.SecureRandom /** * Top-level UI state. Owns: @@ -57,6 +67,16 @@ class MapViewModel(app: Application) : AndroidViewModel(app) { /** Alias for UI «recibidos» = unique node count. */ val devicesReceived: StateFlow = knownNodes + /** Direct-neighbour filtered radio evidence for the rescue radar. This is + * intentionally separate from the signed SOS/message stream. */ + val neighborEstimates: StateFlow> = dao.observeNeighborEstimates() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val chatMessages: StateFlow> = GuacamayaDatabase.get(app).chatDao().observeChatMessages() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val radioPolicy = ChatRuntime.policy + val chatActive = ChatRuntime.active + private val _identity = MutableStateFlow(null) val identity: StateFlow = _identity.asStateFlow() @@ -148,6 +168,37 @@ class MapViewModel(app: Application) : AndroidViewModel(app) { fun setBroadcasting(on: Boolean) { _broadcasting.value = on } fun setObserving(on: Boolean) { _observing.value = on } fun setMode(m: MeshMode) { _mode.value = m } + fun sendChat(text: String): Result = ChatRuntime.send(text) + fun openChat() { ChatRuntime.initialize(getApplication()); ChatRuntime.openActiveWindow() } + + /** + * Queue a signed "Encontrado" report for [node]'s current SOS session and kick + * the mule uploader. The backend re-verifies the signature and computes the + * authoritative near/far verdict against the SOS's last reported point; the + * finder's position travels inside the signed payload, never as loose JSON. + */ + fun markFound(node: MessageEntity, finderLat: Double, finderLon: Double) { + val id = identity.value ?: return + val app = getApplication() + viewModelScope.launch(Dispatchers.IO) { + val frame = buildFoundFrame( + FoundPayload( + latE7 = (finderLat * 1e7).toInt(), + lonE7 = (finderLon * 1e7).toInt(), + tsUnix = System.currentTimeMillis() / 1000, + targetNodeId = node.nodeId.copyOf(), + targetMsgId = node.msgId, + foundMsgId = SecureRandom().nextInt(0x10000), + ), + id.privateKeySeed, + ) + val sigHex = ControlFrame.toHex(frame.copyOfRange(FoundFrame.SIG_OFF, FoundFrame.FRAME_LEN)) + dao.insertOutboundFound( + OutboundFoundEntity(signatureHex = sigHex, frame = frame, queuedAt = System.currentTimeMillis()) + ) + IngestUploadWorker.enqueue(app) + } + } private companion object { const val MAX_LIVE_SOS = 50 diff --git a/android/app/src/main/kotlin/net/guacamaya/ui/SignalRadar.kt b/android/app/src/main/kotlin/net/guacamaya/ui/SignalRadar.kt new file mode 100644 index 0000000..37518fd --- /dev/null +++ b/android/app/src/main/kotlin/net/guacamaya/ui/SignalRadar.kt @@ -0,0 +1,88 @@ +package net.guacamaya.ui + +import net.guacamaya.mesh.NeighborEstimateEntity +import net.guacamaya.mesh.NeighborState +import net.guacamaya.mesh.ObservationSource +import net.guacamaya.mesh.SignalDistance +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** UI-only selection and formatting for direct-neighbour radio evidence. */ +object SignalRadar { + enum class GpsConsistency { NO_SIGNED_GPS, CONSISTENT, INCONSISTENT } + + data class Target( + val nodeId: String, + val estimate: NeighborEstimateEntity, + val critical: Boolean, + val state: NeighborState, + ) + + fun targetFor( + preferredNodeId: String?, + estimates: List, + criticalByNode: Map, + now: Long = System.currentTimeMillis(), + ): Target? { + val candidates = estimates.map { + Target( + nodeId = it.nodeId, + estimate = it, + critical = criticalByNode[it.nodeId] == true, + state = NeighborState.at(it.lastSeenAt, now), + ) + }.filter { it.state != NeighborState.LOST } + // GPS and RF evidence must refer to the same origin. Do not decorate a + // GPS target with the RSSI ring of an unrelated nearby relay/victim. + if (preferredNodeId != null) return candidates.firstOrNull { it.nodeId == preferredNodeId } + return candidates.maxByOrNull { it.estimate.filteredRssi } + } + + fun rangeLabel(target: Target): String { + if (!target.estimate.stable) return "señal detectada · estabilizando" + val distance = target.estimate.distanceMeters ?: return "señal ${SignalDistance.temperature(target.estimate.filteredRssi)}" + val uncertainty = target.estimate.uncertaintyMeters + val base = "~${distance.roundToInt()} m" + return if (uncertainty == null) base else "$base · ±${uncertainty.roundToInt()} m" + } + + fun statusLabel(state: NeighborState): String = when (state) { + NeighborState.HEARD -> "directa no verificada" + NeighborState.DEGRADED -> "directa no verificada · intermitente" + NeighborState.LOST -> "perdido" + } + + /** TTL is mutable outside the signature, so it only suggests a direct RF hop. + * Compare the two uncertain distance estimates at a deliberately conservative + * 3 sigma boundary. An inconsistency stays visible to the rescuer, but never + * displaces the GPS bearing or becomes protocol authority. */ + fun gpsConsistency(gps: GeoProximity.Result?, signal: Target?): GpsConsistency { + if (gps == null || signal?.estimate?.stable != true) return GpsConsistency.NO_SIGNED_GPS + val signalDistance = signal.estimate.distanceMeters ?: return GpsConsistency.NO_SIGNED_GPS + val signalUncertainty = signal.estimate.uncertaintyMeters ?: signalDistance * 0.7f + val combinedSigma = sqrt( + gps.uncertaintyMeters * gps.uncertaintyMeters + signalUncertainty * signalUncertainty, + ) + return if (abs(gps.distanceMeters - signalDistance) > CONSISTENCY_SIGMA * combinedSigma) { + GpsConsistency.INCONSISTENT + } else { + GpsConsistency.CONSISTENT + } + } + + fun consistencyLabel(consistency: GpsConsistency): String = when (consistency) { + GpsConsistency.NO_SIGNED_GPS -> "sin cruce GPS" + GpsConsistency.CONSISTENT -> "coherente con GPS firmado" + GpsConsistency.INCONSISTENT -> "RSSI inconsistente con GPS firmado" + } + + fun sourceLabel(sourceCode: Int): String = when (ObservationSource.fromCode(sourceCode)) { + ObservationSource.BLE_RSSI -> "BLE RSSI" + ObservationSource.NAN_RSSI -> "NAN RSSI" + ObservationSource.NAN_RTT -> "NAN RTT" + ObservationSource.ANDROID_RANGING -> "Android Ranging" + } + + private const val CONSISTENCY_SIGMA = 3f +} diff --git a/android/app/src/test/kotlin/net/guacamaya/aware/NanSyncTest.kt b/android/app/src/test/kotlin/net/guacamaya/aware/NanSyncTest.kt new file mode 100644 index 0000000..3216b33 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/aware/NanSyncTest.kt @@ -0,0 +1,19 @@ +package net.guacamaya.aware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NanSyncTest { + @Test fun antiEntropyReturnsOnlyLocallyMissingAtPeer() { + val digest = NanDigestCodec.encode(listOf("a", "b")) + assertEquals(listOf("c"), NanDigestCodec.missing(listOf("a", "b", "c"), digest)) + assertTrue(digest.size <= AwareConfig.SSI_MAX) + } + + @Test fun dutyCycleAdaptsToBacklog() { + assertEquals(NanDutyCycle.IDLE, NanDutyCycle.forBacklog(0)) + assertEquals(NanDutyCycle.ACTIVE, NanDutyCycle.forBacklog(1)) + assertEquals(NanDutyCycle.URGENT, NanDutyCycle.forBacklog(0, critical = true)) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/backend/OfficialRecordVerifierTest.kt b/android/app/src/test/kotlin/net/guacamaya/backend/OfficialRecordVerifierTest.kt index b906a63..3b6987e 100644 --- a/android/app/src/test/kotlin/net/guacamaya/backend/OfficialRecordVerifierTest.kt +++ b/android/app/src/test/kotlin/net/guacamaya/backend/OfficialRecordVerifierTest.kt @@ -16,7 +16,7 @@ class OfficialRecordVerifierTest { private val backendPubkey = "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737" private val body = - """[{"channel":"alertas","timestamp":1782658760930,"ttl":3,"author":"backend","payload":{"title":"Sismo M6.1","severity":"alta","zona":"Centro"},"verified":true,"id":"923169ac45d5d7c74de385efb2b630e511a69e67f3ebb401e2eb34fa2fa406d9","sig":"f70d69bec71c4eec6fd3049764721950b37a3d6831915b6d7a749d05a20bfb84803e8205f0cc0e008ade196fc8176307af10aa5173011b31cdf45b9567fa270e"}]""" + """[{"channel":"alertas","timestamp":1782658760930,"ttl":3,"author":"backend","payload":{"severity":"alta","title":"Sismo M6.1","zona":"Centro"},"verified":true,"id":"b610be875897946fe22acc9dedc009acab8d16c03b43125d8f08a48ee54c4d85","sig":"d57f70c07a5f51785ab1a9c7813c89e663f68b7b25a2d017b70a048d17d805d4216e3403ef68c3989b5a1fa308274a9f1b929e83b2868013bc0053e51cf2c400"}]""" private fun record() = RecordJson.parseRecords(body).single() diff --git a/android/app/src/test/kotlin/net/guacamaya/ble/RadioPolicyTest.kt b/android/app/src/test/kotlin/net/guacamaya/ble/RadioPolicyTest.kt new file mode 100644 index 0000000..6730c60 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/ble/RadioPolicyTest.kt @@ -0,0 +1,21 @@ +package net.guacamaya.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class RadioPolicyTest { + @Test fun `low and critical thresholds use hysteresis`() { + assertEquals(RadioTier.LOW, RadioPolicyEngine.next(RadioTier.NORMAL, 20, false).tier) + assertEquals(RadioTier.LOW, RadioPolicyEngine.next(RadioTier.LOW, 24, false).tier) + assertEquals(RadioTier.NORMAL, RadioPolicyEngine.next(RadioTier.LOW, 25, false).tier) + assertEquals(RadioTier.CRITICAL, RadioPolicyEngine.next(RadioTier.LOW, 10, false).tier) + assertEquals(RadioTier.CRITICAL, RadioPolicyEngine.next(RadioTier.CRITICAL, 14, false).tier) + assertEquals(RadioTier.LOW, RadioPolicyEngine.next(RadioTier.CRITICAL, 15, false).tier) + } + + @Test fun `charging overrides and critical disables relay`() { + assertEquals(RadioTier.CHARGING, RadioPolicyEngine.next(RadioTier.CRITICAL, 2, true).tier) + assertFalse(RadioPolicyEngine.forTier(RadioTier.CRITICAL).relayEnabled) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt b/android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt index 43c94ce..6e92642 100644 --- a/android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt +++ b/android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt @@ -4,6 +4,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking import net.guacamaya.mesh.MessageDao import net.guacamaya.mesh.MessageEntity +import net.guacamaya.mesh.NeighborEstimateEntity +import net.guacamaya.mesh.ControlReplayEntity +import net.guacamaya.mesh.ControlTrustEntity +import net.guacamaya.mesh.OutboundControlEntity +import net.guacamaya.mesh.OutboundFoundEntity +import net.guacamaya.mesh.NeighborObservationEntity import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -33,6 +39,29 @@ class IngestRepositoryTest { override suspend fun countUploadable(): Int = rows.count { !it.uploaded && it.sig.size == 64 } + // ── Outbound control frames: mule-upload queue (self-cancel → backend) ─────── + val controlRows = mutableListOf() + + override suspend fun insertOutboundControl(entity: OutboundControlEntity): Long { + if (controlRows.any { it.signatureHex == entity.signatureHex }) return -1L + controlRows += entity + return 1L + } + + override suspend fun selectUploadableControl(limit: Int): List = + controlRows.filter { !it.uploaded }.sortedBy { it.queuedAt }.take(limit) + + override suspend fun markControlUploaded(sigs: List) { + val set = sigs.toHashSet() + for (i in controlRows.indices) { + if (controlRows[i].signatureHex in set) { + controlRows[i] = controlRows[i].copy(uploaded = true) + } + } + } + + override suspend fun countUploadableControl(): Int = controlRows.count { !it.uploaded } + // Unused by the uploader. override suspend fun insert(entity: MessageEntity): Long = throw NotImplementedError() override fun observeRecent(limit: Int): Flow> = throw NotImplementedError() @@ -43,6 +72,43 @@ class IngestRepositoryTest { override suspend fun latestHelpFramesPerNode(limit: Int): List = throw NotImplementedError() override suspend fun pruneOldKeeping(keep: Int) = throw NotImplementedError() override suspend fun clear() = throw NotImplementedError() + override suspend fun insertNeighborObservation(entity: NeighborObservationEntity): Long = throw NotImplementedError() + override suspend fun pruneNeighborObservations(nodeId: String, keep: Int) = throw NotImplementedError() + override suspend fun upsertNeighborEstimate(entity: NeighborEstimateEntity) = throw NotImplementedError() + override fun observeNeighborEstimates(limit: Int): Flow> = throw NotImplementedError() + override suspend fun refreshNeighborStates(now: Long, degradedAfter: Long, lostAfter: Long) = throw NotImplementedError() + + // ── Control frames: not touched by the uploader. ────────────────────── + override suspend fun insertControlReplay(entity: ControlReplayEntity): Long = throw NotImplementedError() + override suspend fun hasControlReplay(signatureHex: String): Boolean = throw NotImplementedError() + override suspend fun pruneControlReplay(now: Long): Int = throw NotImplementedError() + override suspend fun countControlReplay(): Int = throw NotImplementedError() + override suspend fun upsertControlTrust(entity: ControlTrustEntity) = throw NotImplementedError() + override suspend fun getControlTrust(nodeId: String, msgId: Int): ControlTrustEntity? = throw NotImplementedError() + override fun observeControlTrust(nodeId: String, msgId: Int): Flow = throw NotImplementedError() + override suspend fun terminalControlTrusts(): List = throw NotImplementedError() + override suspend fun countControlTrust(): Int = throw NotImplementedError() + override suspend fun clearControlTrust() = throw NotImplementedError() + + // ── Outbound found frames: mule-upload queue ("Encontrado" → backend) ──────── + val outboundFound = mutableListOf() + + override suspend fun insertOutboundFound(entity: OutboundFoundEntity): Long { + if (outboundFound.any { it.signatureHex == entity.signatureHex }) return -1 + outboundFound.add(entity) + return outboundFound.size.toLong() + } + + override suspend fun selectUploadableFound(limit: Int): List = + outboundFound.filter { !it.uploaded }.sortedBy { it.queuedAt }.take(limit) + + override suspend fun markFoundUploaded(sigs: List) { + val marked = outboundFound.map { if (it.signatureHex in sigs) it.copy(uploaded = true) else it } + outboundFound.clear() + outboundFound.addAll(marked) + } + + override suspend fun countUploadableFound(): Int = outboundFound.count { !it.uploaded } } private class FakeApi( @@ -51,10 +117,18 @@ class IngestRepositoryTest { ) : IngestApi { var calls = 0 val sentSizes = mutableListOf() + val controlSentSizes = mutableListOf() + val uploadedFound = mutableListOf>() - override suspend fun upload(frames: List): IngestResult { + override suspend fun upload( + frames: List, + controlFrames: List, + foundFrames: List, + ): IngestResult { calls++ sentSizes += frames.size + controlSentSizes += controlFrames.size + uploadedFound += foundFrames if (calls > failAfter) throw IOException("boom") return result } @@ -87,6 +161,14 @@ class IngestRepositoryTest { uploaded = uploaded, ) + private fun ctrl(sig: String, queuedAt: Long) = OutboundControlEntity( + signatureHex = sig, + frameType = 3, + frame = ByteArray(118) { 0xAB.toByte() }, + uploaded = false, + queuedAt = queuedAt, + ) + // ── Tests ──────────────────────────────────────────────────────────────── @Test fun `uploads all pending across batches and marks them`() = runBlocking { @@ -161,4 +243,69 @@ class IngestRepositoryTest { assertEquals(1, summary.rejected) assertTrue(dao.rows.first().uploaded) // marked despite rejection — not retried forever } + + @Test fun `uploads queued self-cancel control frames and marks them`() = runBlocking { + val dao = FakeDao(emptyList()) + dao.controlRows += listOf(ctrl("aa", 1), ctrl("bb", 2)) + val api = FakeApi(IngestResult(ok = true, ingested = 0, controlAccepted = 2)) + val repo = IngestRepository(dao, api, batchSize = 50) + + val summary = repo.uploadPending() + + assertEquals(IngestRepository.Outcome.SUCCESS, summary.outcome) + assertEquals(2, summary.controlFramesSent) + assertEquals(1, summary.batches) + assertEquals(0, summary.framesSent) // no SOS frames in this run + assertEquals(listOf(2), api.controlSentSizes) + assertTrue(dao.controlRows.all { it.uploaded }) + assertEquals(0, dao.countUploadableControl()) + } + + @Test fun `surfaces backend-minted control frames for re-injection`() = runBlocking { + val dao = FakeDao(listOf(entity(1L))) + val api = FakeApi( + IngestResult(ok = true, ingested = 1, controlFrames = listOf("Zm9v", "YmFy")), + ) + val repo = IngestRepository(dao, api, batchSize = 50) + + val summary = repo.uploadPending() + + assertEquals(IngestRepository.Outcome.SUCCESS, summary.outcome) + assertEquals(listOf("Zm9v", "YmFy"), summary.mintedControlFrames) + } + + @Test fun `control-only body still accepted and not treated as empty`() = runBlocking { + val dao = FakeDao(emptyList()) + dao.controlRows += listOf(ctrl("cc", 1)) + val api = FakeApi(IngestResult(ok = true, ingested = 0, controlAccepted = 1)) + val repo = IngestRepository(dao, api, batchSize = 10) + + val summary = repo.uploadPending() + + // No SOS frames, one control frame → exactly one POST carrying controlFrames only. + assertEquals(IngestRepository.Outcome.SUCCESS, summary.outcome) + assertEquals(1, api.calls) + assertEquals(listOf(0), api.sentSizes) + assertEquals(listOf(1), api.controlSentSizes) + } + + @Test + fun `drains found queue and marks uploaded`() = runBlocking { + // Arrange: one pending found frame, no SOS/control work. + val dao = FakeDao(emptyList()) + dao.outboundFound.add( + OutboundFoundEntity( + signatureHex = "ab".repeat(64), + frame = ByteArray(118) { 1 }, + queuedAt = 1L, + ) + ) + val api = FakeApi() + val repo = IngestRepository(dao, api, batchSize = 50) + + val summary = repo.uploadPending() + assertEquals(IngestRepository.Outcome.SUCCESS, summary.outcome) + assertEquals(1, summary.foundFramesSent) + assertEquals(0, dao.countUploadableFound()) + } } diff --git a/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt b/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt index 3b4b6fc..544ddfe 100644 --- a/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt +++ b/android/app/src/test/kotlin/net/guacamaya/mesh/FloodRouterTest.kt @@ -6,16 +6,23 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import net.guacamaya.ble.ControlRelay +import net.guacamaya.ble.Observer import net.guacamaya.crypto.Signer +import net.guacamaya.proto.ControlFrame +import net.guacamaya.proto.ControlPayload +import net.guacamaya.proto.ControlType import net.guacamaya.proto.Crc16 import net.guacamaya.proto.Flags import net.guacamaya.proto.Payload import net.guacamaya.proto.SosType +import net.guacamaya.proto.buildControlFrame import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -235,6 +242,27 @@ class FloodRouterTest { assertEquals(1, dao.inserted.size) } + @Test fun `only an origin TTL frame becomes a neighbour observation`() = runTest(UnconfinedTestDispatcher()) { + val observations = mutableListOf() + val recorder = NeighborObservationSink { observations += it } + val (p22, pub, sig) = sign(buildPayload(hopTtl = 5)) + val router = FloodRouter( + dao = dao, + observationRecorder = recorder, + scope = backgroundScope, + broadcaster = null, + now = { nowMs }, + ) + + router.onFrame(p22, pub, sig, ttl = 4, rssi = -48) // authentic relay, not origin + router.onFrame(p22, pub, sig, ttl = 5, rssi = -72) // direct origin + advanceUntilIdle() + + assertEquals(1, observations.size) + assertEquals(-72, observations.single().rssi) + assertTrue(observations.single().nodeId.contentEquals(nodeId)) + } + // ── multi-frame ─────────────────────────────────────────────────────────── @Test fun `distinct messages from same node are each persisted`() = runTest(UnconfinedTestDispatcher()) { @@ -247,6 +275,156 @@ class FloodRouterTest { assertEquals(5, dao.inserted.size) } + // ── control frames (Phase 4) ────────────────────────────────────────────── + + /** Victim node id under test = SHA-256(this keypair)[0..3]. */ + private val victimNodeIdHex: String get() = ControlFrame.toHex(ControlFrame.nodeIdOf(pub32)) + + /** Prepend the leading mutable hop-TTL byte to a 118 B upload frame → 119 B on-wire. */ + private fun controlOnWire(frame118: ByteArray, ttl: Int = 15): ByteArray = + ByteArray(ControlFrame.ON_WIRE_LEN).also { + it[0] = ttl.toByte() + System.arraycopy(frame118, 0, it, 1, ControlFrame.FRAME_LEN) + } + + /** Self-cancel (0x03) signed by the victim itself → issuer node == target node. */ + private fun selfCancelFrame(ctrlMsgId: Int = 1, targetMsgId: Int = 42): ByteArray { + val payload = ControlPayload( + type = ControlType.SELF_CANCEL, + targetNodeId = ControlFrame.nodeIdOf(pub32), + targetMsgId = targetMsgId, + issuedAt = nowSec, + expiresAt = nowSec + 3_600, + ctrlMsgId = ctrlMsgId, + ) + return buildControlFrame(payload, priv32) + } + + /** Delivery (0x01) or backend-resolve (0x02) signed by the pinned backend key. */ + private fun backendFrame( + type: ControlType, + backendPriv: ByteArray, + ctrlMsgId: Int = 1, + targetMsgId: Int = 42, + ): ByteArray { + val payload = ControlPayload( + type = type, + targetNodeId = ControlFrame.nodeIdOf(pub32), + targetMsgId = targetMsgId, + issuedAt = nowSec, + expiresAt = nowSec + 3_600, + ctrlMsgId = ctrlMsgId, + ) + return buildControlFrame(payload, backendPriv) + } + + private fun radio(rssi: Int = -70) = Observer.RadioMetadata(rssi, null, 0, 0) + + @Test fun `duplicate control frame is acted on once and relayed once`() = + runTest(UnconfinedTestDispatcher()) { + val relays = mutableListOf() + val relay = ControlRelay { _, _, _, ttl -> relays.add(ttl) } + val router = FloodRouter( + dao = dao, + scope = backgroundScope, + controlBroadcaster = relay, + now = { nowMs }, + ) + val onWire = controlOnWire(selfCancelFrame()) + router.onControlFrame(onWire, radio()) // first — acted on + relayed + router.onControlFrame(onWire, radio(-65)) // same signature — IGNOREd + advanceUntilIdle() + + assertEquals(1, dao.countControlReplay()) // persistent gate deduped the 2nd + assertEquals(1, relays.size) // replay never re-relays + assertEquals(14, relays.single()) // ttl-1 + val trust = dao.getControlTrust(victimNodeIdHex, 42) + assertNotNull(trust) + assertTrue(trust!!.terminal) // self-cancel is terminal + } + + @Test fun `delivery does not overwrite a terminal resolve`() = + runTest(UnconfinedTestDispatcher()) { + val (bePriv, _) = generateKeyPair() + val bePubHex = ControlFrame.toHex(Signer.derivePublicKey(bePriv)) + val router = FloodRouter( + dao = dao, + scope = backgroundScope, + backendPublicKeyProvider = { bePubHex }, + now = { nowMs }, + ) + router.onControlFrame(controlOnWire(backendFrame(ControlType.BACKEND_RESOLVE, bePriv)), radio()) + advanceUntilIdle() + // Distinct ctrlMsgId → fresh signature (not a replay), same target SOS. + router.onControlFrame(controlOnWire(backendFrame(ControlType.DELIVERY, bePriv, ctrlMsgId = 2)), radio()) + advanceUntilIdle() + + val trust = dao.getControlTrust(victimNodeIdHex, 42)!! + assertTrue(trust.terminal) // sticky terminal + assertEquals(ControlType.BACKEND_RESOLVE.code, trust.resolveType) // not clobbered by delivery + } + + @Test fun `control frame at hop edge is acted on but not relayed`() = + runTest(UnconfinedTestDispatcher()) { + val relays = mutableListOf() + val relay = ControlRelay { _, _, _, _ -> relays.add(0) } + val router = FloodRouter( + dao = dao, + scope = backgroundScope, + controlBroadcaster = relay, + now = { nowMs }, + ) + router.onControlFrame(controlOnWire(selfCancelFrame(), ttl = 1), radio()) // nextTtl = 0 + advanceUntilIdle() + + assertEquals(1, dao.countControlReplay()) // still verified + persisted + assertEquals(0, relays.size) // TTL exhausted → no relay + } + + @Test fun `self-cancel from a non-target key is rejected and not acted on`() = + runTest(UnconfinedTestDispatcher()) { + val (otherPriv, _) = generateKeyPair() + val payload = ControlPayload( + type = ControlType.SELF_CANCEL, + targetNodeId = ControlFrame.nodeIdOf(pub32), // victim + targetMsgId = 42, + issuedAt = nowSec, + expiresAt = nowSec + 3_600, + ctrlMsgId = 1, + ) + val frame = buildControlFrame(payload, otherPriv) // signed by a non-victim key + val router = FloodRouter(dao = dao, scope = backgroundScope, now = { nowMs }) + router.onControlFrame(controlOnWire(frame), radio()) + advanceUntilIdle() + + assertEquals(0, dao.countControlReplay()) // authz (self-binding) failed → drop + assertEquals(0, dao.countControlTrust()) + } + + @Test fun `delivery without a pinned backend key is rejected`() = + runTest(UnconfinedTestDispatcher()) { + val (bePriv, _) = generateKeyPair() + val router = FloodRouter( + dao = dao, + scope = backgroundScope, + backendPublicKeyProvider = { null }, // nothing pinned + now = { nowMs }, + ) + router.onControlFrame(controlOnWire(backendFrame(ControlType.DELIVERY, bePriv)), radio()) + advanceUntilIdle() + + assertEquals(0, dao.countControlReplay()) // backend-pin authz failed → drop + } + + @Test fun `malformed control envelope is dropped`() = + runTest(UnconfinedTestDispatcher()) { + val router = FloodRouter(dao = dao, scope = backgroundScope, now = { nowMs }) + router.onControlFrame(ByteArray(50), radio()) // neither 118 nor 119 → size reject + advanceUntilIdle() + + assertEquals(0, dao.countControlReplay()) + } + // ── fake DAO ────────────────────────────────────────────────────────────── private class FakeMessageDao : MessageDao { @@ -272,5 +450,60 @@ class FloodRouterTest { override suspend fun selectUploadable(limit: Int): List = throw NotImplementedError() override suspend fun markUploaded(ids: List) = throw NotImplementedError() override suspend fun countUploadable(): Int = throw NotImplementedError() + override suspend fun insertNeighborObservation(entity: NeighborObservationEntity): Long = throw NotImplementedError() + override suspend fun pruneNeighborObservations(nodeId: String, keep: Int) = throw NotImplementedError() + override suspend fun upsertNeighborEstimate(entity: NeighborEstimateEntity) = throw NotImplementedError() + override fun observeNeighborEstimates(limit: Int): Flow> = throw NotImplementedError() + override suspend fun refreshNeighborStates(now: Long, degradedAfter: Long, lostAfter: Long) = throw NotImplementedError() + + // ── Control frames: in-memory replay guard + trust state. Reused by the + // ControlRouter tests (Phase 4). Mirrors Room semantics: IGNORE→-1 on replay, + // REPLACE for trust upsert. ─────────────────────────────────────────────── + private val controlReplay = linkedMapOf() + private val controlTrust = mutableMapOf, ControlTrustEntity>() + + override suspend fun insertControlReplay(entity: ControlReplayEntity): Long = + if (entity.signatureHex in controlReplay) -1L + else { controlReplay[entity.signatureHex] = entity; controlReplay.size.toLong() } + + override suspend fun hasControlReplay(signatureHex: String): Boolean = + signatureHex in controlReplay + + override suspend fun pruneControlReplay(now: Long): Int { + val stale = controlReplay.values.filter { it.expiresAt < now } + stale.forEach { controlReplay.remove(it.signatureHex) } + return stale.size + } + + override suspend fun countControlReplay(): Int = controlReplay.size + + override suspend fun upsertControlTrust(entity: ControlTrustEntity) { + controlTrust[entity.nodeId to entity.msgId] = entity + } + + override suspend fun getControlTrust(nodeId: String, msgId: Int): ControlTrustEntity? = + controlTrust[nodeId to msgId] + + override fun observeControlTrust(nodeId: String, msgId: Int): Flow = + flowOf(controlTrust[nodeId to msgId]) + + override suspend fun terminalControlTrusts(): List = + controlTrust.values.filter { it.terminal } + + override suspend fun countControlTrust(): Int = controlTrust.size + + override suspend fun clearControlTrust() = controlTrust.clear() + + // ── Outbound control queue: uploader-only; routing tests ignore it. ─────── + override suspend fun insertOutboundControl(entity: OutboundControlEntity): Long = 1L + override suspend fun selectUploadableControl(limit: Int): List = emptyList() + override suspend fun markControlUploaded(sigs: List) {} + override suspend fun countUploadableControl(): Int = 0 + + // ── Outbound found queue: uploader-only; routing tests ignore it. ───────── + override suspend fun insertOutboundFound(entity: OutboundFoundEntity): Long = 1L + override suspend fun selectUploadableFound(limit: Int): List = emptyList() + override suspend fun markFoundUploaded(sigs: List) {} + override suspend fun countUploadableFound(): Int = 0 } } diff --git a/android/app/src/test/kotlin/net/guacamaya/mesh/NeighborSignalTest.kt b/android/app/src/test/kotlin/net/guacamaya/mesh/NeighborSignalTest.kt new file mode 100644 index 0000000..d5e3e07 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/mesh/NeighborSignalTest.kt @@ -0,0 +1,138 @@ +package net.guacamaya.mesh + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NeighborSignalTest { + + @Test + fun medianRejectsSingleMultipathSpikeBeforeEwma() { + val filter = NeighborSignalFilter() + repeat(6) { index -> + filter.add(NeighborSignalSample(-70, null, ObservationSource.BLE_RSSI, observedAtMs = index * 200L)) + } + val estimate = filter.add(NeighborSignalSample(-35, null, ObservationSource.BLE_RSSI, observedAtMs = 1_200L)) + + assertTrue("one spike must not make the target look close", estimate.filteredRssi < -65f) + assertEquals(7, filter.rawCount()) + } + + @Test + fun uncalibratedBleDistanceCarriesBroadUncertainty() { + val estimate = NeighborSignalFilter().add( + NeighborSignalSample(-70, null, ObservationSource.BLE_RSSI, observedAtMs = 0L), + ) + + val distance = requireNotNull(estimate.estimatedDistanceMeters) + val uncertainty = requireNotNull(estimate.uncertaintyMeters) + assertTrue(!estimate.calibrated) + assertTrue(uncertainty >= distance * 0.69f) + } + + @Test + fun pathLossProfilesAreCalibratablePerPhy() { + val old = SignalDistance.profiles + try { + SignalDistance.installProfiles(PathLossProfiles(mapOf(2 to PathLossProfile(-50f, 2f, 1f)))) + val estimate = NeighborSignalFilter().add(NeighborSignalSample(-70, -50, ObservationSource.BLE_RSSI, 0L, primaryPhy = 2)) + assertEquals(10f, estimate.estimatedDistanceMeters!!, 0.01f) + assertTrue("mutable TTL evidence stays untrusted", !estimate.calibrated) + } finally { SignalDistance.installProfiles(old) } + } + + @Test + fun rttDistanceIsPreservedRatherThanRunningLogDistance() { + val estimate = NeighborSignalFilter().add( + NeighborSignalSample( + rssi = -70, + txPower = null, + source = ObservationSource.NAN_RTT, + observedAtMs = 0L, + distanceMeters = 4.2f, + distanceStdDevMeters = 0.8f, + ), + ) + + assertEquals(4.2f, estimate.estimatedDistanceMeters!!, 0.001f) + assertEquals(0.8f, estimate.uncertaintyMeters!!, 0.001f) + } + + @Test + fun livenessUsesHysteresis() { + val now = 100_000L + assertEquals(NeighborState.HEARD, NeighborState.at(now - 14_999L, now)) + assertEquals(NeighborState.DEGRADED, NeighborState.at(now - 15_000L, now)) + assertEquals(NeighborState.LOST, NeighborState.at(now - 50_000L, now)) + } + + @Test + fun temporalWindowDropsStaleSamplesBeforeFilteringANewAdvertisingEpoch() { + val filter = NeighborSignalFilter() + repeat(3) { index -> + filter.add(NeighborSignalSample(-88, null, ObservationSource.BLE_RSSI, observedAtMs = index * 200L)) + } + + val fresh = filter.add(NeighborSignalSample(-55, null, ObservationSource.BLE_RSSI, observedAtMs = 10_000L)) + + assertEquals(-55f, fresh.filteredRssi, 0.01f) + assertTrue(!fresh.stable) + } + + @Test + fun nonPhysicalRssiIsClampedToThePhysicalBand() { + val high = NeighborSignalFilter().add( + NeighborSignalSample(127, null, ObservationSource.BLE_RSSI, observedAtMs = 0L), + ) + assertEquals(RssiRange.MAX_DBM.toFloat(), high.filteredRssi, 0.001f) + + val low = NeighborSignalFilter().add( + NeighborSignalSample(-200, null, ObservationSource.BLE_RSSI, observedAtMs = 0L), + ) + assertEquals(RssiRange.MIN_DBM.toFloat(), low.filteredRssi, 0.001f) + } + + @Test + fun slewCapStopsImpossibleProximityJump() { + val filter = NeighborSignalFilter() + filter.add(NeighborSignalSample(-90, null, ObservationSource.BLE_RSSI, observedAtMs = 0L)) + filter.add(NeighborSignalSample(-90, null, ObservationSource.BLE_RSSI, observedAtMs = 200L)) + filter.add(NeighborSignalSample(-35, null, ObservationSource.BLE_RSSI, observedAtMs = 231L)) + // Two "close" samples 31 ms apart flip the median to -35. Without the slew cap the + // EWMA would climb to ~-74.6 dBm in one step; the cap holds it near the far baseline. + val estimate = filter.add(NeighborSignalSample(-35, null, ObservationSource.BLE_RSSI, observedAtMs = 262L)) + + assertTrue("filtered must not snap close on a physics-impossible jump", estimate.filteredRssi <= -88f) + } + + @Test + fun outOfOrderSampleIsRejectedAndPriorEstimateHeld() { + val filter = NeighborSignalFilter() + filter.add(NeighborSignalSample(-70, null, ObservationSource.BLE_RSSI, observedAtMs = 0L)) + filter.add(NeighborSignalSample(-70, null, ObservationSource.BLE_RSSI, observedAtMs = 200L)) + + // A sample 100 ms in the PAST (beyond jitter tolerance) must be rejected: the ring + // and the held estimate stay untouched, the regressed sample does not count. + val held = filter.add(NeighborSignalSample(-40, null, ObservationSource.BLE_RSSI, observedAtMs = 100L)) + + assertEquals(-70f, held.filteredRssi, 0.001f) + assertEquals(2, filter.rawCount()) + } + + @Test + fun outOfBandRttFallsThroughToTheLogDistanceModel() { + val estimate = NeighborSignalFilter().add( + NeighborSignalSample( + rssi = -70, + txPower = null, + source = ObservationSource.NAN_RTT, + observedAtMs = 0L, + distanceMeters = 5_000f, // beyond RTT_MAX_METERS → bogus ranging result + ), + ) + + val distance = requireNotNull(estimate.estimatedDistanceMeters) + assertTrue("a 5 km RTT must not surface as a direct distance", distance < 1_000f) + assertTrue(!estimate.calibrated) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/proto/ChatFrameTest.kt b/android/app/src/test/kotlin/net/guacamaya/proto/ChatFrameTest.kt new file mode 100644 index 0000000..df78547 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/proto/ChatFrameTest.kt @@ -0,0 +1,25 @@ +package net.guacamaya.proto + +import java.security.MessageDigest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import net.guacamaya.crypto.Signer + +class ChatFrameTest { + @Test fun `160 byte signed message fragments and verifies`() { + val seed = ByteArray(32) { it.toByte() } + val pub = Signer.derivePublicKey(seed) + val node = MessageDigest.getInstance("SHA-256").digest(pub).copyOfRange(0, 4) + val text = ByteArray(160) { ('a'.code + it % 26).toByte() } + val canonical = ChatCodec.canonical(node, 42L, 1_000L, text) + val message = ChatMessage(node, 42L, 1_000L, text, pub, Signer.signMessage(seed, canonical)) + val frames = ChatCodec.fragment(message) + assertEquals(4, frames.size) + val decoded = frames.map { assertNotNull(ChatCodec.decode(it)); ChatCodec.decode(it)!! } + assertArrayEquals(text, decoded.sortedBy { it.index }.flatMap { it.chunk.asIterable() }.toByteArray()) + assertTrue(ChatCodec.verify(message, 1_000L)) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/proto/ControlPayloadTest.kt b/android/app/src/test/kotlin/net/guacamaya/proto/ControlPayloadTest.kt new file mode 100644 index 0000000..162b464 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/proto/ControlPayloadTest.kt @@ -0,0 +1,297 @@ +package net.guacamaya.proto + +import net.guacamaya.crypto.Signer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Locks the Android control-frame codec to the backend (`packages/shared/src/mesh/ + * controlFrame.ts`). The golden vectors below are reproduced *and* re-signed here: + * - [ControlPayload.encode] must yield the canonical payload bytes (layout + CRC); + * - [buildControlFrame] must reproduce the canonical 118 B frames (sign + assemble), + * proving the BouncyCastle signature equals noble's on the same domain-prefixed + * message; + * - [verifyControlFrame] must accept the canonical frames and reject each failure + * mode of the cascade with the exact reason string the backend emits. + * + * Keys: VICTIM = 0x11*32, BACKEND = 0x22*32 — same fixtures as + * `packages/shared/src/mesh/controlFrame.test.ts` and `ingestControl.test.ts`. + */ +class ControlPayloadTest { + + private val victimPriv = ByteArray(32) { 0x11 } + private val backendPriv = ByteArray(32) { 0x22 } + private val otherPriv = ByteArray(32) { 0x33 } + + private val victimPubHex = "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737" + private val backendPubHex = "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + private val victimNodeIdHex = "10ba682c" + private val victimNodeId = ControlFrame.fromHex(victimNodeIdHex) + + private val issuedAt = 1_800_000_000L + private val nowMs = 1_800_000_500_000L + + // ── Canonical golden payloads (layout + CRC) ───────────────────────────────── + private val goldenSelfCancelPayloadHex = "0310ba682c12346b49d2006b49e010beef0000006621" + private val goldenBackendResolvePayloadHex = "0210ba682c12346b49d2006b4b238000420000008e61" + private val goldenDeliveryPayloadHex = "0110ba682c12346b49d2006b530c807a5c000000f510" + + // ── Canonical golden frames (payload + pubkey + sig) ───────────────────────── + private val goldenSelfCancelFrameHex = + goldenSelfCancelPayloadHex + victimPubHex + + "e307f2b2fa94ef603e1fce5729a8460ecfbefa2bd4f32b7bc60cdcdf842eda04" + + "feb45b34df0679f04aa0b13b6d8d4f5b4e2c65bab1b910c1120725b8b3663f0f" + private val goldenBackendResolveFrameHex = + goldenBackendResolvePayloadHex + backendPubHex + + "7477a73067adaab04fc091f8712d8c6091ab469399cbf6977f938c3f5a3d4898" + + "1c78b446f48a332365fdb4b296c2f955d6bfbc1148c8792115c5bf1f2ed7db01" + private val goldenDeliveryFrameHex = + goldenDeliveryPayloadHex + backendPubHex + + "afdb6fc106e3d2d0692112d8368fd5bebcbd17a8555833c124889e5be2156d46" + + "a35946206073e6ba60c239677c393f178c3de73eff96d6c3dc21cd636cdf310b" + + private fun selfCancel() = ControlPayload( + ControlType.SELF_CANCEL, victimNodeId, 0x1234, + issuedAt, issuedAt + 3_600L, 0xbeef, + ) + + private fun backendResolve() = ControlPayload( + ControlType.BACKEND_RESOLVE, victimNodeId, 0x1234, + issuedAt, issuedAt + 86_400L, 0x0042, + ) + + private fun delivery() = ControlPayload( + ControlType.DELIVERY, victimNodeId, 0x1234, + issuedAt, issuedAt + 604_800L, 0x7a5c, + ) + + // ── Key derivation parity with the backend (noble) ─────────────────────────── + + @Test fun `victim pubkey matches backend fixture`() { + assertEquals(victimPubHex, ControlFrame.toHex(Signer.derivePublicKey(victimPriv))) + } + + @Test fun `backend pubkey matches backend fixture`() { + assertEquals(backendPubHex, ControlFrame.toHex(Signer.derivePublicKey(backendPriv))) + } + + @Test fun `node id is sha256 prefix of pubkey`() { + assertEquals(victimNodeIdHex, ControlFrame.toHex(ControlFrame.nodeIdOf(ControlFrame.fromHex(victimPubHex)))) + } + + // ── encode: layout + CRC are byte-identical with the backend ───────────────── + + @Test fun `encode self-cancel matches golden payload`() { + assertEquals(goldenSelfCancelPayloadHex, ControlFrame.toHex(selfCancel().encode())) + } + + @Test fun `encode backend-resolve matches golden payload`() { + assertEquals(goldenBackendResolvePayloadHex, ControlFrame.toHex(backendResolve().encode())) + } + + @Test fun `encode delivery matches golden payload`() { + assertEquals(goldenDeliveryPayloadHex, ControlFrame.toHex(delivery().encode())) + } + + // ── buildControlFrame: sign + assemble reproduce the canonical frames ──────── + + @Test fun `build self-cancel frame matches golden`() { + assertEquals(goldenSelfCancelFrameHex, ControlFrame.toHex(buildControlFrame(selfCancel(), victimPriv))) + } + + @Test fun `build backend-resolve frame matches golden`() { + assertEquals(goldenBackendResolveFrameHex, ControlFrame.toHex(buildControlFrame(backendResolve(), backendPriv))) + } + + @Test fun `build delivery frame matches golden`() { + assertEquals(goldenDeliveryFrameHex, ControlFrame.toHex(buildControlFrame(delivery(), backendPriv))) + } + + // ── verifyControlFrame: accept the canonical frames ────────────────────────── + + @Test fun `verify accepts golden self-cancel without backend pin`() { + val r = verifyControlFrame(ControlFrame.fromHex(goldenSelfCancelFrameHex), nowMs, null) + assertTrue(r is ControlFrameResult.Ok) + val c = (r as ControlFrameResult.Ok).control + assertEquals(ControlType.SELF_CANCEL, c.type) + assertEquals(victimNodeIdHex, c.targetNodeIdHex) + assertEquals(0x1234, c.targetMsgId) + assertEquals(0xbeef, c.ctrlMsgId) + assertEquals(victimNodeIdHex, c.issuerNodeIdHex) // self-bound + } + + @Test fun `verify accepts golden backend-resolve with pinned backend`() { + val r = verifyControlFrame(ControlFrame.fromHex(goldenBackendResolveFrameHex), nowMs, backendPubHex) + assertTrue(r is ControlFrameResult.Ok) + assertEquals(ControlType.BACKEND_RESOLVE, (r as ControlFrameResult.Ok).control.type) + assertEquals(backendPubHex, r.control.issuerPublicKeyHex) + } + + @Test fun `verify accepts golden delivery with pinned backend`() { + val r = verifyControlFrame(ControlFrame.fromHex(goldenDeliveryFrameHex), nowMs, backendPubHex) + assertTrue(r is ControlFrameResult.Ok) + assertEquals(ControlType.DELIVERY, (r as ControlFrameResult.Ok).control.type) + } + + @Test fun `verify strips leading hop ttl from 119-byte wire frame`() { + val wire = ByteArray(ControlFrame.ON_WIRE_LEN) + val frame = ControlFrame.fromHex(goldenSelfCancelFrameHex) + wire[0] = 7 // hop TTL + System.arraycopy(frame, 0, wire, 1, frame.size) + val r = verifyControlFrame(wire, nowMs, null) + assertTrue(r is ControlFrameResult.Ok) + } + + // ── round trip ─────────────────────────────────────────────────────────────── + + @Test fun `decode round-trips encode for every type`() { + for (p in listOf(selfCancel(), backendResolve(), delivery())) { + val back = ControlPayload.decode(p.encode()) + assertEquals(p.type, back.type) + assertEquals(p.targetMsgId, back.targetMsgId) + assertEquals(p.issuedAt, back.issuedAt) + assertEquals(p.expiresAt, back.expiresAt) + assertEquals(p.ctrlMsgId, back.ctrlMsgId) + } + } + + // ── reject cascade (cheapest first) ────────────────────────────────────────── + + @Test fun `reject wrong length`() { + val r = verifyControlFrame(ByteArray(50), nowMs, null) + assertEquals("bad control frame length 50 (need 118)", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject unknown type before crc`() { + val bytes = ControlFrame.fromHex(goldenSelfCancelFrameHex) + bytes[0] = 0x09 // corrupts CRC too, but type check fires first + val r = verifyControlFrame(bytes, nowMs, null) + assertEquals("unknown control type 0x9", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject non-zero reserved bytes before crc`() { + val bytes = ControlFrame.fromHex(goldenSelfCancelFrameHex) + bytes[17] = 0x01 // covered by CRC, but reserved check fires first + val r = verifyControlFrame(bytes, nowMs, null) + assertEquals("reserved bytes not zero", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject crc mismatch`() { + val bytes = ControlFrame.fromHex(goldenSelfCancelFrameHex) + bytes[5] = (bytes[5].toInt() xor 0xFF).toByte() // targetMsgId hi byte + val r = verifyControlFrame(bytes, nowMs, null) + assertEquals("crc mismatch", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject expired frame`() { + val expired = ControlPayload( + ControlType.SELF_CANCEL, victimNodeId, 0x1234, + (nowMs / 1000) - 7_200L, (nowMs / 1000) - 3_600L, 0xbeef, + ) + val r = verifyControlFrame(buildControlFrame(expired, victimPriv), nowMs, null) + assertEquals("control frame expired", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject lifetime exceeding per-type maximum`() { + // Hand-crafted (encode refuses this): self-cancel lifetime 100_000s > 86_400 max. + val crafted = craftFrame(0x03, victimNodeId, 0x1234, issuedAt, issuedAt + 100_000L, 0xbeef) + val r = verifyControlFrame(crafted, nowMs, null) + assertEquals("lifetime exceeds per-type maximum", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject expiresAt not after issuedAt`() { + val crafted = craftFrame(0x03, victimNodeId, 0x1234, issuedAt, issuedAt, 0xbeef) + val r = verifyControlFrame(crafted, nowMs, null) + assertEquals("expiresAt not after issuedAt", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject bad signature`() { + val bytes = ControlFrame.fromHex(goldenSelfCancelFrameHex) + bytes[60] = (bytes[60].toInt() xor 0x01).toByte() // flip one sig byte + val r = verifyControlFrame(bytes, nowMs, null) + assertEquals("signature invalid", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject delivery without pinned backend key`() { + val r = verifyControlFrame(ControlFrame.fromHex(goldenDeliveryFrameHex), nowMs, null) + assertEquals("backend key not pinned", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject delivery signed by non-backend issuer`() { + val forged = buildControlFrame(delivery(), victimPriv) // valid sig, wrong issuer + val r = verifyControlFrame(forged, nowMs, backendPubHex) + assertEquals("issuer is not the pinned backend", (r as ControlFrameResult.Err).reason) + } + + @Test fun `reject self-cancel not signed by the target`() { + val otherNodeId = ControlFrame.nodeIdOf(Signer.derivePublicKey(otherPriv)) + val misbound = ControlPayload( + ControlType.SELF_CANCEL, otherNodeId, 0x1234, issuedAt, issuedAt + 3_600L, 0xbeef, + ) + val r = verifyControlFrame(buildControlFrame(misbound, victimPriv), nowMs, null) + assertEquals("self-cancel issuer is not the target", (r as ControlFrameResult.Err).reason) + } + + // ── encode validation throws ───────────────────────────────────────────────── + + @Test fun `encode rejects targetNodeId of wrong size`() { + assertThrows(IllegalArgumentException::class.java) { + ControlPayload(ControlType.DELIVERY, ByteArray(3), 1, issuedAt, issuedAt + 60, 1) + } + } + + @Test fun `encode rejects lifetime exceeding maximum`() { + assertThrows(IllegalArgumentException::class.java) { + ControlPayload(ControlType.SELF_CANCEL, victimNodeId, 1, issuedAt, issuedAt + 100_000L, 1) + } + } + + @Test fun `encode rejects expiresAt not after issuedAt`() { + assertThrows(IllegalArgumentException::class.java) { + ControlPayload(ControlType.DELIVERY, victimNodeId, 1, issuedAt, issuedAt, 1) + } + } + + @Test fun `encode rejects out-of-range u16`() { + assertThrows(IllegalArgumentException::class.java) { + ControlPayload(ControlType.DELIVERY, victimNodeId, 0x10000, issuedAt, issuedAt + 60, 1) + } + } + + // craft helpers bypass [ControlPayload] validation to build malicious wire bytes + // for the defense-in-depth reject cases (lifetime / ordering) that a legit encode + // can never produce. Rejects fire before the signature check, so pubkey+sig are dummy. + private fun craftFrame( + type: Int, targetNodeId: ByteArray, msgId: Int, + issuedAt: Long, expiresAt: Long, ctrlMsgId: Int, + ): ByteArray { + require(targetNodeId.size == 4) + val payload = ByteArray(ControlFrame.PAYLOAD_LEN) + payload[0] = type.toByte() + System.arraycopy(targetNodeId, 0, payload, 1, 4) + putU16(payload, 5, msgId) + putU32(payload, 7, issuedAt) + putU32(payload, 11, expiresAt) + putU16(payload, 15, ctrlMsgId) + // 17..19 stay zero + val crc = Crc16.ccitt(payload, 0, 20) and 0xFFFF + putU16(payload, 20, crc) + val frame = ByteArray(ControlFrame.FRAME_LEN) + System.arraycopy(payload, 0, frame, 0, ControlFrame.PAYLOAD_LEN) + return frame + } + + private fun putU16(b: ByteArray, off: Int, v: Int) { + b[off] = ((v ushr 8) and 0xFF).toByte() + b[off + 1] = (v and 0xFF).toByte() + } + + private fun putU32(b: ByteArray, off: Int, v: Long) { + b[off] = ((v ushr 24) and 0xFF).toByte() + b[off + 1] = ((v ushr 16) and 0xFF).toByte() + b[off + 2] = ((v ushr 8) and 0xFF).toByte() + b[off + 3] = (v and 0xFF).toByte() + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt b/android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt new file mode 100644 index 0000000..ad718d2 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt @@ -0,0 +1,63 @@ +package net.guacamaya.proto + +import net.guacamaya.crypto.Signer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class FoundPayloadTest { + + private val targetNodeId = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte()) + + private fun payload() = FoundPayload( + latE7 = 104_003_000, + lonE7 = -669_000_000, + tsUnix = 1_750_000_000L, + targetNodeId = targetNodeId, + targetMsgId = 4242, + foundMsgId = 777, + ) + + @Test + fun encodeUsesDocumentedBigEndianOffsets() { + val bytes = payload().encode() + assertEquals(FoundFrame.PAYLOAD_LEN, bytes.size) + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + assertEquals(104_003_000, buf.getInt(0)) + assertEquals(-669_000_000, buf.getInt(4)) + assertEquals(1_750_000_000L, buf.getInt(8).toLong() and 0xFFFFFFFFL) + assertArrayEquals(targetNodeId, bytes.copyOfRange(12, 16)) + assertEquals(4242, buf.getShort(16).toInt() and 0xFFFF) + assertEquals(777, buf.getShort(18).toInt() and 0xFFFF) + val crcStored = buf.getShort(20).toInt() and 0xFFFF + assertEquals(Crc16.ccitt(bytes, 0, 20) and 0xFFFF, crcStored) + } + + @Test + fun decodeRoundTrips() { + val decoded = FoundPayload.decode(payload().encode()) + assertEquals(payload(), decoded) + } + + @Test + fun buildFoundFrameSignsWithTheFoundDomain() { + val sk = ByteArray(32) { 7 } + val frame = buildFoundFrame(payload(), sk) + assertEquals(FoundFrame.FRAME_LEN, frame.size) + val payloadBytes = frame.copyOfRange(FoundFrame.PAYLOAD_OFF, FoundFrame.PAYLOAD_OFF + FoundFrame.PAYLOAD_LEN) + val pubkey = frame.copyOfRange(FoundFrame.PUBKEY_OFF, FoundFrame.PUBKEY_OFF + FoundFrame.PUBKEY_LEN) + val sig = frame.copyOfRange(FoundFrame.SIG_OFF, FoundFrame.SIG_OFF + FoundFrame.SIG_LEN) + assertArrayEquals(Signer.derivePublicKey(sk), pubkey) + // Signature covers the domain-prefixed bytes, not the bare payload. + assertTrue(Signer.verifyMessage(pubkey, FoundFrame.signBytes(payloadBytes), sig)) + assertTrue(!Signer.verifyMessage(pubkey, payloadBytes, sig)) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsShortTargetNodeId() { + FoundPayload(0, 0, 0L, ByteArray(3), 1, 1) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/ui/BodyBearingSweepTest.kt b/android/app/src/test/kotlin/net/guacamaya/ui/BodyBearingSweepTest.kt new file mode 100644 index 0000000..4425117 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/ui/BodyBearingSweepTest.kt @@ -0,0 +1,63 @@ +package net.guacamaya.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BodyBearingSweepTest { + + @Test + fun onlyReportsBearingAfterHeadingCoverageAndSignalContrast() { + val sweep = BodyBearingSweep() + listOf( + 0f to -75f, + 45f to -72f, + 90f to -58f, + 135f to -70f, + 180f to -76f, + 225f to -74f, + 270f to -73f, + 315f to -75f, + ).forEachIndexed { index, (heading, rssi) -> + sweep.record(heading, rssi, index * 500L) + } + + val result = requireNotNull(sweep.result()) + assertTrue(result.confident) + assertEquals(90f, result.bearing, 0.01f) + } + + @Test + fun stableSignalDoesNotPretendToHaveABearing() { + val sweep = BodyBearingSweep() + listOf(0f, 45f, 90f, 135f, 180f, 225f, 270f, 315f).forEachIndexed { index, heading -> + sweep.record(heading, -70f, index * 500L) + } + + assertFalse(requireNotNull(sweep.result()).confident) + } + + @Test + fun outOfBandRssiIsClampedBeforeItEntersTheWindow() { + val sweep = BodyBearingSweep() + // A +Inf sentinel (the shape a malformed scan / NaN arithmetic can surface) must + // be clamped, not stored raw: otherwise max = +Inf and the span/bearing go non-finite. + listOf( + 0f to -75f, + 45f to Float.POSITIVE_INFINITY, + 90f to -76f, + 135f to -74f, + 180f to -73f, + 225f to -75f, + 270f to -72f, + 315f to -74f, + ).forEachIndexed { index, (heading, rssi) -> + sweep.record(heading, rssi, index * 500L) + } + + val result = requireNotNull(sweep.result()) + assertTrue("clamped span must stay finite", result.rssiSpanDb.isFinite()) + assertTrue("clamped span must stay within the physical band", result.rssiSpanDb <= 120f) + } +} diff --git a/android/app/src/test/kotlin/net/guacamaya/ui/SignalRadarTest.kt b/android/app/src/test/kotlin/net/guacamaya/ui/SignalRadarTest.kt new file mode 100644 index 0000000..fb1f354 --- /dev/null +++ b/android/app/src/test/kotlin/net/guacamaya/ui/SignalRadarTest.kt @@ -0,0 +1,64 @@ +package net.guacamaya.ui + +import net.guacamaya.mesh.NeighborEstimateEntity +import net.guacamaya.mesh.NeighborState +import org.junit.Assert.assertEquals +import org.junit.Test + +class SignalRadarTest { + + @Test + fun closeRssiWithFarSignedGpsIsMarkedInconsistent() { + val signal = target(distanceMeters = 25f, uncertaintyMeters = 15f) + val gps = gps(distanceMeters = 4_000f, uncertaintyMeters = 20f) + + assertEquals(SignalRadar.GpsConsistency.INCONSISTENT, SignalRadar.gpsConsistency(gps, signal)) + } + + @Test + fun overlappingGpsAndRssiBandsRemainUsable() { + val signal = target(distanceMeters = 25f, uncertaintyMeters = 15f) + val gps = gps(distanceMeters = 42f, uncertaintyMeters = 12f) + + assertEquals(SignalRadar.GpsConsistency.CONSISTENT, SignalRadar.gpsConsistency(gps, signal)) + } + + @Test + fun staleOrPoorGpsWithWideUncertaintyDoesNotSuppressLegitimateNearSignal() { + val signal = target(distanceMeters = 20f, uncertaintyMeters = 12f) + val gps = gps(distanceMeters = 80f, uncertaintyMeters = 80f) + + assertEquals(SignalRadar.GpsConsistency.CONSISTENT, SignalRadar.gpsConsistency(gps, signal)) + } + + private fun target(distanceMeters: Float, uncertaintyMeters: Float) = SignalRadar.Target( + nodeId = "aabbccdd", + estimate = NeighborEstimateEntity( + nodeId = "aabbccdd", + updatedAt = 1L, + lastSeenAt = 1L, + filteredRssi = -62f, + rssiVariance = 4f, + distanceMeters = distanceMeters, + uncertaintyMeters = uncertaintyMeters, + source = 0, + txPower = null, + calibrated = false, + stable = true, + sampleCount = 8, + state = NeighborState.HEARD.code, + ), + critical = true, + state = NeighborState.HEARD, + ) + + private fun gps(distanceMeters: Float, uncertaintyMeters: Float) = GeoProximity.Result( + nodeId = "aabbccdd", + distanceMeters = distanceMeters, + bearing = 0f, + rssi = -70, + critical = true, + coLocated = false, + uncertaintyMeters = uncertaintyMeters, + ) +} diff --git a/app/expo-env.d.ts b/app/expo-env.d.ts new file mode 100644 index 0000000..5411fdd --- /dev/null +++ b/app/expo-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be in your git ignore \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index ce539d3..73f2d88 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,8 @@ NODE_ENV=development # Ed25519 identity for signing official channel records (stable across restarts). BACKEND_PRIVATE_KEY_HEX= BACKEND_PUBLIC_KEY_HEX= +# Required in production. Store BACKEND_PRIVATE_KEY_HEX in managed secret storage; +# never rotate it by restart. App releases pin the matching public key. # Security — required in production (NODE_ENV=production). GUACAMAYA_ADMIN_KEY= # POST /channels/:id/records (official alerts) @@ -18,6 +20,12 @@ MAX_INGEST_BATCH=200 MAX_FRAME_B64_LENGTH=256 MAX_OFFICIAL_PAYLOAD_BYTES=16384 +# Field-rescuer renewal through /ingest. Comma-separated Ed25519 public keys only. +RESCUER_PUBLIC_KEYS= +RESCUER_CERTIFICATE_VALIDITY_H=168 # 7 days; distribute CRL through mules +REVOKED_RESCUER_PUBLIC_KEYS= # comma-separated Ed25519 public keys +RESCUER_CRL_SEQUENCE=0 # increment whenever the revoked set changes + # Supabase (optional — in-memory fallback when unset) SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY= @@ -34,10 +42,18 @@ RESOLVE_QUORUM_REQUIRED=2 # M-of-N witnesses required to clear RESOLVE_QUORUM_TOTAL=3 # max witnesses accepted per target RESOLVE_GEO_RADIUS_KM=5 # max distance (haversine) from witness to target SOS RESOLVE_TARGET_MAX_AGE_H=72 # reject resolves against SOS older than this +RESOLVE_WITNESS_MAX_AGE_MS=600000 # reject stale witness observations/envelopes (10 min) +RESOLVE_CLOCK_SKEW_MS=120000 # tolerated future clock skew (2 min) RESOLVE_COOLDOWN_MIN=15 # pending-clear window before promotion to cleared RESOLVE_PER_WITNESS_PER_H=5 # leaky-bucket cap per witness deviceId RESOLVE_EVIDENCE_TTL_MS=300000 # upload-token validity (5 min) RESOLVE_MAX_IMAGE_BYTES=8388608 # 8 MiB image cap RESOLVE_EVIDENCE_REQUIRE_AUTH=false # set true in prod to require read key on /resolve/evidence +GUACAMAYA_LEGACY_RESOLVE_ENDPOINT=0 # dev-only; production always disables terminal quorum /resolve EVIDENCE_DIR=.evidence # dev-only image storage path (relative to backend CWD) SUPABASE_EVIDENCE_BUCKET=resolve-evidence # prod-only Supabase Storage bucket + +# "Encontrado" (found) reports — proximity gate in meters. A finder within this +# distance of the SOS's last reported point is "near / likely_found"; further is +# "far / possible_false_positive". +FOUND_PROXIMITY_METERS=100 diff --git a/backend/package.json b/backend/package.json index 3dded8e..937e44a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,15 +7,15 @@ "start": "bun src/index.ts" }, "dependencies": { - "@fastify/cors": "^9.0.0", - "@fastify/helmet": "^11.1.1", - "@fastify/rate-limit": "^9.0.0", - "@fastify/static": "^7.0.0", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^13.1.0", + "@fastify/rate-limit": "^11.1.0", + "@fastify/static": "^9.3.0", "@guacamaya/shared": "workspace:*", "@noble/ed25519": "^2.1.0", "@noble/hashes": "^2.2.0", "@supabase/supabase-js": "^2.108.0", - "fastify": "^4.27.0", + "fastify": "^5.10.0", "resend": "^4.0.0", "ws": "^8.17.0" }, diff --git a/backend/src/channels/routes.ts b/backend/src/channels/routes.ts index 16ff4ff..746d039 100644 --- a/backend/src/channels/routes.ts +++ b/backend/src/channels/routes.ts @@ -1,12 +1,42 @@ import { FastifyInstance } from "fastify"; -import { ChannelId, ChannelRecord, LocationPoint } from "@guacamaya/shared"; +import { + ChannelId, + ChannelRecord, + LocationPoint, + issueRescuerCertificate, + issueCertificateRevocationList, + signControlReceipt, + type ControlReceipt, + type LedgerEvent, + type UnsignedLedgerEvent, + type SosCreatedPayload, + type ChannelRecordProof, + SOS_CHANNEL, + deriveSosId, + computeEventId, + computePayloadHash, + MESH_PAYLOAD_OFF, + MESH_PAYLOAD_LEN, + MESH_PUBKEY_OFF, + MESH_PUBKEY_LEN, + MESH_SIG_OFF, + MESH_SIG_LEN, +} from "@guacamaya/shared"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { ledgerRepo } from "../ledger/ledgerRepo.js"; import { channelsRepo } from "../db/channelsRepo.js"; import { locationsRepo } from "../db/locationsRepo.js"; import { resolvesRepo } from "../db/resolvesRepo.js"; import { signRecord } from "../crypto/signer.js"; import { broadcastRecord, broadcastLocation, broadcastResolve } from "../ws/server.js"; -import { publicKeyHex } from "../crypto/keys.js"; +import { privateKey, publicKeyHex } from "../crypto/keys.js"; import { decodeAndVerifyFrame } from "../mesh/frame.js"; +import { processControlFrames, mintControlFrames, type SosRef } from "../control/ingestControl.js"; +import { + processFoundFrames, + type FoundDeps, + type ProcessFoundSummary, +} from "../found/processFound.js"; import { sanitizeRecordsForPublic } from "./sanitize.js"; import { requireApiKey } from "../security/auth.js"; import { securityConfig } from "../security/config.js"; @@ -29,8 +59,23 @@ export async function channelRoutes(fastify: FastifyInstance) { { id: "ayuda-medica", name: "Ayuda Médica", verifiedOnly: true }, { id: "estoy-bien", name: "Estoy Bien (Comunidad)", verifiedOnly: false }, { id: "solicito-ayuda", name: "Solicito Ayuda (Comunidad)", verifiedOnly: false }, + { id: "encontrado", name: "Encontrado (Comunidad)", verifiedOnly: false }, ]); + /** Issues a short-lived NAN-only rescuer certificate. Never expose the backend key. */ + fastify.post<{ Body: { rescuerPublicKey?: string; expiresAt?: number } }>( + "/operator/rescuer-certificates", + { preHandler: requireApiKey(securityConfig.adminApiKey, "rescuer certificates") }, + async (request, reply) => { + const { rescuerPublicKey, expiresAt } = request.body ?? {}; + if (typeof rescuerPublicKey !== "string" || !/^[0-9a-f]{64}$/.test(rescuerPublicKey) || typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt)) { + return reply.code(400).send({ error: "rescuerPublicKey (Ed25519 hex) and expiresAt are required" }); + } + try { return await issueRescuerCertificate(rescuerPublicKey, expiresAt, privateKey); } + catch (err) { return reply.code(400).send({ error: err instanceof Error ? err.message : "invalid certificate" }); } + }, + ); + fastify.get<{ Params: { id: string }; Querystring: { since?: string } }>( "/channels/:id/records", async (request, reply) => { @@ -81,28 +126,43 @@ export async function channelRoutes(fastify: FastifyInstance) { }, ); - fastify.post<{ Body: { frames?: unknown } }>( + fastify.post<{ Body: { frames?: unknown; controlFrames?: unknown; foundFrames?: unknown; rescuerPublicKeys?: unknown } }>( "/ingest", { config: { rateLimit: securityConfig.ingestRateLimit } }, async (request, reply) => { - const { frames } = request.body ?? {}; + const { frames, controlFrames, foundFrames } = request.body ?? {}; - if (!validateIngestBatch(frames)) { - const tooMany = Array.isArray(frames) && frames.length > securityConfig.maxIngestBatch; + // A mule may carry SOS frames, control frames, found frames, or any mix. + // At least one batch must be present and each is independently capped. + const framesValid = validateIngestBatch(frames); + const controlValid = validateIngestBatch(controlFrames); + const foundValid = validateIngestBatch(foundFrames); + if (!framesValid && !controlValid && !foundValid) { + const tooMany = + (Array.isArray(frames) && frames.length > securityConfig.maxIngestBatch) || + (Array.isArray(controlFrames) && controlFrames.length > securityConfig.maxIngestBatch) || + (Array.isArray(foundFrames) && foundFrames.length > securityConfig.maxIngestBatch); return reply.code(400).send({ error: tooMany ? `Batch exceeds max ${securityConfig.maxIngestBatch} frames` - : "Missing or invalid 'frames' array", + : "Missing or invalid 'frames'/'controlFrames'/'foundFrames' array", }); } + const frameList: string[] = framesValid ? (frames as string[]) : []; + const controlList: string[] = controlValid ? (controlFrames as string[]) : []; + const foundList: string[] = foundValid ? (foundFrames as string[]) : []; + let ingested = 0; let duplicate = 0; let rejected = 0; const reasons: Record = {}; const verifiedLocations: LocationPoint[] = []; + const deliveryReceipts: ControlReceipt[] = []; + const renewedCertificates = []; + const sosRefs: SosRef[] = []; - for (const frame of frames) { + for (const frame of frameList) { if (typeof frame !== "string" || !isValidFrameB64(frame)) { rejected++; reasons["invalid frame string"] = (reasons["invalid frame string"] ?? 0) + 1; @@ -121,10 +181,43 @@ export async function channelRoutes(fastify: FastifyInstance) { ingested++; broadcastRecord(result.record); await checkOriginatorVeto(result.record); + if (result.record.channel === SOS_CHANNEL) { + await deriveAndUpsertSosCreated(result.record); + } + // Delivery is deliberately a backend-signed control receipt. A mule can + // carry it back via NAN; it never authorizes a third party to stop SOS TX. + if (result.record.channel === "solicito-ayuda") { + deliveryReceipts.push(await signControlReceipt({ + type: "delivery", + targetSosId: result.record.id, + targetSosAuthor: result.record.author, + issuedAt: Date.now(), + expiresAt: Date.now() + 30 * 24 * 60 * 60_000, + issuerPublicKey: publicKeyHex, + }, privateKey)); + } } else { duplicate++; } + // Collect every verified SOS (fresh or re-uploaded) for control minting. + // A re-upload means the victim is still advertising — a terminal-state + // re-upload mints a backend-resolve stop order below. payload.nodeId is + // sha256(pubkey)[0..4] hex (frame.ts), i.e. the control targetNodeId. + if (result.record.channel === SOS_CHANNEL) { + const p = result.record.payload as { nodeId?: string; msgId?: unknown }; + if (typeof p.nodeId === "string" && typeof p.msgId === "number") { + const pubkeyHex = result.record.author.slice("device-".length); + sosRefs.push({ + pubkeyHex, + nodeIdHex: p.nodeId, + msgId: p.msgId, + fresh: added, + sosId: deriveSosId(hexToBytes(pubkeyHex), p.msgId), + }); + } + } + if (result.location) { verifiedLocations.push(result.location); } @@ -142,11 +235,108 @@ export async function channelRoutes(fastify: FastifyInstance) { request.log.warn({ rejected, reasons }, "rejected unverifiable mesh frames"); } - return { success: true, ingested, duplicate, rejected, locationsIngested, reasons }; + // "Encontrado" reports: verify zero-trust, resolve the target SOS (which may + // have arrived in this very batch — the SOS loop above already persisted it), + // and compute the proximity verdict server-side. + const foundSummary: ProcessFoundSummary = foundList.length > 0 + ? await processFoundFrames(foundList, foundDeps()) + : { accepted: 0, duplicate: 0, rejected: 0, reasons: {}, results: [] }; + if (foundSummary.rejected > 0) { + request.log.warn( + { rejected: foundSummary.rejected, reasons: foundSummary.reasons }, + "rejected unverifiable found frames", + ); + } + + // Mules request renewals for pre-authorized field keys. The allowlist is + // server configuration, never learned from an untrusted ingest request. + const requested = Array.isArray(request.body?.rescuerPublicKeys) + ? request.body.rescuerPublicKeys.filter((v): v is string => typeof v === "string" && securityConfig.rescuerPublicKeys.has(v)).slice(0, 32) + : []; + for (const key of requested) { + renewedCertificates.push(await issueRescuerCertificate(key, Date.now() + securityConfig.rescuerCertificateValidityMs, privateKey)); + } + + // Control frames the mule carried in: verify zero-trust, replay-admit once + // per signature, emit SOS_CANCELLED for accepted self-cancels. Run BEFORE + // minting so a self-cancel accepted this batch flips state to terminal and + // a co-uploaded SOS for the same victim yields a backend-resolve stop order. + const controlResult = controlList.length > 0 + ? await processControlFrames(controlList) + : { accepted: 0, duplicate: 0, rejected: 0, reasons: {} as Record }; + + // Mint control frames for the mule to carry back into the mesh: a delivery + // ack for each freshly-seen active SOS, and a backend-resolve stop order for + // any SOS whose reconciled state is terminal yet is still being re-uploaded. + const minted = sosRefs.length > 0 ? await mintControlFrames(sosRefs) : []; + const certificateRevocationList = await issueCertificateRevocationList( + securityConfig.revokedRescuerPublicKeys, + securityConfig.rescuerCrlSequence, + Date.now() + 24 * 60 * 60_000, + privateKey, + ); + + return { + success: true, + ingested, + duplicate, + rejected, + locationsIngested, + reasons, + deliveryReceipts, + renewedCertificates, + certificateRevocationList, + controlAccepted: controlResult.accepted, + controlDuplicate: controlResult.duplicate, + controlRejected: controlResult.rejected, + controlReasons: controlResult.reasons, + controlFrames: minted.map((m) => m.frameB64), + foundAccepted: foundSummary.accepted, + foundDuplicate: foundSummary.duplicate, + foundRejected: foundSummary.rejected, + foundReasons: foundSummary.reasons, + foundResults: foundSummary.results, + }; }, ); } +/** + * Production wiring for the "Encontrado" processor: the reference point is the + * LATEST verified SOS record for (targetNodeId, targetMsgId) on solicito-ayuda. + * payload.nodeId / payload.msgId are set by decodeAndVerifyFrame from the + * signed frame, so the lookup key is authenticated, never client-supplied. + */ +function foundDeps(): FoundDeps { + return { + proximityNearMeters: securityConfig.foundProximityMeters, + async findTargetSos(targetNodeIdHex, targetMsgId) { + const records = await channelsRepo.getRecords(SOS_CHANNEL, 0); + let best: ChannelRecord | null = null; + for (const r of records) { + const p = r.payload as { nodeId?: unknown; msgId?: unknown }; + if (p.nodeId === targetNodeIdHex && p.msgId === targetMsgId) { + if (!best || r.timestamp > best.timestamp) best = r; + } + } + if (!best) return null; + const p = best.payload as { lat?: unknown; lon?: unknown }; + if ( + typeof p.lat !== "number" || + typeof p.lon !== "number" || + !Number.isFinite(p.lat) || + !Number.isFinite(p.lon) || + (p.lat === 0 && p.lon === 0) + ) { + return null; // SOS exists but carries no fix — nothing to validate against + } + return { recordId: best.id, lat: p.lat, lon: p.lon }; + }, + addRecord: (record) => channelsRepo.addRecord(record), + broadcast: (record) => broadcastRecord(record), + }; +} + /** * Originator veto — if a verified SOS frame arrives from a device that has a * pending-clear on a *different* target, that pending clear is auto-disputed. @@ -166,3 +356,60 @@ async function checkOriginatorVeto(record: ChannelRecord): Promise { broadcastResolve({ ...disputed, status: "disputed", disputedReason: "originator_refire" }); } } + +/** + * Derive the deterministic SOS_CREATED LedgerEvent from a verified mesh frame + * and upsert it into the ledger. eventId is deterministic over the payload, so + * re-uploads of the same frame dedupe idempotently. The proof's authenticity + * comes exclusively from the original device signature (encoding=ble-payload-v1); + * the backend does not re-sign CREATED. + */ +async function deriveAndUpsertSosCreated(record: ChannelRecord): Promise { + const frameB64 = (record.payload as { frameB64?: unknown }).frameB64; + if (typeof frameB64 !== "string") return; + const full = Buffer.from(frameB64, "base64"); + const payloadBytes = Uint8Array.from(full.subarray(MESH_PAYLOAD_OFF, MESH_PAYLOAD_OFF + MESH_PAYLOAD_LEN)); + const pubkeyBytes = Uint8Array.from(full.subarray(MESH_PUBKEY_OFF, MESH_PUBKEY_OFF + MESH_PUBKEY_LEN)); + const sigBytes = Uint8Array.from(full.subarray(MESH_SIG_OFF, MESH_SIG_OFF + MESH_SIG_LEN)); + const messageId = (record.payload as { msgId?: unknown }).msgId; + if (typeof messageId !== "number" || !Number.isFinite(messageId)) return; + + const sosId = deriveSosId(pubkeyBytes, messageId); + const ledgerPayload: SosCreatedPayload = { + payloadVersion: 1, + backingRecordId: record.id, + channel: SOS_CHANNEL, + messageId, + createdAt: record.timestamp, + }; + const payloadHash = computePayloadHash(ledgerPayload); + const proof: ChannelRecordProof = { + proofType: "channel-record", + proofVersion: 1, + encoding: "ble-payload-v1", + recordCanonicalBytes: payloadBytes, + recordSignature: sigBytes, + backingRecordId: record.id, + messageId, + }; + const unsigned: UnsignedLedgerEvent = { + version: 1, + sosId, + type: "SOS_CREATED", + previousSosEventHash: null, + issuedAt: record.timestamp, + issuerPublicKey: pubkeyBytes, + payload: ledgerPayload, + payloadHash, + payloadVersion: 1, + proof, + }; + const event: LedgerEvent = { ...unsigned, eventId: computeEventId(unsigned) }; + try { + await ledgerRepo.upsertEvent(event); + } catch (err) { + // Ledger failure must not break ingest. The frame is already persisted as a + // ChannelRecord; reconcile will catch up on the next frame with same sosId. + console.error("[ledger] SOS_CREATED upsert failed", err); + } +} diff --git a/backend/src/channels/sanitize.test.ts b/backend/src/channels/sanitize.test.ts index 7cd1a9d..d1c94c4 100644 --- a/backend/src/channels/sanitize.test.ts +++ b/backend/src/channels/sanitize.test.ts @@ -54,6 +54,15 @@ describe("sanitizeRecordForPublic", () => { expect("frameB64" in p).toBe(false); }); + test("applies to encontrado too", () => { + const out = sanitizeRecordForPublic(sosRecord({ channel: "encontrado" })); + const p = out.payload as Record; + expect(p.lat).toBe(10.49); + expect(p.lon).toBe(-66.88); + expect("frameB64" in p).toBe(false); + expect(p.locationPrecision).toBe("coarse"); + }); + test("leaves official channels untouched (full precision, full payload)", () => { const rec = sosRecord({ channel: "alertas", diff --git a/backend/src/channels/sanitize.ts b/backend/src/channels/sanitize.ts index 0cba3f5..3602909 100644 --- a/backend/src/channels/sanitize.ts +++ b/backend/src/channels/sanitize.ts @@ -1,4 +1,4 @@ -import type { ChannelRecord } from "@guacamaya/shared"; +import { canonicalJsonString, type ChannelRecord } from "@guacamaya/shared"; /** * Channels whose records are publicly readable (no auth on `GET @@ -13,7 +13,7 @@ import type { ChannelRecord } from "@guacamaya/shared"; * Official operator channels (alertas/refugios/ayuda-medica) are intentionally * public broadcasts and are not coarsened. */ -const COARSE_LOCATION_CHANNELS = new Set(["solicito-ayuda", "estoy-bien"]); +const COARSE_LOCATION_CHANNELS = new Set(["solicito-ayuda", "estoy-bien", "encontrado"]); /** * ~1.1 km at the equator (2 decimal places ≈ 0.01° ≈ 1.11 km lat; ~1.1 km lon @@ -36,7 +36,11 @@ function coarsenCoord(v: number): number { * shapes the public HTTP response. */ export function sanitizeRecordForPublic(record: ChannelRecord): ChannelRecord { - if (!COARSE_LOCATION_CHANNELS.has(record.channel)) return record; + if (!COARSE_LOCATION_CHANNELS.has(record.channel)) { + // Database drivers may materialize jsonb keys in implementation-defined + // order. Emit the signed canonical order for byte-verifying clients. + return { ...record, payload: JSON.parse(canonicalJsonString(record.payload)) }; + } const payload = record.payload; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return record; diff --git a/backend/src/control/ingestControl.test.ts b/backend/src/control/ingestControl.test.ts new file mode 100644 index 0000000..93b100a --- /dev/null +++ b/backend/src/control/ingestControl.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import * as ed from "@noble/ed25519"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { + buildControlFrame, + crc16Ccitt, + deriveSosId, + computeEventId, + computePayloadHash, + verifyControlFrame, + verifyLedgerEvent, + SOS_CHANNEL, + CONTROL_TYPE_BACKEND_RESOLVE, + CONTROL_TYPE_DELIVERY, + CONTROL_TYPE_SELF_CANCEL, + type ChannelRecordProof, + type LedgerEvent, + type SosCreatedPayload, + type UnsignedLedgerEvent, +} from "@guacamaya/shared"; +import { processControlFrames, mintControlFrames, type SosRef } from "./ingestControl.js"; +import { ledgerRepo } from "../ledger/ledgerRepo.js"; +import { ledgerStore } from "../ledger/store.js"; +import { controlReplayRepo } from "./replayRepo.js"; +import { publicKeyBytes, publicKeyHex } from "../crypto/keys.js"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── +// Victim key is deterministic (0x11 * 32) so its pubkey / node id are stable. +const VICTIM_PRIV = hexToBytes("11".repeat(32)); +const OTHER_PRIV = hexToBytes("33".repeat(32)); // a second device, for binding tests + +const NOW_MS = 1_800_000_500_000; // 2027-01-15-ish; fixed for deterministic time windows +const NOW_SEC = Math.floor(NOW_MS / 1000); +const SOS_TS_UNIX = 1_800_000_000; // SOS created ~500s before NOW +const MSG_ID = 0x1234; + +let victimPub: Uint8Array; +let victimNodeId: Uint8Array; // sha256(victimPub)[0..3] +let victimNodeIdHex: string; + +beforeEach(async () => { + ledgerStore.reset(); + controlReplayRepo.reset(); + victimPub = await ed.getPublicKeyAsync(VICTIM_PRIV); + victimNodeId = sha256(victimPub).subarray(0, 4); + victimNodeIdHex = bytesToHex(victimNodeId); +}); + +// ─── SOS frame helpers (shared has no SOS builder; mirror Payload.kt) ───────── +// payload22 BE: latE7 i32@0, lonE7 i32@4, tsUnix u32@8, nodeId[12..15], +// flags@16, sosTypeCode@17, msgId u16@18, CRC16-CCITT@20 (over 0..19). +// SOS sig = Ed25519 over the bare payload22 (no domain) — distinct from control. +function buildSosPayload( + nodeId: Uint8Array, + msgId: number, + tsUnix: number, + sosTypeCode = 0x00, +): Uint8Array { + const p = new Uint8Array(22); + const dv = new DataView(p.buffer); + dv.setInt32(0, 10_000_000, false); // lat = 1.0 + dv.setInt32(4, -80_000_000, false); // lon = -8.0 + dv.setUint32(8, tsUnix, false); + p.set(nodeId, 12); + p[16] = 0; // flags + p[17] = sosTypeCode; + dv.setUint16(18, msgId, false); + dv.setUint16(20, crc16Ccitt(p, 0, 20), false); + return p; +} + +/** Seed a SOS_CREATED ledger event exactly as deriveAndUpsertSosCreated does. */ +async function seedSosCreated(msgId = MSG_ID): Promise<{ sosId: string; eventId: string }> { + const payload22 = buildSosPayload(victimNodeId, msgId, SOS_TS_UNIX); + const sig = await ed.signAsync(payload22, VICTIM_PRIV); + const recordId = bytesToHex(sha256(payload22)); + const sosId = deriveSosId(victimPub, msgId); + + const ledgerPayload: SosCreatedPayload = { + payloadVersion: 1, + backingRecordId: recordId, + channel: SOS_CHANNEL, + messageId: msgId, + createdAt: SOS_TS_UNIX * 1000, + }; + const proof: ChannelRecordProof = { + proofType: "channel-record", + proofVersion: 1, + encoding: "ble-payload-v1", + recordCanonicalBytes: payload22, + recordSignature: sig, + backingRecordId: recordId, + messageId: msgId, + }; + const unsigned: UnsignedLedgerEvent = { + version: 1, + sosId, + type: "SOS_CREATED", + previousSosEventHash: null, + issuedAt: SOS_TS_UNIX * 1000, + issuerPublicKey: victimPub, + payload: ledgerPayload, + payloadHash: computePayloadHash(ledgerPayload), + payloadVersion: 1, + proof, + }; + const event: LedgerEvent = { ...unsigned, eventId: computeEventId(unsigned) }; + await ledgerRepo.upsertEvent(event); + return { sosId, eventId: event.eventId }; +} + +/** Build a 0x03 self-cancel frame signed by `issuerPriv` targeting `targetNodeId`. */ +async function buildSelfCancel( + issuerPriv: Uint8Array, + targetNodeId: Uint8Array, + targetMsgId = MSG_ID, + issuedAt = NOW_SEC, + lifetimeSec = 3600, + ctrlMsgId = 0xabcd, +): Promise { + const frame = await buildControlFrame( + { + type: CONTROL_TYPE_SELF_CANCEL, + targetNodeId, + targetMsgId, + issuedAt, + expiresAt: issuedAt + lifetimeSec, + ctrlMsgId, + }, + issuerPriv, + ); + return Buffer.from(frame).toString("base64"); +} + +function ref(fresh: boolean, msgId = MSG_ID): SosRef { + return { + pubkeyHex: bytesToHex(victimPub), + nodeIdHex: victimNodeIdHex, + msgId, + fresh, + sosId: deriveSosId(victimPub, msgId), + }; +} + +// ─── processControlFrames ──────────────────────────────────────────────────── + +describe("processControlFrames", () => { + test("accepts a valid self-cancel and emits SOS_CANCELLED (terminal)", async () => { + const { sosId, eventId: createdId } = await seedSosCreated(); + const frameB64 = await buildSelfCancel(VICTIM_PRIV, victimNodeId); + + const result = await processControlFrames([frameB64], NOW_MS); + + expect(result).toEqual({ accepted: 1, duplicate: 0, rejected: 0, reasons: {} }); + + const state = await ledgerRepo.getSosState(sosId); + expect(state?.type).toBe("cancelled"); + expect(state?.terminal).toBe(true); + + // The emitted SOS_CANCELLED is attached to the CREATED event and verifies. + const events = await ledgerRepo.getEventsBySosId(sosId); + const cancelled = events.find((s) => s.event.type === "SOS_CANCELLED"); + expect(cancelled).toBeDefined(); + expect(cancelled!.event.previousSosEventHash).toBe(createdId); + const verify = await verifyLedgerEvent(cancelled!.event, publicKeyBytes); + expect(verify).toEqual({ ok: true }); + }); + + test("rejects an expired self-cancel", async () => { + await seedSosCreated(); + const expired = await buildSelfCancel( + VICTIM_PRIV, + victimNodeId, + MSG_ID, + NOW_SEC - 7200, + 3600, // expiresAt = NOW - 3600 → in the past + ); + + const result = await processControlFrames([expired], NOW_MS); + + expect(result.accepted).toBe(0); + expect(result.rejected).toBe(1); + expect(result.reasons["control frame expired"]).toBe(1); + }); + + test("rejects a frame whose CRC does not validate", async () => { + await seedSosCreated(); + const frame = await buildSelfCancel(VICTIM_PRIV, victimNodeId); + // Flip a payload byte covered by the CRC (targetMsgId high byte @ off 5). + const bytes = Uint8Array.from(Buffer.from(frame, "base64")); + bytes[5] ^= 0xff; + + const result = await processControlFrames([Buffer.from(bytes).toString("base64")], NOW_MS); + + expect(result.accepted).toBe(0); + expect(result.rejected).toBe(1); + expect(result.reasons["crc mismatch"]).toBe(1); + }); + + test("rejects a self-cancel not signed by the target victim", async () => { + await seedSosCreated(); + // Issuer = victim, but target points at a different device's node id. + const otherPub = await ed.getPublicKeyAsync(OTHER_PRIV); + const otherNodeId = sha256(otherPub).subarray(0, 4); + const frame = await buildSelfCancel(VICTIM_PRIV, otherNodeId); + + const result = await processControlFrames([frame], NOW_MS); + + expect(result.accepted).toBe(0); + expect(result.rejected).toBe(1); + // Signature is valid (signed by the issuer over its own payload); the + // binding check rejects it: issuer node id != target node id. + expect(result.reasons["self-cancel issuer is not the target"]).toBe(1); + }); + + test("dedupes a replayed self-cancel by signature", async () => { + await seedSosCreated(); + const frame = await buildSelfCancel(VICTIM_PRIV, victimNodeId); + + const first = await processControlFrames([frame], NOW_MS); + const second = await processControlFrames([frame], NOW_MS); + + expect(first.accepted).toBe(1); + expect(second.accepted).toBe(0); + expect(second.duplicate).toBe(1); + }); +}); + +// ─── mintControlFrames ─────────────────────────────────────────────────────── + +describe("mintControlFrames", () => { + test("mints a delivery ack for a fresh, still-active SOS", async () => { + const { sosId } = await seedSosCreated(); + + const minted = await mintControlFrames([ref(true)], NOW_MS); + + expect(minted).toHaveLength(1); + expect(minted[0]!.type).toBe(CONTROL_TYPE_DELIVERY); + + const decoded = await verifyControlFrame( + Uint8Array.from(Buffer.from(minted[0]!.frameB64, "base64")), + { nowMs: NOW_MS, backendPublicKeyHex: publicKeyHex }, + ); + expect(decoded.ok).toBe(true); + if (decoded.ok) { + expect(decoded.control.type).toBe(CONTROL_TYPE_DELIVERY); + expect(decoded.control.targetMsgId).toBe(MSG_ID); + expect(decoded.control.targetNodeIdHex).toBe(victimNodeIdHex); + } + // Active SOS is unchanged. + expect((await ledgerRepo.getSosState(sosId))?.type).toBe("active"); + }); + + test("mints a backend-resolve stop order for a terminal (cancelled) SOS", async () => { + const { sosId } = await seedSosCreated(); + await processControlFrames([await buildSelfCancel(VICTIM_PRIV, victimNodeId)], NOW_MS); + expect((await ledgerRepo.getSosState(sosId))?.terminal).toBe(true); + + // Re-upload of a stale SOS (not fresh) whose state is now terminal. + const minted = await mintControlFrames([ref(false)], NOW_MS); + + expect(minted).toHaveLength(1); + expect(minted[0]!.type).toBe(CONTROL_TYPE_BACKEND_RESOLVE); + + const decoded = await verifyControlFrame( + Uint8Array.from(Buffer.from(minted[0]!.frameB64, "base64")), + { nowMs: NOW_MS, backendPublicKeyHex: publicKeyHex }, + ); + expect(decoded.ok).toBe(true); + if (decoded.ok) expect(decoded.control.type).toBe(CONTROL_TYPE_BACKEND_RESOLVE); + }); + + test("mints nothing for a non-fresh, still-active re-upload", async () => { + await seedSosCreated(); + const minted = await mintControlFrames([ref(false)], NOW_MS); + expect(minted).toEqual([]); + }); + + test("dedupes minted frames per nodeId:msgId:type", async () => { + await seedSosCreated(); + const minted = await mintControlFrames([ref(true), ref(true), ref(true)], NOW_MS); + expect(minted).toHaveLength(1); + }); +}); diff --git a/backend/src/control/ingestControl.ts b/backend/src/control/ingestControl.ts new file mode 100644 index 0000000..319d5f6 --- /dev/null +++ b/backend/src/control/ingestControl.ts @@ -0,0 +1,241 @@ +import * as ed from "@noble/ed25519"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { randomBytes } from "node:crypto"; +import { + buildControlFrame, + controlSignBytes, + canonicalEventBytes, + computeEventId, + computePayloadHash, + deriveSosId, + CONTROL_MAX_LIFETIME_SEC, + CONTROL_TYPE_BACKEND_RESOLVE, + CONTROL_TYPE_DELIVERY, + CONTROL_TYPE_SELF_CANCEL, + type CancelPayload, + type ControlFrameResult, + type LedgerEvent, + type SelfCancelProof, + type UnsignedLedgerEvent, + type VerifiedControlFrame, +} from "@guacamaya/shared"; +import { privateKey, publicKeyBytes, publicKeyHex } from "../crypto/keys.js"; +import { ledgerRepo } from "../ledger/ledgerRepo.js"; +import { controlReplayRepo } from "./replayRepo.js"; +import { decodeAndVerifyControlFrame } from "../mesh/controlFrame.js"; + +/** + * Control-frame ingress on the data-mule path (`POST /ingest`). A mule carries + * verified control frames (delivery / backend-resolve / self-cancel) alongside + * SOS frames. Every frame is re-verified zero-trust (never trust the mule), then + * admitted once per signature via the persistent replay guard. + * + * 0x03 self-cancel → emits SOS_CANCELLED (proofVersion 2; the frame's + * domain-prefixed Ed25519 sig IS the victim proof) + * 0x01 delivery → ack only, no ledger mutation + * 0x02 backend-resolve → ack only; the resolve it represents was already + * emitted at issuance (quorum /resolve path) + * + * Minting of fresh control frames for the mule to carry back is separate + * (`mintControlFrames`): a delivery ack for a freshly-seen active SOS, and a + * backend-resolve stop order for any SOS whose reconciled ledger state is + * terminal yet is still being re-uploaded (victim still advertising a stale + * SOS). A self-cancel accepted this batch flips state to terminal, so a + * co-uploaded SOS frame for the same victim yields a backend-resolve — the + * backend endorses and propagates the stop beyond the victim. + */ + +export interface SosRef { + /** Victim pubkey hex (ChannelRecord.author minus the "device-" prefix). */ + pubkeyHex: string; + /** sha256(victim pubkey)[0..3] hex — the control targetNodeId. */ + nodeIdHex: string; + msgId: number; + /** True iff this SOS frame was newly ingested (not a dedupe hit). */ + fresh: boolean; + sosId: string; +} + +export interface ControlIngestResult { + accepted: number; + duplicate: number; + rejected: number; + reasons: Record; +} + +export interface MintedControlFrame { + type: number; + frameB64: string; +} + +function randomU16(): number { + return randomBytes(2).readUInt16BE(0); +} + +/** + * Verify + replay-admit each control frame, emitting SOS_CANCELLED for accepted + * self-cancels. Verification failures are counted (never persisted); replays are + * deduped by signature. Ledger emission failures are logged but never break + * ingest (the frame is already recorded as a replay entry). + */ +export async function processControlFrames( + controlFrames: string[], + nowMs = Date.now(), +): Promise { + let accepted = 0; + let duplicate = 0; + let rejected = 0; + const reasons: Record = {}; + + for (const frameB64 of controlFrames) { + const result: ControlFrameResult = await decodeAndVerifyControlFrame(frameB64, { + nowMs, + backendPublicKeyHex: publicKeyHex, + }); + if (!result.ok) { + rejected++; + reasons[result.reason] = (reasons[result.reason] ?? 0) + 1; + continue; + } + + const c = result.control; + const admitted = await controlReplayRepo.admit( + { + signatureHex: c.signatureHex, + frameType: c.type, + issuerNodeIdHex: c.issuerNodeIdHex, + ctrlMsgId: c.ctrlMsgId, + expiresAtMs: c.expiresAt * 1000, + }, + nowMs, + ); + if (!admitted) { + duplicate++; + continue; + } + accepted++; + + if (c.type === CONTROL_TYPE_SELF_CANCEL) { + await emitSelfCancelLedgerEvent(c, nowMs); + } + } + + return { accepted, duplicate, rejected, reasons }; +} + +/** + * Derive SOS_CANCELLED from a verified 0x03 self-cancel frame and upsert it. + * proofVersion 2: receiptCanonicalBytes = controlSignBytes(payload22) (UTF8 + * domain + 22-byte control payload), victimSignature = the frame's Ed25519 + * sig64 over those same bytes. verifyLedgerEvent's raw verify holds unchanged. + * The backend co-signs the event envelope (eventSignature); issuerPublicKey is + * the backend, victimPublicKey (in the proof) is the cancel authority. + */ +async function emitSelfCancelLedgerEvent(control: VerifiedControlFrame, nowMs: number): Promise { + const victimPubkey = hexToBytes(control.issuerPublicKeyHex); + const sosId = deriveSosId(victimPubkey, control.targetMsgId); + + const previousEvents = await ledgerRepo.getEventsBySosId(sosId); + const created = previousEvents.find((s) => s.event.type === "SOS_CREATED"); + const previousSosEventHash = created?.event.eventId ?? null; + + const cancelledAt = control.issuedAt * 1000; // frame carries unix seconds; ledger uses ms + const payload: CancelPayload = { + payloadVersion: 1, + cancelledAt, + reason: "self-cancel", + }; + + const proof: SelfCancelProof = { + proofType: "self-cancel", + proofVersion: 2, + receiptCanonicalBytes: controlSignBytes(control.payload), + victimSignature: hexToBytes(control.signatureHex), + victimPublicKey: victimPubkey, + backendReceiptId: bytesToHex(sha256(control.frame)), + }; + + const unsigned: UnsignedLedgerEvent = { + version: 1, + sosId, + type: "SOS_CANCELLED", + previousSosEventHash, + issuedAt: nowMs, + issuerPublicKey: publicKeyBytes, + payload, + payloadHash: computePayloadHash(payload), + payloadVersion: 1, + proof, + }; + + const eventId = computeEventId(unsigned); + const eventSignature = await ed.signAsync(canonicalEventBytes(unsigned), privateKey); + const event: LedgerEvent = { ...unsigned, eventId, eventSignature }; + try { + await ledgerRepo.upsertEvent(event); + } catch (err) { + console.error("[ledger] self-cancel upsert failed", err); + } +} + +/** + * Mint best-effort control frames for the mule to carry back into the mesh: + * - delivery (0x01) for a freshly-ingested, still-active SOS + * - backend-resolve (0x02) for any SOS whose reconciled state is terminal + * (resolved/cancelled/expired) yet is still being re-uploaded — the victim + * is advertising a stale SOS and the backend orders it to stop. + * Deduped per response by `${nodeIdHex}:${msgId}:${type}`. ctrlMsgId is CSPRNG. + */ +export async function mintControlFrames( + sosRefs: SosRef[], + nowMs = Date.now(), +): Promise { + const nowSec = Math.floor(nowMs / 1000); + const minted: MintedControlFrame[] = []; + const seen = new Set(); + + for (const ref of sosRefs) { + const state = await ledgerRepo.getSosState(ref.sosId); + const terminal = state?.terminal === true; + + if (terminal) { + const key = `${ref.nodeIdHex}:${ref.msgId}:${CONTROL_TYPE_BACKEND_RESOLVE}`; + if (seen.has(key)) continue; + seen.add(key); + const frame = await buildControlFrame( + { + type: CONTROL_TYPE_BACKEND_RESOLVE, + targetNodeId: hexToBytes(ref.nodeIdHex), + targetMsgId: ref.msgId, + issuedAt: nowSec, + expiresAt: nowSec + CONTROL_MAX_LIFETIME_SEC[CONTROL_TYPE_BACKEND_RESOLVE], + ctrlMsgId: randomU16(), + }, + privateKey, + ); + minted.push({ type: CONTROL_TYPE_BACKEND_RESOLVE, frameB64: Buffer.from(frame).toString("base64") }); + continue; + } + + if (ref.fresh) { + const key = `${ref.nodeIdHex}:${ref.msgId}:${CONTROL_TYPE_DELIVERY}`; + if (seen.has(key)) continue; + seen.add(key); + const frame = await buildControlFrame( + { + type: CONTROL_TYPE_DELIVERY, + targetNodeId: hexToBytes(ref.nodeIdHex), + targetMsgId: ref.msgId, + issuedAt: nowSec, + expiresAt: nowSec + CONTROL_MAX_LIFETIME_SEC[CONTROL_TYPE_DELIVERY], + ctrlMsgId: randomU16(), + }, + privateKey, + ); + minted.push({ type: CONTROL_TYPE_DELIVERY, frameB64: Buffer.from(frame).toString("base64") }); + } + } + + return minted; +} diff --git a/backend/src/control/replayRepo.ts b/backend/src/control/replayRepo.ts new file mode 100644 index 0000000..1139ed4 --- /dev/null +++ b/backend/src/control/replayRepo.ts @@ -0,0 +1,76 @@ +import { supabase, isSupabaseConfigured } from "../db/supabase.js"; +import { controlReplayStore } from "./replayStore.js"; + +const TABLE = "control_replay"; +const PRUNE_INTERVAL_MS = 60_000; + +let lastPruneMs = 0; + +export interface ControlReplayEntry { + /** 64-byte Ed25519 signature hex (128 chars). */ + signatureHex: string; + /** 0x01 delivery | 0x02 backend-resolve | 0x03 self-cancel. */ + frameType: number; + /** sha256(issuer pubkey)[0..3] hex — dedupe namespace. */ + issuerNodeIdHex: string; + /** u16 CSPRNG control message id. */ + ctrlMsgId: number; + /** Unix ms the underlying frame expires at. */ + expiresAtMs: number; +} + +export const controlReplayRepo = { + /** + * Admit a freshly-verified control frame exactly once per signature. + * Returns true iff this signature is newly recorded (dedupe gate). + * Mirrors the SOS channel_records upsert-ignoreDedup pattern. + */ + async admit(entry: ControlReplayEntry, nowMs: number): Promise { + if (!isSupabaseConfigured || !supabase) { + this._maybePrune(nowMs); + return controlReplayStore.admit(entry.signatureHex, entry.expiresAtMs, nowMs); + } + + const { data, error } = await supabase + .from(TABLE) + .upsert( + { + signature_hex: entry.signatureHex, + frame_type: entry.frameType, + issuer_node_id: entry.issuerNodeIdHex, + ctrl_msg_id: entry.ctrlMsgId, + expires_at: entry.expiresAtMs, + received_at: nowMs, + }, + { onConflict: "signature_hex", ignoreDuplicates: true }, + ) + .select("signature_hex"); + if (error) throw error; + + this._maybePrune(nowMs); + return (data?.length ?? 0) > 0; + }, + + /** Throttled prune of expired rows — bounded DB write load under churn. */ + _maybePrune(nowMs: number): void { + if (nowMs - lastPruneMs < PRUNE_INTERVAL_MS) return; + lastPruneMs = nowMs; + this.prune(nowMs).catch((err) => { + console.error("[control-replay] prune failed", err); + }); + }, + + async prune(nowMs: number): Promise { + if (!isSupabaseConfigured || !supabase) { + controlReplayStore.prune(nowMs); + return; + } + const { error } = await supabase.from(TABLE).delete().lt("expires_at", nowMs); + if (error) throw error; + }, + + reset(): void { + controlReplayStore.reset(); + lastPruneMs = 0; + }, +}; diff --git a/backend/src/control/replayStore.ts b/backend/src/control/replayStore.ts new file mode 100644 index 0000000..b522bf8 --- /dev/null +++ b/backend/src/control/replayStore.ts @@ -0,0 +1,44 @@ +/** + * In-memory replay guard for verified control frames (delivery / + * backend-resolve / self-cancel). The Supabase-backed repo falls back to this + * store when the database is not configured (dev/test — same three-layer + * pattern as channels/locations/ledger). + * + * Keyed by the 64-byte Ed25519 signature hex: a control frame is admitted once + * even if it is re-uploaded by several data mules or relayed across hops. + * `expiresAtMs` lets callers prune entries whose underlying frame has expired. + */ +export const controlReplayStore = { + _map: new Map(), + + has(signatureHex: string): boolean { + return this._map.has(signatureHex); + }, + + /** + * Admit a freshly-verified control frame. Returns true iff this signature had + * not been seen before (or its previous entry already expired and was pruned). + */ + admit(signatureHex: string, expiresAtMs: number, nowMs: number): boolean { + const existing = this._map.get(signatureHex); + if (existing !== undefined && existing > nowMs) return false; + this._map.set(signatureHex, expiresAtMs); + return true; + }, + + /** Delete entries whose frame has already expired. */ + prune(nowMs: number): number { + let removed = 0; + for (const [sig, exp] of this._map) { + if (exp <= nowMs) { + this._map.delete(sig); + removed++; + } + } + return removed; + }, + + reset(): void { + this._map.clear(); + }, +}; diff --git a/backend/src/control/routes.ts b/backend/src/control/routes.ts new file mode 100644 index 0000000..e03e7ac --- /dev/null +++ b/backend/src/control/routes.ts @@ -0,0 +1,168 @@ +import type { FastifyInstance } from "fastify"; +import * as ed from "@noble/ed25519"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { + getRecordId, + ReceiptReplayGuard, + verifyControlReceipt, + deriveSosId, + computeEventId, + computePayloadHash, + canonicalEventBytes, + hash, + type BackendReceiptProof, + type CancelPayload, + type ChannelRecord, + type ControlReceipt, + type LedgerEvent, + type ResolvePayload, + type SelfCancelProof, + type UnsignedLedgerEvent, +} from "@guacamaya/shared"; +import { privateKey, publicKeyBytes, publicKeyHex } from "../crypto/keys.js"; +import { channelsRepo } from "../db/channelsRepo.js"; +import { ledgerRepo } from "../ledger/ledgerRepo.js"; +import { broadcastRecord } from "../ws/server.js"; + +const replay = new ReceiptReplayGuard(); + +/** + * NAN control ingress. Soft resolves are visual-only and need a valid rescuer + * certificate; backend-resolve/self-cancel are the only receipts that may stop + * the originating transmitter. The Android transport owns the actual stop. + * + * On a freshly-accepted receipt, derive the corresponding ledger event: + * - backend-resolve → SOS_RESOLVED (backend-receipt proof) + * - self-cancel → SOS_CANCELLED (self-cancel proof; requires victimSignature) + * - soft-resolve / delivery → no ledger mutation + */ +export async function controlRoutes(fastify: FastifyInstance) { + fastify.post<{ Body: ControlReceipt }>("/control/receipts", async (request, reply) => { + const receipt = request.body; + if (!receipt || !await verifyControlReceipt(receipt, publicKeyHex) || !replay.admit(receipt)) { + return reply.code(400).send({ accepted: false, error: "invalid_or_replayed_receipt" }); + } + const unsigned = { + channel: "resuelto" as const, + timestamp: receipt.issuedAt, + ttl: 0, + author: `device-${receipt.issuerPublicKey}`, + verified: false, + payload: { source: "control-receipt", receipt }, + }; + const record: ChannelRecord = { ...unsigned, id: getRecordId(unsigned) }; + const added = await channelsRepo.addRecord(record); + if (added) { + broadcastRecord(record); + await emitControlLedgerEvent(receipt); + } + return { accepted: true, duplicate: !added, visualOnly: receipt.type === "soft-resolve" }; + }); +} + +/** + * Derive SOS_RESOLVED (backend-resolve) or SOS_CANCELLED (self-cancel) from a + * freshly-accepted control receipt and upsert it into the ledger. The receipt + * authorizes the action; the ledger event is *additive* — reconcileState picks + * the winner per compareAuthority if a competing event already exists for the + * same sosId. + * + * sosId is recomputed from the origin SOS frame (looked up via targetSosId), + * not from the receipt issuer: a rescuer-cancel chains under the victim's sosId, + * not the rescuer's. + */ +async function emitControlLedgerEvent(receipt: ControlReceipt): Promise { + if (receipt.type !== "backend-resolve" && receipt.type !== "self-cancel") return; + + const targetRec = await channelsRepo.getById(receipt.targetSosId); + if (!targetRec || !targetRec.author.startsWith("device-")) return; + const messageId = (targetRec.payload as { msgId?: unknown }).msgId; + if (typeof messageId !== "number" || !Number.isFinite(messageId)) return; + + const originPubkey = hexToBytes(targetRec.author.slice("device-".length)); + const sosId = deriveSosId(originPubkey, messageId); + const previousEvents = await ledgerRepo.getEventsBySosId(sosId); + const created = previousEvents.find((s) => s.event.type === "SOS_CREATED"); + const previousSosEventHash = created?.event.eventId ?? null; + const now = Date.now(); + const issuerPublicKey = publicKeyBytes; + + let unsigned: UnsignedLedgerEvent; + if (receipt.type === "backend-resolve") { + const payload: ResolvePayload = { + payloadVersion: 1, + resolvedAt: receipt.issuedAt, + quorumReachedAt: receipt.issuedAt, + backendIssuedAt: now, + witnesses: [], + }; + const proof: BackendReceiptProof = { + proofType: "backend-receipt", + proofVersion: 1, + backingReceiptId: getRecordId({ + channel: "resuelto", + timestamp: receipt.issuedAt, + ttl: 0, + author: `device-${receipt.issuerPublicKey}`, + verified: false, + payload: { source: "control-receipt", receipt }, + }), + receiptType: receipt.type, + receiptIssuedAt: receipt.issuedAt, + }; + unsigned = { + version: 1, sosId, type: "SOS_RESOLVED", previousSosEventHash, + issuedAt: now, issuerPublicKey, payload, payloadHash: computePayloadHash(payload), + payloadVersion: 1, proof, + }; + } else { + // self-cancel — victimSignature is required. Without it, we cannot build a + // verifiable self-cancel proof; skip ledger emission but keep the receipt + // persisted as a ChannelRecord. + if (!receipt.victimSignature || !receipt.victimPublicKey) return; + if (receipt.victimPublicKey !== targetRec.author.slice("device-".length)) return; + const payload: CancelPayload = { + payloadVersion: 1, + cancelledAt: receipt.issuedAt, + reason: "self-cancel", + }; + const receiptCanonicalBytes = hash("guacamaya.control-receipt.v1", { + type: receipt.type, + targetSosId: receipt.targetSosId, + targetSosAuthor: receipt.targetSosAuthor, + issuedAt: receipt.issuedAt, + expiresAt: receipt.expiresAt, + issuerPublicKey: receipt.issuerPublicKey, + ...(receipt.certificate ? { certificate: receipt.certificate } : {}), + }); + const proof: SelfCancelProof = { + proofType: "self-cancel", + proofVersion: 1, + receiptCanonicalBytes, + victimSignature: hexToBytes(receipt.victimSignature), + victimPublicKey: hexToBytes(receipt.victimPublicKey), + backendReceiptId: getRecordId({ + channel: "resuelto", + timestamp: receipt.issuedAt, + ttl: 0, + author: `device-${receipt.issuerPublicKey}`, + verified: false, + payload: { source: "control-receipt", receipt }, + }), + }; + unsigned = { + version: 1, sosId, type: "SOS_CANCELLED", previousSosEventHash, + issuedAt: now, issuerPublicKey, payload, payloadHash: computePayloadHash(payload), + payloadVersion: 1, proof, + }; + } + + const eventId = computeEventId(unsigned); + const eventSignature = await ed.signAsync(canonicalEventBytes(unsigned), privateKey); + const event: LedgerEvent = { ...unsigned, eventId, eventSignature }; + try { + await ledgerRepo.upsertEvent(event); + } catch (err) { + console.error("[ledger] control receipt upsert failed", err); + } +} diff --git a/backend/src/crypto/keys.ts b/backend/src/crypto/keys.ts index 84b3b58..c0e9525 100644 --- a/backend/src/crypto/keys.ts +++ b/backend/src/crypto/keys.ts @@ -9,6 +9,9 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; // signed records. const hexKey = process.env.BACKEND_PRIVATE_KEY_HEX ?? process.env.BACKEND_PRIVATE_KEY; if (!hexKey) { + if (process.env.NODE_ENV === "production") { + throw new Error("BACKEND_PRIVATE_KEY_HEX is required in production; refusing an ephemeral backend identity"); + } console.warn( "[crypto/keys] BACKEND_PRIVATE_KEY_HEX not set — generating an EPHEMERAL keypair. " + "The public key will change on every restart. Set it in backend/.env for a stable identity." diff --git a/backend/src/crypto/signer.ts b/backend/src/crypto/signer.ts index df63d22..f4a5d37 100644 --- a/backend/src/crypto/signer.ts +++ b/backend/src/crypto/signer.ts @@ -1,6 +1,6 @@ import * as ed from "@noble/ed25519"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; -import { ChannelRecord, getRecordId } from "@guacamaya/shared"; +import { ChannelRecord, canonicalJsonString, getRecordId } from "@guacamaya/shared"; import { privateKey } from "./keys.js"; /** @@ -12,6 +12,9 @@ export async function signRecord( ): Promise { const verifiedRecord = { ...record, + // Emit the same sorted representation that is signed. This keeps clients + // which verify the wire payload byte-for-byte compatible with jsonb. + payload: JSON.parse(canonicalJsonString(record.payload)), verified: true, }; diff --git a/backend/src/dashboard/routes.ts b/backend/src/dashboard/routes.ts index 1967fd0..7dacac0 100644 --- a/backend/src/dashboard/routes.ts +++ b/backend/src/dashboard/routes.ts @@ -25,8 +25,8 @@ const LOGO_SVG = readFileSync( * → DESIGN.md): "yellow + black brand voltage", dark-only, capa semántica de * emergencia (danger/warning/success/info). Tokens espejados como CSS vars abajo. * - * helmet corre con contentSecurityPolicy:false (ver index.ts), así que el script - * inline + CDN + tiles cargan sin bloqueo. + * La CSP global permite de forma explícita los scripts/estilos de esta página y + * limita las imágenes remotas a los tiles de OpenStreetMap. */ const PAGE = ` diff --git a/backend/src/found/processFound.test.ts b/backend/src/found/processFound.test.ts new file mode 100644 index 0000000..c04b733 --- /dev/null +++ b/backend/src/found/processFound.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { buildFoundFrame, type ChannelRecord } from "@guacamaya/shared"; +import { processFoundFrames, type FoundDeps, FOUND_CHANNEL } from "./processFound.js"; + +const sk = new Uint8Array(32).fill(9); +const TARGET_NODE_ID = new Uint8Array([0x11, 0x22, 0x33, 0x44]); +const TARGET_NODE_ID_HEX = "11223344"; +const TARGET_MSG_ID = 100; +// The SOS victim's last reported point. +const SOS_LAT = 10.4; +const SOS_LON = -66.9; + +function makeDeps(overrides: Partial = {}) { + const store = new Map(); + const broadcasts: ChannelRecord[] = []; + const deps: FoundDeps = { + proximityNearMeters: 100, + async findTargetSos(nodeIdHex, msgId) { + if (nodeIdHex === TARGET_NODE_ID_HEX && msgId === TARGET_MSG_ID) { + return { recordId: "sos-record-1", lat: SOS_LAT, lon: SOS_LON }; + } + return null; + }, + async addRecord(record) { + if (store.has(record.id)) return false; + store.set(record.id, record); + return true; + }, + broadcast(record) { + broadcasts.push(record); + }, + ...overrides, + }; + return { deps, store, broadcasts }; +} + +async function frameB64(latE7: number, lonE7: number, foundMsgId = 1): Promise { + const frame = await buildFoundFrame( + { + latE7, + lonE7, + tsUnix: 1_750_000_000, + targetNodeId: TARGET_NODE_ID, + targetMsgId: TARGET_MSG_ID, + foundMsgId, + }, + sk, + ); + return Buffer.from(frame).toString("base64"); +} + +describe("processFoundFrames", () => { + test("finder ~33 m away → accepted, near, likely_found", async () => { + const { deps, store, broadcasts } = makeDeps(); + const summary = await processFoundFrames([await frameB64(104_003_000, -669_000_000)], deps); + expect(summary.accepted).toBe(1); + expect(summary.rejected).toBe(0); + expect(summary.results).toHaveLength(1); + expect(summary.results[0]!.proximity).toBe("near"); + expect(summary.results[0]!.verdict).toBe("likely_found"); + expect(summary.results[0]!.targetRecordId).toBe("sos-record-1"); + expect(summary.results[0]!.distanceMeters).toBeLessThan(100); + expect(store.size).toBe(1); + expect(broadcasts).toHaveLength(1); + const record = [...store.values()][0]!; + expect(record.channel).toBe(FOUND_CHANNEL); + expect(record.verified).toBe(false); + expect(record.author.startsWith("device-")).toBe(true); + const p = record.payload as { verdict: string; distanceMeters: number; source: string }; + expect(p.source).toBe("mesh-found"); + expect(p.verdict).toBe("likely_found"); + }); + + test("finder ~1.1 km away → accepted but far, possible_false_positive", async () => { + const { deps } = makeDeps(); + const summary = await processFoundFrames([await frameB64(104_100_000, -669_000_000)], deps); + expect(summary.accepted).toBe(1); + expect(summary.results[0]!.proximity).toBe("far"); + expect(summary.results[0]!.verdict).toBe("possible_false_positive"); + expect(summary.results[0]!.distanceMeters).toBeGreaterThan(1000); + }); + + test("unknown target SOS → rejected", async () => { + const { deps } = makeDeps({ findTargetSos: async () => null }); + const summary = await processFoundFrames([await frameB64(104_003_000, -669_000_000)], deps); + expect(summary.accepted).toBe(0); + expect(summary.rejected).toBe(1); + expect(summary.reasons["unknown target sos"]).toBe(1); + expect(summary.results).toHaveLength(0); + }); + + test("re-upload of the same frame → duplicate, verdict still returned", async () => { + const { deps } = makeDeps(); + const b64 = await frameB64(104_003_000, -669_000_000); + await processFoundFrames([b64], deps); + const second = await processFoundFrames([b64], deps); + expect(second.accepted).toBe(0); + expect(second.duplicate).toBe(1); + expect(second.results).toHaveLength(1); + expect(second.results[0]!.verdict).toBe("likely_found"); + }); + + test("garbage frame → rejected without touching the store", async () => { + const { deps, store } = makeDeps(); + const summary = await processFoundFrames(["!!!not-base64!!!", "QUJD"], deps); + expect(summary.accepted).toBe(0); + expect(summary.rejected).toBe(2); + expect(store.size).toBe(0); + }); +}); diff --git a/backend/src/found/processFound.ts b/backend/src/found/processFound.ts new file mode 100644 index 0000000..6e7036b --- /dev/null +++ b/backend/src/found/processFound.ts @@ -0,0 +1,140 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { + type ChannelRecord, + haversineMeters, + verifyFoundFrame, +} from "@guacamaya/shared"; +import { isValidFrameB64 } from "../security/validation.js"; + +/** Community channel that holds "Encontrado" (found) reports. */ +export const FOUND_CHANNEL = "encontrado"; + +/** The target SOS's last reported point, resolved by the caller's data layer. */ +export interface FoundTargetSos { + recordId: string; + lat: number; + lon: number; +} + +export interface FoundDeps { + /** Meters; at/under → near/likely_found, over → far/possible_false_positive. */ + proximityNearMeters: number; + /** Latest SOS record for (targetNodeId, targetMsgId), or null when none with a GPS fix exists. */ + findTargetSos(targetNodeIdHex: string, targetMsgId: number): Promise; + /** Persist; returns false when the id already exists (dedupe). */ + addRecord(record: ChannelRecord): Promise; + /** WS fan-out for freshly persisted records only. */ + broadcast(record: ChannelRecord): void; +} + +export interface FoundResultEntry { + /** ChannelRecord id of the found report (SHA-256(payload22) hex). */ + id: string; + targetRecordId: string; + distanceMeters: number; + proximity: "near" | "far"; + verdict: "likely_found" | "possible_false_positive"; +} + +export interface ProcessFoundSummary { + accepted: number; + duplicate: number; + rejected: number; + reasons: Record; + results: FoundResultEntry[]; +} + +/** + * Zero-trust "Encontrado" ingestion: every frame is re-verified (CRC + Ed25519 + * over the found domain) before anything is persisted, the target SOS must + * already exist server-side, and the proximity verdict is computed from the + * finder's *signed* GPS position against the SOS's last reported point — the + * client never supplies a verdict or a record id. + */ +export async function processFoundFrames( + frames: string[], + deps: FoundDeps, +): Promise { + const summary: ProcessFoundSummary = { + accepted: 0, + duplicate: 0, + rejected: 0, + reasons: {}, + results: [], + }; + const reject = (reason: string) => { + summary.rejected++; + summary.reasons[reason] = (summary.reasons[reason] ?? 0) + 1; + }; + + for (const b64 of frames) { + if (typeof b64 !== "string" || !isValidFrameB64(b64)) { + reject("invalid frame string"); + continue; + } + const bytes = Uint8Array.from(Buffer.from(b64, "base64")); + + const res = await verifyFoundFrame(bytes); + if (!res.ok) { + reject(res.reason); + continue; + } + const f = res.found; + + const target = await deps.findTargetSos(f.targetNodeIdHex, f.targetMsgId); + if (!target) { + reject("unknown target sos"); + continue; + } + + const distanceMeters = Math.round(haversineMeters(f.lat, f.lon, target.lat, target.lon)); + const near = distanceMeters <= deps.proximityNearMeters; + const proximity = near ? ("near" as const) : ("far" as const); + const verdict = near ? ("likely_found" as const) : ("possible_false_positive" as const); + + const record: ChannelRecord = { + id: bytesToHex(sha256(f.payload)), + channel: FOUND_CHANNEL, + timestamp: f.tsUnix * 1000, + ttl: 0, + author: `device-${f.finderPublicKeyHex}`, + verified: false, + payload: { + source: "mesh-found", + finderNodeId: f.finderNodeIdHex, + foundMsgId: f.foundMsgId, + tsUnix: f.tsUnix, + lat: f.lat, + lon: f.lon, + targetNodeId: f.targetNodeIdHex, + targetMsgId: f.targetMsgId, + targetRecordId: target.recordId, + distanceMeters, + proximity, + verdict, + frameB64: Buffer.from(f.frame).toString("base64"), + }, + sig: f.signatureHex, + }; + + const added = await deps.addRecord(record); + if (added) { + summary.accepted++; + deps.broadcast(record); + } else { + summary.duplicate++; + } + // The verdict is returned even on duplicate — the mule uploading the frame + // is usually the finder's own phone, and it needs the answer both times. + summary.results.push({ + id: record.id, + targetRecordId: target.recordId, + distanceMeters, + proximity, + verdict, + }); + } + + return summary; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 706b127..11fb347 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,8 @@ import { locationRoutes } from "./locations/routes.js"; import { resolveRoutes } from "./resolve/routes.js"; import { dashboardRoutes } from "./dashboard/routes.js"; import { waitlistRoutes } from "./waitlist/routes.js"; +import { controlRoutes } from "./control/routes.js"; +import { ledgerRoutes } from "./ledger/routes.js"; import { resolvesRepo } from "./db/resolvesRepo.js"; import { initWebSocketServer } from "./ws/server.js"; import { securityConfig } from "./security/config.js"; @@ -21,7 +23,22 @@ const SWEEP_INTERVAL_MS = 60_000; const app = Fastify({ logger: true }); await app.register(helmet, { - contentSecurityPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + formAction: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], + scriptSrcAttr: ["'none'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], + imgSrc: ["'self'", "data:", "blob:", "https://*.tile.openstreetmap.org"], + connectSrc: ["'self'"], + fontSrc: ["'self'", "data:"], + upgradeInsecureRequests: [], + }, + }, crossOriginResourcePolicy: { policy: "cross-origin" }, }); @@ -38,20 +55,25 @@ await app.register(locationRoutes); await app.register(resolveRoutes); await app.register(dashboardRoutes); await app.register(waitlistRoutes); +await app.register(controlRoutes); +await app.register(ledgerRoutes); app.get("/health", async () => ({ ok: true })); -// Serves the compiled marketing/waitlist page (web/landing) at GET / and its -// asset paths — same origin as the API, so the waitlist form needs no CORS -// config. Falls back to a JSON message if the frontend hasn't been built -// (e.g. a fresh checkout that only ran `bun run dev:backend`). -const LANDING_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../web/landing/dist"); +// Serves the compiled marketing/waitlist page (guacamalla-landing/landing) at +// GET / and its asset paths — same origin as the API, so the waitlist form +// needs no CORS config. Falls back to a JSON message if the frontend hasn't +// been built (e.g. a fresh checkout that only ran `bun run dev:backend`). +const LANDING_DIST = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../guacamalla-landing/landing/dist", +); if (fs.existsSync(path.join(LANDING_DIST, "index.html"))) { await app.register(fastifyStatic, { root: LANDING_DIST }); } else { app.log.warn( `Landing page build not found at ${LANDING_DIST} — GET / falls back to a JSON message. ` + - "Run 'bun run --cwd web/landing build' to serve the real page.", + "Run 'bun run --cwd guacamalla-landing/landing build' to serve the real page.", ); app.get("/", async () => ({ message: "Welcome to GuacaMalla Net!" })); } @@ -79,8 +101,13 @@ try { initWebSocketServer(app.server); // Cooldown sweeper — every 60 s, promote pending receipts whose cooldown - // expired (and which were not disputed) to cleared. Emits a `resuelto` - // record per promotion so subscribers see the final state. + // expired (and which were not disputed) to cleared. Promotion only flips the + // receipt status (pending → cleared); no SOS record is deleted and no new + // ledger event is emitted, because the authoritative terminal state was + // already recorded at quorum time — the additive SOS_RESOLVED ledger event + // (reconcileState picks it over a competing self-cancel via compareAuthority) + // plus the "resuelto" channel record. The sweep is a bookkeeping transition, + // not a destructive cleanup. sweepTimer = setInterval(async () => { try { const promoted = await resolvesRepo.getExpiredPendingClears(Date.now()); diff --git a/backend/src/ledger/ledgerRepo.ts b/backend/src/ledger/ledgerRepo.ts new file mode 100644 index 0000000..20f84dd --- /dev/null +++ b/backend/src/ledger/ledgerRepo.ts @@ -0,0 +1,356 @@ +import { + reconcileState, + type LedgerEvent, + type SosState, + type EventId, + type LedgerRejectReason, + type ReconcileResult, +} from "@guacamaya/shared"; +import { supabase, isSupabaseConfigured } from "../db/supabase.js"; +import { + ledgerStore, + type ChainStatus, + type RejectedEvent, + type StoredLedgerEvent, +} from "./store.js"; + +const EVENTS_TABLE = "ledger_events"; +const STATE_TABLE = "sos_state"; +const REJECTED_TABLE = "ledger_rejected_events"; + +export interface LedgerCursor { + receivedAt: number; + eventId: EventId; +} + +export type { ChainStatus, RejectedEvent, StoredLedgerEvent }; + +// ─── bytes<->hex ───────────────────────────────────────────────────────────── + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += bytes[i]!.toString(16).padStart(2, "0"); + return out; +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(Math.floor(hex.length / 2)); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} + +// ─── JSONB (de)serialization: Uint8Array <-> { $bytes: hex } ───────────────── + +function toJsonValue(v: unknown): unknown { + if (v instanceof Uint8Array) return { $bytes: bytesToHex(v) }; + if (Array.isArray(v)) return v.map(toJsonValue); + if (v && typeof v === "object") { + const out: Record = {}; + const src = v as Record; + for (const k of Object.keys(src)) out[k] = toJsonValue(src[k]); + return out; + } + return v; +} + +function fromJsonValue(v: unknown): unknown { + if (Array.isArray(v)) return v.map(fromJsonValue); + if (v && typeof v === "object") { + const obj = v as Record; + const keys = Object.keys(obj); + if (keys.length === 1 && typeof obj.$bytes === "string") { + return hexToBytes(obj.$bytes); + } + const out: Record = {}; + for (const k of keys) out[k] = fromJsonValue(obj[k]); + return out; + } + return v; +} + +// ─── Row mapping ───────────────────────────────────────────────────────────── + +function eventToRow(event: LedgerEvent, receivedAt: number, chainStatus: ChainStatus) { + return { + event_id: event.eventId, + sos_id: event.sosId, + type: event.type, + previous_sos_event_hash: event.previousSosEventHash ?? null, + issued_at: event.issuedAt, + issuer_public_key: bytesToHex(event.issuerPublicKey), + payload: toJsonValue(event.payload), + payload_hash: bytesToHex(event.payloadHash), + proof: toJsonValue(event.proof), + event_signature: event.eventSignature ? bytesToHex(event.eventSignature) : null, + chain_status: chainStatus, + received_at: receivedAt, + }; +} + +function rowToEvent(row: Record): LedgerEvent { + return { + version: 1, + eventId: row.event_id as EventId, + sosId: row.sos_id as string, + type: row.type as LedgerEvent["type"], + previousSosEventHash: (row.previous_sos_event_hash as string | null) ?? null, + issuedAt: row.issued_at as number, + issuerPublicKey: hexToBytes(row.issuer_public_key as string), + payload: fromJsonValue(row.payload) as LedgerEvent["payload"], + payloadHash: hexToBytes(row.payload_hash as string), + payloadVersion: 1, + proof: fromJsonValue(row.proof) as LedgerEvent["proof"], + eventSignature: row.event_signature ? hexToBytes(row.event_signature as string) : undefined, + }; +} + +function rowToStored(row: Record): StoredLedgerEvent { + return { + event: rowToEvent(row), + receivedAt: row.received_at as number, + chainStatus: row.chain_status as ChainStatus, + }; +} + +function stateToRow(state: SosState) { + return { + sos_id: state.sosId, + current_type: state.type, + terminal: state.terminal, + last_event_id: state.lastEventId, + last_event_at: state.lastEventAt, + witness_count: state.witnessCount, + retention_until: state.retentionUntil ?? null, + updated_at: Date.now(), + }; +} + +function rowToState(row: Record): SosState { + return { + sosId: row.sos_id as string, + type: row.current_type as SosState["type"], + terminal: row.terminal as boolean, + lastEventId: row.last_event_id as EventId, + lastEventAt: row.last_event_at as number, + witnessCount: row.witness_count as number, + retentionUntil: (row.retention_until as number | null) ?? null, + }; +} + +// ─── Repository ────────────────────────────────────────────────────────────── + +export const ledgerRepo = { + async upsertEvent(event: LedgerEvent): Promise<{ inserted: boolean; chainStatus: ChainStatus }> { + if (!isSupabaseConfigured || !supabase) { + if (ledgerStore.hasEvent(event.eventId)) { + const existing = ledgerStore.getEvent(event.eventId)!; + return { inserted: false, chainStatus: existing.chainStatus }; + } + ledgerStore.putEvent(event, Date.now(), "orphan"); + const result = await this.reconcile(event.sosId); + const chainStatus = this.classifyAfter(result, event.eventId); + ledgerStore.setChainStatus(event.eventId, chainStatus); + return { inserted: true, chainStatus }; + } + + const { data: existing } = await supabase + .from(EVENTS_TABLE) + .select("event_id, chain_status") + .eq("event_id", event.eventId) + .maybeSingle(); + if (existing) { + return { inserted: false, chainStatus: (existing as Record).chain_status as ChainStatus }; + } + + const { error } = await supabase + .from(EVENTS_TABLE) + .upsert(eventToRow(event, Date.now(), "orphan"), { + onConflict: "event_id", + ignoreDuplicates: true, + }); + if (error) throw error; + + const result = await this.reconcile(event.sosId); + return { inserted: true, chainStatus: this.classifyAfter(result, event.eventId) }; + }, + + classifyAfter(result: ReconcileResult, eventId: EventId): ChainStatus { + if (result.attached.includes(eventId)) return "attached"; + if (result.conflicts.includes(eventId)) return "conflict"; + return "orphan"; + }, + + async getEvent(eventId: EventId): Promise { + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.getEvent(eventId) ?? null; + } + const { data, error } = await supabase + .from(EVENTS_TABLE) + .select("*") + .eq("event_id", eventId) + .maybeSingle(); + if (error) throw error; + return data ? rowToStored(data as Record) : null; + }, + + async getEventsBySosId(sosId: string): Promise { + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.getEventsBySosId(sosId); + } + const { data, error } = await supabase + .from(EVENTS_TABLE) + .select("*") + .eq("sos_id", sosId) + .order("issued_at", { ascending: true }) + .order("event_id", { ascending: true }); + if (error) throw error; + return (data ?? []).map((r) => rowToStored(r as Record)); + }, + + async listEventsAfter(cursor: LedgerCursor | null, limit: number): Promise { + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.listEventsAfter(cursor, limit); + } + let query = supabase + .from(EVENTS_TABLE) + .select("*") + .order("received_at", { ascending: true }) + .order("event_id", { ascending: true }) + .limit(limit); + if (cursor) { + query = query.or( + `and(received_at.gt.${cursor.receivedAt}),and(received_at.eq.${cursor.receivedAt},event_id.gt.${cursor.eventId})`, + ); + } + const { data, error } = await query; + if (error) throw error; + return (data ?? []).map((r) => rowToStored(r as Record)); + }, + + async addRejected(input: { + rawEvent: unknown; + reason: LedgerRejectReason; + claimedEventId?: string; + sosId?: string; + }): Promise { + const receivedAt = Date.now(); + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.addRejected({ + claimedEventId: input.claimedEventId, + sosId: input.sosId, + reason: input.reason, + rawEvent: input.rawEvent, + receivedAt, + }); + } + const row = { + claimed_event_id: input.claimedEventId ?? null, + sos_id: input.sosId ?? null, + reason: input.reason, + raw_event: toJsonValue(input.rawEvent), + received_at: receivedAt, + }; + const { data, error } = await supabase + .from(REJECTED_TABLE) + .insert(row) + .select("rejection_id") + .maybeSingle(); + if (error) throw error; + return { + rejectionId: (data as Record | null)?.rejection_id as string | undefined ?? "", + claimedEventId: input.claimedEventId, + sosId: input.sosId, + reason: input.reason, + rawEvent: input.rawEvent, + receivedAt, + }; + }, + + async listRejected(since: number, limit: number): Promise { + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.listRejected(since, limit); + } + const { data, error } = await supabase + .from(REJECTED_TABLE) + .select("*") + .gte("received_at", since) + .order("received_at", { ascending: true }) + .limit(limit); + if (error) throw error; + return (data ?? []).map((r) => { + const row = r as Record; + return { + rejectionId: row.rejection_id as string, + claimedEventId: (row.claimed_event_id as string | null) ?? undefined, + sosId: (row.sos_id as string | null) ?? undefined, + reason: row.reason as string, + rawEvent: fromJsonValue(row.raw_event), + receivedAt: row.received_at as number, + }; + }); + }, + + async getSosState(sosId: string): Promise { + if (!isSupabaseConfigured || !supabase) { + return ledgerStore.getSosState(sosId) ?? null; + } + const { data, error } = await supabase + .from(STATE_TABLE) + .select("*") + .eq("sos_id", sosId) + .maybeSingle(); + if (error) throw error; + return data ? rowToState(data as Record) : null; + }, + + async reconcile(sosId: string): Promise { + const stored = await this.getEventsBySosId(sosId); + const events = stored.map((s) => s.event); + const result = reconcileState(events); + + const batches: Array<{ ids: EventId[]; status: ChainStatus }> = [ + { ids: result.attached, status: "attached" }, + { ids: result.conflicts, status: "conflict" }, + { ids: result.orphans, status: "orphan" }, + ]; + for (const batch of batches) { + if (batch.ids.length === 0) continue; + if (!isSupabaseConfigured || !supabase) { + for (const id of batch.ids) ledgerStore.setChainStatus(id, batch.status); + } else { + const { error } = await supabase + .from(EVENTS_TABLE) + .update({ chain_status: batch.status }) + .in("event_id", batch.ids); + if (error) throw error; + } + } + + if (result.state) { + await this.putSosState(result.state); + } else { + await this.clearSosState(sosId); + } + return result; + }, + + async putSosState(state: SosState): Promise { + if (!isSupabaseConfigured || !supabase) { + ledgerStore.putSosState(state); + return; + } + const { error } = await supabase.from(STATE_TABLE).upsert(stateToRow(state), { + onConflict: "sos_id", + }); + if (error) throw error; + }, + + async clearSosState(sosId: string): Promise { + if (!isSupabaseConfigured || !supabase) { + ledgerStore.clearSosState(sosId); + return; + } + const { error } = await supabase.from(STATE_TABLE).delete().eq("sos_id", sosId); + if (error) throw error; + }, +}; diff --git a/backend/src/ledger/routes.ts b/backend/src/ledger/routes.ts new file mode 100644 index 0000000..10bd525 --- /dev/null +++ b/backend/src/ledger/routes.ts @@ -0,0 +1,112 @@ +import type { FastifyInstance } from "fastify"; +import { SOS_ID_RE, EVENT_ID_RE } from "@guacamaya/shared"; +import { ledgerRepo, type LedgerCursor } from "./ledgerRepo.js"; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +export async function ledgerRoutes(fastify: FastifyInstance) { + fastify.get<{ Params: { sosId: string } }>( + "/ledger/state/:sosId", + async (request, reply) => { + const { sosId } = request.params; + if (!SOS_ID_RE.test(sosId)) { + return reply.code(400).send({ error: "INVALID_SOS_ID" }); + } + const state = await ledgerRepo.getSosState(sosId); + if (!state) return reply.code(404).send({ error: "NOT_FOUND" }); + return state; + }, + ); + + fastify.get<{ Params: { sosId: string } }>( + "/ledger/events/:sosId", + async (request, reply) => { + const { sosId } = request.params; + if (!SOS_ID_RE.test(sosId)) { + return reply.code(400).send({ error: "INVALID_SOS_ID" }); + } + const stored = await ledgerRepo.getEventsBySosId(sosId); + const attached = stored.filter((s) => s.chainStatus === "attached"); + const orphans = stored.filter((s) => s.chainStatus === "orphan"); + const conflicts = stored.filter((s) => s.chainStatus === "conflict"); + const decorate = (s: { event: unknown; receivedAt: number; chainStatus: string }) => ({ + ...(serializeEvent(s.event) as Record), + receivedAt: s.receivedAt, + chainStatus: s.chainStatus, + }); + return { + sosId, + attached: attached.map(decorate), + orphans: orphans.map(decorate), + conflicts: conflicts.map(decorate), + }; + }, + ); + + fastify.get<{ + Querystring: { afterReceivedAt?: string; afterEventId?: string; limit?: string }; + }>("/ledger/events", async (request) => { + const rawLimit = Number(request.query.limit ?? DEFAULT_LIMIT); + const limit = Number.isFinite(rawLimit) + ? Math.max(1, Math.min(MAX_LIMIT, Math.floor(rawLimit))) + : DEFAULT_LIMIT; + + let cursor: LedgerCursor | null = null; + const afterReceivedAt = request.query.afterReceivedAt; + const afterEventId = request.query.afterEventId; + if (afterReceivedAt !== undefined && afterEventId !== undefined) { + const receivedAt = Number(afterReceivedAt); + if (!Number.isFinite(receivedAt)) { + return { events: [], nextCursor: null }; + } + if (!EVENT_ID_RE.test(afterEventId)) { + return { events: [], nextCursor: null }; + } + cursor = { receivedAt, eventId: afterEventId }; + } + + const items = await ledgerRepo.listEventsAfter(cursor, limit); + const events = items.map((s) => serializeEvent(s.event)); + const nextCursor = + items.length === limit && items.length > 0 + ? { + receivedAt: items[items.length - 1]!.receivedAt, + eventId: items[items.length - 1]!.event.eventId, + } + : null; + return { events, nextCursor }; + }); + + fastify.post("/ledger/events", async (_request, reply) => { + return reply.code(501).send({ + error: "NOT_IMPLEMENTED", + reason: "peer-to-peer event transport not enabled in this build", + }); + }); +} + +function serializeEvent(event: unknown) { + // LedgerEvent contains Uint8Array fields (issuerPublicKey, payloadHash, proof + // bytes, optional eventSignature). JSON.stringify coerces Uint8Array to + // {"0":n,"1":n,...}; we want hex. Use the canonical wire form via toJsonValue. + return toJsonSerializable(event); +} + +function toJsonSerializable(v: unknown): unknown { + if (v instanceof Uint8Array) return { $bytes: bytesToHex(v) }; + if (Array.isArray(v)) return v.map(toJsonSerializable); + if (v && typeof v === "object") { + const out: Record = {}; + const src = v as Record; + for (const k of Object.keys(src)) out[k] = toJsonSerializable(src[k]); + return out; + } + return v; +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += bytes[i]!.toString(16).padStart(2, "0"); + return out; +} diff --git a/backend/src/ledger/store.ts b/backend/src/ledger/store.ts new file mode 100644 index 0000000..d6c4921 --- /dev/null +++ b/backend/src/ledger/store.ts @@ -0,0 +1,135 @@ +import type { LedgerEvent, SosState, EventId } from "@guacamaya/shared"; + +export type ChainStatus = "attached" | "orphan" | "conflict"; + +export interface RejectedEvent { + rejectionId: string; + claimedEventId?: string; + sosId?: string; + reason: string; + rawEvent: unknown; + receivedAt: number; +} + +export interface StoredLedgerEvent { + event: LedgerEvent; + receivedAt: number; + chainStatus: ChainStatus; +} + +interface MemoryRecord { + event: LedgerEvent; + receivedAt: number; + chainStatus: ChainStatus; +} + +const events = new Map(); +const bySosId = new Map(); +const states = new Map(); +const rejected: RejectedEvent[] = []; +let rejectionCounter = 0; + +function compareReceived(a: MemoryRecord, b: MemoryRecord): number { + if (a.receivedAt !== b.receivedAt) return a.receivedAt - b.receivedAt; + return a.event.eventId.localeCompare(b.event.eventId); +} + +export const ledgerStore = { + hasEvent(eventId: EventId): boolean { + return events.has(eventId); + }, + + getEvent(eventId: EventId): StoredLedgerEvent | undefined { + const rec = events.get(eventId); + if (!rec) return undefined; + return { event: rec.event, receivedAt: rec.receivedAt, chainStatus: rec.chainStatus }; + }, + + getEventsBySosId(sosId: string): StoredLedgerEvent[] { + const ids = bySosId.get(sosId) ?? []; + const out: StoredLedgerEvent[] = []; + for (const id of ids) { + const rec = events.get(id); + if (rec) out.push({ event: rec.event, receivedAt: rec.receivedAt, chainStatus: rec.chainStatus }); + } + return out; + }, + + putEvent(event: LedgerEvent, receivedAt: number, chainStatus: ChainStatus): void { + if (events.has(event.eventId)) return; + events.set(event.eventId, { event, receivedAt, chainStatus }); + const list = bySosId.get(event.sosId) ?? []; + list.push(event.eventId); + bySosId.set(event.sosId, list); + }, + + setChainStatus(eventId: EventId, status: ChainStatus): void { + const rec = events.get(eventId); + if (rec) rec.chainStatus = status; + }, + + getSosState(sosId: string): SosState | undefined { + return states.get(sosId); + }, + + putSosState(state: SosState): void { + states.set(state.sosId, state); + }, + + clearSosState(sosId: string): void { + states.delete(sosId); + }, + + addRejected(input: Omit): RejectedEvent { + const rejectionId = `rej-${input.receivedAt}-${rejectionCounter++}`; + const entry: RejectedEvent = { rejectionId, ...input }; + rejected.push(entry); + return entry; + }, + + listRejected(since: number, limit: number): RejectedEvent[] { + return rejected + .filter((r) => r.receivedAt >= since) + .sort((a, b) => a.receivedAt - b.receivedAt) + .slice(0, limit); + }, + + listEventsAfter(cursor: { receivedAt: number; eventId: EventId } | null, limit: number): StoredLedgerEvent[] { + const all = [...events.values()].sort(compareReceived); + if (!cursor) { + return all.slice(0, limit).map((rec) => ({ + event: rec.event, + receivedAt: rec.receivedAt, + chainStatus: rec.chainStatus, + })); + } + const idx = all.findIndex((r) => r.event.eventId === cursor.eventId); + if (idx === -1) { + return all + .filter( + (r) => + r.receivedAt > cursor.receivedAt || + (r.receivedAt === cursor.receivedAt && r.event.eventId > cursor.eventId), + ) + .slice(0, limit) + .map((rec) => ({ + event: rec.event, + receivedAt: rec.receivedAt, + chainStatus: rec.chainStatus, + })); + } + return all.slice(idx + 1, idx + 1 + limit).map((rec) => ({ + event: rec.event, + receivedAt: rec.receivedAt, + chainStatus: rec.chainStatus, + })); + }, + + reset(): void { + events.clear(); + bySosId.clear(); + states.clear(); + rejected.length = 0; + rejectionCounter = 0; + }, +}; diff --git a/backend/src/mesh/controlFrame.ts b/backend/src/mesh/controlFrame.ts new file mode 100644 index 0000000..0fadc6f --- /dev/null +++ b/backend/src/mesh/controlFrame.ts @@ -0,0 +1,32 @@ +import { + verifyControlFrame, + type VerifyControlFrameOptions, + type ControlFrameResult, +} from "@guacamaya/shared"; +import { publicKeyHex } from "../crypto/keys.js"; + +/** + * Server-side decoder + verifier for GuacaMalla control frames uploaded by a + * data mule alongside SOS frames. Wraps the shared zero-trust cascade + * (`packages/shared` controlFrame.ts) — the wire layout, reject cascade and + * domain-prefixed Ed25519 verification are owned by the shared contract so both + * sides stay byte-identical. This module only adds base64 framing + the pinned + * backend public key. + * + * Dedupe (signature replay) is intentionally NOT done here — the caller owns the + * persistent replay guard (`controlReplayRepo`). A mule legitimately re-uploads + * the same control frame; replay protection is an admission decision, not a + * verification one. + */ +export async function decodeAndVerifyControlFrame( + frameB64: string, + opts: VerifyControlFrameOptions = { nowMs: Date.now(), backendPublicKeyHex: publicKeyHex }, +): Promise { + let raw: Buffer; + try { + raw = Buffer.from(frameB64, "base64"); + } catch { + return { ok: false, reason: "invalid base64" }; + } + return verifyControlFrame(Uint8Array.from(raw), opts); +} diff --git a/backend/src/resolve/evidence.ts b/backend/src/resolve/evidence.ts index 1a0bd0c..f40fed5 100644 --- a/backend/src/resolve/evidence.ts +++ b/backend/src/resolve/evidence.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { supabase, isSupabaseConfigured } from "../db/supabase.js"; import { securityConfig } from "../security/config.js"; import { hashImage } from "@guacamaya/shared"; -import { publicKeyHex } from "../crypto/keys.js"; +import { privateKey } from "../crypto/keys.js"; /** * Evidence-image storage abstraction. @@ -26,6 +26,8 @@ const EXT_BY_MAGIC: Array<{ magic: number[]; ext: string }> = [ { magic: [0x42, 0x4d], ext: "bmp" }, { magic: [0x52, 0x49, 0x46, 0x46], ext: "webp" }, // RIFF — could be webp or wav; assume webp ]; +const IMAGE_HASH_RE = /^[0-9a-f]{64}$/; +const ALLOWED_EXTENSIONS = new Set(["jpg", "png", "webp", "gif", "bmp", "bin"]); function detectExt(bytes: Uint8Array): string { for (const { magic, ext } of EXT_BY_MAGIC) { @@ -34,9 +36,15 @@ function detectExt(bytes: Uint8Array): string { return "bin"; } -/** HMAC key — the backend's stable public key hex (ephemeral in dev). */ -function hmacKey(): string { - return publicKeyHex; +/** + * HMAC key — the backend's private signing seed (ephemeral in dev). + * + * This must never be derived from the public key: /pubkey intentionally exposes + * that value, which would let anybody mint a valid-looking upload token without + * first uploading evidence. + */ +function hmacKey(): Uint8Array { + return privateKey; } export function makeUploadToken(imageHash: string, expiresAtMs: number): string { @@ -100,6 +108,9 @@ export async function storeEvidence(imageBytes: Uint8Array): Promise { + // Both values become storage paths. Reject rather than normalize so no input + // can escape the evidence directory/bucket prefix. + if (!IMAGE_HASH_RE.test(imageHash) || !ALLOWED_EXTENSIONS.has(ext)) return null; if (isSupabaseConfigured && supabase) { const path = `${imageHash}.${ext}`; const { data, error } = await supabase diff --git a/backend/src/resolve/routes.ts b/backend/src/resolve/routes.ts index 25eed8f..685f215 100644 --- a/backend/src/resolve/routes.ts +++ b/backend/src/resolve/routes.ts @@ -1,18 +1,31 @@ import { FastifyInstance } from "fastify"; +import * as ed from "@noble/ed25519"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { getResolveId, verifyResolveEnvelope, verifyWitnessSignature, + hashCanonical, + deriveSosId, + computeEventId, + computePayloadHash, + canonicalEventBytes, type ChannelId, type ChannelRecord, + type LedgerEvent, type ResolveEnvelope, + type ResolvePayload, + type ResolveQuorumProof, type ResolveReceipt, type ResolveWitness, + type UnsignedLedgerEvent, } from "@guacamaya/shared"; import { channelsRepo } from "../db/channelsRepo.js"; import { resolvesRepo } from "../db/resolvesRepo.js"; +import { ledgerRepo } from "../ledger/ledgerRepo.js"; import { signRecord } from "../crypto/signer.js"; import { broadcastRecord, broadcastResolve } from "../ws/server.js"; +import { privateKey, publicKeyBytes } from "../crypto/keys.js"; import { requireApiKey } from "../security/auth.js"; import { securityConfig, effectiveReadKey } from "../security/config.js"; import { isValidResolveEnvelope } from "../security/validation.js"; @@ -106,6 +119,78 @@ async function emitResueltoRecord( } } +/** + * Derive SOS_RESOLVED ledger event from a freshly-crossed quorum and upsert it. + * The witnesses (cumulative set) carry their own Ed25519 signatures; the backend + * re-canonicalizes them into witnessEnvelopeHash, signs canonicalEventBytes with + * the backend key, and persists. Idempotent by eventId — re-crossing the same + * quorum with the same witness set produces the same eventId and dedupes. + * + * The ledger event is *additive* — it cannot veto a CANCELLED with higher rank. + * reconcileState picks the winner per compareAuthority. + */ +async function emitResolveQuorumLedgerEvent( + targetRec: ChannelRecord, + receipt: ResolveReceipt, + witnesses: ResolveWitness[], +): Promise { + if (!targetRec.author.startsWith("device-")) return; + const pubkeyHex = targetRec.author.slice("device-".length); + const messageId = (targetRec.payload as { msgId?: unknown }).msgId; + if (typeof messageId !== "number" || !Number.isFinite(messageId)) return; + + // The sosId belongs to the originator (author of the SOS frame), not to the + // backend. The backend *signs* the event via eventSignature, but issuerPublicKey + // here is the backend key — verifyLedgerEvent checks the backend signature, and + // the proof.witnessEnvelopeHash binds the witnesses. sosId is recomputed from + // the originator's pubkey so this RESOLVED event chains under the same sosId + // as the SOS_CREATED. + const originPubkey = hexToBytes(pubkeyHex); + const sosId = deriveSosId(originPubkey, messageId); + const issuerPublicKey = publicKeyBytes; + + const previousEvents = await ledgerRepo.getEventsBySosId(sosId); + const created = previousEvents.find((s) => s.event.type === "SOS_CREATED"); + const previousSosEventHash = created?.event.eventId ?? null; + + const now = Date.now(); + const payload: ResolvePayload = { + payloadVersion: 1, + resolvedAt: now, + quorumReachedAt: now, + backendIssuedAt: now, + witnesses, + }; + const payloadHash = computePayloadHash(payload); + const proof: ResolveQuorumProof = { + proofType: "resolve-quorum", + proofVersion: 1, + resolveReceiptId: receipt.id, + witnessEnvelopeHash: bytesToHex(hashCanonical(witnesses)), + quorumCount: witnesses.length, + }; + const unsigned: UnsignedLedgerEvent = { + version: 1, + sosId, + type: "SOS_RESOLVED", + previousSosEventHash, + issuedAt: now, + issuerPublicKey, + payload, + payloadHash, + payloadVersion: 1, + proof, + }; + const eventId = computeEventId(unsigned); + const eventSignature = await ed.signAsync(canonicalEventBytes(unsigned), privateKey); + const event: LedgerEvent = { ...unsigned, eventId, eventSignature }; + try { + await ledgerRepo.upsertEvent(event); + } catch (err) { + console.error("[ledger] SOS_RESOLVED quorum upsert failed", err); + } +} + export async function resolveRoutes(fastify: FastifyInstance) { // Accept raw image bytes for /resolve/evidence. Default Fastify parser only // handles application/json. @@ -146,11 +231,15 @@ export async function resolveRoutes(fastify: FastifyInstance) { } ); + // The quorum endpoint emits a terminal SOS_RESOLVED event and therefore does + // not implement certified, visual-only soft-resolve semantics. Keep it out of + // the route table unless a developer explicitly opts into legacy testing. + if (!securityConfig.resolve.legacyEndpointEnabled) return; + fastify.post<{ Body: ResolveEnvelope }>( "/resolve", { config: { rateLimit: securityConfig.ingestRateLimit }, - preHandler: perWitnessResolveLimit(), }, async (request, reply) => { const envelope = request.body; @@ -162,6 +251,18 @@ export async function resolveRoutes(fastify: FastifyInstance) { }); } + const now = Date.now(); + if ( + envelope.submittedAt < now - securityConfig.resolve.witnessMaxAgeMs || + envelope.submittedAt > now + securityConfig.resolve.clockSkewMs + ) { + return reply.code(400).send({ + accepted: false, + status: "rejected", + reason: "submitted_at_out_of_window", + }); + } + const submitterDeviceId = envelope.witnesses[0]!.deviceId; (request as { witnessDeviceId?: string }).witnessDeviceId = submitterDeviceId; @@ -206,6 +307,24 @@ export async function resolveRoutes(fastify: FastifyInstance) { }); } + // Authenticate the identity before consuming its device-scoped tokens. + // Otherwise an attacker can spoof a victim's deviceId and exhaust that + // victim's bucket with invalid envelopes. + const submitterSignatureValid = await verifyWitnessSignature( + envelope, + envelope.witnesses[0]!, + ); + if (!submitterSignatureValid) { + return reply.code(400).send({ + accepted: false, + status: "rejected", + targetSosId: envelope.targetSosId, + reason: "signature_invalid", + }); + } + const rateLimitResponse = await perWitnessResolveLimit()(request, reply); + if (rateLimitResponse) return rateLimitResponse; + // Gates 5–9: per-witness processing. Skip dups and originator silently; // skip witnesses that fail geo/sig/token. Count only newly-accepted. const newAccepted: ResolveWitness[] = []; @@ -215,6 +334,14 @@ export async function resolveRoutes(fastify: FastifyInstance) { reasons.push("witness_is_originator"); continue; } + if ( + w.ts < now - securityConfig.resolve.witnessMaxAgeMs || + w.ts > now + securityConfig.resolve.clockSkewMs || + Math.abs(w.ts - envelope.submittedAt) > securityConfig.resolve.witnessMaxAgeMs + ) { + reasons.push("witness_timestamp_out_of_window"); + continue; + } const alreadyWitnessed = await resolvesRepo.hasWitness(envelope.targetSosId, w.deviceId); if (alreadyWitnessed) { reasons.push("duplicate_witness"); @@ -265,7 +392,6 @@ export async function resolveRoutes(fastify: FastifyInstance) { witnesses: allWitnesses, }; const receiptId = getResolveId(canonicalEnvelope); - const now = Date.now(); const previousReceipt = await resolvesRepo.getReceiptByTarget(envelope.targetSosId); const previouslyPending = previousReceipt?.status === "pending" ? previousReceipt.cooldownEndsAt : undefined; @@ -290,13 +416,20 @@ export async function resolveRoutes(fastify: FastifyInstance) { }; await resolvesRepo.upsertReceipt(receipt); - // On first quorum crossing, kick the cooldown + broadcast pending-clear. + // On first quorum crossing, kick the cooldown and emit the ledger verdict. + // The ledger SOS_RESOLVED event is the single source of truth for all visible + // effects (terminalControlTrusts → stop-victim-transmission on the mule path). + // The legacy broadcast (receipt WS push + "resuelto" record) is gated off by + // default and forced off in production — it only duplicated the ledger verdict. const justReachedQuorum = quorumSeen >= quorumNeeded && !previouslyPending; if (justReachedQuorum) { - broadcastResolve(receipt); - await emitResueltoRecord(target, receipt, "pending"); - } else { - // Accumulating or repeat submission — broadcast updated receipt. + if (securityConfig.resolve.legacyBroadcast) { + broadcastResolve(receipt); + await emitResueltoRecord(target, receipt, "pending"); + } + await emitResolveQuorumLedgerEvent(targetRec, receipt, allWitnesses); + } else if (securityConfig.resolve.legacyBroadcast) { + // Accumulating or repeat submission — broadcast updated receipt (legacy). broadcastResolve(receipt); } diff --git a/backend/src/security/auth.ts b/backend/src/security/auth.ts index e92d72d..9e87fc2 100644 --- a/backend/src/security/auth.ts +++ b/backend/src/security/auth.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; function extractToken(request: FastifyRequest): string | undefined { @@ -13,9 +13,11 @@ function extractToken(request: FastifyRequest): string | undefined { } function keysMatch(provided: string, expected: string): boolean { - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; + // Compare fixed-size digests so different-length inputs follow the same path. + // SHA-256 is used only to normalize the input length; timingSafeEqual remains + // the final comparison primitive. + const a = createHash("sha256").update(provided, "utf8").digest(); + const b = createHash("sha256").update(expected, "utf8").digest(); return timingSafeEqual(a, b); } diff --git a/backend/src/security/config.ts b/backend/src/security/config.ts index d4fb9fc..84130e5 100644 --- a/backend/src/security/config.ts +++ b/backend/src/security/config.ts @@ -1,9 +1,31 @@ import { randomBytes } from "node:crypto"; -function parseOrigins(raw: string | undefined): string[] | true { - if (!raw || raw.trim() === "*") return true; +function parseOrigins(raw: string | undefined, production: boolean): string[] | true { + if (!raw || raw.trim() === "*") { + if (production) { + throw new Error("CORS_ORIGINS must be an explicit comma-separated allowlist in production"); + } + return true; + } const list = raw.split(",").map((s) => s.trim()).filter(Boolean); - return list.length > 0 ? list : true; + if (list.length === 0) { + throw new Error("CORS_ORIGINS must contain at least one origin"); + } + for (const origin of list) { + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + throw new Error(`Invalid CORS origin: ${origin}`); + } + if (parsed.origin !== origin || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + throw new Error(`CORS origin must be an exact http(s) origin without a path: ${origin}`); + } + if (production && parsed.protocol !== "https:") { + throw new Error(`CORS origin must use HTTPS in production: ${origin}`); + } + } + return list; } function resolveKey(envName: string, fallback?: string): string | undefined { @@ -14,12 +36,32 @@ function resolveKey(envName: string, fallback?: string): string | undefined { const isProduction = process.env.NODE_ENV === "production"; -/** Ephemeral dev key — logged once at startup when no env key is set. */ +function boundedNumber( + envName: string, + fallback: number, + min: number, + max: number, + integer = false, +): number { + const raw = process.env[envName]; + const parsed = raw === undefined || raw.trim() === "" ? fallback : Number(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`${envName} must be a finite number`); + } + const value = integer ? Math.trunc(parsed) : parsed; + if (value < min || value > max) { + throw new Error(`${envName} must be between ${min} and ${max}`); + } + return value; +} + +/** Ephemeral dev key — generated once without ever exposing it in logs. */ function devFallback(label: string): string | undefined { if (isProduction) return undefined; const key = randomBytes(32).toString("hex"); console.warn( - `[guacamaya-net] ${label} not set — using ephemeral dev key (set ${label} in production):\n ${key}`, + `[guacamaya-net] ${label} not set — using an unlogged ephemeral dev key; ` + + `set ${label} explicitly to authenticate development clients`, ); return key; } @@ -27,7 +69,7 @@ function devFallback(label: string): string | undefined { export const securityConfig = { isProduction, - corsOrigins: parseOrigins(process.env.CORS_ORIGINS), + corsOrigins: parseOrigins(process.env.CORS_ORIGINS, isProduction), /** Signs official channel records (POST /channels/:id/records). */ adminApiKey: @@ -44,9 +86,18 @@ export const securityConfig = { /** WebSocket upgrade token (query `?token=` or header). Falls back to read key. */ wsApiKey: resolveKey("GUACAMAYA_WS_KEY"), - maxIngestBatch: Math.min(Number(process.env.MAX_INGEST_BATCH ?? 200), 1000), - maxFrameB64Length: Number(process.env.MAX_FRAME_B64_LENGTH ?? 256), - maxOfficialPayloadBytes: Number(process.env.MAX_OFFICIAL_PAYLOAD_BYTES ?? 16_384), + maxIngestBatch: boundedNumber("MAX_INGEST_BATCH", 200, 1, 1000, true), + maxFrameB64Length: boundedNumber("MAX_FRAME_B64_LENGTH", 256, 64, 4096, true), + /** "Encontrado" proximity gate: finder within this many meters of the SOS's + * last reported point → likely_found; further → possible_false_positive. */ + foundProximityMeters: boundedNumber("FOUND_PROXIMITY_METERS", 100, 1, 100_000), + maxOfficialPayloadBytes: boundedNumber("MAX_OFFICIAL_PAYLOAD_BYTES", 16_384, 1, 1_048_576, true), + + /** Allowlisted field rescuers may receive a fresh certificate through a mule ingest. */ + rescuerPublicKeys: new Set((process.env.RESCUER_PUBLIC_KEYS ?? "").split(",").map((v) => v.trim()).filter((v) => /^[0-9a-f]{64}$/.test(v))), + rescuerCertificateValidityMs: boundedNumber("RESCUER_CERTIFICATE_VALIDITY_H", 168, 1, 168) * 60 * 60_000, + revokedRescuerPublicKeys: [...new Set((process.env.REVOKED_RESCUER_PUBLIC_KEYS ?? "").split(",").map((v) => v.trim()).filter((v) => /^[0-9a-f]{64}$/.test(v)))], + rescuerCrlSequence: boundedNumber("RESCUER_CRL_SEQUENCE", 0, 0, Number.MAX_SAFE_INTEGER, true), globalRateLimit: { max: 100, timeWindow: "1 minute" as const }, ingestRateLimit: { max: 30, timeWindow: "1 minute" as const }, @@ -55,14 +106,28 @@ export const securityConfig = { /** Resolve flow — finder co-signed disarm of an active SOS. */ resolve: { - quorumRequired: Math.max(1, Number(process.env.RESOLVE_QUORUM_REQUIRED ?? 2)), - quorumTotal: Math.max(1, Number(process.env.RESOLVE_QUORUM_TOTAL ?? 3)), - geoRadiusKm: Number(process.env.RESOLVE_GEO_RADIUS_KM ?? 5), - targetMaxAgeH: Number(process.env.RESOLVE_TARGET_MAX_AGE_H ?? 72), - cooldownMin: Number(process.env.RESOLVE_COOLDOWN_MIN ?? 15), - perWitnessPerH: Number(process.env.RESOLVE_PER_WITNESS_PER_H ?? 5), - evidenceTtlMs: Number(process.env.RESOLVE_EVIDENCE_TTL_MS ?? 300_000), - maxImageBytes: Number(process.env.RESOLVE_MAX_IMAGE_BYTES ?? 8 * 1024 * 1024), + /** Legacy quorum endpoint can silence an SOS; never expose it in production. */ + legacyEndpointEnabled: + !isProduction && process.env.GUACAMAYA_LEGACY_RESOLVE_ENDPOINT === "1", + /** + * Legacy /resolve broadcast: the receipt WS push (broadcastResolve) and the + * "resuelto" channel record (emitResueltoRecord). These predate the ledger and + * duplicate its verdict. Off by default — the moderated ledger state is now the + * single source of visible effects (stop-victim-transmission control frames are + * minted from terminal ledger state on the mule path). Forced off in production; + * set GUACAMAYA_LEGACY_RESOLVE_BROADCAST=1 to re-enable in dev for back-compat. + */ + legacyBroadcast: isProduction ? false : process.env.GUACAMAYA_LEGACY_RESOLVE_BROADCAST === "1", + quorumRequired: boundedNumber("RESOLVE_QUORUM_REQUIRED", 2, 1, 100, true), + quorumTotal: boundedNumber("RESOLVE_QUORUM_TOTAL", 3, 1, 100, true), + geoRadiusKm: boundedNumber("RESOLVE_GEO_RADIUS_KM", 5, 0.01, 1000), + targetMaxAgeH: boundedNumber("RESOLVE_TARGET_MAX_AGE_H", 72, 1, 24 * 365), + witnessMaxAgeMs: boundedNumber("RESOLVE_WITNESS_MAX_AGE_MS", 10 * 60_000, 1000, 24 * 60 * 60_000, true), + clockSkewMs: boundedNumber("RESOLVE_CLOCK_SKEW_MS", 2 * 60_000, 0, 60 * 60_000, true), + cooldownMin: boundedNumber("RESOLVE_COOLDOWN_MIN", 15, 0, 24 * 60), + perWitnessPerH: boundedNumber("RESOLVE_PER_WITNESS_PER_H", 5, 1, 10_000, true), + evidenceTtlMs: boundedNumber("RESOLVE_EVIDENCE_TTL_MS", 300_000, 1000, 24 * 60 * 60_000, true), + maxImageBytes: boundedNumber("RESOLVE_MAX_IMAGE_BYTES", 8 * 1024 * 1024, 1024, 25 * 1024 * 1024, true), evidenceRequireAuth: isProduction ? process.env.RESOLVE_EVIDENCE_REQUIRE_AUTH !== "false" : process.env.RESOLVE_EVIDENCE_REQUIRE_AUTH === "true", @@ -82,3 +147,11 @@ export function effectiveWsKey(): string | undefined { if (isProduction && !securityConfig.adminApiKey) { throw new Error("GUACAMAYA_ADMIN_KEY is required when NODE_ENV=production"); } + +if (securityConfig.resolve.quorumRequired > securityConfig.resolve.quorumTotal) { + throw new Error("RESOLVE_QUORUM_REQUIRED cannot exceed RESOLVE_QUORUM_TOTAL"); +} + +if (isProduction && securityConfig.adminApiKey!.length < 32) { + throw new Error("GUACAMAYA_ADMIN_KEY must contain at least 32 characters in production"); +} diff --git a/backend/src/security/validation.ts b/backend/src/security/validation.ts index ff1a432..d8f3a78 100644 --- a/backend/src/security/validation.ts +++ b/backend/src/security/validation.ts @@ -8,6 +8,7 @@ const VALID_CHANNEL_IDS = new Set([ "estoy-bien", "solicito-ayuda", "resuelto", + "encontrado", ]); export function isValidChannelId(id: string): boolean { diff --git a/backend/supabase/schema.sql b/backend/supabase/schema.sql index 3753362..0792b02 100644 --- a/backend/supabase/schema.sql +++ b/backend/supabase/schema.sql @@ -102,6 +102,132 @@ create index if not exists resolve_witnesses_target_ts_idx alter table public.resolve_witnesses enable row level security; +-- ─── Ledger distribuido firmado ─────────────────────────────────────────────── +-- Cadena por sosId (NO blockchain global). Cada SOS_CREATED inicia una cadena +-- independiente; RESOLVED/CANCELLED/EXPIRED/KEY_REVOKED encadenan por +-- previous_sos_event_hash. La reconciliación (reconcileState en packages/shared) +-- resuelve bifurcaciones por compareAuthority (rank → quorumCount → eventId). +-- chain_status: 'attached' (cadena válida desde SOS_CREATED), 'orphan' +-- (previous_sos_event_hash apunta a evento ausente), 'conflict' (perdió la +-- bifurcación contra otro attached). Eventos inválidos van a +-- ledger_rejected_events aparte (DoS de reserva de event_id falso). + +create table if not exists public.ledger_events ( + event_id text primary key + check (event_id ~ '^[0-9a-f]{64}$'), + sos_id text not null + check (sos_id ~ '^[0-9a-f]{64}$'), + type text not null check (type in ( + 'SOS_CREATED', 'SOS_RESOLVED', 'SOS_CANCELLED', 'SOS_EXPIRED', 'KEY_REVOKED' + )), + previous_sos_event_hash text + check (previous_sos_event_hash is null + or previous_sos_event_hash ~ '^[0-9a-f]{64}$'), + issued_at bigint not null, -- ms; emisión del evento (auditable, no orden) + issuer_public_key text not null + check (issuer_public_key ~ '^[0-9a-f]{64}$'), + payload jsonb not null, -- objeto canónicamente hashable + payload_hash text not null + check (payload_hash ~ '^[0-9a-f]{64}$'), -- sha256(canonicalJsonBytes(payload)) + proof jsonb not null, + event_signature bytea, -- 64 B Ed25519; null solo en SOS_CREATED + chain_status text not null + check (chain_status in ('attached', 'orphan', 'conflict')), + received_at bigint not null + default (extract(epoch from now()) * 1000)::bigint +); + +-- Reconstrucción de cadena por sosId (padre→hijos). +create index if not exists idx_ledger_events_sos_id_issued_at + on public.ledger_events (sos_id, issued_at); + +-- Feed paginado con cursor estable (receivedAt + eventId). +create index if not exists idx_ledger_events_received_at_event_id + on public.ledger_events (received_at, event_id); + +-- Un solo SOS_CREATED por sosId. Insertar un segundo rompe la cadena. +create unique index if not exists uq_ledger_created_per_sos + on public.ledger_events (sos_id) where type = 'SOS_CREATED'; + +alter table public.ledger_events enable row level security; +create policy "service_role all ledger_events" on public.ledger_events + for all using (auth.role() = 'service_role'); + +-- Estado materializado por sosId. Se reconcilia desde ledger_events vía +-- reconcileState; null mientras la cadena esté rota (orphans puros). +create table if not exists public.sos_state ( + sos_id text primary key + check (sos_id ~ '^[0-9a-f]{64}$'), + current_type text not null check (current_type in ( + 'active', 'resolved', 'cancelled', 'expired' + )), + terminal boolean not null, + last_event_id text not null + check (last_event_id ~ '^[0-9a-f]{64}$'), + last_event_at bigint not null, + witness_count integer not null default 0 + check (witness_count >= 0), + retention_until bigint, -- null = sin expiración programada + updated_at bigint not null + default (extract(epoch from now()) * 1000)::bigint +); + +alter table public.sos_state enable row level security; +create policy "service_role all sos_state" on public.sos_state + for all using (auth.role() = 'service_role'); + +-- Eventos criptográficamente inválidos. No comparten PK con ledger_events: +-- un atacante podría reservar un event_id falso y bloquear el legítimo. +-- raw_event es solo auditoría forense. +create table if not exists public.ledger_rejected_events ( + rejection_id uuid primary key default gen_random_uuid(), + claimed_event_id text + check (claimed_event_id is null or claimed_event_id ~ '^[0-9a-f]{64}$'), + sos_id text + check (sos_id is null or sos_id ~ '^[0-9a-f]{64}$'), + reason text not null, + raw_event jsonb not null, + received_at bigint not null + default (extract(epoch from now()) * 1000)::bigint +); + +create index if not exists idx_ledger_rejected_claimed_event_id + on public.ledger_rejected_events (claimed_event_id) + where claimed_event_id is not null; + +create index if not exists idx_ledger_rejected_received_at + on public.ledger_rejected_events (received_at); + +alter table public.ledger_rejected_events enable row level security; +create policy "service_role all ledger_rejected_events" on public.ledger_rejected_events + for all using (auth.role() = 'service_role'); + +-- ─── Replay guard de tramas de control ──────────────────────────────────────── +-- Una trama de control v1 (delivery / backend-resolve / self-cancel) ya verificada +-- se admite una sola vez por firma (signature_hex = hex de los 64 B Ed25519), aunque +-- llegue reenviada por múltiples mules o saltos. expires_at permite la purga periódica +-- de entradas vencidas (la trama en sí lleva expiresAt firmado). Es la contraparte +-- persistente del DedupeCache LRU en memoria de Android (Fase 2/4). + +create table if not exists public.control_replay ( + signature_hex text primary key + check (signature_hex ~ '^[0-9a-f]{128}$'), -- 64 B Ed25519 hex + frame_type smallint not null, -- 0x01 delivery | 0x02 backend-resolve | 0x03 self-cancel + issuer_node_id text not null, -- sha256(issuer pubkey)[0..3] hex — namespace de dedupe + ctrl_msg_id integer not null, -- u16 CSPRNG de la trama + expires_at bigint not null, -- unix ms; para purga + received_at bigint not null + default (extract(epoch from now()) * 1000)::bigint +); + +-- Purga periódica de entradas vencidas. +create index if not exists control_replay_expires_at_idx + on public.control_replay (expires_at); + +alter table public.control_replay enable row level security; +create policy "service_role all control_replay" on public.control_replay + for all using (auth.role() = 'service_role'); + -- ─── Waitlist ───────────────────────────────────────────────────────────────── -- Pre-launch email signups from the marketing landing page. Not part of the -- mesh protocol, so dedup is by the normalized email itself (a natural unique diff --git a/backend_final.md b/backend_final.md index a45a988..5c255f0 100644 --- a/backend_final.md +++ b/backend_final.md @@ -283,3 +283,58 @@ Pautas: > Estado actual: el `IngestClient` en Android **aún no existe** (trabajo abierto, ver > `android/CLAUDE.md`). El lado backend está completo y verificado end-to-end contra Supabase. + +## POST /ingest — `foundFrames` ("Encontrado" reports) + +Alongside `frames` and `controlFrames`, the ingest body may carry +`"foundFrames": ["", ...]` — signed 118-byte found frames +(`22 B payload + 32 B finder pubkey + 64 B signature`; a 119-byte frame with a +leading hop-TTL byte is tolerated). No API key: zero-trust by signature. + +Found payload v1 (22 bytes, big-endian; signature domain +`"guacamaya.found-frame.v1\n"` prefixed to the payload before signing): + +| Offset | Field | Type | +|---|---|---| +| 0..3 | latE7 (finder latitude × 1e7) | int32 | +| 4..7 | lonE7 (finder longitude × 1e7) | int32 | +| 8..11 | tsUnix | u32 | +| 12..15 | targetNodeId = SHA-256(victim pubkey)[0..3] | 4 B | +| 16..17 | targetMsgId (SOS session) | u16 | +| 18..19 | foundMsgId (CSPRNG) | u16 | +| 20..21 | CRC16-CCITT over 0..19 | u16 | + +Server-side, per frame: CRC → GPS-fix sanity (0,0 / out-of-range rejected) → +Ed25519 over the domain-prefixed payload → target lookup (latest +`solicito-ayuda` record with `payload.nodeId == targetNodeId` and +`payload.msgId == targetMsgId`; missing or fixless target ⇒ rejected, +`"unknown target sos"`). Accepted frames become `verified:false` records on +channel `encontrado` with `id = SHA-256(payload22)` (dedupe), plus a proximity +verdict computed from the finder's signed position vs the SOS's last reported +point: distance ≤ `FOUND_PROXIMITY_METERS` (default 100 m) ⇒ +`proximity:"near"`, `verdict:"likely_found"`; otherwise `proximity:"far"`, +`verdict:"possible_false_positive"`. + +Response additions: + +```json +{ + "foundAccepted": 1, + "foundDuplicate": 0, + "foundRejected": 0, + "foundReasons": {}, + "foundResults": [ + { + "id": "", + "targetRecordId": "", + "distanceMeters": 33, + "proximity": "near", + "verdict": "likely_found" + } + ] +} +``` + +Verdicts are returned for duplicates too (the uploading mule is usually the +finder's own phone). Recommended app flow: queue the frame in `outbound_found`, +drain it with the regular mule upload, and log/render the returned verdict. diff --git a/bun.lock b/bun.lock index 2777d4b..2500a23 100644 --- a/bun.lock +++ b/bun.lock @@ -9,15 +9,15 @@ "name": "@guacamaya/backend", "version": "0.1.0", "dependencies": { - "@fastify/cors": "^9.0.0", - "@fastify/helmet": "^11.1.1", - "@fastify/rate-limit": "^9.0.0", - "@fastify/static": "^7.0.0", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^13.1.0", + "@fastify/rate-limit": "^11.1.0", + "@fastify/static": "^9.3.0", "@guacamaya/shared": "workspace:*", "@noble/ed25519": "^2.1.0", "@noble/hashes": "^2.2.0", "@supabase/supabase-js": "^2.108.0", - "fastify": "^4.27.0", + "fastify": "^5.10.0", "resend": "^4.0.0", "ws": "^8.17.0", }, @@ -37,425 +37,48 @@ "typescript": "^5.4.0", }, }, - "web/landing": { - "name": "@guacamaya/landing", - "version": "0.1.0", - "dependencies": { - "@fontsource/jetbrains-mono": "^5.2.8", - "@fontsource/outfit": "^5.2.8", - "@hookform/resolvers": "^5.2.2", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-aspect-ratio": "^1.1.8", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-hover-card": "^1.1.15", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-menubar": "^1.1.16", - "@radix-ui/react-navigation-menu": "^1.2.14", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.2.8", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.575.0", - "react": "^19.2.0", - "react-day-picker": "^9.14.0", - "react-dom": "^19.2.0", - "react-hook-form": "^7.71.2", - "react-resizable-panels": "^4.6.5", - "recharts": "^2.15.4", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "three": "^0.185.0", - "tw-animate-css": "^1.3.4", - "vaul": "^1.1.2", - "zod": "^4.4.3", - }, - "devDependencies": { - "@eslint/js": "^9.32.0", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^22.16.5", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@types/three": "^0.185.0", - "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.32.0", - "eslint-config-prettier": "^10.1.1", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^15.15.0", - "prettier": "^3.7.3", - "tailwindcss": "^4.2.1", - "typescript": "^5.8.3", - "typescript-eslint": "^8.56.1", - "vite": "^8.0.16", - "vite-tsconfig-paths": "^6.0.2", - }, - }, }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - - "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - - "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], - - "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - - "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], - - "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], - - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@fastify/cors": ["@fastify/cors@11.3.0", "", { "dependencies": { "fastify-plugin": "^6.0.0", "toad-cache": "^3.7.0" } }, "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.1.0", "", { "dependencies": { "fast-json-stringify": "^7.0.0" } }, "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + "@fastify/helmet": ["@fastify/helmet@13.1.0", "", { "dependencies": { "fastify-plugin": "^6.0.0", "helmet": "^8.0.0" } }, "sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@fastify/rate-limit": ["@fastify/rate-limit@11.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^6.0.0", "toad-cache": "^3.7.0" } }, "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA=="], - "@fastify/accept-negotiator": ["@fastify/accept-negotiator@1.1.0", "", {}, "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ=="], + "@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="], - "@fastify/ajv-compiler": ["@fastify/ajv-compiler@3.6.0", "", { "dependencies": { "ajv": "^8.11.0", "ajv-formats": "^2.1.1", "fast-uri": "^2.0.0" } }, "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ=="], - - "@fastify/cors": ["@fastify/cors@9.0.1", "", { "dependencies": { "fastify-plugin": "^4.0.0", "mnemonist": "0.39.6" } }, "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q=="], - - "@fastify/error": ["@fastify/error@3.4.1", "", {}, "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ=="], - - "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@4.3.0", "", { "dependencies": { "fast-json-stringify": "^5.7.0" } }, "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA=="], - - "@fastify/helmet": ["@fastify/helmet@11.1.1", "", { "dependencies": { "fastify-plugin": "^4.2.1", "helmet": "^7.0.0" } }, "sha512-pjJxjk6SLEimITWadtYIXt6wBMfFC1I6OQyH/jYVCqSAn36sgAIFjeNiibHtifjCd+e25442pObis3Rjtame6A=="], - - "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.1.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA=="], - - "@fastify/rate-limit": ["@fastify/rate-limit@9.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.1", "fastify-plugin": "^4.0.0", "toad-cache": "^3.3.1" } }, "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA=="], - - "@fastify/send": ["@fastify/send@2.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.1", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "2.0.0", "mime": "^3.0.0" } }, "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA=="], - - "@fastify/static": ["@fastify/static@7.0.4", "", { "dependencies": { "@fastify/accept-negotiator": "^1.0.0", "@fastify/send": "^2.0.0", "content-disposition": "^0.5.3", "fastify-plugin": "^4.0.0", "fastq": "^1.17.0", "glob": "^10.3.4" } }, "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q=="], - - "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - - "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.2.8", "", {}, "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="], - - "@fontsource/outfit": ["@fontsource/outfit@5.2.8", "", {}, "sha512-rXC6g0MqD7cOBjht0bMqc43qM6lRqDLML9KXsmg9uykz0wLQhy8Z/ajrMG6iyoT3NJR+MYgU+OEHp7uHoTb+Yw=="], + "@fastify/static": ["@fastify/static@9.3.0", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^6.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w=="], "@guacamaya/backend": ["@guacamaya/backend@workspace:backend"], - "@guacamaya/landing": ["@guacamaya/landing@workspace:web/landing"], - "@guacamaya/shared": ["@guacamaya/shared@workspace:packages/shared"], - "@hookform/resolvers": ["@hookform/resolvers@5.4.0", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw=="], - - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - - "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - - "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@noble/ed25519": ["@noble/ed25519@2.3.0", "", {}, "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], - "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - - "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], - - "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], - - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], - - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.15", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg=="], - - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.18", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA=="], - - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="], - - "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw=="], - - "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.1", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g=="], - - "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g=="], - - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw=="], - - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.11", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="], - - "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ=="], - - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw=="], - - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], - - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ=="], - - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw=="], - - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], - - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.11", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w=="], - - "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw=="], - - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], - - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="], - - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg=="], - - "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw=="], - - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg=="], - - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg=="], - - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="], - - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], - - "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw=="], - - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw=="], - - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.13", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw=="], - - "@radix-ui/react-select": ["@radix-ui/react-select@2.3.2", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q=="], - - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ=="], - - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.2", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - - "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA=="], - - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw=="], - - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg=="], - - "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-toggle": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg=="], - - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], - - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], - - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], - - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], - - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], - - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], - "@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@supabase/auth-js": ["@supabase/auth-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag=="], "@supabase/functions-js": ["@supabase/functions-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw=="], @@ -470,207 +93,33 @@ "@supabase/supabase-js": ["@supabase/supabase-js@2.108.2", "", { "dependencies": { "@supabase/auth-js": "2.108.2", "@supabase/functions-js": "2.108.2", "@supabase/postgrest-js": "2.108.2", "@supabase/realtime-js": "2.108.2", "@supabase/storage-js": "2.108.2" } }, "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ=="], - "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="], - - "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], - - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], - - "@types/three": ["@types/three@0.185.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA=="], - - "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.1", "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.1", "@typescript-eslint/tsconfig-utils": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], - "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], - "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - "avvio": ["avvio@8.4.0", "", { "dependencies": { "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], - - "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - - "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], - - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], - "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - - "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - - "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -680,119 +129,31 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.383", "", {}, "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw=="], - - "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], - - "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], - - "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], - - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], - - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - - "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], - - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "fast-content-type-parse": ["fast-content-type-parse@1.1.0", "", {}, "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ=="], - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], - - "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-json-stringify": ["fast-json-stringify@5.16.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.1.0", "ajv": "^8.10.0", "ajv-formats": "^3.0.1", "fast-deep-equal": "^3.1.3", "fast-uri": "^2.1.0", "json-schema-ref-resolver": "^1.0.1", "rfdc": "^1.2.0" } }, "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-json-stringify": ["fast-json-stringify@7.0.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA=="], "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-uri": ["fast-uri@2.4.0", "", {}, "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fastify": ["fastify@4.29.1", "", { "dependencies": { "@fastify/ajv-compiler": "^3.5.0", "@fastify/error": "^3.4.0", "@fastify/fast-json-stringify-compiler": "^4.3.0", "abstract-logging": "^2.0.1", "avvio": "^8.3.0", "fast-content-type-parse": "^1.1.0", "fast-json-stringify": "^5.8.0", "find-my-way": "^8.0.0", "light-my-request": "^5.11.0", "pino": "^9.0.0", "process-warning": "^3.0.0", "proxy-addr": "^2.0.7", "rfdc": "^1.3.0", "secure-json-parse": "^2.7.0", "semver": "^7.5.4", "toad-cache": "^3.3.0" } }, "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ=="], + "fastify": ["fastify@5.10.0", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^7.0.0", "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg=="], - "fastify-plugin": ["fastify-plugin@4.5.1", "", {}, "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ=="], + "fastify-plugin": ["fastify-plugin@6.0.0", "", {}, "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "find-my-way": ["find-my-way@8.2.2", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^3.1.0" } }, "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="], - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "helmet": ["helmet@7.2.0", "", {}, "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw=="], + "helmet": ["helmet@8.2.0", "", {}, "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], @@ -802,221 +163,71 @@ "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], - - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema-ref-resolver": ["json-schema-ref-resolver@1.0.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw=="], + "ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "light-my-request": ["light-my-request@5.14.0", "", { "dependencies": { "cookie": "^0.7.0", "process-warning": "^3.0.0", "set-cookie-parser": "^2.4.1" } }, "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "mnemonist": ["mnemonist@0.39.6", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], - "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.9.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg=="], - "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], - - "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], - - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-hook-form": ["react-hook-form@7.80.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg=="], - - "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="], - "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - - "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], - - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - - "react-resizable-panels": ["react-resizable-panels@4.12.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-t/Gp57qSCxGQ52ckhz+8lM7dnuymeU95TEzl2U203qEbGkSLHrtm7US2/ANzq/zOlja3CwPTAfCDuh1unv9mfw=="], - - "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], - - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - - "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], - "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], - "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], - - "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "ret": ["ret@0.4.3", "", {}, "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safe-regex2": ["safe-regex2@3.1.0", "", { "dependencies": { "ret": "~0.4.0" } }, "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug=="], + "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], @@ -1026,186 +237,30 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], - "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], - - "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - - "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], - - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.62.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - - "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - - "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], - - "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], - - "vite": ["vite@8.1.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ=="], - - "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@types/ws/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "fast-json-stringify/fast-uri": ["fast-uri@4.1.0", "", {}, "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "fast-json-stringify/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "fast-json-stringify/ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - - "pino/process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], - - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], - - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@fastify/ajv-compiler/ajv/fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@types/ws/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], - - "ajv-formats/ajv/fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "fast-json-stringify/ajv/fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/docs/IMPLEMENTATION_REPORT.md b/docs/IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..abe6dd9 --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT.md @@ -0,0 +1,153 @@ +# Reporte tecnico de implementacion + +Fecha: 2026-07-09 + +## Alcance y fuente + +Este reporte describe el arbol de trabajo `guacamaya-net-develop-latest`, basado en +la rama remota `develop` de `vmlcode/guacamaya-net`. Incluye los cambios locales no +commitados de control, seguridad, flooding y Sonar Fase 1. No describe la copia +antigua fuera de este arbol ni revierte cambios existentes en SOSNet. + +## Stack y limites de responsabilidad + +| Capa | Implementacion | Responsabilidad | +| --- | --- | --- | +| Android | Kotlin, Compose, Room, FGS, Bluetooth LE, Wi-Fi Aware | Mesh hot path y radio viven nativamente; la UI solo observa estado. | +| Mesh | `Observer`, `FloodRouter`, `MessageStore`, `Broadcaster` | Recepcion, cascada criptografica, dedupe, persistencia y relay BLE. | +| Protocolo | Frame BLE v1 de 119 B | TTL mutable + payload firmado de 22 B + pubkey Ed25519 + firma. | +| Backend | Fastify, canales, ingest, WebSocket | Verifica ingest, persiste, emite alertas/certificados/recibos. | +| Compartido | TypeScript, noble Ed25519 | Tipos, bytes canonicos, verificacion de receipts y certificados. | + +La aplicacion movil de este arbol es Android nativo; no depende de Expo Go ni de un +runtime JavaScript en el camino de retransmision. + +## Implementado + +### Identidad, control y continuidad + +- Los `ControlReceipt` distinguen `delivery`, `soft-resolve`, `backend-resolve` y + `self-cancel`. +- Un `soft-resolve` certificado solo cambia el estado visual; no apaga el SOS de la + victima. Solo `self-cancel` de la victima y `backend-resolve` pueden detenerlo. +- El backend emite certificados de rescatista de hasta siete dias y puede renovarlos + en la respuesta de `/ingest` para que una mula los distribuya offline. +- La firma del certificado se valida respecto a `issuedAt` del receipt, no al reloj de + recepcion. Esto evita invalidar un receipt legitimo que viajo dias por la malla. +- Los receipts tienen ventana de replay y dedupe en memoria compartida. El `msgId` de + SOS se genera por sesion con CSPRNG para evitar colision con un SOS futuro. +- Produccion exige `BACKEND_PRIVATE_KEY_HEX` persistente. Android ancla + `BACKEND_PUBLIC_KEY_HEX` en el build y no sustituye esa ancla por TOFU de `/pubkey`. +- `ChannelRecord.prevHash` permite una hash-chain por autor: detecta huecos/forks sin + cadena global, consenso, mineria ni proof-of-work. + +### Frame y flooding + +- La cascada en `FloodRouter` valida enlace pubkey/nodeId, CRC, ventana temporal y + firma Ed25519 antes de persistir o retransmitir. +- El flooding aplica token bucket por pubkey, suppression por contador y retardo + inverso a RSSI para favorecer avance geografico. +- El servicio de victima usa `MeshClock` para timestamps, `msgId` aleatorio por + sesion y degradacion de cadencia por edad/bateria. +- Existe UUID BLE reservado para un futuro frame v2 incompatible. Los nodos nuevos + deberan escuchar v1 y v2; no se reutilizara el UUID v1 para un formato incompatible. + +### Sonar Fase 1 + +- `NeighborObservation` y `NeighborEstimate` viven en Room separados de los SOS + firmados. Guardan nodeId, `MeshClock`, RSSI crudo, fuente, PHY, potencia TX cuando + Android la expone y posicion reciente del observador. +- El filtro mantiene hasta 50 muestras en RAM por vecino; usa mediana temporal de + hasta 9 muestras en 8 s, minimo 3, y EWMA sobre dBm. Tras silencio prolongado se + reinicia la estimacion de rango, no el liveness. +- `lastSeenAt` y el estado estable se persisten. Tras reinicio, el timer vuelve a + calcular `HEARD`, `DEGRADED` y `LOST` desde Room: 15 s para degradado y 50 s para + perdido. Aun no se publica `NODE_LOST` al backend. +- Solo se registra RSSI cuando el TTL observado coincide con el TTL de origen. Como + ese byte es mutable y no firmado, el resultado se etiqueta siempre como **directa + no verificada**: nunca autentica proximidad. +- El radar cruza RSSI con la distancia GPS del mismo frame firmado usando discrepancia + de `3 * sqrt(sigma_gps^2 + sigma_rssi^2)`. Si no son consistentes, conserva visibles + ambas evidencias y advierte al rescatista; no usa RSSI para desplazar el bearing GPS. +- Los perfiles de path-loss distinguen 1M, 2M y Coded por `n` y offset provisionales. + Son parametros iniciales, no calibracion de campo. +- El radar usa scan balanced normalmente y low-latency solo durante busqueda. Al salir + conserva low-latency 10 s para no exceder el rate-limit Android de start/stop scan. +- El bearing corporal requiere ocho muestras, cuatro sectores de 45 grados y 6 dB de + contraste; pide giro de 15-20 s o dos vueltas. No implementa AoA/AoD. + +## Garantias y no-garantias + +| Propiedad | Estado | +| --- | --- | +| Identidad/origen del SOS | Ed25519 validado. | +| Integridad del payload y coordenadas que contiene | Firmada por el origen. | +| Proximidad fisica por RSSI | No autenticada; solo guia visual con incertidumbre. | +| TTL directo | Heuristica falsificable; puede existir wormhole/relay sin decremento. | +| Distancia RTT | No implementada aun. | +| Soft-resolve de tercero detiene victima | Prohibido por el modelo de control. | +| Receipt recibido por BLE/NAN en campo | No implementado aun. | + +## Deuda critica antes de piloto + +1. El endpoint legado `/resolve` basado en quorum todavia puede limpiar un SOS y no + cumple la semantica de soft-resolve. Debe restringirse, migrarse o deshabilitarse. +2. Falta CRL firmada/distribuida por mula para certificados de rescatista. +3. Falta LRU persistente movil para replay de control; el dedupe actual no sobrevive + reinicio. +4. Falta codec y transporte BLE para `self-cancel`, `backend-resolve` y `delivery`. + NAN debe llevar certificados, soft-resolve y digest, pero BLE es el fallback + garantizado para dispositivos sin Wi-Fi Aware. +5. `MeshClock` se usa al emitir SOS; falta aplicar su confianza/offset a toda + validacion Android de receipts y definir la UI de tiempo no verificado. +6. NAN actual no tiene rafagas duty-cycled, RTT, anti-entropia, digest persistente ni + intercambio de observaciones. Nearby Connections tampoco esta integrado. + +## Calibracion: pendientes definidos, no implementados + +### Logger Kotlin de campo + +Debe capturar cada `ScanResult` crudo en CSV, sin dedupe de advertising. Esquema +minimo: + +```text +session_id,wall_time_ms,mesh_time_ms,distance_m,environment, +transmitter_model,observer_model,node_id,rssi_dbm,tx_power_dbm, +primary_phy,secondary_phy,is_legacy,advertising_sid,frame_ttl +``` + +La sesion debe declarar distancia real, entorno (`open`, `body`, `wall`, `rubble`), +modelo emisor/observador y orientacion. El logger no debe entrar al hot path de relay: +usa buffer acotado y escritura batch. Requiere consentimiento visible porque registra +identificadores de dispositivo/sesion. + +### Ajuste robusto y tabla de perfiles + +Falta un script que lea esos CSV, agrupe por par emisor-observador + PHY + entorno, +ajuste `P0` y `n` del modelo `RSSI = P0 - 10*n*log10(d)` con perdida Huber y emita: + +```json +{ + "transmitterModel|observerModel|secondaryPhy": { + "p0Dbm": -59.0, + "pathLossExponent": 2.7, + "residualSigmaDb": 7.2, + "distanceUncertaintyFactor": 0.85, + "samples": 120 + } +} +``` + +`distanceUncertaintyFactor` debe derivarse de la desviacion residual del ajuste y +alimentar el anillo del radar. No se deben promocionar los perfiles provisionales a +operativos sin pruebas a 1, 5, 10, 25 y 50 m, primero a cielo abierto y luego con +obstaculo corporal/estructural. + +## Verificacion ejecutada + +- `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest`: correcto. +- `bun test`: 33 pruebas correctas, 0 fallos. +- `git diff --check`: sin errores de espacios. + +No se ha ejecutado validacion RF, ranging NAN ni presupuesto de bateria de 24 h sobre +hardware real. Esa medicion sigue siendo requisito de salida para piloto. diff --git a/docs/PROTOCOL_CONTROL.md b/docs/PROTOCOL_CONTROL.md new file mode 100644 index 0000000..f931dc7 --- /dev/null +++ b/docs/PROTOCOL_CONTROL.md @@ -0,0 +1,43 @@ +# Protocolo de control y continuidad + +## Resoluciones y recibos + +Un `soft-resolve` de un rescatista **nunca detiene** el broadcast de la victima. Solo cambia el estado del radar a "alguien lo atiende" y permite reducir la retransmision. Debe incluir un `RescuerCertificate`: clave publica del rescatista, emision, expiracion y firma Ed25519 del backend. Al validarlo se compara su vigencia con `issuedAt` del recibo, no con el momento de recepcion: un recibo firmado legitimamente no se vuelve falso al viajar en una mula. Es un mensaje exclusivo de NAN, donde el SSI de 255 B tiene margen; no se encapsula en BLE de 119 B. + +Solo `self-cancel` (firmado por la clave de la victima) y `backend-resolve` (firmado por el backend) pueden parar el emisor. `delivery` significa que central persistio el SOS y tampoco lo detiene. + +Los recibos y certificados se firman sobre `SHA-256("\n" + JSON canonico)`. El JSON canonico es el de `packages/shared/src/canonical.ts` (claves ordenadas, escalares JSON; rechaza `undefined`/no finitos): `control.ts` ya no tiene serializador propio y un vector dorado en `control.test.ts` fija que los bytes y las firmas historicas siguen validando. + +Todo `ControlReceipt` lleva `issuedAt`, `expiresAt`, objetivo por `targetSosId` y firma. La ventana maxima de soft-resolve es 10 minutos; la de delivery es 30 dias. La implementacion compartida deduplica firmas en memoria hasta `expiresAt`; el LRU persistente movil sigue pendiente y por eso un reinicio no debe considerarse protegido contra replay. El `msgId` de la sesion SOS se genera con CSPRNG, no a partir del reloj. + +El backend expone `POST /operator/rescuer-certificates` (autenticado), `POST /control/receipts` y renueva certificados de claves incluidas en `RESCUER_PUBLIC_KEYS` en la respuesta de `/ingest`. Así una mula puede llevar la renovación al rescatista sin conectividad. La vigencia máxima es siete días; una CRL firmada y distribuida por mula sigue siendo obligatoria antes del piloto. La clave de backend debe venir de `BACKEND_PRIVATE_KEY_HEX`; producción se niega a iniciar con identidad efímera. La aplicación ancla `BACKEND_PUBLIC_KEY_HEX` al build y no confía en una clave recibida por `GET /pubkey`. + +## Cadena por autor, no blockchain + +`ChannelRecord.prevHash` es el hash truncado (8-16 B hex) del registro anterior del mismo autor. Permite detectar huecos y forks: dos registros con el mismo `prevHash` del mismo autor son una alerta de clave comprometida o emisor malicioso. No existe cadena global, consenso, mineria, proof-of-work ni ninguna tarea computacional en telefonos. + +El frame BLE v1 no tiene bytes libres: sus 22 B de payload estan todos firmados y el frame completo ocupa 119 B. Por compatibilidad, `prevHash` viaja hoy en registros canonicos/NAN y no se inventa dentro de BLE. V2 ya reserva su propio UUID de servicio BLE (`bb34a92c-3b5f-4c13-9fa5-599a2ed26b3e`): nodos v1 no lo ven y nodos nuevos haran dual-scan v1/v2. No versionar dentro de la trama ni reutilizar el UUID v1 para formatos incompatibles. + +## Anti-entropia y Sonar + +NAN intercambia `MeshDigest` de IDs y luego los registros faltantes. El digest y el cache de dedupe deben persistirse con TTL y limite LRU. Nearby Connections `P2P_CLUSTER` es la capa indicada para transferencia de volumen; BLE permanece como latido connectionless. + +Un `NeighborObservation` no es una trama de protocolo ni se firma por si mismo: es evidencia local derivada de una trama de origen que ya supero CRC, enlace pubkey/nodeId, ventana temporal y firma Ed25519. Solo se conserva para Sonar si el TTL recibido coincide con el TTL de origen firmado. Ese TTL vive fuera de la firma y un relay bugueado o malicioso puede no decrementarlo: la coincidencia solo califica la medicion como **directa no verificada**, nunca como prueba de proximidad. Un relay autentico normal se descarta porque su RSSI ubica al relay, no a la victima. + +La Fase 1 implementada persiste `NeighborObservation` (nodeId, `MeshClock`, RSSI crudo, potencia anunciada si existe, PHY primario/secundario, fuente y fix del observador si tiene menos de dos minutos) y una fila `NeighborEstimate` por vecino. En RAM se conservan las 50 muestras directas mas recientes; la mediana usa hasta 9 muestras de los ultimos 8 s y necesita un minimo de 3, seguida de EWMA sobre dBm. Si hay un silencio mayor a la ventana, el filtro reinicia su estado para no guiar con una posicion RF vieja. Room guarda una muestra representativa cada 200 ms y un historico acotado de aproximadamente 50 filas por nodo para no saturar SQLite durante un barrido de baja latencia. El estado y `lastSeenAt` sobreviven en Room y se recalculan a degradado/perdido tras reinicio del FGS. + +RSSI BLE es universal pero no metrico: v1 no incluye `measuredPower` del emisor. El valor de -59 dBm a 1 m es una aproximacion conservadora y la UI muestra distancia con `~` y banda amplia, nunca una posicion. Si Android expone una potencia TX anunciada se reduce, pero no desaparece, la incertidumbre. El estimador usa perfiles provisionales por PHY (1M, 2M, Coded) con exponente y offset propios; deben sustituirse por calibracion de campo por chipset/modelo. V2 debe reservar un byte firmado para potencia calibrada; no existe una forma fiable de inferir el modelo del emisor solo desde un frame v1. Ninguna distancia modifica confianza criptografica, prioridades de recibos ni decisiones de routing. + +El radar cruza la distancia RSSI con la posicion GPS firmada del mismo SOS mediante una prueba conservadora de 3 sigma combinado. Si no son consistentes, deja visible la advertencia y el rango RSSI como evidencia directa no verificada, pero no usa esa señal para anillo/bearing; conserva el GPS reportado por la mesh. Sin conflicto, la señal se etiqueta siempre como directa no verificada. El radar normal usa `SCAN_MODE_BALANCED`; al abrirse sube a `SCAN_MODE_LOW_LATENCY` con `CALLBACK_TYPE_ALL_MATCHES`. Al cerrarse conserva baja latencia 10 s antes de volver a balanced para no chocar con el limite Android de ciclos `startScan`/`stopScan`; un rechazo de registro se registra en log. Sin GPS, un barrido corporal de hasta 45 s solo muestra direccion si reunio 8 muestras, cuatro sectores de 45 grados y >=6 dB de contraste. La guia pide un giro de 15-20 s o dos vueltas. Es una guia de baja confianza, no AoA/AoD. `NeighborState` tiene histeresis: degradado despues de 3 x 5 s sin oir al origen y perdido despues de 10 x 5 s. Aun no se publica un evento `NODE_LOST` al backend. + +Las fuentes previstas, de menor a mayor precision, son RSSI BLE, RSSI NAN, RTT NAN 802.11mc y `android.ranging` (Android 16+) para UWB/Channel Sounding/NAN-RTT. El modelo y la tabla Room ya reservan `source`, distancia y desviacion estandar para que RTT no pase por el modelo RSSI. Siguen pendientes `WifiRttManager`, discovery NAN por rafagas (attach, publish/subscribe 5-10 s, detach cada 60 s con jitter +-15 s), intercambio de observaciones y el solver de multilateracion ponderado. Nearby Connections `P2P_CLUSTER` transportara volumen/anti-entropia, no beacons. + +`MeshClock` conserva un offset obtenido de `Location.time` de GPS durante 24 h y se usa al emitir SOS. Sin una fuente confiable, la UI debe etiquetar el tiempo como no verificado; la futura validación Android de receipts aceptará un `backend-resolve` firmado pero marcará esa incertidumbre en lugar de apagarlo por reloj local. Antes de ajustar los ciclos, medir 24 h en dispositivos reales: BLE continuo, NAN cada 60 s y pantalla apagada. + +La calibracion de campo minima antes de tratar los anillos como operativos debe registrar RSSI crudo, PHY y potencia TX anunciada entre dos telefonos a 1, 5, 10, 25 y 50 m, primero a cielo abierto y despues con obstaculo corporal/estructural. El CSV debe identificar modelo de emisor/receptor y ajustar por minimos cuadrados `txPower` efectivo y `n`; los perfiles de codigo son solo puntos de partida. Un futuro `NODE_LOST` se debe firmar por el observador e incluir quien dejo de oir a quien, `MeshClock`, ultima posicion/varianza y evidencia, nunca ser un booleano global. + +## Estado de implementacion + +Implementado en esta rama: tipos y verificacion de control, expiracion/dedupe en memoria, certificados de siete dias emitidos o renovados por mula para claves autorizadas, receipt de entrega devuelto por ingest, cadena por autor para registros canonicos, anclaje de clave publica en build, correccion de reloj GPS, supresion counter/RSSI + token bucket en `FloodRouter`, y Sonar Fase 1 BLE (observaciones directas, filtro mediana+EWMA, liveness con histeresis, radar RSSI/GPS y barrido corporal de baja confianza). + +Pendiente de integrar: retransmision NAN y BLE de los receipts. BLE debe transportar `self-cancel`, `backend-resolve` y `delivery` en un formato de control v1 aun por definir, sin certificados; NAN queda para certificados, soft-resolve y digest. El UUID v2 esta reservado, pero **no** existe todavia un UUID/control-codec BLE v1 implementado. Tambien faltan persistencia movil del replay/LRU, CRL distribuida, NAN-RTT/Android Ranging, intercambio y multilateracion de observaciones, evento `NODE_LOST`, Nearby Connections y almacenamiento durable de control receipts. Estos pendientes no deben presentarse como disponibles en campo. diff --git a/docs/superpowers/plans/2026-07-17-encontrado-feature.md b/docs/superpowers/plans/2026-07-17-encontrado-feature.md new file mode 100644 index 0000000..6ea8387 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-encontrado-feature.md @@ -0,0 +1,2003 @@ +# "Encontrado" (Found) Feature Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Suggested subagent models:** Tasks 1–3 (shared wire format + backend) and Tasks 4–7 (Android) involve crypto/wire code — dispatch **sonnet** subagents. Task 8 (docs/CHANGELOG) is mechanical — a **haiku** subagent is enough. Task order: 1 → 2 → 3 must be sequential; Tasks 4–7 must be sequential among themselves but the Android chain (4–7) may start as soon as Task 1 is committed (the wire format is frozen there). Task 8 runs last. + +**Goal:** Let a finder mark an SOS victim as *encontrado* (found) by target ID + finder location; the backend validates the finder's proximity to the SOS's last reported point (≤100 m = "near / likely found", further = "far / ⚠️ possible false positive") and stores the report as a community record. + +**Architecture:** A new signed 118-byte "found frame" (22 B payload + 32 B finder pubkey + 64 B Ed25519 signature, domain-separated `guacamaya.found-frame.v1\n`) travels from the Android app to the backend inside the existing `POST /ingest` body as a new `foundFrames` array. The backend re-verifies each frame zero-trust, looks up the target SOS by `(targetNodeId, targetMsgId)` on channel `solicito-ayuda`, computes the haversine distance from the finder's signed GPS position to the SOS's last reported point, and persists a `verified:false` record on a new community channel `encontrado` carrying the verdict. The Android app adds a "Marcar como encontrado" action on the Radar screen with a local near/far pre-check dialog, queues the signed frame in Room, and drains it through the existing data-mule uploader. + +**Tech Stack:** Bun + TypeScript + Fastify (backend), `@noble/ed25519` / `@noble/hashes`, `bun:test`; Kotlin + Jetpack Compose + Room + WorkManager (Android), JUnit4 unit tests. + +## Global Constraints + +- **Bun only** — never npm/node. All backend/shared commands run from the repo root (`/Users/carlosramirez/desarrollo/hackathons/build4venezuela/guacamaya-net`). Android Gradle commands run from `android/`. +- **Code identifiers stay `guacamaya`** (packages `@guacamaya/shared`, Kotlin package `net.guacamaya`, signing domains `guacamaya.*`). Never rename them to `guacamalla`. Prose/UI copy uses **GuacaMalla**. +- **Wire format must be byte-identical on both sides**: the TypeScript encoder/verifier in `packages/shared/src/mesh/` and the Kotlin encoder in `android/app/src/main/kotlin/net/guacamaya/proto/` implement the exact same 22-byte big-endian layout and the exact same signature domain string `"guacamaya.found-frame.v1\n"` (note the trailing `\n`). +- **All signatures are domain-separated** — the found-frame signature covers `UTF8("guacamaya.found-frame.v1\n") || payload22`, never the bare payload. +- **Zero-trust ingest**: the backend never persists a frame whose signature it hasn't re-verified, and always computes record `id` server-side (`SHA-256(payload22)` hex). +- **User-facing copy is Spanish** (matches existing UI: "Marcar como encontrado", "posible falso positivo"). +- `packages/shared` stays framework-agnostic — no backend-only deps in it. +- Threshold default is **100 meters**, env-overridable on the backend via `FOUND_PROXIMITY_METERS`. + +## Wire format (frozen in Task 1 — every other task consumes this verbatim) + +**Found payload v1 — 22 bytes, big-endian:** + +| Offset | Field | Type | Meaning | +|---|---|---|---| +| 0..3 | `latE7` | int32 | Finder latitude × 1e7 (where the person was found) | +| 4..7 | `lonE7` | int32 | Finder longitude × 1e7 | +| 8..11 | `tsUnix` | u32 | Unix seconds when marked found | +| 12..15 | `targetNodeId` | 4 B | SHA-256(victim pubkey)[0..3] — the SOS node being marked | +| 16..17 | `targetMsgId` | u16 | msgId of the SOS session being marked | +| 18..19 | `foundMsgId` | u16 | CSPRNG per found report (dedupe entropy) | +| 20..21 | `crc` | u16 | CRC16-CCITT over bytes 0..19 | + +**Frame (118 B upload form):** `payload22 + finderPubkey32 + sig64`. Signature = Ed25519 over `UTF8("guacamaya.found-frame.v1\n") || payload22`. A 119-byte frame (leading hop-TTL byte) is tolerated by stripping the first byte, same as SOS/control frames. + +**Backend verdict rule:** `distanceMeters = haversine(finder latE7/lonE7, target SOS last reported lat/lon)`. `distanceMeters ≤ FOUND_PROXIMITY_METERS (default 100)` → `proximity: "near"`, `verdict: "likely_found"`; otherwise `proximity: "far"`, `verdict: "possible_false_positive"`. If no SOS record matches `(targetNodeId, targetMsgId)` (or it has no GPS fix), the frame is **rejected** with reason `"unknown target sos"` — you cannot mark found what was never reported. + +--- + +### Task 1: Shared wire format — `foundFrame.ts`, geo helper, constants + +**Files:** +- Modify: `packages/shared/src/mesh/constants.ts` +- Create: `packages/shared/src/geo.ts` +- Create: `packages/shared/src/mesh/foundFrame.ts` +- Modify: `packages/shared/src/mesh/index.ts` +- Modify: `packages/shared/src/index.ts` +- Test: `packages/shared/src/mesh/foundFrame.test.ts` + +**Interfaces:** +- Consumes: existing `crc16Ccitt(bytes, start, end)` from `packages/shared/src/mesh/crc16.ts`, `MESH_PUBKEY_LEN`/`MESH_SIG_LEN` from `constants.ts`. +- Produces (used by Tasks 2–3): + - `FOUND_SIGN_DOMAIN: string`, `FOUND_PAYLOAD_LEN/FOUND_FRAME_LEN/FOUND_ON_WIRE_LEN/FOUND_PAYLOAD_OFF/FOUND_PUBKEY_OFF/FOUND_SIG_OFF: number`, `FOUND_PROXIMITY_NEAR_METERS = 100` + - `haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number` + - `encodeFoundPayload(fields: FoundPayloadFields): Uint8Array` + - `foundSignBytes(payload: Uint8Array): Uint8Array` + - `buildFoundFrame(fields: FoundPayloadFields, privateKey: Uint8Array): Promise` + - `verifyFoundFrame(bytes: Uint8Array): Promise` where `FoundFrameResult = { ok: true; found: VerifiedFoundFrame } | { ok: false; reason: string }` and `VerifiedFoundFrame = { latE7, lonE7, tsUnix, targetMsgId, foundMsgId: number; targetNodeId: Uint8Array; lat, lon: number; targetNodeIdHex, finderPublicKeyHex, finderNodeIdHex, signatureHex: string; payload: Uint8Array; frame: Uint8Array }` + +- [ ] **Step 1: Add constants** + +Append to `packages/shared/src/mesh/constants.ts` (at the end of the file): + +```ts +/** + * Found frame v1 ("Encontrado") — a finder marks an SOS victim as found. + * Same envelope as the SOS frame: 22 B payload + 32 B pubkey + 64 B signature, + * with a leading mutable hop-TTL byte tolerated on the wire. Must stay + * byte-identical with net.guacamaya.proto.FoundPayload (Android). + * + * off 0..3 latE7 int32 — finder latitude × 1e7 + * off 4..7 lonE7 int32 — finder longitude × 1e7 + * off 8..11 tsUnix u32 unix seconds + * off 12..15 targetNodeId SHA-256(victim pubkey)[0..3] + * off 16..17 targetMsgId u16 — msgId of the SOS session being marked + * off 18..19 foundMsgId u16 — CSPRNG per found report (dedupe entropy) + * off 20..21 crc CRC16-CCITT over bytes 0..19 + */ +export const FOUND_PAYLOAD_LEN = 22; +export const FOUND_FRAME_LEN = FOUND_PAYLOAD_LEN + MESH_PUBKEY_LEN + MESH_SIG_LEN; // 118 (upload, TTL stripped) +export const FOUND_ON_WIRE_LEN = FOUND_FRAME_LEN + 1; // 119 (leading hop TTL) + +export const FOUND_PAYLOAD_OFF = 0; +export const FOUND_PUBKEY_OFF = FOUND_PAYLOAD_OFF + FOUND_PAYLOAD_LEN; +export const FOUND_SIG_OFF = FOUND_PUBKEY_OFF + MESH_PUBKEY_LEN; + +/** + * Domain separation for the found-frame Ed25519 signature: + * sig = Ed25519(sk, UTF8(FOUND_SIGN_DOMAIN) || payload22). + * An SOS or control signature can never be replayed as a found report or + * vice versa. Mirrored verbatim in net.guacamaya.proto.FoundPayload. + */ +export const FOUND_SIGN_DOMAIN = "guacamaya.found-frame.v1\n"; + +/** Default proximity gate: finder within this many meters of the SOS's last + * reported point → "near / likely_found"; further → "far / possible_false_positive". + * Backend default; env-overridable via FOUND_PROXIMITY_METERS. */ +export const FOUND_PROXIMITY_NEAR_METERS = 100; +``` + +- [ ] **Step 2: Create the geo helper** + +Create `packages/shared/src/geo.ts`: + +```ts +/** Great-circle distance in meters between two WGS-84 points (haversine). */ +export function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6_371_000; // mean Earth radius, meters + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} +``` + +- [ ] **Step 3: Write the failing test** + +Create `packages/shared/src/mesh/foundFrame.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import * as ed from "@noble/ed25519"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { crc16Ccitt } from "./crc16.js"; +import { FOUND_FRAME_LEN, FOUND_SIG_OFF, FOUND_PUBKEY_OFF } from "./constants.js"; +import { + buildFoundFrame, + encodeFoundPayload, + verifyFoundFrame, + type FoundPayloadFields, +} from "./foundFrame.js"; +import { haversineMeters } from "../geo.js"; + +const sk = new Uint8Array(32).fill(7); + +function fields(overrides: Partial = {}): FoundPayloadFields { + return { + latE7: 104_003_000, // 10.4003 + lonE7: -669_000_000, // -66.9 + tsUnix: 1_750_000_000, + targetNodeId: new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]), + targetMsgId: 4242, + foundMsgId: 777, + ...overrides, + }; +} + +describe("encodeFoundPayload", () => { + test("byte layout is big-endian at the documented offsets with a valid CRC", () => { + const p = encodeFoundPayload(fields()); + expect(p.length).toBe(22); + const dv = new DataView(p.buffer, p.byteOffset, p.byteLength); + expect(dv.getInt32(0, false)).toBe(104_003_000); + expect(dv.getInt32(4, false)).toBe(-669_000_000); + expect(dv.getUint32(8, false)).toBe(1_750_000_000); + expect([...p.subarray(12, 16)]).toEqual([0xaa, 0xbb, 0xcc, 0xdd]); + expect(dv.getUint16(16, false)).toBe(4242); + expect(dv.getUint16(18, false)).toBe(777); + expect(dv.getUint16(20, false)).toBe(crc16Ccitt(p, 0, 20)); + }); + + test("rejects a targetNodeId that is not 4 bytes", () => { + expect(() => encodeFoundPayload(fields({ targetNodeId: new Uint8Array(3) }))).toThrow(); + }); +}); + +describe("verifyFoundFrame", () => { + test("round-trips a built frame", async () => { + const frame = await buildFoundFrame(fields(), sk); + expect(frame.length).toBe(FOUND_FRAME_LEN); + const res = await verifyFoundFrame(frame); + if (!res.ok) throw new Error(res.reason); + expect(res.found.lat).toBeCloseTo(10.4003, 6); + expect(res.found.lon).toBeCloseTo(-66.9, 6); + expect(res.found.targetNodeIdHex).toBe("aabbccdd"); + expect(res.found.targetMsgId).toBe(4242); + expect(res.found.foundMsgId).toBe(777); + const pub = await ed.getPublicKeyAsync(sk); + expect(res.found.finderPublicKeyHex).toBe(bytesToHex(pub)); + expect(res.found.finderNodeIdHex).toBe(bytesToHex(sha256(pub).subarray(0, 4))); + }); + + test("accepts the 119-byte on-wire form by stripping the leading TTL byte", async () => { + const frame = await buildFoundFrame(fields(), sk); + const onWire = new Uint8Array(frame.length + 1); + onWire[0] = 5; // hop TTL + onWire.set(frame, 1); + const res = await verifyFoundFrame(onWire); + expect(res.ok).toBe(true); + }); + + test("rejects a tampered payload (crc mismatch)", async () => { + const frame = await buildFoundFrame(fields(), sk); + frame[0] ^= 0xff; + const res = await verifyFoundFrame(frame); + expect(res.ok).toBe(false); + }); + + test("rejects a signature made without the domain prefix", async () => { + const payload = encodeFoundPayload(fields()); + const pub = await ed.getPublicKeyAsync(sk); + const badSig = await ed.signAsync(payload, sk); // bare payload — wrong domain + const frame = new Uint8Array(FOUND_FRAME_LEN); + frame.set(payload, 0); + frame.set(pub, FOUND_PUBKEY_OFF); + frame.set(badSig, FOUND_SIG_OFF); + const res = await verifyFoundFrame(frame); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toBe("signature invalid"); + }); + + test("rejects a frame with no GPS fix (0,0)", async () => { + const frame = await buildFoundFrame(fields({ latE7: 0, lonE7: 0 }), sk); + const res = await verifyFoundFrame(frame); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toBe("no gps fix"); + }); + + test("rejects a wrong-length frame", async () => { + const res = await verifyFoundFrame(new Uint8Array(50)); + expect(res.ok).toBe(false); + }); +}); + +describe("haversineMeters", () => { + test("~33 m for a 0.0003° latitude offset", () => { + const d = haversineMeters(10.4, -66.9, 10.4003, -66.9); + expect(d).toBeGreaterThan(25); + expect(d).toBeLessThan(45); + }); + + test("~1.1 km for a 0.01° latitude offset", () => { + const d = haversineMeters(10.4, -66.9, 10.41, -66.9); + expect(d).toBeGreaterThan(1000); + expect(d).toBeLessThan(1250); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `bun test packages/shared/src/mesh/foundFrame.test.ts` +Expected: FAIL — cannot resolve `./foundFrame.js`. + +- [ ] **Step 5: Implement `foundFrame.ts`** + +Create `packages/shared/src/mesh/foundFrame.ts`: + +```ts +import * as ed from "@noble/ed25519"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { crc16Ccitt } from "./crc16.js"; +import { + FOUND_FRAME_LEN, + FOUND_ON_WIRE_LEN, + FOUND_PAYLOAD_LEN, + FOUND_PAYLOAD_OFF, + FOUND_PUBKEY_OFF, + FOUND_SIGN_DOMAIN, + FOUND_SIG_OFF, + MESH_PUBKEY_LEN, + MESH_SIG_LEN, +} from "./constants.js"; + +/** + * Found payload v1 ("Encontrado") — 22 bytes, big-endian, byte-identical with + * net.guacamaya.proto.FoundPayload (Android). See constants.ts for the byte map. + * + * Signature is domain-separated: Ed25519 over UTF8(FOUND_SIGN_DOMAIN) || payload22 + * — never over the bare payload, so SOS/control/found signatures are never + * interchangeable. + */ + +export interface FoundPayloadFields { + /** Finder latitude × 1e7 (int32). */ + latE7: number; + /** Finder longitude × 1e7 (int32). */ + lonE7: number; + /** Unix seconds. */ + tsUnix: number; + /** SHA-256(victim pubkey)[0..3], 4 bytes. */ + targetNodeId: Uint8Array; + targetMsgId: number; + /** CSPRNG per found report (dedupe entropy). */ + foundMsgId: number; +} + +export interface VerifiedFoundFrame extends FoundPayloadFields { + lat: number; + lon: number; + targetNodeIdHex: string; + finderPublicKeyHex: string; + /** SHA-256(finder pubkey)[0..3] as hex. */ + finderNodeIdHex: string; + signatureHex: string; + payload: Uint8Array; + /** Canonical 118-byte upload frame (TTL stripped). */ + frame: Uint8Array; +} + +export type FoundFrameResult = + | { ok: true; found: VerifiedFoundFrame } + | { ok: false; reason: string }; + +function checkU16(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new Error(`${name} out of u16 range`); +} + +function checkU32(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new Error(`${name} out of u32 range`); +} + +function checkI32(value: number, name: string): void { + if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) throw new Error(`${name} out of i32 range`); +} + +/** Encodes the 22-byte found payload, computing the trailing CRC. Throws on invalid fields. */ +export function encodeFoundPayload(fields: FoundPayloadFields): Uint8Array { + if (fields.targetNodeId.length !== 4) throw new Error("targetNodeId must be 4 bytes"); + checkI32(fields.latE7, "latE7"); + checkI32(fields.lonE7, "lonE7"); + checkU32(fields.tsUnix, "tsUnix"); + checkU16(fields.targetMsgId, "targetMsgId"); + checkU16(fields.foundMsgId, "foundMsgId"); + const payload = new Uint8Array(FOUND_PAYLOAD_LEN); + const dv = new DataView(payload.buffer); + dv.setInt32(0, fields.latE7, false); + dv.setInt32(4, fields.lonE7, false); + dv.setUint32(8, fields.tsUnix, false); + payload.set(fields.targetNodeId, 12); + dv.setUint16(16, fields.targetMsgId, false); + dv.setUint16(18, fields.foundMsgId, false); + dv.setUint16(20, crc16Ccitt(payload, 0, 20), false); + return payload; +} + +/** The exact bytes the Ed25519 signature covers: UTF8 domain prefix + payload. */ +export function foundSignBytes(payload: Uint8Array): Uint8Array { + const domain = new TextEncoder().encode(FOUND_SIGN_DOMAIN); + const out = new Uint8Array(domain.length + payload.length); + out.set(domain, 0); + out.set(payload, domain.length); + return out; +} + +/** Builds the canonical 118-byte upload frame: payload22 + pubkey32 + sig64. */ +export async function buildFoundFrame( + fields: FoundPayloadFields, + privateKey: Uint8Array, +): Promise { + const payload = encodeFoundPayload(fields); + const pubkey = await ed.getPublicKeyAsync(privateKey); + const sig = await ed.signAsync(foundSignBytes(payload), privateKey); + const frame = new Uint8Array(FOUND_FRAME_LEN); + frame.set(payload, FOUND_PAYLOAD_OFF); + frame.set(pubkey, FOUND_PUBKEY_OFF); + frame.set(sig, FOUND_SIG_OFF); + return frame; +} + +/** + * Zero-trust verification cascade, cheapest first (mirrors the SOS/control + * verifiers): size → CRC → GPS-fix sanity → Ed25519 (domain-prefixed). + * Timestamp skew is intentionally omitted — a data mule may upload stale reports. + * Target existence / proximity verdicts stay with the backend caller. + */ +export async function verifyFoundFrame(bytes: Uint8Array): Promise { + let frame = bytes; + if (frame.length === FOUND_ON_WIRE_LEN) frame = frame.subarray(1); + if (frame.length !== FOUND_FRAME_LEN) { + return { ok: false, reason: `bad found frame length ${bytes.length} (need ${FOUND_FRAME_LEN})` }; + } + + const payload = frame.subarray(FOUND_PAYLOAD_OFF, FOUND_PAYLOAD_OFF + FOUND_PAYLOAD_LEN); + const pubkey = frame.subarray(FOUND_PUBKEY_OFF, FOUND_PUBKEY_OFF + MESH_PUBKEY_LEN); + const sig = frame.subarray(FOUND_SIG_OFF, FOUND_SIG_OFF + MESH_SIG_LEN); + const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + + if (dv.getUint16(20, false) !== crc16Ccitt(payload, 0, 20)) { + return { ok: false, reason: "crc mismatch" }; + } + + const latE7 = dv.getInt32(0, false); + const lonE7 = dv.getInt32(4, false); + const lat = latE7 / 1e7; + const lon = lonE7 / 1e7; + const hasFix = + lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 && !(latE7 === 0 && lonE7 === 0); + if (!hasFix) { + // A found report is a proximity claim; without the finder's fix there is + // nothing to validate against, so the frame is useless — reject it. + return { ok: false, reason: "no gps fix" }; + } + + let sigValid = false; + try { + sigValid = await ed.verifyAsync(sig, foundSignBytes(payload), pubkey); + } catch { + sigValid = false; + } + if (!sigValid) return { ok: false, reason: "signature invalid" }; + + const targetNodeId = payload.subarray(12, 16); + return { + ok: true, + found: { + latE7, + lonE7, + lat, + lon, + tsUnix: dv.getUint32(8, false), + targetNodeId: new Uint8Array(targetNodeId), + targetNodeIdHex: bytesToHex(targetNodeId), + targetMsgId: dv.getUint16(16, false), + foundMsgId: dv.getUint16(18, false), + finderPublicKeyHex: bytesToHex(pubkey), + finderNodeIdHex: bytesToHex(sha256(pubkey).subarray(0, 4)), + signatureHex: bytesToHex(sig), + payload: new Uint8Array(payload), + frame: new Uint8Array(frame), + }, + }; +} +``` + +- [ ] **Step 6: Export the new modules** + +In `packages/shared/src/mesh/index.ts` add at the end: + +```ts +export * from "./foundFrame.js"; +``` + +In `packages/shared/src/index.ts` add after the `export * from "./merge.js";` line: + +```ts +export * from "./geo.js"; +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `bun test packages/shared/src/mesh/foundFrame.test.ts` +Expected: PASS (all tests green). + +Run: `bun test` — expected: full suite still green (no existing test touched). + +- [ ] **Step 8: Commit** + +```bash +git add packages/shared/src/mesh/constants.ts packages/shared/src/geo.ts packages/shared/src/mesh/foundFrame.ts packages/shared/src/mesh/index.ts packages/shared/src/index.ts packages/shared/src/mesh/foundFrame.test.ts +git commit -m "feat(shared): found-frame v1 wire format + haversine helper" +``` + +--- + +### Task 2: Backend — `processFoundFrames` with proximity verdict + +**Files:** +- Modify: `backend/src/security/config.ts` (add `foundProximityMeters`) +- Create: `backend/src/found/processFound.ts` +- Test: `backend/src/found/processFound.test.ts` +- Modify: `backend/.env.example` (document the new env var) + +**Interfaces:** +- Consumes (from Task 1, via `@guacamaya/shared`): `verifyFoundFrame`, `haversineMeters`, `buildFoundFrame` (tests only). +- Produces (used by Task 3): + - `FOUND_CHANNEL = "encontrado"` (const string) + - `interface FoundTargetSos { recordId: string; lat: number; lon: number }` + - `interface FoundDeps { proximityNearMeters: number; findTargetSos(targetNodeIdHex: string, targetMsgId: number): Promise; addRecord(record: ChannelRecord): Promise; broadcast(record: ChannelRecord): void }` + - `interface FoundResultEntry { id: string; targetRecordId: string; distanceMeters: number; proximity: "near" | "far"; verdict: "likely_found" | "possible_false_positive" }` + - `interface ProcessFoundSummary { accepted: number; duplicate: number; rejected: number; reasons: Record; results: FoundResultEntry[] }` + - `processFoundFrames(frames: string[], deps: FoundDeps): Promise` + +- [ ] **Step 1: Add the config knob** + +In `backend/src/security/config.ts`, directly below the line `maxFrameB64Length: boundedNumber("MAX_FRAME_B64_LENGTH", 256, 64, 4096, true),` add: + +```ts + /** "Encontrado" proximity gate: finder within this many meters of the SOS's + * last reported point → likely_found; further → possible_false_positive. */ + foundProximityMeters: boundedNumber("FOUND_PROXIMITY_METERS", 100, 1, 100_000), +``` + +In `backend/.env.example`, append at the end: + +``` +# "Encontrado" (found) reports — proximity gate in meters. A finder within this +# distance of the SOS's last reported point is "near / likely_found"; further is +# "far / possible_false_positive". +FOUND_PROXIMITY_METERS=100 +``` + +- [ ] **Step 2: Write the failing test** + +Create `backend/src/found/processFound.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { buildFoundFrame, type ChannelRecord } from "@guacamaya/shared"; +import { processFoundFrames, type FoundDeps, FOUND_CHANNEL } from "./processFound.js"; + +const sk = new Uint8Array(32).fill(9); +const TARGET_NODE_ID = new Uint8Array([0x11, 0x22, 0x33, 0x44]); +const TARGET_NODE_ID_HEX = "11223344"; +const TARGET_MSG_ID = 100; +// The SOS victim's last reported point. +const SOS_LAT = 10.4; +const SOS_LON = -66.9; + +function makeDeps(overrides: Partial = {}) { + const store = new Map(); + const broadcasts: ChannelRecord[] = []; + const deps: FoundDeps = { + proximityNearMeters: 100, + async findTargetSos(nodeIdHex, msgId) { + if (nodeIdHex === TARGET_NODE_ID_HEX && msgId === TARGET_MSG_ID) { + return { recordId: "sos-record-1", lat: SOS_LAT, lon: SOS_LON }; + } + return null; + }, + async addRecord(record) { + if (store.has(record.id)) return false; + store.set(record.id, record); + return true; + }, + broadcast(record) { + broadcasts.push(record); + }, + ...overrides, + }; + return { deps, store, broadcasts }; +} + +async function frameB64(latE7: number, lonE7: number, foundMsgId = 1): Promise { + const frame = await buildFoundFrame( + { + latE7, + lonE7, + tsUnix: 1_750_000_000, + targetNodeId: TARGET_NODE_ID, + targetMsgId: TARGET_MSG_ID, + foundMsgId, + }, + sk, + ); + return Buffer.from(frame).toString("base64"); +} + +describe("processFoundFrames", () => { + test("finder ~33 m away → accepted, near, likely_found", async () => { + const { deps, store, broadcasts } = makeDeps(); + const summary = await processFoundFrames([await frameB64(104_003_000, -669_000_000)], deps); + expect(summary.accepted).toBe(1); + expect(summary.rejected).toBe(0); + expect(summary.results).toHaveLength(1); + expect(summary.results[0]!.proximity).toBe("near"); + expect(summary.results[0]!.verdict).toBe("likely_found"); + expect(summary.results[0]!.targetRecordId).toBe("sos-record-1"); + expect(summary.results[0]!.distanceMeters).toBeLessThan(100); + expect(store.size).toBe(1); + expect(broadcasts).toHaveLength(1); + const record = [...store.values()][0]!; + expect(record.channel).toBe(FOUND_CHANNEL); + expect(record.verified).toBe(false); + expect(record.author.startsWith("device-")).toBe(true); + const p = record.payload as { verdict: string; distanceMeters: number; source: string }; + expect(p.source).toBe("mesh-found"); + expect(p.verdict).toBe("likely_found"); + }); + + test("finder ~1.1 km away → accepted but far, possible_false_positive", async () => { + const { deps } = makeDeps(); + const summary = await processFoundFrames([await frameB64(104_100_000, -669_000_000)], deps); + expect(summary.accepted).toBe(1); + expect(summary.results[0]!.proximity).toBe("far"); + expect(summary.results[0]!.verdict).toBe("possible_false_positive"); + expect(summary.results[0]!.distanceMeters).toBeGreaterThan(1000); + }); + + test("unknown target SOS → rejected", async () => { + const { deps } = makeDeps({ findTargetSos: async () => null }); + const summary = await processFoundFrames([await frameB64(104_003_000, -669_000_000)], deps); + expect(summary.accepted).toBe(0); + expect(summary.rejected).toBe(1); + expect(summary.reasons["unknown target sos"]).toBe(1); + expect(summary.results).toHaveLength(0); + }); + + test("re-upload of the same frame → duplicate, verdict still returned", async () => { + const { deps } = makeDeps(); + const b64 = await frameB64(104_003_000, -669_000_000); + await processFoundFrames([b64], deps); + const second = await processFoundFrames([b64], deps); + expect(second.accepted).toBe(0); + expect(second.duplicate).toBe(1); + expect(second.results).toHaveLength(1); + expect(second.results[0]!.verdict).toBe("likely_found"); + }); + + test("garbage frame → rejected without touching the store", async () => { + const { deps, store } = makeDeps(); + const summary = await processFoundFrames(["!!!not-base64!!!", "QUJD"], deps); + expect(summary.accepted).toBe(0); + expect(summary.rejected).toBe(2); + expect(store.size).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun test backend/src/found/processFound.test.ts` +Expected: FAIL — cannot resolve `./processFound.js`. + +- [ ] **Step 4: Implement `processFound.ts`** + +Create `backend/src/found/processFound.ts`: + +```ts +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { + type ChannelRecord, + haversineMeters, + verifyFoundFrame, +} from "@guacamaya/shared"; +import { isValidFrameB64 } from "../security/validation.js"; + +/** Community channel that holds "Encontrado" (found) reports. */ +export const FOUND_CHANNEL = "encontrado"; + +/** The target SOS's last reported point, resolved by the caller's data layer. */ +export interface FoundTargetSos { + recordId: string; + lat: number; + lon: number; +} + +export interface FoundDeps { + /** Meters; at/under → near/likely_found, over → far/possible_false_positive. */ + proximityNearMeters: number; + /** Latest SOS record for (targetNodeId, targetMsgId), or null when none with a GPS fix exists. */ + findTargetSos(targetNodeIdHex: string, targetMsgId: number): Promise; + /** Persist; returns false when the id already exists (dedupe). */ + addRecord(record: ChannelRecord): Promise; + /** WS fan-out for freshly persisted records only. */ + broadcast(record: ChannelRecord): void; +} + +export interface FoundResultEntry { + /** ChannelRecord id of the found report (SHA-256(payload22) hex). */ + id: string; + targetRecordId: string; + distanceMeters: number; + proximity: "near" | "far"; + verdict: "likely_found" | "possible_false_positive"; +} + +export interface ProcessFoundSummary { + accepted: number; + duplicate: number; + rejected: number; + reasons: Record; + results: FoundResultEntry[]; +} + +/** + * Zero-trust "Encontrado" ingestion: every frame is re-verified (CRC + Ed25519 + * over the found domain) before anything is persisted, the target SOS must + * already exist server-side, and the proximity verdict is computed from the + * finder's *signed* GPS position against the SOS's last reported point — the + * client never supplies a verdict or a record id. + */ +export async function processFoundFrames( + frames: string[], + deps: FoundDeps, +): Promise { + const summary: ProcessFoundSummary = { + accepted: 0, + duplicate: 0, + rejected: 0, + reasons: {}, + results: [], + }; + const reject = (reason: string) => { + summary.rejected++; + summary.reasons[reason] = (summary.reasons[reason] ?? 0) + 1; + }; + + for (const b64 of frames) { + if (typeof b64 !== "string" || !isValidFrameB64(b64)) { + reject("invalid frame string"); + continue; + } + const bytes = Uint8Array.from(Buffer.from(b64, "base64")); + + const res = await verifyFoundFrame(bytes); + if (!res.ok) { + reject(res.reason); + continue; + } + const f = res.found; + + const target = await deps.findTargetSos(f.targetNodeIdHex, f.targetMsgId); + if (!target) { + reject("unknown target sos"); + continue; + } + + const distanceMeters = Math.round(haversineMeters(f.lat, f.lon, target.lat, target.lon)); + const near = distanceMeters <= deps.proximityNearMeters; + const proximity = near ? ("near" as const) : ("far" as const); + const verdict = near ? ("likely_found" as const) : ("possible_false_positive" as const); + + const record: ChannelRecord = { + id: bytesToHex(sha256(f.payload)), + channel: FOUND_CHANNEL, + timestamp: f.tsUnix * 1000, + ttl: 0, + author: `device-${f.finderPublicKeyHex}`, + verified: false, + payload: { + source: "mesh-found", + finderNodeId: f.finderNodeIdHex, + foundMsgId: f.foundMsgId, + tsUnix: f.tsUnix, + lat: f.lat, + lon: f.lon, + targetNodeId: f.targetNodeIdHex, + targetMsgId: f.targetMsgId, + targetRecordId: target.recordId, + distanceMeters, + proximity, + verdict, + frameB64: Buffer.from(f.frame).toString("base64"), + }, + sig: f.signatureHex, + }; + + const added = await deps.addRecord(record); + if (added) { + summary.accepted++; + deps.broadcast(record); + } else { + summary.duplicate++; + } + // The verdict is returned even on duplicate — the mule uploading the frame + // is usually the finder's own phone, and it needs the answer both times. + summary.results.push({ + id: record.id, + targetRecordId: target.recordId, + distanceMeters, + proximity, + verdict, + }); + } + + return summary; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test backend/src/found/processFound.test.ts` +Expected: PASS. + +- [ ] **Step 6: Type-check the backend** + +Run: `bun run --cwd backend tsc --noEmit` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add backend/src/found/processFound.ts backend/src/found/processFound.test.ts backend/src/security/config.ts backend/.env.example +git commit -m "feat(backend): zero-trust Encontrado processing with proximity verdict" +``` + +--- + +### Task 3: Backend — wire `foundFrames` into `POST /ingest` and register the channel + +**Files:** +- Modify: `backend/src/security/validation.ts` (add `"encontrado"` channel) +- Modify: `backend/src/channels/routes.ts` (accept + process `foundFrames`, list the channel) + +**Interfaces:** +- Consumes: `processFoundFrames`, `FOUND_CHANNEL`, `FoundDeps`, `ProcessFoundSummary` from Task 2; existing `channelsRepo`, `broadcastRecord`, `securityConfig`, `validateIngestBatch`, `SOS_CHANNEL`. +- Produces: `POST /ingest` accepts `{ foundFrames?: string[] }` and returns four new response fields: `foundAccepted: number`, `foundDuplicate: number`, `foundRejected: number`, `foundReasons: Record`, `foundResults: FoundResultEntry[]`. `GET /channels` lists `encontrado`; `GET /channels/encontrado/records` works. + +- [ ] **Step 1: Register the channel id** + +In `backend/src/security/validation.ts`, change the `VALID_CHANNEL_IDS` set: + +```ts +const VALID_CHANNEL_IDS = new Set([ + "alertas", + "refugios", + "ayuda-medica", + "estoy-bien", + "solicito-ayuda", + "resuelto", + "encontrado", +]); +``` + +- [ ] **Step 2: List the channel in `GET /channels`** + +In `backend/src/channels/routes.ts`, inside the `fastify.get("/channels", ...)` array, add after the `solicito-ayuda` entry: + +```ts + { id: "encontrado", name: "Encontrado (Comunidad)", verifiedOnly: false }, +``` + +- [ ] **Step 3: Import the found module** + +In `backend/src/channels/routes.ts`, after the line `import { processControlFrames, mintControlFrames, type SosRef } from "../control/ingestControl.js";` add: + +```ts +import { + processFoundFrames, + FOUND_CHANNEL, + type FoundDeps, + type ProcessFoundSummary, +} from "../found/processFound.js"; +``` + +- [ ] **Step 4: Add the default deps factory** + +In `backend/src/channels/routes.ts`, add this function at module level, directly above `async function checkOriginatorVeto(...)` near the bottom of the file: + +```ts +/** + * Production wiring for the "Encontrado" processor: the reference point is the + * LATEST verified SOS record for (targetNodeId, targetMsgId) on solicito-ayuda. + * payload.nodeId / payload.msgId are set by decodeAndVerifyFrame from the + * signed frame, so the lookup key is authenticated, never client-supplied. + */ +function foundDeps(): FoundDeps { + return { + proximityNearMeters: securityConfig.foundProximityMeters, + async findTargetSos(targetNodeIdHex, targetMsgId) { + const records = await channelsRepo.getRecords(SOS_CHANNEL, 0); + let best: ChannelRecord | null = null; + for (const r of records) { + const p = r.payload as { nodeId?: unknown; msgId?: unknown }; + if (p.nodeId === targetNodeIdHex && p.msgId === targetMsgId) { + if (!best || r.timestamp > best.timestamp) best = r; + } + } + if (!best) return null; + const p = best.payload as { lat?: unknown; lon?: unknown }; + if ( + typeof p.lat !== "number" || + typeof p.lon !== "number" || + !Number.isFinite(p.lat) || + !Number.isFinite(p.lon) || + (p.lat === 0 && p.lon === 0) + ) { + return null; // SOS exists but carries no fix — nothing to validate against + } + return { recordId: best.id, lat: p.lat, lon: p.lon }; + }, + addRecord: (record) => channelsRepo.addRecord(record), + broadcast: (record) => broadcastRecord(record), + }; +} +``` + +- [ ] **Step 5: Accept `foundFrames` in the `/ingest` body** + +Still in `backend/src/channels/routes.ts`, make these four edits inside the `/ingest` route: + +**(a)** Change the route generic and destructuring: + +```ts + fastify.post<{ Body: { frames?: unknown; controlFrames?: unknown; foundFrames?: unknown; rescuerPublicKeys?: unknown } }>( + "/ingest", + { config: { rateLimit: securityConfig.ingestRateLimit } }, + async (request, reply) => { + const { frames, controlFrames, foundFrames } = request.body ?? {}; +``` + +**(b)** Extend the batch validation block (replace the existing `framesValid`/`controlValid` guard): + +```ts + // A mule may carry SOS frames, control frames, found frames, or any mix. + // At least one batch must be present and each is independently capped. + const framesValid = validateIngestBatch(frames); + const controlValid = validateIngestBatch(controlFrames); + const foundValid = validateIngestBatch(foundFrames); + if (!framesValid && !controlValid && !foundValid) { + const tooMany = + (Array.isArray(frames) && frames.length > securityConfig.maxIngestBatch) || + (Array.isArray(controlFrames) && controlFrames.length > securityConfig.maxIngestBatch) || + (Array.isArray(foundFrames) && foundFrames.length > securityConfig.maxIngestBatch); + return reply.code(400).send({ + error: tooMany + ? `Batch exceeds max ${securityConfig.maxIngestBatch} frames` + : "Missing or invalid 'frames'/'controlFrames'/'foundFrames' array", + }); + } + + const frameList: string[] = framesValid ? (frames as string[]) : []; + const controlList: string[] = controlValid ? (controlFrames as string[]) : []; + const foundList: string[] = foundValid ? (foundFrames as string[]) : []; +``` + +**(c)** Process found frames AFTER the SOS loop and the locations block (so a same-batch SOS provides the reference point). Insert directly after the `if (rejected > 0) { request.log.warn(...) }` block: + +```ts + // "Encontrado" reports: verify zero-trust, resolve the target SOS (which may + // have arrived in this very batch — the SOS loop above already persisted it), + // and compute the proximity verdict server-side. + const foundSummary: ProcessFoundSummary = foundList.length > 0 + ? await processFoundFrames(foundList, foundDeps()) + : { accepted: 0, duplicate: 0, rejected: 0, reasons: {}, results: [] }; + if (foundSummary.rejected > 0) { + request.log.warn( + { rejected: foundSummary.rejected, reasons: foundSummary.reasons }, + "rejected unverifiable found frames", + ); + } +``` + +**(d)** Extend the response object (add these fields to the returned object, after `controlFrames: minted.map((m) => m.frameB64),`): + +```ts + foundAccepted: foundSummary.accepted, + foundDuplicate: foundSummary.duplicate, + foundRejected: foundSummary.rejected, + foundReasons: foundSummary.reasons, + foundResults: foundSummary.results, +``` + +- [ ] **Step 6: Type-check and run the full suite** + +Run: `bun run --cwd backend tsc --noEmit` — expected: no errors. +Run: `bun test` — expected: full suite green. + +- [ ] **Step 7: End-to-end smoke check against the dev server** + +Start the backend in the background: `bun run dev:backend` (leave it running; it listens on :3000 with in-memory stores). + +Write this throwaway script to `/private/tmp/claude-501/-Users-carlosramirez-desarrollo-hackathons-build4venezuela-guacamaya-net/6fbe51d6-5a96-445d-bb13-6a860fdbd1f1/scratchpad/found-smoke.ts` and run it with `bun run `: + +```ts +import * as ed from "@noble/ed25519"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { buildFoundFrame, crc16Ccitt } from "@guacamaya/shared"; + +// 1. Build a valid SOS frame (victim at 10.4, -66.9) the same way frame.test.ts does. +const victimSk = new Uint8Array(32).fill(1); +const victimPk = await ed.getPublicKeyAsync(victimSk); +const nodeId = sha256(victimPk).subarray(0, 4); +const payload = new Uint8Array(22); +const dv = new DataView(payload.buffer); +dv.setInt32(0, 104_000_000, false); // lat 10.4 +dv.setInt32(4, -669_000_000, false); // lon -66.9 +dv.setUint32(8, Math.floor(Date.now() / 1000), false); +payload.set(nodeId, 12); +payload[16] = 0x02; // critical +payload[17] = 1; // distress +dv.setUint16(18, 100, false); // msgId +dv.setUint16(20, crc16Ccitt(payload, 0, 20), false); +const sig = await ed.signAsync(payload, victimSk); +const sosFrame = new Uint8Array(118); +sosFrame.set(payload, 0); +sosFrame.set(victimPk, 22); +sosFrame.set(sig, 54); + +// 2. Build the found frame: finder ~33 m away marks (nodeId, msgId=100) as found. +const finderSk = new Uint8Array(32).fill(2); +const foundFrame = await buildFoundFrame( + { + latE7: 104_003_000, + lonE7: -669_000_000, + tsUnix: Math.floor(Date.now() / 1000), + targetNodeId: new Uint8Array(nodeId), + targetMsgId: 100, + foundMsgId: 42, + }, + finderSk, +); + +const res = await fetch("http://localhost:3000/ingest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frames: [Buffer.from(sosFrame).toString("base64")], + foundFrames: [Buffer.from(foundFrame).toString("base64")], + }), +}); +console.log(JSON.stringify(await res.json(), null, 2)); +``` + +Expected output includes `"ingested": 1`, `"foundAccepted": 1`, and a `foundResults` entry with `"proximity": "near"`, `"verdict": "likely_found"`, `distanceMeters` ≈ 33. Then verify the channel read: `curl -s http://localhost:3000/channels/encontrado/records | head -c 600` should show one record with `"verdict":"likely_found"`. Stop the dev server afterwards. + +- [ ] **Step 8: Commit** + +```bash +git add backend/src/security/validation.ts backend/src/channels/routes.ts +git commit -m "feat(backend): accept foundFrames in POST /ingest and serve the encontrado channel" +``` + +--- + +### Task 4: Android — `proto/FoundPayload.kt` (wire mirror) + +**Files:** +- Create: `android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt` +- Test: `android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt` + +**Interfaces:** +- Consumes: existing `Crc16.ccitt`, `Signer.derivePublicKey/signMessage/verifyMessage` (`net.guacamaya.crypto.Signer`), `ControlFrame.toHex` (`net.guacamaya.proto`). +- Produces (used by Tasks 6–7): + - `object FoundFrame { PAYLOAD_LEN=22; PUBKEY_LEN=32; SIG_LEN=64; FRAME_LEN=118; ON_WIRE_LEN=119; PAYLOAD_OFF=0; PUBKEY_OFF=22; SIG_OFF=54; FOUND_SIGN_DOMAIN; fun signBytes(payload22: ByteArray): ByteArray }` + - `data class FoundPayload(latE7: Int, lonE7: Int, tsUnix: Long, targetNodeId: ByteArray, targetMsgId: Int, foundMsgId: Int)` with `encode(): ByteArray` and `companion fun decode(bytes: ByteArray): FoundPayload` + - `fun buildFoundFrame(payload: FoundPayload, privateKey: ByteArray): ByteArray` (returns the 118-byte upload frame) + +- [ ] **Step 1: Write the failing test** + +Create `android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt`: + +```kotlin +package net.guacamaya.proto + +import net.guacamaya.crypto.Signer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class FoundPayloadTest { + + private val targetNodeId = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte()) + + private fun payload() = FoundPayload( + latE7 = 104_003_000, + lonE7 = -669_000_000, + tsUnix = 1_750_000_000L, + targetNodeId = targetNodeId, + targetMsgId = 4242, + foundMsgId = 777, + ) + + @Test + fun encodeUsesDocumentedBigEndianOffsets() { + val bytes = payload().encode() + assertEquals(FoundFrame.PAYLOAD_LEN, bytes.size) + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + assertEquals(104_003_000, buf.getInt(0)) + assertEquals(-669_000_000, buf.getInt(4)) + assertEquals(1_750_000_000L, buf.getInt(8).toLong() and 0xFFFFFFFFL) + assertArrayEquals(targetNodeId, bytes.copyOfRange(12, 16)) + assertEquals(4242, buf.getShort(16).toInt() and 0xFFFF) + assertEquals(777, buf.getShort(18).toInt() and 0xFFFF) + val crcStored = buf.getShort(20).toInt() and 0xFFFF + assertEquals(Crc16.ccitt(bytes, 0, 20) and 0xFFFF, crcStored) + } + + @Test + fun decodeRoundTrips() { + val decoded = FoundPayload.decode(payload().encode()) + assertEquals(payload(), decoded) + } + + @Test + fun buildFoundFrameSignsWithTheFoundDomain() { + val sk = ByteArray(32) { 7 } + val frame = buildFoundFrame(payload(), sk) + assertEquals(FoundFrame.FRAME_LEN, frame.size) + val payloadBytes = frame.copyOfRange(FoundFrame.PAYLOAD_OFF, FoundFrame.PAYLOAD_OFF + FoundFrame.PAYLOAD_LEN) + val pubkey = frame.copyOfRange(FoundFrame.PUBKEY_OFF, FoundFrame.PUBKEY_OFF + FoundFrame.PUBKEY_LEN) + val sig = frame.copyOfRange(FoundFrame.SIG_OFF, FoundFrame.SIG_OFF + FoundFrame.SIG_LEN) + assertArrayEquals(Signer.derivePublicKey(sk), pubkey) + // Signature covers the domain-prefixed bytes, not the bare payload. + assertTrue(Signer.verifyMessage(pubkey, FoundFrame.signBytes(payloadBytes), sig)) + assertTrue(!Signer.verifyMessage(pubkey, payloadBytes, sig)) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsShortTargetNodeId() { + FoundPayload(0, 0, 0L, ByteArray(3), 1, 1) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `android/`): `./gradlew :app:testDebugUnitTest --tests "net.guacamaya.proto.FoundPayloadTest"` +Expected: compilation FAILURE — `FoundPayload` unresolved. + +- [ ] **Step 3: Implement `FoundPayload.kt`** + +Create `android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt`: + +```kotlin +package net.guacamaya.proto + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import net.guacamaya.crypto.Signer + +/** + * Found frame v1 ("Encontrado") — a finder marks an SOS victim as found, carried + * to the backend as `foundFrames[]` in POST /ingest. + * + * Same envelope as the SOS frame: 22 B payload + 32 B pubkey + 64 B signature. + * Byte-identical with `packages/shared/src/mesh/foundFrame.ts` on the backend. + * + * off 0..3 latE7 int32 — finder latitude × 1e7 + * off 4..7 lonE7 int32 — finder longitude × 1e7 + * off 8..11 tsUnix u32 unix seconds + * off 12..15 targetNodeId SHA-256(victim pubkey)[0..3] + * off 16..17 targetMsgId u16 — msgId of the SOS session being marked + * off 18..19 foundMsgId u16 — CSPRNG per found report (dedupe entropy) + * off 20..21 crc CRC16-CCITT over bytes 0..19 + * + * Signature is domain-separated: Ed25519 over UTF8(FOUND_SIGN_DOMAIN) || payload22 + * — never over the bare payload, so SOS/control/found signatures are never + * interchangeable. + */ +object FoundFrame { + const val PAYLOAD_LEN = 22 + const val PUBKEY_LEN = 32 + const val SIG_LEN = 64 + const val FRAME_LEN = PAYLOAD_LEN + PUBKEY_LEN + SIG_LEN // 118 (upload, TTL stripped) + const val ON_WIRE_LEN = FRAME_LEN + 1 // 119 (leading hop TTL) + + const val PAYLOAD_OFF = 0 + const val PUBKEY_OFF = PAYLOAD_OFF + PAYLOAD_LEN // 22 + const val SIG_OFF = PUBKEY_OFF + PUBKEY_LEN // 54 + + /** Mirrored verbatim in packages/shared/src/mesh/constants.ts. */ + const val FOUND_SIGN_DOMAIN = "guacamaya.found-frame.v1\n" + + /** The exact bytes the Ed25519 signature covers: UTF8 domain prefix + payload. */ + fun signBytes(payload22: ByteArray): ByteArray { + val domain = FOUND_SIGN_DOMAIN.toByteArray(Charsets.UTF_8) + val out = ByteArray(domain.size + payload22.size) + System.arraycopy(domain, 0, out, 0, domain.size) + System.arraycopy(payload22, 0, out, domain.size, payload22.size) + return out + } +} + +/** + * Encodable found payload. [encode] throws on invalid fields — mirrors + * `encodeFoundPayload` on the backend. The caller appends pubkey + signature to + * build the 118-byte frame (see [buildFoundFrame]). + */ +data class FoundPayload( + val latE7: Int, + val lonE7: Int, + val tsUnix: Long, // u32 unix seconds + /** SHA-256(victim pubkey)[0..3], exactly 4 bytes. */ + val targetNodeId: ByteArray, + val targetMsgId: Int, // u16 + val foundMsgId: Int, // u16 +) { + init { + require(targetNodeId.size == 4) { "targetNodeId must be 4 bytes" } + require(tsUnix in 0..0xFFFFFFFFL) { "tsUnix out of u32 range" } + require(targetMsgId in 0..0xFFFF) { "targetMsgId out of u16 range" } + require(foundMsgId in 0..0xFFFF) { "foundMsgId out of u16 range" } + } + + /** Encode to a fresh 22-byte big-endian array with the trailing CRC16 filled in. */ + fun encode(): ByteArray { + val buf = ByteBuffer.allocate(FoundFrame.PAYLOAD_LEN).order(ByteOrder.BIG_ENDIAN) + buf.putInt(latE7) + buf.putInt(lonE7) + buf.putInt(tsUnix.toInt()) + buf.put(targetNodeId) + buf.putShort(targetMsgId.toShort()) + buf.putShort(foundMsgId.toShort()) + val withoutCrc = buf.array().copyOfRange(0, FoundFrame.PAYLOAD_LEN - 2) + val crc = Crc16.ccitt(withoutCrc) + buf.putShort(FoundFrame.PAYLOAD_LEN - 2, crc.toShort()) + return buf.array() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FoundPayload) return false + return latE7 == other.latE7 && + lonE7 == other.lonE7 && + tsUnix == other.tsUnix && + targetNodeId.contentEquals(other.targetNodeId) && + targetMsgId == other.targetMsgId && + foundMsgId == other.foundMsgId + } + + override fun hashCode(): Int { + var r = latE7 + r = 31 * r + lonE7 + r = 31 * r + tsUnix.hashCode() + r = 31 * r + targetNodeId.contentHashCode() + r = 31 * r + targetMsgId + r = 31 * r + foundMsgId + return r + } + + companion object { + /** Decode a 22-byte array; throws on size or CRC failure. Does not verify the signature. */ + fun decode(bytes: ByteArray): FoundPayload { + require(bytes.size == FoundFrame.PAYLOAD_LEN) { + "found payload must be ${FoundFrame.PAYLOAD_LEN} bytes, got ${bytes.size}" + } + val crcStored = ((bytes[FoundFrame.PAYLOAD_LEN - 2].toInt() and 0xFF) shl 8) or + (bytes[FoundFrame.PAYLOAD_LEN - 1].toInt() and 0xFF) + val crcComputed = Crc16.ccitt(bytes, 0, FoundFrame.PAYLOAD_LEN - 2) and 0xFFFF + require(crcStored == crcComputed) { "crc mismatch" } + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + val latE7 = buf.getInt(0) + val lonE7 = buf.getInt(4) + val tsUnix = buf.getInt(8).toLong() and 0xFFFFFFFFL + val targetNodeId = bytes.copyOfRange(12, 16) + val targetMsgId = buf.getShort(16).toInt() and 0xFFFF + val foundMsgId = buf.getShort(18).toInt() and 0xFFFF + return FoundPayload(latE7, lonE7, tsUnix, targetNodeId, targetMsgId, foundMsgId) + } + } +} + +/** + * Assemble the canonical 118-byte upload frame: payload22 + pubkey32 + sig64. The + * signature covers [FoundFrame.signBytes] of the encoded payload (domain-prefixed). + */ +fun buildFoundFrame(payload: FoundPayload, privateKey: ByteArray): ByteArray { + require(privateKey.size == FoundFrame.PUBKEY_LEN) { + "private key must be ${FoundFrame.PUBKEY_LEN} bytes, got ${privateKey.size}" + } + val payloadBytes = payload.encode() + val pubkey = Signer.derivePublicKey(privateKey) + val sig = Signer.signMessage(privateKey, FoundFrame.signBytes(payloadBytes)) + val frame = ByteArray(FoundFrame.FRAME_LEN) + System.arraycopy(payloadBytes, 0, frame, FoundFrame.PAYLOAD_OFF, FoundFrame.PAYLOAD_LEN) + System.arraycopy(pubkey, 0, frame, FoundFrame.PUBKEY_OFF, FoundFrame.PUBKEY_LEN) + System.arraycopy(sig, 0, frame, FoundFrame.SIG_OFF, FoundFrame.SIG_LEN) + return frame +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (from `android/`): `./gradlew :app:testDebugUnitTest --tests "net.guacamaya.proto.FoundPayloadTest"` +Expected: BUILD SUCCESSFUL, all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/kotlin/net/guacamaya/proto/FoundPayload.kt android/app/src/test/kotlin/net/guacamaya/proto/FoundPayloadTest.kt +git commit -m "feat(android): FoundPayload wire mirror with domain-separated signature" +``` + +--- + +### Task 5: Android — Room queue `outbound_found` (migration v9 → v10) + +**Files:** +- Modify: `android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt` + +**Interfaces:** +- Consumes: nothing new. +- Produces (used by Tasks 6–7): + - `data class OutboundFoundEntity(signatureHex: String, frame: ByteArray, uploaded: Boolean = false, queuedAt: Long)` (table `outbound_found`) + - DAO methods on `MessageDao`: `insertOutboundFound(entity): Long`, `selectUploadableFound(limit: Int): List`, `markFoundUploaded(sigs: List)`, `countUploadableFound(): Int` + +- [ ] **Step 1: Add the entity** + +In `android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt`, directly below the `OutboundControlEntity` class (after its closing brace at ~line 210), add: + +```kotlin +/** + * A locally-authored "Encontrado" (found) frame queued for data-mule upload to + * the backend (`foundFrames[]` in POST /ingest). Keyed by the 64-byte Ed25519 + * signature hex so the same report is queued exactly once. [frame] is the + * canonical 118 B upload blob (payload22 ‖ pubkey32 ‖ sig64) built by + * net.guacamaya.proto.buildFoundFrame. + */ +@Entity( + tableName = "outbound_found", + indices = [Index(value = ["queued_at"])], +) +data class OutboundFoundEntity( + /** 64-byte Ed25519 signature hex (128 chars). The dedup key. */ + @PrimaryKey @ColumnInfo(name = "signature_hex") val signatureHex: String, + /** Canonical 118 B upload frame (payload22 + pubkey32 + sig64). */ + @ColumnInfo(name = "frame") val frame: ByteArray, + /** Set once the backend accepted/duplicated the frame in `foundFrames[]`. */ + @ColumnInfo(name = "uploaded", defaultValue = "0") val uploaded: Boolean = false, + @ColumnInfo(name = "queued_at") val queuedAt: Long, +) { + // Room needs equals/hashCode for ByteArray fields. + override fun equals(other: Any?): Boolean = this === other + override fun hashCode(): Int = System.identityHashCode(this) +} +``` + +- [ ] **Step 2: Add the DAO methods** + +In the `MessageDao` interface, directly below the `countUploadableControl()` method, add: + +```kotlin + // ── Outbound found frames: mule-upload queue ("Encontrado" → backend) ──────── + + /** Queue a locally-authored found frame. IGNORE on the PK dedupes by signature. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOutboundFound(entity: OutboundFoundEntity): Long + + /** Oldest-first batch of found frames not yet uploaded, drained alongside SOS + * frames in the same `/ingest` call (the backend's `foundFrames[]` field). */ + @Query("SELECT * FROM outbound_found WHERE uploaded = 0 ORDER BY queued_at ASC LIMIT :limit") + suspend fun selectUploadableFound(limit: Int): List + + @Query("UPDATE outbound_found SET uploaded = 1 WHERE signature_hex IN (:sigs)") + suspend fun markFoundUploaded(sigs: List) + + @Query("SELECT COUNT(*) FROM outbound_found WHERE uploaded = 0") + suspend fun countUploadableFound(): Int +``` + +- [ ] **Step 3: Add the migration and bump the DB version** + +Below `MIGRATION_8_9`, add: + +```kotlin +/** + * v9 → v10: outbound queue for "Encontrado" (found) frames awaiting data-mule + * upload to the backend's `foundFrames[]` ingest path. Independent of signed SOS + * storage and of the control tables, so the protocol's upload/relay semantics + * are unchanged. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS outbound_found (" + + "signature_hex TEXT NOT NULL PRIMARY KEY, " + + "frame BLOB NOT NULL, " + + "uploaded INTEGER NOT NULL DEFAULT 0, queued_at INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_outbound_found_queued_at ON outbound_found (queued_at)") + } +} +``` + +Then in the `@Database` annotation: add `OutboundFoundEntity::class,` to the `entities` list (after `OutboundControlEntity::class,`) and change `version = 9` to `version = 10`. In the `Room.databaseBuilder` chain, change the `.addMigrations(...)` call to include the new migration: + +```kotlin +.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) +``` + +- [ ] **Step 4: Verify it compiles** + +Run (from `android/`): `./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL (Room validates entity/DAO consistency at compile time). + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/kotlin/net/guacamaya/mesh/MessageStore.kt +git commit -m "feat(android): outbound_found Room queue for Encontrado mule upload" +``` + +--- + +### Task 6: Android — upload pipeline (`IngestApi` / `IngestClient` / `IngestRepository` / worker) + +**Files:** +- Modify: `android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt` +- Modify: `android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt` +- Modify: `android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt` +- Test: `android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt` (update fakes + add a case) + +**Interfaces:** +- Consumes: `OutboundFoundEntity` + DAO methods from Task 5; backend response fields from Task 3 (`foundAccepted`, `foundDuplicate`, `foundRejected`, `foundResults[]` with `{ id, targetRecordId, distanceMeters, proximity, verdict }`). +- Produces (used by Task 7): nothing new for the UI — this task closes the queue-to-backend loop. + - `IngestApi.upload(frames, controlFrames, foundFrames)` — 3-list signature. + - `IngestResult` gains `foundAccepted/foundDuplicate/foundRejected: Int` and `foundResults: List`; `data class FoundVerdict(id: String, targetRecordId: String, distanceMeters: Int, proximity: String, verdict: String)`. + - `IngestRepository.UploadSummary` gains `foundFramesSent/foundAccepted/foundDuplicate/foundRejected: Int` and `foundResults: List`. + +- [ ] **Step 1: Extend `IngestClient.kt`** + +Apply these edits to `android/app/src/main/kotlin/net/guacamaya/ingest/IngestClient.kt`: + +**(a)** Add to `IngestResult` (after `controlFrames: List = emptyList(),`): + +```kotlin + val foundAccepted: Int = 0, + val foundDuplicate: Int = 0, + val foundRejected: Int = 0, + val foundResults: List = emptyList(), +``` + +**(b)** Below the `MuleCrl` data class, add: + +```kotlin +/** Backend proximity verdict for one uploaded "Encontrado" frame. */ +data class FoundVerdict( + val id: String, + val targetRecordId: String, + val distanceMeters: Int, + /** "near" | "far" */ + val proximity: String, + /** "likely_found" | "possible_false_positive" */ + val verdict: String, +) +``` + +**(c)** Change the `IngestApi` interface method to: + +```kotlin + /** + * POST a batch of base64 SOS [frames], (optional) base64 [controlFrames] + * (self-cancels) and (optional) base64 [foundFrames] ("Encontrado" reports). + * Any list may be empty; the backend accepts a batch with at least one + * non-empty side. Throws [IOException] on transport failure. + */ + suspend fun upload( + frames: List, + controlFrames: List, + foundFrames: List, + ): IngestResult +``` + +**(d)** Change `IngestClient.upload` signature to match (`override suspend fun upload(frames: List, controlFrames: List, foundFrames: List): IngestResult`), pass `foundFrames` to `buildBody`, and add the found fields to the returned `IngestResult` (after `controlFrames = ...`): + +```kotlin + foundAccepted = json.optInt("foundAccepted", 0), + foundDuplicate = json.optInt("foundDuplicate", 0), + foundRejected = json.optInt("foundRejected", 0), + foundResults = json.optJSONArray("foundResults")?.let { arr -> + List(arr.length()) { i -> + val o = arr.getJSONObject(i) + FoundVerdict( + id = o.optString("id"), + targetRecordId = o.optString("targetRecordId"), + distanceMeters = o.optInt("distanceMeters"), + proximity = o.optString("proximity"), + verdict = o.optString("verdict"), + ) + } + } ?: emptyList(), +``` + +**(e)** Change `buildBody` to: + +```kotlin + private fun buildBody( + frames: List, + controlFrames: List, + foundFrames: List, + ): String { + val obj = JSONObject() + if (frames.isNotEmpty()) obj.put("frames", JSONArray(frames)) + if (controlFrames.isNotEmpty()) obj.put("controlFrames", JSONArray(controlFrames)) + if (foundFrames.isNotEmpty()) obj.put("foundFrames", JSONArray(foundFrames)) + return obj.toString() + } +``` + +- [ ] **Step 2: Extend `IngestRepository.kt`** + +In `android/app/src/main/kotlin/net/guacamaya/ingest/IngestRepository.kt`: + +**(a)** Add fields to `UploadSummary` (after `controlRejected: Int = 0,`): + +```kotlin + val foundFramesSent: Int = 0, + val foundAccepted: Int = 0, + val foundDuplicate: Int = 0, + val foundRejected: Int = 0, + /** Backend proximity verdicts for the "Encontrado" frames this run uploaded. */ + val foundResults: List = emptyList(), +``` + +**(b)** Rework `uploadPending` so the found queue drains alongside the other two. The existing method builds `UploadSummary` in four places; to keep them in sync, replace the whole body of `uploadPending` with this version (same logic plus the found side, with the repeated construction factored into a local helper): + +```kotlin + suspend fun uploadPending(maxBatches: Int = DEFAULT_MAX_BATCHES): UploadSummary { + var batches = 0 + var framesSent = 0 + var controlFramesSent = 0 + var foundFramesSent = 0 + var ingested = 0 + var duplicate = 0 + var rejected = 0 + var controlAccepted = 0 + var controlDuplicate = 0 + var controlRejected = 0 + var foundAccepted = 0 + var foundDuplicate = 0 + var foundRejected = 0 + val foundResults = mutableListOf() + val minted = mutableListOf() + var crl: MuleCrl? = null + + fun summary(outcome: Outcome) = UploadSummary( + outcome = outcome, + batches = batches, + framesSent = framesSent, + controlFramesSent = controlFramesSent, + foundFramesSent = foundFramesSent, + ingested = ingested, + duplicate = duplicate, + rejected = rejected, + controlAccepted = controlAccepted, + controlDuplicate = controlDuplicate, + controlRejected = controlRejected, + foundAccepted = foundAccepted, + foundDuplicate = foundDuplicate, + foundRejected = foundRejected, + foundResults = foundResults.toList(), + mintedControlFrames = minted.toList(), + certificateRevocationList = crl, + ) + + repeat(maxBatches) { + val batch = dao.selectUploadable(batchSize) + val controlBatch = dao.selectUploadableControl(batchSize) + val foundBatch = dao.selectUploadableFound(batchSize) + if (batch.isEmpty() && controlBatch.isEmpty() && foundBatch.isEmpty()) { + return summary(Outcome.SUCCESS) + } + + val frames = batch.map { IngestFrame.toBase64(it.payloadRaw, it.pubkey, it.sig) } + // Control/found frames are stored as their canonical 118 B blob, so they + // only need base64 — same std encoder IngestFrame uses, keeping this + // class Android-free. + val controlFrames = controlBatch.map { Base64.getEncoder().encodeToString(it.frame) } + val foundFrames = foundBatch.map { Base64.getEncoder().encodeToString(it.frame) } + val result = try { + api.upload(frames, controlFrames, foundFrames) + } catch (_: IOException) { + return summary(Outcome.RETRY) + } + if (!result.ok) { + return summary(Outcome.RETRY) + } + + dao.markUploaded(batch.map { it.id }) + dao.markControlUploaded(controlBatch.map { it.signatureHex }) + dao.markFoundUploaded(foundBatch.map { it.signatureHex }) + batches++ + framesSent += frames.size + controlFramesSent += controlFrames.size + foundFramesSent += foundFrames.size + ingested += result.ingested + duplicate += result.duplicate + rejected += result.rejected + controlAccepted += result.controlAccepted + controlDuplicate += result.controlDuplicate + controlRejected += result.controlRejected + foundAccepted += result.foundAccepted + foundDuplicate += result.foundDuplicate + foundRejected += result.foundRejected + foundResults += result.foundResults + minted += result.controlFrames + result.certificateRevocationList?.let { candidate -> + if (crl == null || candidate.sequence > crl!!.sequence) crl = candidate + } + } + + return summary(Outcome.SUCCESS) + } +``` + +Also update the class KDoc's first line to mention found frames: change "pull un-uploaded verified frames in batches" to "pull un-uploaded verified frames (SOS, self-cancel control, and Encontrado reports) in batches". + +- [ ] **Step 3: Update the worker log line** + +In `android/app/src/main/kotlin/net/guacamaya/ingest/IngestUploadWorker.kt`, extend the `Log.i(TAG, "ingest run ...")` message: after `"ctrlDup=${summary.controlDuplicate} ctrlRej=${summary.controlRejected}"` append `+ " foundSent=${summary.foundFramesSent} foundOk=${summary.foundAccepted} foundRej=${summary.foundRejected}"`. Then, after the existing `if (summary.controlRejected > 0) { ... }` block, add: + +```kotlin + for (v in summary.foundResults) { + Log.i(TAG, "found verdict target=${v.targetRecordId.take(12)} d=${v.distanceMeters}m ${v.proximity}/${v.verdict}") + } +``` + +- [ ] **Step 4: Update the repository test and add a found case** + +In `android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt`: + +1. The fake `IngestApi` (around line 101) must change its `upload` signature to `override suspend fun upload(frames: List, controlFrames: List, foundFrames: List): IngestResult` — keep its existing behavior, and record `foundFrames` the same way it records the other lists (add a `val uploadedFound = mutableListOf>()` alongside the existing recording lists if the fake records calls). +2. The fake `MessageDao` in that file must implement the three new methods. Add an in-memory list: + +```kotlin + val outboundFound = mutableListOf() + + override suspend fun insertOutboundFound(entity: OutboundFoundEntity): Long { + if (outboundFound.any { it.signatureHex == entity.signatureHex }) return -1 + outboundFound.add(entity) + return outboundFound.size.toLong() + } + + override suspend fun selectUploadableFound(limit: Int): List = + outboundFound.filter { !it.uploaded }.sortedBy { it.queuedAt }.take(limit) + + override suspend fun markFoundUploaded(sigs: List) { + val marked = outboundFound.map { if (it.signatureHex in sigs) it.copy(uploaded = true) else it } + outboundFound.clear() + outboundFound.addAll(marked) + } + + override suspend fun countUploadableFound(): Int = outboundFound.count { !it.uploaded } +``` + +(If the fake DAO is a partial fake that only overrides some methods, mirror however it implements the `outbound_control` methods — the found methods are structurally identical. Add the import `net.guacamaya.mesh.OutboundFoundEntity`.) + +3. Add a test: + +```kotlin + @Test + fun drainsFoundQueueAndMarksUploaded() = runBlocking { + // Arrange: one pending found frame, no SOS/control work. + dao.outboundFound.add( + OutboundFoundEntity( + signatureHex = "ab".repeat(64), + frame = ByteArray(118) { 1 }, + queuedAt = 1L, + ) + ) + val summary = repo.uploadPending() + assertEquals(IngestRepository.Outcome.SUCCESS, summary.outcome) + assertEquals(1, summary.foundFramesSent) + assertEquals(0, dao.countUploadableFound()) + } +``` + +Adapt the arrange/act names (`dao`, `repo`) to how the existing tests in that file construct them — reuse the same fixtures/setup the neighboring tests use. + +- [ ] **Step 5: Run the tests** + +Run (from `android/`): `./gradlew :app:testDebugUnitTest --tests "net.guacamaya.ingest.IngestRepositoryTest"` — expected: PASS. +Then the full unit suite: `./gradlew :app:testDebugUnitTest` — expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add android/app/src/main/kotlin/net/guacamaya/ingest/ android/app/src/test/kotlin/net/guacamaya/ingest/IngestRepositoryTest.kt +git commit -m "feat(android): drain Encontrado queue through /ingest foundFrames" +``` + +--- + +### Task 7: Android — "Marcar como encontrado" UI on the Radar screen + +**Files:** +- Modify: `android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt` +- Modify: `android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt` + +**Interfaces:** +- Consumes: `FoundPayload`/`buildFoundFrame`/`FoundFrame` (Task 4), `OutboundFoundEntity` + `insertOutboundFound` (Task 5), `IngestUploadWorker.enqueue` (existing), `ControlFrame.toHex` (existing), `Identity` (existing, `vm.identity: StateFlow` with `privateKeySeed`), `MessageEntity` (has `nodeId: ByteArray`, `msgId: Int`, `latE7: Int`, `lonE7: Int`). +- Produces: `MapViewModel.markFound(node: MessageEntity, finderLat: Double, finderLon: Double)`; RadarScreen shows a proximity pre-check dialog and queues the frame. + +- [ ] **Step 1: Add `markFound` to `MapViewModel`** + +In `android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt`, add these imports (merge with existing ones): + +```kotlin +import java.security.SecureRandom +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import androidx.lifecycle.viewModelScope +import net.guacamaya.ingest.IngestUploadWorker +import net.guacamaya.mesh.MessageEntity +import net.guacamaya.mesh.OutboundFoundEntity +import net.guacamaya.proto.ControlFrame +import net.guacamaya.proto.FoundFrame +import net.guacamaya.proto.FoundPayload +import net.guacamaya.proto.buildFoundFrame +``` + +(Skip any import already present.) Then add this method next to `sendChat` (around line 164): + +```kotlin + /** + * Queue a signed "Encontrado" report for [node]'s current SOS session and kick + * the mule uploader. The backend re-verifies the signature and computes the + * authoritative near/far verdict against the SOS's last reported point; the + * finder's position travels inside the signed payload, never as loose JSON. + */ + fun markFound(node: MessageEntity, finderLat: Double, finderLon: Double) { + val id = identity.value ?: return + val app = getApplication() + viewModelScope.launch(Dispatchers.IO) { + val frame = buildFoundFrame( + FoundPayload( + latE7 = (finderLat * 1e7).toInt(), + lonE7 = (finderLon * 1e7).toInt(), + tsUnix = System.currentTimeMillis() / 1000, + targetNodeId = node.nodeId.copyOf(), + targetMsgId = node.msgId, + foundMsgId = SecureRandom().nextInt(0x10000), + ), + id.privateKeySeed, + ) + val sigHex = ControlFrame.toHex(frame.copyOfRange(FoundFrame.SIG_OFF, FoundFrame.FRAME_LEN)) + dao.insertOutboundFound( + OutboundFoundEntity(signatureHex = sigHex, frame = frame, queuedAt = System.currentTimeMillis()) + ) + IngestUploadWorker.enqueue(app) + } + } +``` + +(`Application` is already imported/used by `AndroidViewModel`; if not, add `import android.app.Application`.) + +- [ ] **Step 2: Pass the callback into `RadarScreen`** + +In `android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt`, find the `RadarScreen(` call site inside `Screen()` (around line 633) and add the parameter: + +```kotlin + RadarScreen( + latestNodes = latestNodes, + neighborEstimates = neighborEstimates, + onBack = { showRadar = false }, + onMarkFound = { node, lat, lon -> vm.markFound(node, lat, lon) }, + ) +``` + +(Keep the existing named/positional argument style of the call site — the key change is the new `onMarkFound` lambda.) Then change the `RadarScreen` composable signature (around line 1269): + +```kotlin +@Composable +private fun RadarScreen( + latestNodes: List, + neighborEstimates: List, + onBack: () -> Unit, + onMarkFound: (MessageEntity, Double, Double) -> Unit, +) { +``` + +- [ ] **Step 3: Render the found section** + +Inside `RadarScreen`, after the `signalTarget?.let { signal -> ... }` block and before `Spacer(Modifier.height(Space.md))` (the one preceding the "Precisión" card), add: + +```kotlin + targetNode?.let { node -> + FoundReportSection(targetNode = node, location = location, onMarkFound = onMarkFound) + } +``` + +Then add these composables + helpers near the bottom of the file, above `private fun ByteArray.toHex()`: + +```kotlin +/** Local mirror of the backend's FOUND_PROXIMITY_METERS default (100 m). The + * backend computes the authoritative verdict; this only powers the pre-check UI. */ +private const val FOUND_NEAR_METERS = 100.0 + +/** + * "Marcar como encontrado": queues a signed Encontrado report for the radar's + * current target. Before queueing, shows a proximity pre-check against the + * target's last reported point — near (≤100 m) reads as "probable encontrado", + * far warns "posible falso positivo" so trolls/mistakes are flagged, not hidden. + */ +@Composable +private fun FoundReportSection( + targetNode: MessageEntity, + location: Location?, + onMarkFound: (MessageEntity, Double, Double) -> Unit, +) { + // Without the target's fix there is no reference point to validate against. + if (targetNode.latE7 == 0 && targetNode.lonE7 == 0) return + val nodeKey = "${targetNode.nodeId.toHex()}-${targetNode.msgId}" + var showDialog by remember(nodeKey) { mutableStateOf(false) } + var queued by remember(nodeKey) { mutableStateOf(false) } + + Spacer(Modifier.height(Space.sm)) + if (queued) { + Text( + "Reporte \"Encontrado\" en cola — se subirá al backend con conexión", + color = InfoC, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + ) + return + } + FilledTonalButton( + onClick = { showDialog = true }, + enabled = location != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (location != null) "Marcar como encontrado" else "Marcar como encontrado (esperando GPS)") + } + + if (showDialog && location != null) { + val distance = haversineMeters( + location.latitude, location.longitude, + targetNode.latE7 / 1e7, targetNode.lonE7 / 1e7, + ) + val near = distance <= FOUND_NEAR_METERS + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text("¿Marcar nodo ${targetNode.nodeId.toHex().take(8)} como encontrado?") }, + text = { + Text( + if (near) { + "✅ Estás a ~${distance.roundToInt()} m del último punto reportado. " + + "Probable encontrado." + } else { + "⚠️ Estás a ~${distance.roundToInt()} m del último punto reportado " + + "(más de ${FOUND_NEAR_METERS.roundToInt()} m). El reporte se marcará " + + "como posible falso positivo." + } + ) + }, + confirmButton = { + TextButton(onClick = { + onMarkFound(targetNode, location.latitude, location.longitude) + queued = true + showDialog = false + }) { Text("Confirmar") } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { Text("Cancelar") } + }, + ) + } +} + +/** Great-circle distance in meters — mirror of `haversineMeters` in @guacamaya/shared. */ +private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2) + return 2 * r * asin(sqrt(a)) +} +``` + +Add any missing imports to `MainActivity.kt` (merge with existing): + +```kotlin +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton +import kotlin.math.asin +import kotlin.math.pow +import kotlin.math.sqrt +``` + +(`sin`, `cos`, `roundToInt`, `Location`, `FilledTonalButton`, `TextAlign` are already imported — verify and only add what the compiler asks for.) + +- [ ] **Step 4: Build + unit tests** + +Run (from `android/`): `./gradlew :app:assembleDebug :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Device verification note (required by repo rule)** + +The Cursor rule `.cursor/rules/device-test-first.mdc` requires Android UI/BLE changes to be verified on a connected adb device before claiming they work. If a device is connected: `cd android && ./scripts/demo.sh install`, open the Radar with a second device broadcasting an SOS, tap "Marcar como encontrado", confirm the dialog shows the near/far pre-check, then check `./scripts/demo.sh logcat` for the `guacamaya.ingest` "found verdict" line once connectivity is available. If no device is connected, note in the task report that on-device verification is pending — do not claim the UI is verified. + +- [ ] **Step 6: Commit** + +```bash +git add android/app/src/main/kotlin/net/guacamaya/ui/MapViewModel.kt android/app/src/main/kotlin/net/guacamaya/ui/MainActivity.kt +git commit -m "feat(android): Marcar como encontrado action with proximity pre-check" +``` + +--- + +### Task 8: Docs — CHANGELOG.md + `backend_final.md` endpoint reference + +**Files:** +- Create: `CHANGELOG.md` (repo root — it does not exist yet) +- Modify: `backend_final.md` (repo root — append the foundFrames contract) + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: Create `CHANGELOG.md`** + +Create `CHANGELOG.md` at the repo root: + +```markdown +# Changelog + +All notable changes to GuacaMalla are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **"Encontrado" (Found) reports** — end users can mark an SOS victim as found by + target ID + their own location, end to end: + - New signed 118-byte *found frame* (`22 B payload + 32 B finder pubkey + 64 B + Ed25519 signature`, domain `guacamaya.found-frame.v1`), byte-identical between + `packages/shared/src/mesh/foundFrame.ts` and the Android + `net.guacamaya.proto.FoundPayload`. + - `POST /ingest` accepts a new `foundFrames` array. Every frame is re-verified + zero-trust (CRC16 → GPS-fix sanity → domain-separated Ed25519) before anything + is persisted; reports whose target SOS is unknown are rejected. + - **Proximity validation against the SOS's last reference point**: the backend + computes the haversine distance from the finder's signed GPS position to the + target SOS's last reported point. Within `FOUND_PROXIMITY_METERS` (default + 100 m) → `near` / `likely_found`; further → `far` / `possible_false_positive` + (⚠️ flags trolls and mistaken reports instead of hiding them). Verdicts are + returned in the ingest response (`foundAccepted`, `foundDuplicate`, + `foundRejected`, `foundReasons`, `foundResults[]`). + - New community channel **`encontrado`** (`GET /channels/encontrado/records`, + WebSocket broadcasts) holding the reports as `verified:false` records. + - Android: "**Marcar como encontrado**" action on the Radar screen with a local + near/far pre-check dialog, a new Room queue (`outbound_found`, DB v10), and + data-mule upload of queued reports through the existing `/ingest` worker. + - New backend env var `FOUND_PROXIMITY_METERS` (default 100). +``` + +- [ ] **Step 2: Document the endpoint contract in `backend_final.md`** + +Append this section at the end of `backend_final.md` (repo root): + +```markdown +## POST /ingest — `foundFrames` ("Encontrado" reports) + +Alongside `frames` and `controlFrames`, the ingest body may carry +`"foundFrames": ["", ...]` — signed 118-byte found frames +(`22 B payload + 32 B finder pubkey + 64 B signature`; a 119-byte frame with a +leading hop-TTL byte is tolerated). No API key: zero-trust by signature. + +Found payload v1 (22 bytes, big-endian; signature domain +`"guacamaya.found-frame.v1\n"` prefixed to the payload before signing): + +| Offset | Field | Type | +|---|---|---| +| 0..3 | latE7 (finder latitude × 1e7) | int32 | +| 4..7 | lonE7 (finder longitude × 1e7) | int32 | +| 8..11 | tsUnix | u32 | +| 12..15 | targetNodeId = SHA-256(victim pubkey)[0..3] | 4 B | +| 16..17 | targetMsgId (SOS session) | u16 | +| 18..19 | foundMsgId (CSPRNG) | u16 | +| 20..21 | CRC16-CCITT over 0..19 | u16 | + +Server-side, per frame: CRC → GPS-fix sanity (0,0 / out-of-range rejected) → +Ed25519 over the domain-prefixed payload → target lookup (latest +`solicito-ayuda` record with `payload.nodeId == targetNodeId` and +`payload.msgId == targetMsgId`; missing or fixless target ⇒ rejected, +`"unknown target sos"`). Accepted frames become `verified:false` records on +channel `encontrado` with `id = SHA-256(payload22)` (dedupe), plus a proximity +verdict computed from the finder's signed position vs the SOS's last reported +point: distance ≤ `FOUND_PROXIMITY_METERS` (default 100 m) ⇒ +`proximity:"near"`, `verdict:"likely_found"`; otherwise `proximity:"far"`, +`verdict:"possible_false_positive"`. + +Response additions: + +```json +{ + "foundAccepted": 1, + "foundDuplicate": 0, + "foundRejected": 0, + "foundReasons": {}, + "foundResults": [ + { + "id": "", + "targetRecordId": "", + "distanceMeters": 33, + "proximity": "near", + "verdict": "likely_found" + } + ] +} +``` + +Verdicts are returned for duplicates too (the uploading mule is usually the +finder's own phone). Recommended app flow: queue the frame in `outbound_found`, +drain it with the regular mule upload, and log/render the returned verdict. +``` + +- [ ] **Step 3: Final full verification** + +Run: `bun test` — expected: green. +Run: `bun run --cwd backend tsc --noEmit` — expected: no errors. +Run (from `android/`): `./gradlew :app:testDebugUnitTest` — expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md backend_final.md +git commit -m "docs: changelog + ingest foundFrames contract for Encontrado" +``` + +--- + +## Self-review checklist (already applied) + +- Spec coverage: mark-by-ID+location (Tasks 1, 4, 7), proximity validation near/far with 100 m default (Tasks 1–3, 7), `/ingest` as the single entry point (Task 3), mobile availability (Tasks 4–7), CHANGELOG (Task 8), haiku/sonnet subagent guidance (header). +- Type consistency: `FoundPayloadFields`/`FoundPayload` field names and offsets match across TS and Kotlin; `FoundVerdict` fields match the backend `FoundResultEntry`; DAO method names (`insertOutboundFound`/`selectUploadableFound`/`markFoundUploaded`/`countUploadableFound`) are identical in Tasks 5, 6, and 7. +- Rejected alternatives (do not re-open): reusing the control-frame layout (no room for lat/lon), a trusted JSON endpoint (violates the zero-trust/no-client-location invariant), emitting ledger `SOS_RESOLVED` events (the witness-quorum resolve flow is the authoritative disarm; Encontrado is an informational community report). diff --git a/guacamalla-landing/landing/.claude/settings.json b/guacamalla-landing/landing/.claude/settings.json new file mode 100644 index 0000000..a1812ff --- /dev/null +++ b/guacamalla-landing/landing/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "lovable@claude-plugins-official": true + } +} diff --git a/web/landing/.env.example b/guacamalla-landing/landing/.env.example similarity index 100% rename from web/landing/.env.example rename to guacamalla-landing/landing/.env.example diff --git a/web/landing/.gitignore b/guacamalla-landing/landing/.gitignore similarity index 100% rename from web/landing/.gitignore rename to guacamalla-landing/landing/.gitignore diff --git a/web/landing/.prettierignore b/guacamalla-landing/landing/.prettierignore similarity index 100% rename from web/landing/.prettierignore rename to guacamalla-landing/landing/.prettierignore diff --git a/web/landing/.prettierrc b/guacamalla-landing/landing/.prettierrc similarity index 100% rename from web/landing/.prettierrc rename to guacamalla-landing/landing/.prettierrc diff --git a/web/landing/AGENTS.md b/guacamalla-landing/landing/AGENTS.md similarity index 100% rename from web/landing/AGENTS.md rename to guacamalla-landing/landing/AGENTS.md diff --git a/web/landing/bun.lock b/guacamalla-landing/landing/bun.lock similarity index 72% rename from web/landing/bun.lock rename to guacamalla-landing/landing/bun.lock index 01ee8ea..3bf2a17 100644 --- a/web/landing/bun.lock +++ b/guacamalla-landing/landing/bun.lock @@ -34,11 +34,6 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", - "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.101.1", - "@tanstack/react-router": "^1.170.16", - "@tanstack/react-start": "^1.168.26", - "@tanstack/router-plugin": "^1.168.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -54,16 +49,14 @@ "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.1", "three": "^0.185.0", "tw-animate-css": "^1.3.4", "vaul": "^1.1.2", - "vite-tsconfig-paths": "^6.0.2", "zod": "^4.4.3", }, "devDependencies": { "@eslint/js": "^9.32.0", - "@lovable.dev/vite-tanstack-config": "^2.6.4", + "@tailwindcss/vite": "^4.2.1", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -75,17 +68,16 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", - "nitro": "3.0.260603-beta", "prettier": "^3.7.3", + "tailwindcss": "^4.2.1", "typescript": "^5.8.3", "typescript-eslint": "^8.56.1", "vite": "^8.0.16", + "vite-tsconfig-paths": "^6.0.2", }, }, }, "packages": { - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -240,29 +232,9 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.0.2", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-1c4XLFkpS614VxPyq+IX0us52vm5JWxdO1NfG2L1FjH0asKvHNu5//6wpnKHWmx60Y4HR1gcLaIiLACXIdeFyg=="], - - "@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.1.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-ySRUzOoGQYhj4Pd9EjaA7i1+2RiaS5lSVZasun72wVZZmJfFIciGYGQQDUu88HISn4icz0sugQxFRJGCNlTggg=="], - - "@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.6.4", "", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.0.2", "@lovable.dev/vite-plugin-hmr-gate": "^1.1.1", "lightningcss": "^1.30.0", "lovable-tagger": "1.2.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-eav5blPqeu6n054y+7UtbtwIOiIexB2U7gGvHJIo2rsLyDFkJL1QL3qKnmmax1FLIProfoYXuC0lq1QczFR+dw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], - - "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], - - "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], - - "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], - - "@oxc-project/types": ["@oxc-project/types@0.134.0", "", {}, "sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], @@ -374,88 +346,38 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gCYzGOSkYY6Z034suzd20euvds7lPzMEEla62DJGE/ZAlR4OMBnNbvnBSsIGUCAr52gaWMsloGxP4tVGtN5aCA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQBD77MNgu+4Z6RAyg69acugdrhhVoWesr3l47zohYZ2YV2fwkWMArkN/2p4l6Ei+Sno7W5q+UsKdVWq5Ens0w=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p/8cXUTK4Sob604e+xxPhVSbDFf29E6J0l/xESM9rdCfn3aDai3nEs6TnMHUsdD5aNlFz0+gDbiGlozLKGa2YA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KbtOSlVv6fElujiZWMcC3aQYhEwLVVf073RcwlSmpGQvIsKZFUqc0ef4sjUuurRwfbiI6JJXji9DQn+86hawmQ=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9fZ9i0o0/MQaw7om6Z6TsT7tfCk0jtbEFtC+aPqZL5RNsGWNcHvn6EHgL3dAprjq+AZzPTAQjg2JtpJaMt+6pg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tog7T66i+yFyIuuAnjL6xmW182W/qTBOUt6BtQ6lBIM1Eikh/fSMz4HGgvuCp5uU0zuIVWng7kDYthjCMOHcg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4b7yruLIIj/oZ3GpcLOvxcLCLDMraohn3IhQfN2hBP4w9UekG0DTIajWguJosRGfySf/+h/NwRUiMKoCpxCrqQ=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QRDOVZd0bhQ5jLsUsCC3dUxDWdTSVY9WMznowZgCGOrZfLLgctWpelhUASEiBwsXfat/JwYnVd1EaxMhqyT+UQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ypxT+Hq76NFG7woFbNbySnGEajFuYuIXeKz/jfCU+lXUoxfi3zLE6OG/ZQNeK3RpZSYJlAe2bokpsQ046CaieQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IdovCmfROFmpTLahdecTDFL74aLERVYN68F/mLZjfVh6LfoplPfI6deyHNMTcVujbokDV5k05XrFO22zfv+qjg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pcA8xlFp2tyk9T2R6Fi/rPe3bQ1MA+sSMDNUU5Ogu80GHOatkE4P8YCreGAvZErm5Ho2YRXnyvNrWiRncfVysQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4+fexHayrLCWpriPh4c6dNvL4an34DEZCG7zOM/FD5QNF6h8DT+bDXzyB/kfC8lDJbaFb7jKShtnjDQFXVQEjg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+xTE6XC7wBgk0VKRXGG+QAnyW5S9b8vfsFpiMjf0waQTmSQSU8onsH/beyZ8X4aXVveJnotiy7VDjLOaW8bTrg=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ogji1TQNqH3ACLnYr+1Ns1nyrJ0CO2P585u9Hsh02pXvtFiFpgtgT2b3P4PnCOU86VVCvqtAeCN4OftMT8KU4w=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], - "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], @@ -490,46 +412,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], - "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], - - "@tanstack/query-core": ["@tanstack/query-core@5.101.1", "", {}, "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g=="], - - "@tanstack/react-query": ["@tanstack/react-query@5.101.1", "", { "dependencies": { "@tanstack/query-core": "5.101.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew=="], - - "@tanstack/react-router": ["@tanstack/react-router@1.170.16", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.13", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-w6eq1IJklujs1tESazaK/FxH0+H2l8vm/QPuu1cD3oRW/ubgKneQpd7b64ti/8gUyEimzimJQZDmJr6YHfP5+g=="], - - "@tanstack/react-start": ["@tanstack/react-start@1.168.26", "", { "dependencies": { "@tanstack/react-router": "1.170.16", "@tanstack/react-start-client": "1.168.14", "@tanstack/react-start-rsc": "0.1.25", "@tanstack/react-start-server": "1.167.20", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-plugin-core": "1.171.18", "@tanstack/start-server-core": "1.169.15", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-ZzNecqKWC0p2643/kIcFUdsMrXZ8A4dXm4Yfe9zV/Y0u14MtSkh/Sk76RQYIU5S84VyDyKhHo0Ffh8HQbLdvFw=="], - - "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.14", "", { "dependencies": { "@tanstack/react-router": "1.170.16", "@tanstack/router-core": "1.171.13", "@tanstack/start-client-core": "1.170.12" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-oaz43fdOhBWfOPsdLkp3IejwYEXRyeaZ4CYexOPZx3eVXzm4LLozvNkkkBE9aje1sM5MVC2Yo6+cyv2HdVzkHQ=="], - - "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.25", "", { "dependencies": { "@tanstack/react-router": "1.170.16", "@tanstack/router-core": "1.171.13", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.18", "@tanstack/start-server-core": "1.169.15", "@tanstack/start-storage-context": "1.167.15", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-Rwm6cjcS148y2XAebr8jyrwU0SqsRSkW4/CuXesoHg+G3IOnVRHV0HOylJfnznUTVuH1nVhfQRPI5uWPJaw2TA=="], - - "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.20", "", { "dependencies": { "@tanstack/react-router": "1.170.16", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.15" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hg9xVI6yNDu+6NCalKz1h3Ea18HZEUu35i/ZMJz1WadwqcArLMp41nM1GNhOAtGIdpycrp9tT7ccqc9zuRMRpQ=="], - - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], - - "@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="], - - "@tanstack/router-generator": ["@tanstack/router-generator@1.167.17", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA=="], - - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-generator": "1.167.17", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.15", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg=="], - - "@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="], - - "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.12", "", { "dependencies": { "@tanstack/router-core": "1.171.13", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.15", "seroval": "^1.5.4" } }, "sha512-gwtZRMPUIAxmDV2AIQUhC0kSW262SV7BkHXEgy5B1woHQdrdsELuGOdJwdweLxrjyefORxk+9MYGqDY0Cxn0bw=="], - - "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], - - "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.18", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-generator": "1.167.17", "@tanstack/router-plugin": "1.168.18", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.15", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-weuOOjRD03BiKcF9NYKL5QADEQOp3yGHb0qIsOX42ENz5XUE4in2fXcVYHjSwwpzs+XGhWIFLp5J385pOVPuZQ=="], - - "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/router-core": "1.171.13", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-storage-context": "1.167.15", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" } }, "sha512-V8ie2G2Ecb3Nklk8/QuKzulxb+tDUqgz6rjJe8Isdp4iKVXZu/TscItkUP/4Q5Bu7W4gUy3P25O6soC1oW1SXQ=="], - - "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="], - - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], - "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -606,42 +488,24 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], - - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001790", "", {}, "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -652,22 +516,12 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -696,8 +550,6 @@ "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], - "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], @@ -708,12 +560,6 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], - - "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - - "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], @@ -726,10 +572,6 @@ "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], - "env-runner": ["env-runner@0.1.9", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.15" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -762,32 +604,22 @@ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], - "fflate": ["fflate@0.8.3", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/fflate/-/fflate-0.8.3.tgz", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -796,8 +628,6 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -812,18 +642,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="], - - "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], - - "httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -834,18 +654,10 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -892,10 +704,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -904,48 +712,26 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lovable-tagger": ["lovable-tagger@1.2.0", "", { "dependencies": { "esbuild": "^0.25.0", "tailwindcss": "^3.4.17" }, "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-L7Qvc51gYlJSdvdIbUrUw/gsG9JFGBWOmW9vbVYwW7sBnI436p4M50aTh3wpBrdfdR38tHavUSNYLxCm8FBesw=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "meshoptimizer": ["meshoptimizer@1.1.1", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/meshoptimizer/-/meshoptimizer-1.1.1.tgz", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "nf3": ["nf3@0.3.17", "", {}, "sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ=="], - - "nitro": ["nitro@3.0.260603-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.9", "h3": "^2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.3", "srvx": "^0.11.16", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.2.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.60.4", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-ffaSHK00a7YDlDizoEHwcxPwpQpdBRRA8k42ymTsRnfl3ipGeKgv4gnPr6DgmCNTo4tYVPK3bHBEv1gNhWpo/A=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - - "ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="], - - "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], - - "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -958,32 +744,12 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], - - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], - - "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], - - "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - - "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - - "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -994,8 +760,6 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], @@ -1020,58 +784,32 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], - "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], - - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], - "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - - "rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="], - - "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], - - "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - - "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -1080,22 +818,14 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], - - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "three": ["three@0.185.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/three/-/three-0.185.0.tgz", {}, "sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1110,16 +840,8 @@ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - - "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], - - "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1130,8 +852,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], @@ -1140,16 +860,10 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], - "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1202,10 +916,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - - "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1216,38 +926,16 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "env-runner/srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], - - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "h3-v2/srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], - - "lovable-tagger/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "sucrase/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "vite/rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "lovable-tagger/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - - "lovable-tagger/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - - "lovable-tagger/tailwindcss/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -1300,48 +988,6 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], - - "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - - "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], - - "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - - "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - - "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - - "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], - - "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - - "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], - - "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], - - "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], - - "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - - "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], - - "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], - - "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], - - "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "lovable-tagger/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "lovable-tagger/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - - "lovable-tagger/tailwindcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "lovable-tagger/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/web/landing/bunfig.toml b/guacamalla-landing/landing/bunfig.toml similarity index 100% rename from web/landing/bunfig.toml rename to guacamalla-landing/landing/bunfig.toml diff --git a/web/landing/components.json b/guacamalla-landing/landing/components.json similarity index 100% rename from web/landing/components.json rename to guacamalla-landing/landing/components.json diff --git a/web/landing/eslint.config.js b/guacamalla-landing/landing/eslint.config.js similarity index 100% rename from web/landing/eslint.config.js rename to guacamalla-landing/landing/eslint.config.js diff --git a/web/landing/index.html b/guacamalla-landing/landing/index.html similarity index 100% rename from web/landing/index.html rename to guacamalla-landing/landing/index.html diff --git a/web/landing/package.json b/guacamalla-landing/landing/package.json similarity index 100% rename from web/landing/package.json rename to guacamalla-landing/landing/package.json diff --git a/web/landing/public/favicon.png b/guacamalla-landing/landing/public/favicon.png similarity index 100% rename from web/landing/public/favicon.png rename to guacamalla-landing/landing/public/favicon.png diff --git a/web/landing/src/App.tsx b/guacamalla-landing/landing/src/App.tsx similarity index 82% rename from web/landing/src/App.tsx rename to guacamalla-landing/landing/src/App.tsx index 324abd3..447049d 100644 --- a/web/landing/src/App.tsx +++ b/guacamalla-landing/landing/src/App.tsx @@ -1,5 +1,7 @@ import { Nav } from "@/components/site/Nav"; import { Hero } from "@/components/site/Hero"; +import { ProofBand } from "@/components/site/ProofBand"; +import { HowItWorks } from "@/components/site/HowItWorks"; import { Architecture } from "@/components/site/Architecture"; import { ProofPacket } from "@/components/site/ProofPacket"; import { Showcase } from "@/components/site/Showcase"; @@ -13,6 +15,8 @@ export function App() {