Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* we accept either that or plain JSON since the spec is in flux.
*
* The route does NOT require auth — anyone can post a CSP report (the
* browser is the originator, not the user). We rate-limit via the
* existing `spamLimiter` keyed on IP to keep a hostile receiver from
* flooding our logs.
* browser is the originator, not the user). We rate-limit on IP to keep a
* hostile poster from flooding our logs, but with a ceiling sized for
* browser-generated traffic: see cspReportLimiter for why the old
* spamLimiter budget made this endpoint useless.
*
* The report is logged as a structured event (`event: "csp_violation"`)
* so an operator scanning `console` output during the report-only
Expand All @@ -19,15 +20,15 @@
*/

import { NextResponse, type NextRequest } from "next/server";
import { applyRateLimit, spamLimiter } from "@/lib/rate-limit";
import { applyRateLimit, cspReportLimiter } from "@/lib/rate-limit";

export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
req.headers.get("x-real-ip") ??
"unknown";

const rl = await applyRateLimit(spamLimiter, `csp:${ip}`);
const rl = await applyRateLimit(cspReportLimiter, `csp:${ip}`);
if (rl) return rl;

const body = await req.json().catch(() => null);
Expand Down
24 changes: 21 additions & 3 deletions docs/enterprise/20-iam-and-security/05-security-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ flowchart LR
LOG --> OP["operator scans during rollout, fixes allow-list"]
```

**The observation window only works if the reports actually arrive.** `/api/csp-report` originally shared `spamLimiter`, which allows 5 requests per hour — a budget sized for a human deciding to file a support ticket. A browser emits one report per violated directive per navigation, so a single person opening a few dashboard pages exhausted the hour in seconds and every subsequent report was rejected with a `429`. The rollout was therefore blind in precisely the situation it exists to observe. The endpoint now has its own limiter (`cspReportLimiter`) sized for browser-generated volume. If you add a report sink in future, size its limiter by who generates the traffic, not by how much you want to receive.

## Header inventory (production)

All seven production headers, their values, and what each one defends against, are listed in the table below.
Expand All @@ -71,11 +73,27 @@ and the public marketplace pages alike.

Anything outside the directives below will be blocked once `ENABLE_CSP_ENFORCE=true`, so each external origin earns its place by being load-bearing for a real product surface.

The `script-src` directive keeps `'self' 'unsafe-inline' 'unsafe-eval'`, which is non-negotiable until Next.js 16 ships hashed inline runtime chunks. Its external origins are Razorpay's checkout CDN (`https://checkout.razorpay.com`, the payment SDK), Sentry (`https://*.sentry.io`, error reporting), Stream.io (`https://*.getstream.io`, the call widget), and Supabase (`https://*.supabase.co`, storage signed URLs).
The `script-src` directive keeps `'self' 'unsafe-inline' 'unsafe-eval'`, which is non-negotiable until Next.js 16 ships hashed inline runtime chunks. Its external origins are Razorpay's checkout CDN (`https://checkout.razorpay.com`, the payment SDK), Stripe (`https://js.stripe.com`), Sentry (`https://*.sentry.io`, error reporting), Stream.io (`https://*.getstream.io`), and Supabase (`https://*.supabase.co`, storage signed URLs). The Stream entry is inherited rather than observed — the SDK is bundled from npm and self-served, so no `script-src` fetch to Stream was seen in practice.

The `connect-src` directive governs XHR, fetch, and WebSocket targets. It opens `https://api.razorpay.com` for payments, the three Stream domains described below, `https://*.supabase.co` and `https://*.upstash.io` for storage and Redis, `https://*.sentry.io` for error reporting, `https://api.resend.com` for transactional email, and `https://*.novu.co` plus `wss://*.novu.co` for the notification inbox.

The `media-src` directive serves Stream.io recording and call audio/video, so it requires `blob:` for local recording playback alongside Stream's CDN and API origins.

### Stream.io does not run on getstream.io

This is the mistake the allow-list originally made, and it is worth stating plainly because it is easy to repeat: `getstream.io` is Stream's marketing and documentation domain. No SDK traffic goes there. The clients talk to three unrelated domains, and a CSP host wildcard does not span them:

