Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { db } from "./db";
import { getTRPCSession } from "./db/queries/session";
import { polarRouters } from "./polar";
import { realtimeEmitter } from "./realtime/emitter";
import { registerRealtimeRoutes } from "./realtime/routes";
import { workflowsRouters } from "./workflows";
import { upgradedWebsocket, websocket } from "./ws/socket";

Expand Down Expand Up @@ -139,6 +140,8 @@ app.route("/polar", polarRouters);

app.route("/workflow", workflowsRouters);

registerRealtimeRoutes(app);

// WebSocket endpoint for real-time communication with rate limiting
app.use("/ws", websocketRateLimiter);
app.get("/ws", upgradedWebsocket);
Expand Down
35 changes: 21 additions & 14 deletions apps/api/src/realtime/emitter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { type EventContext, routeEvent } from "@api/ws/router";
import {
sendEventToConnection,
sendEventToVisitor,
sendEventToWebsite,
sendEventToConnection,
sendEventToVisitor,
sendEventToWebsite,
} from "@api/ws/socket";
import { forwardMessageEventToRealtimeStreams } from "./stream-dispatcher";
import {
type RealtimeEvent,
type RealtimeEventData,
Expand Down Expand Up @@ -126,11 +127,11 @@ export class RealtimeEmitter {
);
}

const event: RealtimeEvent<TType> = {
type,
payload: data,
timestamp: options.timestamp ?? Date.now(),
websiteId,
const event: RealtimeEvent<TType> = {
type,
payload: data,
timestamp: options.timestamp ?? Date.now(),
websiteId,
organizationId,
visitorId: options.visitorId ?? extractVisitorId(data) ?? null,
};
Expand All @@ -142,12 +143,18 @@ export class RealtimeEmitter {
userId: options.userId ?? extractUserId(data) ?? undefined,
organizationId,
sendToConnection: sendEventToConnection,
sendToVisitor: sendEventToVisitor,
sendToWebsite: sendEventToWebsite,
};

await routeEvent(event, context);
}
sendToVisitor: sendEventToVisitor,
sendToWebsite: sendEventToWebsite,
};

try {
await forwardMessageEventToRealtimeStreams(event);
} catch (error) {
console.error("[Realtime] Failed to forward stream event", error);
}

await routeEvent(event, context);
}
}

const realtimeEmitter = new RealtimeEmitter();
Expand Down
152 changes: 152 additions & 0 deletions apps/api/src/realtime/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { db } from "@api/db";
import { getTRPCSession } from "@api/db/queries/session";
import { findVisitorForWebsite } from "@api/db/queries/visitor";
import { getWebsiteByIdWithAccess } from "@api/db/queries/website";
import {
AuthValidationError,
performAuthentication,
} from "@api/lib/auth-validation";
import { realtimeStreams } from "@api/realtime/streams";
import { handle } from "@cossistant/realtime/server";
import type { RealtimeEvent } from "@cossistant/types/realtime-events";
import type { OpenAPIHono } from "@hono/zod-openapi";

function parseOriginHeaders(request: Request): {
origin?: string;
protocol?: string;
hostname?: string;
} {
const origin = request.headers.get("origin") ?? undefined;
if (!origin) {
return {};
}

try {
const url = new URL(origin);
return { origin, protocol: url.protocol, hostname: url.hostname };
} catch {
return { origin };
}
}

