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
8 changes: 8 additions & 0 deletions .changeset/harden-webhook-tenancy.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions apps/docs/content/adapters/official/gchat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/nextjs-chat/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 7 additions & 3 deletions examples/nextjs-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,16 @@ pnpm recording:export <session-id>

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.
4 changes: 3 additions & 1 deletion examples/nextjs-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
}
55 changes: 42 additions & 13 deletions examples/nextjs-chat/src/app/api/settings/preview-branch/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient> | null = null;
Expand Down Expand Up @@ -29,7 +33,12 @@ async function getRedisClient() {
return redisClient;
}

export async function GET(): Promise<Response> {
export async function GET(request: Request): Promise<Response> {
const unauthorized = authorizePreviewBranchRequest(request);
if (unauthorized) {
return unauthorized;
}

try {
const client = await getRedisClient();
const value = await client.get(PREVIEW_BRANCH_KEY);
Expand All @@ -45,26 +54,46 @@ export async function GET(): Promise<Response> {
}

export async function POST(request: Request): Promise<Response> {
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(
Expand Down
12 changes: 7 additions & 5 deletions examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
142 changes: 111 additions & 31 deletions examples/nextjs-chat/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "saving" | "error">(
"loading"
"idle"
);
const [error, setError] = useState<string | null>(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");
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -101,9 +120,63 @@ export default function SettingsPage() {
webhook traffic.
</p>

{status === "loading" ? (
<p>Loading...</p>
) : (
<div style={{ marginBottom: "1rem" }}>
<label
htmlFor="operator-secret"
style={{
display: "block",
marginBottom: "0.5rem",
fontWeight: 500,
}}
>
Operator Secret
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
autoComplete="current-password"
id="operator-secret"
onChange={(event) => {
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}
/>
<button
disabled={!operatorSecret || status === "loading"}
onClick={handleLoad}
style={{
padding: "0.5rem 1rem",
fontSize: "1rem",
whiteSpace: "nowrap",
}}
type="button"
>
{status === "loading" ? "Loading..." : "Load settings"}
</button>
</div>
<p style={{ color: "#666", fontSize: "0.875rem" }}>
This secret stays in this browser tab and is sent only in the
authorization header.
</p>
</div>

{error && (
<p role="alert" style={{ color: "red", marginBottom: "1rem" }}>
{error}
</p>
)}

{authenticated ? (
<>
<div style={{ marginBottom: "1rem" }}>
<label
Expand Down Expand Up @@ -133,10 +206,6 @@ export default function SettingsPage() {
/>
</div>

{error && (
<p style={{ color: "red", marginBottom: "1rem" }}>{error}</p>
)}

<div style={{ display: "flex", gap: "0.5rem" }}>
<button
disabled={status === "saving"}
Expand Down Expand Up @@ -205,6 +274,17 @@ export default function SettingsPage() {
</p>
)}
</>
) : (
<p
style={{
marginTop: "1rem",
padding: "0.75rem",
backgroundColor: "#f8f9fa",
borderRadius: "4px",
}}
>
Enter the operator secret to view or change this setting.
</p>
)}
</section>

Expand Down
Loading