| Domain | Carries |
| --- | --- |
| `*.stream-io-api.com` | REST calls and both websockets (`wss://video.stream-io-api.com`, `wss://chat.stream-io-api.com`) |
| `*.stream-io-video.com` | the edge-latency hint (`hint.stream-io-video.com`) the client fetches before a call to choose an SFU, then the SFU edge itself |
| `*.stream-io-cdn.com` | call recordings and chat attachments |

Because only `*.getstream.io` was listed, every dashboard load filed violation reports for traffic the product cannot function without, and video calling would have failed outright the moment `ENABLE_CSP_ENFORCE=true` was set. The domains above were confirmed against a real browser network log on a deploy preview rather than read off Stream's docs, which is the only way to catch this class of drift.

The `connect-src` directive governs XHR, fetch, and WebSocket targets. It opens `https://api.razorpay.com` for payments, both `wss://*.getstream.io` and `https://*.getstream.io` for Stream call signalling and media, `https://*.supabase.co` and `https://*.upstash.io` for storage and Redis, `https://*.sentry.io` for error reporting, and `https://api.resend.com` for transactional email.
`*.getstream.io` remains in the list: Stream still serves some static assets from it, and dropping it is a separate change with its own unobserved blast radius.

The `media-src` directive serves Stream.io recording and call audio/video, so it requires both `blob:` (local recording playback) and the getstream.io CDN.
`worker-src` is deliberately absent. Nothing in the app constructs a `Worker`, and Stream's background-filter and noise-cancellation add-ons — the features that would need `blob:` workers and `wasm-unsafe-eval` — are not installed. If they are ever adopted, `worker-src` is the directive that breaks first, and it will fall back to `default-src 'self'`.

The `frame-src` directive allows Razorpay's checkout iframe; without this entry, payments break the moment CSP is flipped to enforce mode.

Expand Down
195 changes: 113 additions & 82 deletions docs/stream/03-provider-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,33 @@

### Dual-Client Design Pattern

**File:** `/providers/StreamProvider.tsx` (Lines 1-382)
**File:** `providers/StreamProviderImpl.tsx` (the SDK-free shell lives in `providers/StreamProvider.tsx`)

StreamProvider implements a **dual-client architecture**, managing two independent SDK clients simultaneously:
StreamProvider manages two SDK clients — one for chat, one for video — but holds them in a **single piece of state**:

```typescript
// Two separate client instances
const [chatClient, setChatClient] = useState<StreamChat | null>(null);
const [videoClient, setVideoClient] = useState<StreamVideoClient | null>(null);
// One settled value, not two independent ones
interface SettledStreamClients {
chat: StreamChat | null;
video: StreamVideoClient | null;
}
const [clients, setClients] = useState<SettledStreamClients | null>(null);
```

`null` means "not settled yet", which is deliberately distinct from a settled result whose `chat` or `video` is `null` because that particular connect failed.

#### Why one state and not two

This is the most important thing to understand before changing this file, because the obvious refactor — a `useState` per client — is the bug.

The provider wraps its children in `<Chat>` and `<StreamVideo>` only once a client exists. With two independent states set by two async connects that race, the element occupying that wrapper slot changed **type** between renders: `children`, then `<StreamVideo>`, then `<Chat>`, in whichever order the sockets happened to settle. React cannot reconcile a change of element type in place — it unmounts the old tree and mounts a new one — and the subtree here is the entire dashboard.

The user-visible symptom was a join button that appeared to do nothing: the click started a join, the dashboard remounted underneath it as the second client connected, and the in-flight join was destroyed. People pressed it repeatedly.

Committing both clients in one `setClients` makes the tree shape a pure function of one value, so it changes exactly once per session: unwrapped while connecting, then wrapped once both connects settle. A connect that genuinely _fails_ can still cost a second change if a later retry succeeds; that is accepted, because withholding the client that did connect would break the sidebar's chat-unread badge on every route (#248).

The nesting order is fixed — `<Chat>` outside, `<StreamVideo>` inside — for the same reason. Order must not depend on arrival order.

#### 1. Chat Client (`StreamChat`)