const visitorStreamHandler = handle({
realtime: realtimeStreams,
middleware: async ({ request, channel }) => {
const url = new URL(request.url);
const publicKey = url.searchParams.get("publicKey");
const visitorId = url.searchParams.get("visitorId");

if (!publicKey || !visitorId) {
return new Response("Missing credentials", { status: 401 });
}

try {
const authResult = await performAuthentication(
undefined,
publicKey,
db,
parseOriginHeaders(request)
);

const websiteId = authResult.apiKey.website?.id;

if (!websiteId) {
return new Response("Website not found", { status: 403 });
}

if (channel !== websiteId) {
return new Response("Channel mismatch", { status: 403 });
}

const visitor = await findVisitorForWebsite(db, {
visitorId,
websiteId,
});

if (!visitor || visitor.blockedAt) {
return new Response("Visitor not allowed", { status: 403 });
}
} catch (error) {
if (error instanceof AuthValidationError) {
return new Response(error.message, { status: error.statusCode });
}
console.error("[Realtime] Visitor auth failed", error);
return new Response("Authentication failed", { status: 401 });
}
},
filter: async ({ event, request }) => {
if (!Array.isArray(event.__event_path)) {
return false;
}

if (event.__event_path[0] !== "visitor") {
return false;
}

const data = event.data as RealtimeEvent;
if (!data || data.type !== "MESSAGE_CREATED") {
return false;
}

const visitorId = new URL(request.url).searchParams.get("visitorId");
if (!visitorId) {
return false;
}

const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;

return !payloadVisitorId || payloadVisitorId === visitorId;
Comment on lines +96 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Verify visitor filter privacy: null visitorId events sent to all visitors.

The filter returns true when payloadVisitorId is null (line 99: !payloadVisitorId || ...), meaning MESSAGE_CREATED events without a visitorId in the payload are forwarded to all visitors on that website.

Confirm this is intentional. If MESSAGE_CREATED events should only reach the specific visitor who owns the conversation, change the logic to reject null visitorId:

Apply this diff to enforce strict visitor matching:

-		return !payloadVisitorId || payloadVisitorId === visitorId;
+		return payloadVisitorId === visitorId;

If null visitorId events are valid broadcast messages (e.g., system announcements), document this behavior clearly and verify the upstream event creator never accidentally omits visitorId for private conversations.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
return !payloadVisitorId || payloadVisitorId === visitorId;
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
return payloadVisitorId === visitorId;
🤖 Prompt for AI Agents
In apps/api/src/realtime/routes.ts around lines 96 to 99, the current filter
treats a null payloadVisitorId as a match and will forward MESSAGE_CREATED
events with no visitorId to all visitors; if messages must only reach their
owning visitor, change the condition to require a non-null visitor id and exact
match (i.e., only return true when payloadVisitorId is present and equals
visitorId), update any related tests, and add a short comment or docs clarifying
that null payloadVisitorId is a broadcast only if intentionally allowed.

},
Comment on lines +96 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten visitor filter to prevent unintended broadcasts

Allowing null payloadVisitorId sends events to all visitors. Require an exact match to the requesting visitor.

Apply this diff:

-                const payloadVisitorId =
-                        data.payload.message.visitorId ?? data.visitorId ?? null;
-
-                return !payloadVisitorId || payloadVisitorId === visitorId;
+                const payloadVisitorId =
+                  data.payload.message.visitorId ?? data.visitorId ?? null;
+
+                if (!payloadVisitorId) {
+                  return false;
+                }
+                return payloadVisitorId === visitorId;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
return !payloadVisitorId || payloadVisitorId === visitorId;
},
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
if (!payloadVisitorId) {
return false;
}
return payloadVisitorId === visitorId;
🤖 Prompt for AI Agents
In apps/api/src/realtime/routes.ts around lines 96 to 100, the current filter
treats a null/undefined payloadVisitorId as a wildcard and broadcasts to all
visitors; change the condition so only an exact match passes. Replace the
existing logic that computes payloadVisitorId and returns !payloadVisitorId ||
payloadVisitorId === visitorId with a comparison that returns payloadVisitorId
!== null && payloadVisitorId !== undefined && payloadVisitorId === visitorId (or
simply return payloadVisitorId === visitorId), ensuring null/undefined values do
not match and thus prevent unintended broadcasts.

});
Comment on lines +32 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add CORS headers to visitor stream handler

Cross-origin EventSource requires ACAO. Mirror dashboard’s approach to avoid connection failures.

