Rising Tide Developers
UI Package

Providers

RisingTideProviders - the full provider stack for a Rising Tide app in one config-driven component, including the entry curtain every app gets.

RisingTideProviders (from @rising-tide/ui/providers) is the one component that wires an app's entire client stack: idle sleep, auth method tracking, the invite-code stash, app-mode gating, the Privy wallet provider, the SDK token provider, the React Query client, the protocol user context, the sign-in dialog, and the entry curtain - in the order they have to be in.

That order is load-bearing. The real Privy provider must mount above the SDK's token provider, or the first signup request 401s. RisingTideProviders encodes the correct order once so you never reassemble it by hand.

Usage

Because a Next.js root layout is a Server Component, configure the providers in a thin client wrapper and render that from the layout. This is the complete minimum: every field shown is required by the config type.

src/providers/AppProviders.tsx
"use client";

import { RisingTideProviders } from "@rising-tide/ui/providers";
import { rtClient } from "@/lib/sdk/client";

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <RisingTideProviders
      config={{
        sdk: rtClient,
        storagePrefix: "myapp",
        network: process.env.NEXT_PUBLIC_STACKS_NETWORK,
        privy: {
          appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
          appearance: { theme: "light" },
        },
        auth: {
          appName: "My App",
          signupRedirect: "/welcome",
        },
      }}
    >
      {children}
    </RisingTideProviders>
  );
}
src/app/layout.tsx
import { AppProviders } from "@/providers/AppProviders";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AppProviders>{children}</AppProviders>
      </body>
    </html>
  );
}

Config

FieldRequiredPurpose
sdkyesThe app's configured SDK client (rtClient)
storagePrefixyesNamespace for auth-method / app-mode / invite-code / user state in local storage
networkyesThe chain this build targets, read literally from NEXT_PUBLIC_STACKS_NETWORK. Everything network-gated in the stack derives from it, so pass it even if the value is undefined
appIdnoThis app's numeric protocol id. Falls back to NEXT_PUBLIC_APP_ID, which is how every app in the estate sets it. It is what the signup posture (requiresInvite), the server-set app mode and the entry curtain are read against, so a gated app must have one or the other
idleSleep.afterMsnoIdle deadline before live updates pause. Defaults to 10 minutes, testnet only
idleSleep.disablednoOpt out of idle sleep even on testnet
privy.appIdyesPrivy app ID. Without it, Privy is skipped and a console error is logged
privy.appearanceyesPrivy appearance config
privy.loginMethodsnoDefaults to Google and X, and nothing else. An invite is the only way in and the claim works with those two, so a method that signs somebody in but cannot claim leaves them with an empty account. No app in the estate passes this, and narrowing it can only ask for less than the Privy dashboard allows
auth.appNameyesThe sign-in dialog's heading, and the default room name for the entry curtain and the app-mode gate
auth.appIconSrcnoThe sign-in dialog's icon, served from this app's own public/. Defaults to /rt-logo-square-t.png, which is a path convention rather than a brand - a client app must serve its own mark here or the dialog shows the protocol's
auth.audienceno"consumer" (default) or "operator". Operator drops the education block from the sign-in dialog
auth.signupRedirectnoRoute pushed after first-time signup
auth.signOutRedirectnoRoute pushed after sign-out (defaults to /)
auth.onSignupCompletenoCustom handler ({ user, router, queryClient }) - overrides signupRedirect (e.g. invalidate caches before routing)
auth.onSignOutnoCustom handler ({ router, queryClient }) - overrides signOutRedirect
auth.linkInviteCodesnoLink a pending invite code at signup. Defaults to true
modeGateno{ appName } to gate on maintenance / coming-soon mode. Omit for always-live apps
accessGate.appNamenoRoom name for the curtain's locked screen. Defaults to auth.appName
accessGate.teasernoYour own locked screen, replacing the shared one. Receives { appName, authenticated, onSignIn }
accessGate.selfGatednoOpt this app out of the curtain because it already gates its own entry with something stricter. See below before setting it
query.onMutationErrornoMutation error sink (wire your error reporter here)
welcomeDialognoA component shown to first-time users, receiving { stxAddress, onComplete, onSkip }

There is no auth.appDescription. The sign-in dialog shows a heading and no description paragraph, to buy vertical space on a phone.

App-specific outer providers (theme, toasts, a campaign context) wrap RisingTideProviders as its parent or sit inside as children - everything Privy / Query / auth-shaped lives within.

The entry curtain

Every app rendered through RisingTideProviders gets an entry curtain, whether or not the accessGate block is present. It sits below the protocol user context (it needs the resolved viewer) and wraps the app's whole tree, chrome included. What it draws is decided by the server, not by the app:

  • It asks GET /api/apps/{id}/access for the app named by appId / NEXT_PUBLIC_APP_ID. That one route answers for a signed-in viewer (does this account hold a grant?) and for a stranger (does this room have a door at all?).
  • An app whose accessPolicy is PUBLIC resolves OPEN and the curtain never appears. That is why the hub and the events calendar have no locked screen - they have no door, not because a list exempts them.
  • An app whose accessPolicy is GRANTED resolves UNLOCKED for a viewer with an active grant and LOCKED for everyone else, signed in or not. Signing in never reduces what a viewer can see.
  • It never reads the catalog or the app's catalogTease flag. The catalog omits gated rooms an operator chose not to advertise, so a gate that resolved from it would turn "do not advertise this room" into "let everyone into it".
  • A signed-in viewer is held until the bearer token has reached the SDK client. Asking earlier asks as a stranger, and a stranger's answer at a gated room is LOCKED - which would then be cached for somebody who holds a grant.
  • While the verdict is in flight the room renders, and the locked screen replaces it only on a resolved LOCKED. The cost is that an ungranted viewer sees the room for the round trip it takes their verdict to arrive; every action is still refused server-side by requireAppAccess. The reason it is this way round: a server render never has a restored session, so a curtain that held while resolving sent every page of every app as an empty body. Pass loading on AccessGate yourself only if you mount it directly and prefer a blank.
  • On fetch errors or a missing app id the curtain fails open. The binding enforcement is the server's; the curtain exists so a locked room shows a door instead of a wall of 403s.

The curtain replaces the app's own chrome on the locked screen, so the shared teaser carries its own sign-in control (onSignIn opens the shared dialog). A custom accessGate.teaser must do the same: it receives authenticated, and copy that addresses "your account" has to branch on it, because a stranger reaches the same screen.

accessGate.selfGated: true opts an app out. The claim has to be true - "this app already gates its own entry with something stricter than an app grant". rt-admin is the one app in the estate that sets it: its AdminGate asks for an entitlement behind its own sign-in screen, and a curtain above it would add a way to be locked out of the tool that fixes a lockout. An app whose reads are simply open is not self-gated, and setting this on one reopens the hole the curtain closes. Say why at the call site.

The consequence for an app you are building: App.accessPolicy is a product decision. GRANTED no longer means only "an ungranted user's writes 403"; it means "an ungranted viewer is shown the door".