Documentation

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Features
    Multi-Vendor Store
    SubscriptionConductor
    PaymentConductor
    Payments Overview
    WayForPay Payment Integration
    Wallet & Credit System
    WalletConductor
    Affiliate & Referral Enablement
    Referral Codes (Refcodes)
    NFT Exhibition Marketplace
    Solana NFT Gates
    Token Staking System
    Owner Project Lab
    Entities
    Opportunities
    Real-Time Messaging
    Ring Tasks
    WebRTC Calls & STUNner TURN
    News Module
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Email AI-CRM
    Ring Mailer & RingdomX Mail
    Tunnel Protocol
    VideoConductor
    MediaConductor
    Generative Gallery
    Authentication
    Security & Compliance
    Admin console
    Admin Wiki
    Manage via Telegram
    Locale System
    Mobile Experience
    Performance Optimization Patterns
    Examples
    Quick Start
    Basic Setup
    White Label
    Custom Branding
    Web3 Integration
    Real World
    Advanced Features
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Branding
    Themes
    Features
    Localization
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Web3
    Token launch jurisdictions
    Wallet
    Wallet Security Tips
    Integrations
    Ethereum wallets (Wagmi v3)
    RingFileBase (object storage API)
    Ring CDN (RingFileBase edge)
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Environment Configuration
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Architecture
    Data Model
    Security
    Real Time
    Discovery Mutation Sync
    PaymentConductor architecture
    WalletConductor architecture
    Development
    Ring MCP Server

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)

    Documentation

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Features
    Multi-Vendor Store
    SubscriptionConductor
    PaymentConductor
    Payments Overview
    WayForPay Payment Integration
    Wallet & Credit System
    WalletConductor
    Affiliate & Referral Enablement
    Referral Codes (Refcodes)
    NFT Exhibition Marketplace
    Solana NFT Gates
    Token Staking System
    Owner Project Lab
    Entities
    Opportunities
    Real-Time Messaging
    Ring Tasks
    WebRTC Calls & STUNner TURN
    News Module
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Email AI-CRM
    Ring Mailer & RingdomX Mail
    Tunnel Protocol
    VideoConductor
    MediaConductor
    Generative Gallery
    Authentication
    Security & Compliance
    Admin console
    Admin Wiki
    Manage via Telegram
    Locale System
    Mobile Experience
    Performance Optimization Patterns
    Examples
    Quick Start
    Basic Setup
    White Label
    Custom Branding
    Web3 Integration
    Real World
    Advanced Features
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Branding
    Themes
    Features
    Localization
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Web3
    Token launch jurisdictions
    Wallet
    Wallet Security Tips
    Integrations
    Ethereum wallets (Wagmi v3)
    RingFileBase (object storage API)
    Ring CDN (RingFileBase edge)
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Environment Configuration
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Architecture
    Data Model
    Security
    Real Time
    Discovery Mutation Sync
    PaymentConductor architecture
    WalletConductor architecture
    Development
    Ring MCP Server

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)

    Documentation

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Features
    Multi-Vendor Store
    SubscriptionConductor
    PaymentConductor
    Payments Overview
    WayForPay Payment Integration
    Wallet & Credit System
    WalletConductor
    Affiliate & Referral Enablement
    Referral Codes (Refcodes)
    NFT Exhibition Marketplace
    Solana NFT Gates
    Token Staking System
    Owner Project Lab
    Entities
    Opportunities
    Real-Time Messaging
    Ring Tasks
    WebRTC Calls & STUNner TURN
    News Module
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Email AI-CRM
    Ring Mailer & RingdomX Mail
    Tunnel Protocol
    VideoConductor
    MediaConductor
    Generative Gallery
    Authentication
    Security & Compliance
    Admin console
    Admin Wiki
    Manage via Telegram
    Locale System
    Mobile Experience
    Performance Optimization Patterns
    Examples
    Quick Start
    Basic Setup
    White Label
    Custom Branding
    Web3 Integration
    Real World
    Advanced Features
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Branding
    Themes
    Features
    Localization
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Web3
    Token launch jurisdictions
    Wallet
    Wallet Security Tips
    Integrations
    Ethereum wallets (Wagmi v3)
    RingFileBase (object storage API)
    Ring CDN (RingFileBase edge)
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Environment Configuration
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Architecture
    Data Model
    Security
    Real Time
    Discovery Mutation Sync
    PaymentConductor architecture
    WalletConductor architecture
    Development
    Ring MCP Server

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)
    1. Docs
    2. /Features
    3. /Tunnel Protocol

    Updated Jul 21, 20268 min listen

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    1. Docs
    2. /Features
    3. /Tunnel Protocol

    Updated Jul 21, 20268 min listen

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    1. Docs
    2. /Features
    3. /Tunnel Protocol

    Updated Jul 21, 20268 min listen

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity; 2026-07-20 credit publish + wallet:list; 2026-07-21 call system messages + discovery snippets + entity live surfaces

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related documentation

    Related documentation

    Real Time

    Deep-dive: TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery Mutation Sync

    Same-workflow: topic channel publishes after opportunity and entity CRUD.

    Wallet API

    Depends-on: wallet list + credit balance HTTP surface that tunnel invalidate signals refresh.

    Notifications API

    See-also: inbox REST + `notifications:inbox` / `notifications:unread` channels.

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity; 2026-07-20 credit publish + wallet:list; 2026-07-21 call system messages + discovery snippets + entity live surfaces

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related documentation

    Related documentation

    Real Time

    Deep-dive: TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery Mutation Sync

    Same-workflow: topic channel publishes after opportunity and entity CRUD.

    Wallet API

    Depends-on: wallet list + credit balance HTTP surface that tunnel invalidate signals refresh.

    Notifications API

    See-also: inbox REST + `notifications:inbox` / `notifications:unread` channels.

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity; 2026-07-20 credit publish + wallet:list; 2026-07-21 call system messages + discovery snippets + entity live surfaces

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related documentation

    Related documentation

    Real Time

    Deep-dive: TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery Mutation Sync

    Same-workflow: topic channel publishes after opportunity and entity CRUD.

    Wallet API

    Depends-on: wallet list + credit balance HTTP surface that tunnel invalidate signals refresh.

    Notifications API

    See-also: inbox REST + `notifications:inbox` / `notifications:unread` channels.

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Custodial wallet list

    Native balances and primary wallet updates push on wallet:list so open /wallet tabs refresh without a 60s poll when Tunnel is connected.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    /api/tunnel/subscribe
    POST
    Channel subscription (deduped client-side)
    /api/tunnel/unsubscribePOSTChannel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance, wallet list):

    2026-07-20 remediation: never call publishToChannel(userId, 'credit:balance', data) — that treats userId as the channel name. Credit and wallet-list pushes must use publishToUserTunnel. Clients subscribe to the base channel (credit:balance, wallet:list) with userScoped: false.

    Wallet list helper (invalidate signal — client re-fetches GET /api/wallet/list):

    Publish sites: ensure-wallet.ts, refreshBalancesForUser, setPrimaryWallet, WalletConductor.transferNative. Do not publish from getCachedBalancesForUser (read path — avoids fetch→publish loops).

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers. Polling falls back to 60s only when tunnel is disconnected.

    Custodial wallet list (same poll-when-disconnected pattern):

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceService.publishBalanceUpdate → publishToUserTunnelCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL); tunnel via useTunnelChannel (userScoped: false); 60s poll only if tunnel down
    Per-user inboxwallet:listlib/wallet/publish-wallet-list.tsWalletListProvider → useTunnelChannel('wallet:list'); 60s poll only if tunnel down
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat MessageService / sendSystemMessage; call-invite/call-event return data.message for local appenduse-messaging.ts (message:new + conversation-message CustomEvent + quiet poll when tunnel down)
    Discoveryopportunities, entitiessync-discovery.ts ({ id, event, snippet? })use-realtime-opportunities.ts, use-realtime-entities.ts (+ parseDiscoveryTunnelMessage); entity-details / confidential / my-entities subscribed
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })

    Messaging API

    See-also: `conversation:${id}` channel patterns for live chat.

    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    await publishToUserTunnel(userId, 'credit:balance', balanceData)
    await publishToUserTunnel(userId, 'wallet:list', { action: 'updated', timestamp: Date.now() })
    typescript
    
    import { publishWalletListUpdate } from '@/lib/wallet/publish-wallet-list'
    
    await publishWalletListUpdate(userId, 'updated') // | 'refreshed' | 'provisioned'
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        // Prefer parseDiscoveryTunnelMessage(message) — syncDiscovery payload is { id, event, snippet? }
        // Production: hooks/use-realtime-opportunities.ts
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    // components/providers/wallet-list-provider.tsx (mounted by wallet-wrapper)
    useTunnelChannel({
      channel: 'wallet:list',
      enabled: true,
      onMessage: () => void fetchWallets(false), // invalidate → GET /api/wallet/list
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Custodial wallet list

    Native balances and primary wallet updates push on wallet:list so open /wallet tabs refresh without a 60s poll when Tunnel is connected.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    /api/tunnel/subscribe
    POST
    Channel subscription (deduped client-side)
    /api/tunnel/unsubscribePOSTChannel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance, wallet list):

    2026-07-20 remediation: never call publishToChannel(userId, 'credit:balance', data) — that treats userId as the channel name. Credit and wallet-list pushes must use publishToUserTunnel. Clients subscribe to the base channel (credit:balance, wallet:list) with userScoped: false.

    Wallet list helper (invalidate signal — client re-fetches GET /api/wallet/list):

    Publish sites: ensure-wallet.ts, refreshBalancesForUser, setPrimaryWallet, WalletConductor.transferNative. Do not publish from getCachedBalancesForUser (read path — avoids fetch→publish loops).

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers. Polling falls back to 60s only when tunnel is disconnected.

    Custodial wallet list (same poll-when-disconnected pattern):

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceService.publishBalanceUpdate → publishToUserTunnelCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL); tunnel via useTunnelChannel (userScoped: false); 60s poll only if tunnel down
    Per-user inboxwallet:listlib/wallet/publish-wallet-list.tsWalletListProvider → useTunnelChannel('wallet:list'); 60s poll only if tunnel down
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat MessageService / sendSystemMessage; call-invite/call-event return data.message for local appenduse-messaging.ts (message:new + conversation-message CustomEvent + quiet poll when tunnel down)
    Discoveryopportunities, entitiessync-discovery.ts ({ id, event, snippet? })use-realtime-opportunities.ts, use-realtime-entities.ts (+ parseDiscoveryTunnelMessage); entity-details / confidential / my-entities subscribed
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })

    Messaging API

    See-also: `conversation:${id}` channel patterns for live chat.

    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    await publishToUserTunnel(userId, 'credit:balance', balanceData)
    await publishToUserTunnel(userId, 'wallet:list', { action: 'updated', timestamp: Date.now() })
    typescript
    
    import { publishWalletListUpdate } from '@/lib/wallet/publish-wallet-list'
    
    await publishWalletListUpdate(userId, 'updated') // | 'refreshed' | 'provisioned'
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        // Prefer parseDiscoveryTunnelMessage(message) — syncDiscovery payload is { id, event, snippet? }
        // Production: hooks/use-realtime-opportunities.ts
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    // components/providers/wallet-list-provider.tsx (mounted by wallet-wrapper)
    useTunnelChannel({
      channel: 'wallet:list',
      enabled: true,
      onMessage: () => void fetchWallets(false), // invalidate → GET /api/wallet/list
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Custodial wallet list

    Native balances and primary wallet updates push on wallet:list so open /wallet tabs refresh without a 60s poll when Tunnel is connected.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    /api/tunnel/subscribe
    POST
    Channel subscription (deduped client-side)
    /api/tunnel/unsubscribePOSTChannel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance, wallet list):

    2026-07-20 remediation: never call publishToChannel(userId, 'credit:balance', data) — that treats userId as the channel name. Credit and wallet-list pushes must use publishToUserTunnel. Clients subscribe to the base channel (credit:balance, wallet:list) with userScoped: false.

    Wallet list helper (invalidate signal — client re-fetches GET /api/wallet/list):

    Publish sites: ensure-wallet.ts, refreshBalancesForUser, setPrimaryWallet, WalletConductor.transferNative. Do not publish from getCachedBalancesForUser (read path — avoids fetch→publish loops).

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers. Polling falls back to 60s only when tunnel is disconnected.

    Custodial wallet list (same poll-when-disconnected pattern):

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceService.publishBalanceUpdate → publishToUserTunnelCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL); tunnel via useTunnelChannel (userScoped: false); 60s poll only if tunnel down
    Per-user inboxwallet:listlib/wallet/publish-wallet-list.tsWalletListProvider → useTunnelChannel('wallet:list'); 60s poll only if tunnel down
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat MessageService / sendSystemMessage; call-invite/call-event return data.message for local appenduse-messaging.ts (message:new + conversation-message CustomEvent + quiet poll when tunnel down)
    Discoveryopportunities, entitiessync-discovery.ts ({ id, event, snippet? })use-realtime-opportunities.ts, use-realtime-entities.ts (+ parseDiscoveryTunnelMessage); entity-details / confidential / my-entities subscribed
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })

    Messaging API

    See-also: `conversation:${id}` channel patterns for live chat.

    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    await publishToUserTunnel(userId, 'credit:balance', balanceData)
    await publishToUserTunnel(userId, 'wallet:list', { action: 'updated', timestamp: Date.now() })
    typescript
    
    import { publishWalletListUpdate } from '@/lib/wallet/publish-wallet-list'
    
    await publishWalletListUpdate(userId, 'updated') // | 'refreshed' | 'provisioned'
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        // Prefer parseDiscoveryTunnelMessage(message) — syncDiscovery payload is { id, event, snippet? }
        // Production: hooks/use-realtime-opportunities.ts
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    // components/providers/wallet-list-provider.tsx (mounted by wallet-wrapper)
    useTunnelChannel({
      channel: 'wallet:list',
      enabled: true,
      onMessage: () => void fetchWallets(false), // invalidate → GET /api/wallet/list
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"