Apply this diff to add responseHeaders:

 const visitorStreamHandler = handle({
   realtime: realtimeStreams,
   middleware: async ({ request, channel }) => {
     …
   },
   filter: async ({ event, request }) => {
     …
-  },
+  },
+  responseHeaders: ({ request }) => {
+    const origin = request.headers.get("origin");
+    if (!origin) {
+      return undefined;
+    }
+    return {
+      "Access-Control-Allow-Origin": origin,
+      "Access-Control-Allow-Credentials": "true",
+      Vary: "Origin",
+    } satisfies HeadersInit;
+  },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const visitorStreamHandler = handle({
realtime: realtimeStreams,
middleware: async ({ request, channel }) => {
const url = new URL(request.url);
const publicKey = url.searchParams.get("publicKey");
const visitorId = url.searchParams.get("visitorId");
if (!publicKey || !visitorId) {
return new Response("Missing credentials", { status: 401 });
}
try {
const authResult = await performAuthentication(
undefined,
publicKey,
db,
parseOriginHeaders(request)
);
const websiteId = authResult.apiKey.website?.id;
if (!websiteId) {
return new Response("Website not found", { status: 403 });
}
if (channel !== websiteId) {
return new Response("Channel mismatch", { status: 403 });
}
const visitor = await findVisitorForWebsite(db, {
visitorId,
websiteId,
});
if (!visitor || visitor.blockedAt) {
return new Response("Visitor not allowed", { status: 403 });
}
} catch (error) {
if (error instanceof AuthValidationError) {
return new Response(error.message, { status: error.statusCode });
}
console.error("[Realtime] Visitor auth failed", error);
return new Response("Authentication failed", { status: 401 });
}
},
filter: async ({ event, request }) => {
if (!Array.isArray(event.__event_path)) {
return false;
}
if (event.__event_path[0] !== "visitor") {
return false;
}
const data = event.data as RealtimeEvent;
if (!data || data.type !== "MESSAGE_CREATED") {
return false;
}
const visitorId = new URL(request.url).searchParams.get("visitorId");
if (!visitorId) {
return false;
}
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
return !payloadVisitorId || payloadVisitorId === visitorId;
},
});
const visitorStreamHandler = handle({
realtime: realtimeStreams,
middleware: async ({ request, channel }) => {
const url = new URL(request.url);
const publicKey = url.searchParams.get("publicKey");
const visitorId = url.searchParams.get("visitorId");
if (!publicKey || !visitorId) {
return new Response("Missing credentials", { status: 401 });
}
try {
const authResult = await performAuthentication(
undefined,
publicKey,
db,
parseOriginHeaders(request)
);
const websiteId = authResult.apiKey.website?.id;
if (!websiteId) {
return new Response("Website not found", { status: 403 });
}
if (channel !== websiteId) {
return new Response("Channel mismatch", { status: 403 });
}
const visitor = await findVisitorForWebsite(db, {
visitorId,
websiteId,
});
if (!visitor || visitor.blockedAt) {
return new Response("Visitor not allowed", { status: 403 });
}
} catch (error) {
if (error instanceof AuthValidationError) {
return new Response(error.message, { status: error.statusCode });
}
console.error("[Realtime] Visitor auth failed", error);
return new Response("Authentication failed", { status: 401 });
}
},
filter: async ({ event, request }) => {
if (!Array.isArray(event.__event_path)) {
return false;
}
if (event.__event_path[0] !== "visitor") {
return false;
}
const data = event.data as RealtimeEvent;
if (!data || data.type !== "MESSAGE_CREATED") {
return false;
}
const visitorId = new URL(request.url).searchParams.get("visitorId");
if (!visitorId) {
return false;
}
const payloadVisitorId =
data.payload.message.visitorId ?? data.visitorId ?? null;
return !payloadVisitorId || payloadVisitorId === visitorId;
},
responseHeaders: ({ request }) => {
const origin = request.headers.get("origin");
if (!origin) {
return undefined;
}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
Vary: "Origin",
} satisfies HeadersInit;
},
});
🤖 Prompt for AI Agents
In apps/api/src/realtime/routes.ts around lines 32 to 101, the visitor stream
handler lacks CORS response headers which breaks cross-origin EventSource
connections; add a responseHeaders property to the handler that mirrors the
dashboard approach: set "Access-Control-Allow-Origin" to the request origin
(fallback to "*" if absent), include "Access-Control-Allow-Credentials": "true",
and add "Vary": "Origin" so browsers accept the EventSource connection from
other origins.


