diff --git a/.changeset/harden-webhook-tenancy.md b/.changeset/harden-webhook-tenancy.md
new file mode 100644
index 000000000..c063774ab
--- /dev/null
+++ b/.changeset/harden-webhook-tenancy.md
@@ -0,0 +1,8 @@
+---
+"@chat-adapter/gchat": minor
+"@chat-adapter/slack": patch
+"@chat-adapter/teams": patch
+"chat": patch
+---
+
+Harden webhook tenant isolation, require explicit Google Chat bot identity for reliable mention handling, use native Google Chat pagination, isolate Slack caches, and bound recording storage.
diff --git a/apps/docs/content/adapters/official/gchat.mdx b/apps/docs/content/adapters/official/gchat.mdx
index 6cdfe90a1..edcf3cd60 100644
--- a/apps/docs/content/adapters/official/gchat.mdx
+++ b/apps/docs/content/adapters/official/gchat.mdx
@@ -95,6 +95,11 @@ bot.onNewMention(async (thread, message) => {
description:
"Service account credentials JSON. Auto-detected from `GOOGLE_CHAT_CREDENTIALS`.",
},
+ botUserId: {
+ type: "string",
+ description:
+ "Canonical users/... resource name of this Chat app. Required for exact self-message and mention detection; auto-detected from `GOOGLE_CHAT_BOT_USER_ID`.",
+ },
useApplicationDefaultCredentials: {
type: "boolean",
description:
@@ -144,6 +149,10 @@ bot.onNewMention(async (thread, message) => {
One of `googleChatProjectNumber`, `endpointUrl`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive.
+Set `botUserId` (or `GOOGLE_CHAT_BOT_USER_ID`) to the canonical `sender.name` from a verified message authored by your Chat app, such as `users/123456789`. The adapter never learns this identity from inbound mentions. When it is omitted, all `BOT` senders are conservatively treated as self to prevent reply loops.
+
+Apps upgrading from an earlier release that rely on mention handlers must configure `botUserId` to preserve mention handling. Without it, bot mention annotations are left unchanged and the default `onNewMention` detection may no longer match them.
+
## Authentication
### 1. Create a GCP project
diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example
index 19e0d3eac..e53f13da0 100644
--- a/examples/nextjs-chat/.env.example
+++ b/examples/nextjs-chat/.env.example
@@ -13,6 +13,8 @@ BOT_USERNAME=mybot
# Google Chat (optional)
# GOOGLE_CHAT_CREDENTIALS={"type":"service_account",...}
+# Canonical sender.name from a verified message authored by your Chat app.
+# GOOGLE_CHAT_BOT_USER_ID=users/123456789
# Discord (optional)
# DISCORD_BOT_TOKEN=your-bot-token
@@ -73,3 +75,9 @@ BOT_USERNAME=mybot
# Redis (for production state persistence)
# REDIS_URL=redis://localhost:6379
+
+# Preview branch webhook proxy (optional)
+# Required to view or update /settings. Use a long, randomly generated value.
+# PREVIEW_BRANCH_SECRET=replace-with-a-random-secret
+# Optional comma-separated exact hostnames. Defaults to any *.vercel.app host.
+# PREVIEW_BRANCH_ALLOWED_HOSTS=my-feature-git-main-team.vercel.app
diff --git a/examples/nextjs-chat/README.md b/examples/nextjs-chat/README.md
index 684771ce6..a8a09de6b 100644
--- a/examples/nextjs-chat/README.md
+++ b/examples/nextjs-chat/README.md
@@ -132,12 +132,16 @@ pnpm recording:export
See `packages/integration-tests/fixtures/replay/README.md` for the full workflow.
+Only successful, adapter-verified webhook deliveries are recorded. Individual records are limited to 256 KiB and each session retains at most 500 entries.
+
## Preview branch testing
Test PRs with real webhook traffic by proxying requests from production to a preview deployment:
1. Deploy a preview branch to Vercel
-2. Go to `/settings` on the production deployment
-3. Enter the preview branch URL and save
+2. Set `PREVIEW_BRANCH_SECRET` on the production deployment to a long, random value
+3. Optionally set `PREVIEW_BRANCH_ALLOWED_HOSTS` to a comma-separated list of exact preview hostnames (otherwise any `*.vercel.app` hostname is accepted)
+4. Go to `/settings` on the production deployment
+5. Enter the operator secret, load the setting, then enter the preview branch URL and save
-All webhook requests are proxied until the URL is cleared.
+The settings API requires the operator secret as a bearer token. Only HTTPS Vercel deployments or explicitly allowed hosts are accepted, and all webhook requests are proxied until the URL is cleared.
diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json
index b1741e20c..a69135945 100644
--- a/examples/nextjs-chat/package.json
+++ b/examples/nextjs-chat/package.json
@@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
+ "test": "vitest run",
"typecheck": "tsc --noEmit",
"recording:list": "tsx src/lib/recorder.ts --list",
"recording:export": "tsx src/lib/recorder.ts"
@@ -45,6 +46,7 @@
"dotenv": "^17.2.3",
"postcss": "^8.5.26",
"tsx": "^4.21.0",
- "typescript": "^5.7.2"
+ "typescript": "^5.7.2",
+ "vitest": "^4.0.18"
}
}
diff --git a/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts b/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts
index 46c705e54..8944ca659 100644
--- a/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts
+++ b/examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts
@@ -1,7 +1,11 @@
import { createClient } from "redis";
+import { authorizePreviewBranchRequest } from "@/lib/authorization";
+import {
+ PREVIEW_BRANCH_KEY,
+ parseAllowedPreviewBranchUrl,
+} from "@/lib/preview-branch";
const REDIS_URL = process.env.REDIS_URL || "";
-const PREVIEW_BRANCH_KEY = "chat-sdk:cache:preview-branch-url";
// Redis client singleton
let redisClient: ReturnType | null = null;
@@ -29,7 +33,12 @@ async function getRedisClient() {
return redisClient;
}
-export async function GET(): Promise {
+export async function GET(request: Request): Promise {
+ const unauthorized = authorizePreviewBranchRequest(request);
+ if (unauthorized) {
+ return unauthorized;
+ }
+
try {
const client = await getRedisClient();
const value = await client.get(PREVIEW_BRANCH_KEY);
@@ -45,26 +54,46 @@ export async function GET(): Promise {
}
export async function POST(request: Request): Promise {
+ const unauthorized = authorizePreviewBranchRequest(request);
+ if (unauthorized) {
+ return unauthorized;
+ }
+
try {
- const body = await request.json();
- const { url } = body;
+ const body: unknown = await request.json();
+ if (!(body && typeof body === "object" && "url" in body)) {
+ return Response.json({ error: "Missing URL" }, { status: 400 });
+ }
+ const { url } = body as { url: unknown };
const client = await getRedisClient();
- if (url) {
- // Validate URL
- try {
- new URL(url);
- } catch {
- return Response.json({ error: "Invalid URL" }, { status: 400 });
+ if (typeof url === "string" && url.length > 0) {
+ const allowedUrl = parseAllowedPreviewBranchUrl(url);
+ if (!allowedUrl) {
+ return Response.json(
+ {
+ error:
+ "URL must be an HTTPS Vercel deployment or a configured allowed host",
+ },
+ { status: 400 }
+ );
}
- await client.set(PREVIEW_BRANCH_KEY, url);
- } else {
+ await client.set(PREVIEW_BRANCH_KEY, allowedUrl.origin);
+ return Response.json({ success: true, url: allowedUrl.origin });
+ }
+ if (url === null || url === "") {
// Clear the preview branch URL
await client.del(PREVIEW_BRANCH_KEY);
+ return Response.json({ success: true, url: null });
}
- return Response.json({ success: true, url: url || null });
+ return Response.json(
+ { error: "URL must be a string or null" },
+ {
+ status: 400,
+ }
+ );
} catch (error) {
console.error("[settings] Error setting preview branch URL:", error);
return Response.json(
diff --git a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts
index 3ae0ce1bb..36f6bc5fd 100644
--- a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts
+++ b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts
@@ -16,16 +16,18 @@ export async function POST(
return new Response(`Unknown platform: ${platform}`, { status: 404 });
}
- // Record webhook if enabled (no-op if disabled)
- if (recorder.isEnabled) {
- await recorder.recordWebhook(platform, request.clone());
- }
+ const recordingRequest = recorder.isEnabled ? request.clone() : null;
// Handle the webhook with waitUntil for background processing
// Next.js after() ensures work completes after the response is sent
- return webhookHandler(request, {
+ const response = await webhookHandler(request, {
waitUntil: (task) => after(() => task),
});
+ // Only successful, adapter-verified deliveries are eligible for recording.
+ if (recordingRequest && response.ok) {
+ await recorder.recordWebhook(platform, recordingRequest);
+ }
+ return response;
}
// GET handler — serves as health check, but also forwards to webhook handler
diff --git a/examples/nextjs-chat/src/app/settings/page.tsx b/examples/nextjs-chat/src/app/settings/page.tsx
index fca87d8d4..25cd52c6d 100644
--- a/examples/nextjs-chat/src/app/settings/page.tsx
+++ b/examples/nextjs-chat/src/app/settings/page.tsx
@@ -1,34 +1,47 @@
"use client";
-import { useEffect, useState } from "react";
+import { useState } from "react";
export default function SettingsPage() {
+ const [operatorSecret, setOperatorSecret] = useState("");
+ const [authenticated, setAuthenticated] = useState(false);
const [previewBranchUrl, setPreviewBranchUrl] = useState("");
const [savedUrl, setSavedUrl] = useState(null);
const [status, setStatus] = useState<"idle" | "loading" | "saving" | "error">(
- "loading"
+ "idle"
);
const [error, setError] = useState(null);
- // Load current setting on mount
- useEffect(() => {
- fetch("/api/settings/preview-branch")
- .then((res) => res.json())
- .then((data) => {
- if (data.error) {
- setError(data.error);
- setStatus("error");
- } else {
- setPreviewBranchUrl(data.url || "");
- setSavedUrl(data.url);
- setStatus("idle");
- }
- })
- .catch((err) => {
- setError(err.message);
- setStatus("error");
+ const authorizationHeaders = {
+ Authorization: `Bearer ${operatorSecret}`,
+ };
+
+ const handleLoad = async () => {
+ setStatus("loading");
+ setError(null);
+
+ try {
+ const res = await fetch("/api/settings/preview-branch", {
+ headers: authorizationHeaders,
});
- }, []);
+ const data = await res.json();
+ if (!res.ok || data.error) {
+ setAuthenticated(false);
+ setError(data.error || "Unable to load settings");
+ setStatus("error");
+ return;
+ }
+
+ setAuthenticated(true);
+ setPreviewBranchUrl(data.url || "");
+ setSavedUrl(data.url);
+ setStatus("idle");
+ } catch (err) {
+ setAuthenticated(false);
+ setError(err instanceof Error ? err.message : "Unknown error");
+ setStatus("error");
+ }
+ };
const handleSave = async () => {
setStatus("saving");
@@ -37,13 +50,16 @@ export default function SettingsPage() {
try {
const res = await fetch("/api/settings/preview-branch", {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ ...authorizationHeaders,
+ "Content-Type": "application/json",
+ },
body: JSON.stringify({ url: previewBranchUrl || null }),
});
const data = await res.json();
- if (data.error) {
+ if (!res.ok || data.error) {
setError(data.error);
setStatus("error");
} else {
@@ -64,13 +80,16 @@ export default function SettingsPage() {
try {
const res = await fetch("/api/settings/preview-branch", {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ ...authorizationHeaders,
+ "Content-Type": "application/json",
+ },
body: JSON.stringify({ url: null }),
});
const data = await res.json();
- if (data.error) {
+ if (!res.ok || data.error) {
setError(data.error);
setStatus("error");
} else {
@@ -101,9 +120,63 @@ export default function SettingsPage() {
webhook traffic.
- {status === "loading" ? (
- Loading...
- ) : (
+
+
+
+ {
+ setOperatorSecret(event.target.value);
+ setAuthenticated(false);
+ }}
+ placeholder="PREVIEW_BRANCH_SECRET"
+ style={{
+ width: "100%",
+ padding: "0.5rem",
+ fontSize: "1rem",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ boxSizing: "border-box",
+ }}
+ type="password"
+ value={operatorSecret}
+ />
+
+
+
+ This secret stays in this browser tab and is sent only in the
+ authorization header.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {authenticated ? (
<>
- {error && (
- {error}
- )}
-