VC Relay API — gateway specification

    tilescore.net transit gateway for P2P messages between Visit Card owners' mobile apps. The server never sees plaintext — only encrypted envelopes, deleted on delivery.

    Principles

    • Server is a blind relay. Plaintext lives only on devices.
    • Envelope ≤ 16 KB, stored as bytea, transferred as base64.
    • Pending messages are removed on ack; >7 days old are purged by cron.
    • Both sender and recipient must have an active VC tile.
    • Multi-device: envelope is fanned out to every recipient device.

    Authentication: Pairing Code

    The mobile messenger uses one-time pairing codes instead of passwords. A user already logged into the portal opens the «Messenger» section of their VC tile editor and receives an 8-character code in XXXX-XXXX format (10-min TTL, single-use).

    1. Portal → POST /vc-pair-code-issue (JWT) → returns code.
    2. User types code into the app.
    3. App → POST /vc-pair-redeem (no JWT) with code, device_id and public keys → receives access_token + refresh_token.
    4. Further /relay-* calls use Bearer JWT, refreshed via standard Supabase auth.

    Security: 32^8 ≈ 1.1T combinations; rate-limit 10 redeem/min per IP, 5 issue/hour per user; the code is bound to the VC owner's user_id and atomically consumed on first successful redeem.

    Production endpoint for mobile apps

    Mobile apps (iOS/Android) MUST use the proxy domain api.lazytraders.club. Direct *.supabase.co calls are blocked on the DNS level for many ISPs/carriers in Russia.

    Mobile base URL: https://api.lazytraders.club/api/v1/messenger

    Endpoint mapping:

    Mobile (api.lazytraders.club)Internal implementation
    POST /api/v1/messenger/pair-redeemPOST https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/vc-pair-redeem
    POST /api/v1/messenger/auth/refreshPOST https://qrsjwoxhybhgiwkotybz.supabase.co/auth/v1/token?grant_type=refresh_token
    GET /api/v1/messenger/devices/keysGET https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/vc-device-keys
    POST /api/v1/messenger/relay/sendPOST https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/relay-send
    GET /api/v1/messenger/relay/pullGET https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/relay-pull

    Implementation requirements on api.lazytraders.club:

    • Pure pass-through reverse proxy. Does NOT validate JWT, does NOT rewrite headers, does NOT hold SUPABASE_SERVICE_ROLE_KEY. All user verification and service-role access happen INSIDE the Edge Function (see «Auth model» section below).
    • Forward unchanged: Authorization: Bearer <user_jwt>, apikey: <anon_key>, Content-Type, CORS preflight.
    • Proxy ENV: SUPABASE_URL=https://qrsjwoxhybhgiwkotybz.supabase.co (upstream address only), optional UPSTREAM_TIMEOUT, CACHE_DIR. No Supabase secrets on the proxy.
    • Rate limit: 10 redeem/min per IP, 60 relay-send/min per user, 120 relay-pull/min per user.
    • Logging: x-request-id header, latency, status — for end-to-end tracing.

    Auth model (important for proxy maintainers)

    Client (browser/app)              Proxy (api.lazytraders.club)        Supabase Edge Function
    ─────────────────────             ───────────────────────────         ─────────────────────────
    Authorization: Bearer <user_jwt>  →  forward as-is                 →  Authorization: Bearer <user_jwt>
    apikey: <anon_key>                →  forward as-is                 →  apikey: <anon_key>
    Content-Type / CORS               →  forward as-is                 →  ...
                                                                            ↓
                                                                        getClaims(jwt) → user_id
                                                                            ↓
                                                                        service-role client (внутри!)
                                                                            ↓
                                                                        RLS-bypass write
    • Proxy does NOT validate JWT — the Edge Function does it via supa.auth.getClaims().
    • Proxy does NOT hold SUPABASE_SERVICE_ROLE_KEY. Service-role is used only inside the Edge Function to bypass RLS after successful JWT verification.
    • Proxy does NOT rewrite Authorization / apikey. No «user JWT → service-role» substitutions.
    • anon_key is public; sending it openly in the apikey header is the standard Supabase pattern.
    • Exception: for /api/v1/games/* the proxy may inject Authorization: Bearer <anon_key> + apikey: <anon_key> itself, since serve-game returns public content with no user context.

    Proxy extensions: games + ai (TODO on api.lazytraders.club side)

    Beyond the messenger, the same api.lazytraders.club proxy must cover two more route groups. This removes the VPN requirement in RU for HTML5 games and (phase 2) the AI widget. Bundle uploads stay on *.supabase.co — they are admin-only.

    1. Games delivery (HTML5 bundles)

    Proxy (api.lazytraders.club)Internal implementation
    GET /api/v1/games/<tile_id>/<path...>GET https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/serve-game/<tile_id>/<path...>
    HEAD /api/v1/games/<tile_id>/<path...>HEAD https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/serve-game/<tile_id>/<path...>
    • GET / HEAD / OPTIONS only. No auth (delivery is public).
    • Forward as-is: Range, If-None-Match, If-Modified-Since.
    • Pass through upstream Content-Type, Cache-Control, ETag unchanged (critical for blob-iframe and ?v= cache-busting).
    • CORS: Access-Control-Allow-Origin: https://tilescore.net, https://www.tilescore.net (plus preview domains if needed).
    • Edge cache: 5 min for index.html, 1 h for static (.js, .css, .png, .webp, .ogg, .m4a, .wasm). Reduces Supabase load and speeds first launch.
    • Response size cap — up to 50 MB (bundles + WASM).
    • Response header x-served-by: api.lazytraders.club for tracing.

    Once the route is live, set VITE_GAME_BASE=https://api.lazytraders.club/api/v1/games on the portal — the frontend switches to the proxy automatically (fallback to *.supabase.co stays in code).

    2. AI widget / live-support (phase 2, optional)

    ProxyInternal implementation
    POST /api/v1/ai/<function>POST https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/<function>
    GET /api/v1/ai/<function>GET https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1/<function>
    • Covers embed-widget functions: ai-consultant, live-support-poll, live-support-send, widget-config, etc.
    • Pass-through: forward Authorization: Bearer <user_jwt | anon_key> and apikey: <anon_key> as-is, no substitution, no service-role.
    • Support streaming responses (SSE / chunked) — critical for the AI chat.
    • Rate-limit: 60 req/min per IP for anonymous, 600 req/min per JWT.

    3. What is NOT proxied

    • upload-game-bundle — admin operation from the portal admin UI, admins already have direct Supabase access.
    • Direct Storage URLs (storage.v1/object/public/…) — not critical yet, revisit after games.

    Internal endpoints (debug / desktop)

    Direct Supabase Edge endpoints — for debugging, the web portal, and desktop clients without DNS issues. Mobile clients must use api.lazytraders.club (see section above).

    Debug base URL: https://qrsjwoxhybhgiwkotybz.supabase.co/functions/v1

    All requests (except system-notify, vc-pair-redeem) require Authorization: Bearer <supabase_user_jwt> plus apikey: <anon_key>.

    POST/vc-pair-code-issue

    Issue pairing code (portal → JWT)

    Request
    // no body — user_id берётся из JWT
    Response
    {
      "ok": true,
      "code": "K7M2-9XQ4",
      "expires_at": "2026-04-23T08:50:00Z",
      "expires_in_sec": 600
    }
    // errors (HTTP 200): { "error": "no_vc" | "rate_limited" | "issue_failed" }
    POST/vc-pair-redeem

    Redeem code and get session (app → no JWT)

    Request
    {
      "code": "K7M2-9XQ4",
      "device_id": "uuid-from-app",
      "public_key":        "base64(X25519, 32 bytes)",
      "system_public_key": "base64(X25519, 32 bytes)",   // optional
      "platform": "ios" | "android",
      "app_version": "1.0.0"
    }
    Response
    {
      "ok": true,
      "user_id": "uuid",
      "device_id": "uuid-from-app",
      "device_uuid": "server-side uuid",
      "access_token": "eyJ...",
      "refresh_token": "...",
      "expires_in": 3600,
      "expires_at": 1714000000,
      "token_type": "bearer",
      "key_rotated": false
    }
    // errors (HTTP 200): { "error": "invalid_or_expired" | "no_vc" | "invalid_public_key" | "rate_limited" }
    POST/vc-device-register

    Register / upsert device

    Request
    {
      "device_id": "uuid-from-app",
      "public_key":        "base64(X25519, 32 bytes)",   // REQUIRED for new device
      "system_public_key": "base64(X25519, 32 bytes)",   // recommended (for system notifications)
      "push_token": "FCM/APNs token",                    // optional
      "platform": "ios" | "android" | "web",
      "app_version": "1.0.0",
      "key_algo": "x25519-xsalsa20-poly1305"             // optional, default
    }
    Response
    {
      "ok": true,
      "device": { "id": "uuid", "device_id": "...", "created_at": "..." },
      "key_rotated": false,
      "invalidated_pending": 0
    }
    GET/vc-device-keys?user_id=<uuid>

    Recipient device public keys (for E2EE)

    Request
    // no body — user_id passes as query string
    Response
    {
      "ok": true,
      "devices": [
        {
          "recipient_device_id": "uuid",
          "device_id": "uuid-from-app",
          "public_key": "base64(X25519, 32 bytes)",
          "system_public_key": "base64(X25519, 32 bytes)",
          "key_algo": "x25519-xsalsa20-poly1305"
        }
      ]
    }
    // Business errors (HTTP 200): { "error": "no_vc_caller" } | { "error": "no_vc_recipient" }
    POST/relay-send

    Send envelopes to recipient (one per device)

    Request
    {
      "recipient_user_id": "uuid",
      "envelopes": [
        {
          "recipient_device_id": "uuid",
          "ciphertext":       "base64",   // crypto_box_easy output
          "nonce":            "base64",   // 24 bytes
          "ephemeral_pubkey": "base64"    // sender X25519 pub for this message
        }
      ]
      // — OR (Stage 1 legacy, not E2EE) —
      // "envelope_b64": "base64(<= 16 KB)"
    }
    Response
    // Success
    { "ok": true, "delivered_to": 2, "skipped_devices": [], "ids": [...] }
    // skipped_devices reasons: "no_key" | "missing_envelope"
    // Business errors (HTTP 200):
    { "error": "no_vc_sender" }
    { "error": "no_vc_recipient" }
    { "error": "no_devices" }
    { "error": "blocked" }
    { "error": "rate_limited", "retry_after_sec": 60 }
    POST/relay-pull

    Fetch pending messages (long-poll up to 25s)

    Request
    {
      "device_id": "uuid-from-app",
      "wait": 25            // seconds, 0..25
    }
    Response
    {
      "ok": true,
      "messages": [
        {
          "id": "uuid",
          "sender_user_id": "uuid | null (null for system)",
          "kind": "user_msg" | "system_notification",
          "system_source": "wallet_topup" | null,
          "envelope_b64": "base64(...)",
          "created_at": "ISO"
        }
      ]
    }
    POST/relay-ack

    Acknowledge delivery → delete from inbox

    Request
    { "message_ids": ["uuid", "uuid", ...]   // ≤ 200 }
    Response
    { "ok": true, "deleted": 3 }
    POST/system-notify

    Internal system notification channel (server-to-server, NOT via proxy)

    Request
    // Только server-to-server (другая Edge Function / cron). НЕ вызывать с клиента и НЕ проксировать через api.lazytraders.club.
    Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
    
    {
      "recipient_user_id": "uuid",
      "system_source": "wallet_topup",
      "payload": { "amount": 100, "currency": "LAZY" }
    }
    Response
    { "ok": true, "delivered_to": 1 }

    Envelope (recommended plaintext layout)

    {
      "v": 1,
      "type": "text" | "system",
      "ts": 1730000000,                    // sender clock
      "msg_id": "uuid",                    // client-generated, idempotent
      "body": "Привет!",                   // for type=text
      "reply_to": "uuid|null"
    }

    Stage 2: encrypt this structure with AES-256-GCM using a key derived via X25519 ECDH. Server sees only ciphertext.

    Push (Stage 3)

    In Stage 3 the FCM connector will be wired up. After inbox insert the gateway pushes a minimal payload {"type":"new_msg"} to the device's push_token — content is fetched via relay-pull.

    E2EE encryption (Stage 2)

    Server is blind — sees only ciphertext. Encryption happens on-device via NaCl crypto_box (X25519 ECDH + XSalsa20-Poly1305). Each device holds two keypairs:

    • user keypair for P2P messages (crypto_box_easy / open_easy).
    • system keypair for system notifications from the platform (crypto_box_seal — anonymous sender).

    Registration without public_key is now rejected (400). Legacy Stage 1 devices without keys are skipped in relay-send with reason='no_key' in skipped_devices.

    Key rotation: re-POSTing /vc-device-register with the same device_id and a new public_key updates the row and purges any pending envelopes for that device (they can't be decrypted under the new key).

    Platform libraries

    • iOS (Swift): Swift-Sodium (CocoaPods) + store in Keychain with access control .whenUnlockedThisDeviceOnly.
    • Android (Kotlin): com.goterl:lazysodium-android + Android Keystore + EncryptedSharedPreferences for private keys.
    • JS / React Native: libsodium-wrappers (npm) — reference-сниппет ниже работает напрямую.

    Reference client (TypeScript)

    Canonical URL of this documentation: https://tilescore.net/docs/vc-relay-api#reference-client. Full snippet is published below — copy it into your project and install npm i libsodium-wrappers.

    import sodium from "libsodium-wrappers";
    
    // 1. Generate keys once per device, store private keys in Keychain/Keystore
    await sodium.ready;
    const userKp   = sodium.crypto_box_keypair();
    const systemKp = sodium.crypto_box_keypair();
    
    // 2. Register device
    await fetch(`${API}/vc-device-register`, {
      method: "POST",
      headers: { Authorization: `Bearer ${jwt}`, apikey, "Content-Type": "application/json" },
      body: JSON.stringify({
        device_id: deviceId,
        public_key:        sodium.to_base64(userKp.publicKey,   sodium.base64_variants.ORIGINAL),
        system_public_key: sodium.to_base64(systemKp.publicKey, sodium.base64_variants.ORIGINAL),
        platform: "ios", app_version: "1.0.0",
      }),
    });
    
    // 3. Send P2P message
    const { devices } = await (await fetch(`${API}/vc-device-keys?user_id=${recipientId}`, {
      headers: { Authorization: `Bearer ${jwt}`, apikey },
    })).json();
    
    const plain = new TextEncoder().encode(JSON.stringify({ v:1, type:"text", body:"Привет!" }));
    const envelopes = devices.map(d => {
      const recipientPub = sodium.from_base64(d.public_key, sodium.base64_variants.ORIGINAL);
      const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
      const ct = sodium.crypto_box_easy(plain, nonce, recipientPub, userKp.privateKey);
      return {
        recipient_device_id: d.recipient_device_id,
        ciphertext:       sodium.to_base64(ct,    sodium.base64_variants.ORIGINAL),
        nonce:            sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
        ephemeral_pubkey: sodium.to_base64(userKp.publicKey, sodium.base64_variants.ORIGINAL),
      };
    });
    await fetch(`${API}/relay-send`, {
      method: "POST",
      headers: { Authorization: `Bearer ${jwt}`, apikey, "Content-Type": "application/json" },
      body: JSON.stringify({ recipient_user_id: recipientId, envelopes }),
    });
    
    // 4. Decrypt incoming user message
    function decryptUser(envelopeB64: string) {
      const env = JSON.parse(new TextDecoder().decode(
        sodium.from_base64(envelopeB64, sodium.base64_variants.ORIGINAL)
      ));
      const ct       = sodium.from_base64(env.ciphertext,       sodium.base64_variants.ORIGINAL);
      const nonce    = sodium.from_base64(env.nonce,            sodium.base64_variants.ORIGINAL);
      const senderPk = sodium.from_base64(env.ephemeral_pubkey, sodium.base64_variants.ORIGINAL);
      return JSON.parse(new TextDecoder().decode(
        sodium.crypto_box_open_easy(ct, nonce, senderPk, userKp.privateKey)
      ));
    }
    
    // 5. Decrypt incoming system notification (sealed box, anonymous sender)
    function decryptSystem(envelopeB64: string) {
      const env = JSON.parse(new TextDecoder().decode(
        sodium.from_base64(envelopeB64, sodium.base64_variants.ORIGINAL)
      ));
      const ct = sodium.from_base64(env.ciphertext, sodium.base64_variants.ORIGINAL);
      return JSON.parse(new TextDecoder().decode(
        sodium.crypto_box_seal_open(ct, systemKp.publicKey, systemKp.privateKey)
      ));
    }

    Known limitations

    • TOFU: the server distributes public keys and could in theory swap them. Safety numbers verification — Stage 5.
    • Forward secrecy: device keys are long-lived. Rotation — via app reinstall or manual trigger.

    Limits

    • envelope ≤ 16 384 bytes
    • 30 envelopes / minute per sender→recipient pair
    • relay-pull batch ≤ 50 messages
    • relay-ack ≤ 200 ids
    • Pending TTL: 7 days

    Roadmap stages

    1. Stage 1: transit without E2EE, JWT auth, rate limit, blocks.
    2. Stage 2 (current): client-side E2EE (X25519 + XSalsa20-Poly1305 via NaCl), server blind, sealed box for system msgs.
    3. Stage 3: FCM push notifications.
    4. Stage 4: system event integration (wallet, bookings) via system-notify.
    5. Stage 5: attachments via Storage signed URLs, safety numbers, group chats.