const dashboardStreamHandler = handle({
realtime: realtimeStreams,
middleware: async ({ request, channel }) => {
if (!channel) {
return new Response("Channel is required", { status: 400 });
}

const session = await getTRPCSession(db, { headers: request.headers });

if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}

const website = await getWebsiteByIdWithAccess(db, {
userId: session.user.id,
websiteId: channel,
});

if (!website) {
return new Response("Forbidden", { status: 403 });
}
},
filter: ({ event }) => {
return Array.isArray(event.__event_path) && event.__event_path[0] === "dashboard";
},
responseHeaders: ({ request }) => {
const origin = request.headers.get("origin");
if (!origin) {
return undefined;
}

return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
Vary: "Origin",
} satisfies HeadersInit;
},
});

export function registerRealtimeRoutes(app: OpenAPIHono): void {
app.get("/v1/realtime/visitor", async (c) => {
const response = await visitorStreamHandler(c.req.raw);
return response ?? new Response(null, { status: 204 });
});

app.get("/v1/realtime/dashboard", async (c) => {
const response = await dashboardStreamHandler(c.req.raw);
return response ?? new Response(null, { status: 204 });
});
}
39 changes: 39 additions & 0 deletions apps/api/src/realtime/stream-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { RealtimeEvent } from "@cossistant/types/realtime-events";
import { realtimeStreams } from "./streams";

async function emitVisitorMessage(
event: RealtimeEvent<"MESSAGE_CREATED">
): Promise<void> {
if (!event.websiteId) {
return;
}

await realtimeStreams
.channel(event.websiteId)
.visitor.MESSAGE_CREATED.emit(event);
}

async function emitDashboardMessage(
event: RealtimeEvent<"MESSAGE_CREATED">
): Promise<void> {
if (!event.websiteId) {
return;
}

await realtimeStreams
.channel(event.websiteId)
.dashboard.MESSAGE_CREATED.emit(event);
}

export async function forwardMessageEventToRealtimeStreams(
event: RealtimeEvent
): Promise<void> {
if (event.type !== "MESSAGE_CREATED") {
return;
}

await Promise.allSettled([
emitVisitorMessage(event),
emitDashboardMessage(event),
]);
}
30 changes: 30 additions & 0 deletions apps/api/src/realtime/streams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getRedis } from "@api/redis";
import { Realtime } from "@cossistant/realtime/server";
import { RealtimeEvents } from "@cossistant/types/realtime-events";
import { z } from "zod";

const messageCreatedEventSchema = z.object({
type: z.literal("MESSAGE_CREATED"),
payload: RealtimeEvents.MESSAGE_CREATED,
timestamp: z.number(),
organizationId: z.string(),
websiteId: z.string(),
visitorId: z.string().nullable(),
});

const redis = getRedis();

export const realtimeStreams = new Realtime({
redis,
verbose: process.env.NODE_ENV !== "production",
schema: {
visitor: z.object({
MESSAGE_CREATED: messageCreatedEventSchema,
}),
dashboard: z.object({
MESSAGE_CREATED: messageCreatedEventSchema,
}),
},
});

export type RealtimeMessageEvent = z.infer<typeof messageCreatedEventSchema>;
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,4 @@
"typescript": "^5",
"@types/bun": "^1.2.22"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type RealtimeEventHandlersMap,
useRealtime,
} from "@cossistant/next/realtime";
import { useDashboardRealtime } from "@cossistant/realtime/client";
import type { RealtimeEvent } from "@cossistant/types/realtime-events";
import { useQueryNormalizer } from "@normy/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useMemo } from "react";
Expand Down Expand Up @@ -47,7 +49,6 @@ export function DashboardRealtimeProvider({
});
},
],
MESSAGE_CREATED: handleMessageCreated,
CONVERSATION_SEEN: [
(_data, meta) => {
void handleConversationSeen({
Expand Down Expand Up @@ -80,5 +81,18 @@ export function DashboardRealtimeProvider({
},
});

useDashboardRealtime({
websiteId: website.id,
onMessageCreated: (event: RealtimeEvent<"MESSAGE_CREATED">) => {
const handlers = Array.isArray(handleMessageCreated)
? handleMessageCreated
: [handleMessageCreated];

for (const handler of handlers) {
handler(event.payload, { event, context: realtimeContext });
}
},
});

return children;
}
Loading
Loading