**Package:** `stream-chat`
Expand Down Expand Up @@ -105,9 +122,8 @@ export default function StreamProvider({
enableChat = true,
enableVideo = true,
}: StreamProviderProps) {
// Connection state
const [chatClient, setChatClient] = useState<StreamChat | null>(null);
const [videoClient, setVideoClient] = useState<StreamVideoClient | null>(null);
// Connection state — both clients in ONE value, see "Why one state and not two"
const [clients, setClients] = useState<SettledStreamClients | null>(null);
const [chatConnected, setChatConnected] = useState(false);
const [videoConnected, setVideoConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
Expand All @@ -132,22 +148,20 @@ export default function StreamProvider({
};
}, [userDetails, isLoading, apiKey]);

// Built up in a fixed order from the single settled value, so the tree shape
// never depends on which socket connected first.
let content = children;
if (clients?.video) {
content = <StreamVideo client={clients.video}>{content}</StreamVideo>;
}
if (clients?.chat) {
content = <Chat client={clients.chat}>{content}</Chat>;
}

return (
<StreamErrorBoundary onError={handleError} enableRetry={true}>
<StreamConnectionContext.Provider value={connectionState}>
{enableVideo && videoClient ? (
<StreamVideo client={videoClient}>
{enableChat && chatClient ? (
<Chat client={chatClient}>{children}</Chat>
) : (
children
)}
</StreamVideo>
) : enableChat && chatClient ? (
<Chat client={chatClient}>{children}</Chat>
) : (
children
)}
{content}
</StreamConnectionContext.Provider>
</StreamErrorBoundary>
);
Expand All @@ -158,7 +172,9 @@ export default function StreamProvider({

- Outermost: `StreamErrorBoundary` (error handling)
- Middle: `StreamConnectionContext` (connection state)
- Inner: `StreamVideo` → `Chat` → `children` (SDK providers)
- Inner: `Chat` → `StreamVideo` → `children` (SDK providers)

Note that the connection _flags_ (`chatConnected`, `videoConnected`, `isConnecting`) are still separate state. That is fine and intentional: they feed the context value, which changes what consumers render but not the shape of the tree above them.

---

Expand Down Expand Up @@ -475,11 +491,26 @@ const connectServices = useCallback(async () => {
setError(null);

try {
const promises = [];
if (enableChat && !chatConnected) promises.push(connectChat());
if (enableVideo && !videoConnected) promises.push(connectVideo());
// allSettled, not all: `all` rejects on the first failure and abandons the
// other promise's result, so a chat failure threw away a good video client.
// Each connect RESOLVES to its client (or null) rather than setting state,
// so both land in one commit below.
const [chatResult, videoResult] = await Promise.allSettled([
connectChat(),
connectVideo(),
]);

setClients({
chat: chatResult.status === "fulfilled" ? chatResult.value : null,
video: videoResult.status === "fulfilled" ? videoResult.value : null,
});

// Retry is still driven by a rejection, so re-throw the first one.
const failure = [chatResult, videoResult].find(
(result) => result.status === "rejected",
);
if (failure?.status === "rejected") throw failure.reason;

await Promise.all(promises); // Parallel execution
setConnectionAttempts(0); // Reset on success
} catch (error) {
const errorMessage =
Expand Down Expand Up @@ -620,25 +651,21 @@ export function ConnectionStatus() {

### Loading States

Provider shows loading UI while connecting (Lines 321-334):
**The provider no longer blocks children behind a spinner.** It used to: while either client was unconnected it returned a spinner instead of `children`, which gated the entire dashboard on two websocket handshakes even on routes with no chat or video UI at all.

Children now render immediately and the wrappers appear around them once the clients settle. Video consumers already guard a null client, and chat consumers only render on the chat route, underneath `<Chat>`.

The only spinner left is in the SDK-free shell (`providers/StreamProvider.tsx`), shown during the brief window where the lazy impl chunk is still downloading:

```typescript
if (
(enableChat && !chatClient && !error) ||
(enableVideo && !videoClient && !error) ||
isConnecting
) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
{isConnecting && (
<p className="ml-4 text-sm text-gray-600">Connecting to Stream...</p>
)}
</div>
);
}
const LazyStreamProviderImpl = dynamic(
() => import("@/providers/StreamProviderImpl"),
{ ssr: false, loading: () => <StreamProviderLoading /> },
);
```

If you are tempted to reintroduce a connection gate here, note that it interacts badly with the single-commit design above: gating on "all clients ready" turns one shape change into a shape change plus an unmount, and a permanently failing service would hold the whole dashboard hostage.

### Error States

Error UI shown after max retries (Lines 337-352):
Expand Down Expand Up @@ -775,37 +802,35 @@ See: [Troubleshooting - Token Expiry Race Condition](./troubleshooting.md#token-

### Cache Invalidation (Lines 276-298)

Disconnection is **not** owned by the provider and does **not** happen on unmount. The clients live in module-level refs in `lib/stream/disconnect.ts` — an SDK-free module so that callers which only need to disconnect (Navbar, UserDropdown, the org/admin/staff layouts) do not statically link the heavy SDK into their bundles.

```typescript
const disconnect = useCallback(async () => {
const promises = [];

if (chatClient) {
promises.push(
chatClient.disconnectUser().then(() => {
console.log("Chat client disconnected");
setChatClient(null);
setChatConnected(false);
}),
);
}
export async function disconnectStreamClients(): Promise<void> {
const promises: Promise<void>[] = [];
if (globalChatClient) promises.push(globalChatClient.disconnectUser().then(...));
if (globalVideoClient) promises.push(globalVideoClient.disconnectUser().then(...));

// allSettled (not all): a rejected disconnectUser() must NOT skip the global
// teardown below — stale refs after a failed logout would let the next login
// adopt the prior user's connection.
await Promise.allSettled(promises);

globalChatClient = null;
globalVideoClient = null;
currentUserId = null;
clearAllStreamCaches();
}
```

if (videoClient) {
// Note: StreamVideoClient doesn't have explicit disconnect method
setVideoClient(null);
setVideoConnected(false);
}
**Why unmount does not disconnect:** the clients are deliberately kept alive across remounts and tab switches, so navigating between dashboard routes does not pay for a fresh websocket handshake each time. The provider adopts an existing global client for the same user rather than building a new one.

await Promise.all(promises);
setTokenCache({}); // Clear token cache
}, [chatClient, videoClient]);
```
**The one case that must tear down** is a _different_ user appearing — on a fresh mount the provider's local `clients` state is `null` while the globals still point at the previous user. The provider therefore calls `disconnectStreamClients()` (global refs) rather than any local teardown, which would no-op and leak the prior user's connection for the next connect to adopt.

**Cache cleared on:**
**Disconnect happens on:**

- User logout
- Component unmount
- Manual disconnect
- Connection error (after max retries)
- User logout (the primary path)
- A different user mounting the provider
- Not on unmount, and not on connection error — the retry loop owns that

---

Expand Down Expand Up @@ -1070,26 +1095,32 @@ const { retryConnection } = useStreamConnection();

## Advanced Topics

### Cleanup on Unmount (Lines 301-309)
### Cleanup on Unmount

```typescript
useEffect(() => {
if (!isLoading && userDetails && apiKey) {
connectServices();
}
Unmount cancels _pending work_ and nothing else. It does not disconnect.

return () => {
disconnect(); // Cleanup function
};
}, [userDetails, isLoading, apiKey]);
```typescript
return () => {
// Cancel the scheduled idle connect and any pending retry so nothing calls
// setState after unmount.
if (idleHandle !== undefined) cancelIdleCallback(idleHandle);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);

// Intentionally NOT calling disconnect() here.
// Global clients are reused across component remounts.
};
```

**Cleanup Process:**
**Cleanup process:**

1. Cancel the deferred `requestIdleCallback` connect (#248)
2. Clear the retry backoff timer
3. Leave the clients connected

The third point is the whole design. Disconnecting here would mean a websocket handshake on every dashboard navigation, and — before the single-commit change described above — the provider remounted on its own during connection anyway, so an unmount-disconnect would have torn down the connection it had just established.

1. Disconnect chat client (`chatClient.disconnectUser()`)
2. Nullify video client (no explicit disconnect method)
3. Clear token cache
4. Reset connection states
Actual disconnection happens on logout, via `disconnectStreamClients()`. See §Disconnection.

### Connection State Persistence

Expand Down
Loading
Loading