Skip to content

Commit c43e0b5

Browse files
u
1 parent 3ff38aa commit c43e0b5

62 files changed

Lines changed: 2396 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎next.config.ts‎

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,23 @@ import type { NextConfig } from "next"
33
const isDev = process.env.NODE_ENV !== "production"
44

55
/**
6-
* Content Security Policy. Email bodies are rendered inside sandboxed
7-
* iframes without script execution, so remote content can never run code.
6+
* Page CSP (with a per-request script nonce) is set in `src/proxy.ts`. API
7+
* routes skip the proxy, so they get this static policy without any inline
8+
* script allowance.
89
*/
9-
const csp = [
10+
const apiCsp = [
1011
"default-src 'self'",
11-
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://js.stripe.com`,
12+
"script-src 'self'",
1213
"style-src 'self' 'unsafe-inline'",
13-
"img-src 'self' data: blob: https: http:",
14+
"img-src 'self' data: blob: https:",
1415
"font-src 'self' data:",
15-
"media-src 'self' blob: data:",
16-
`connect-src 'self'${isDev ? " ws: wss:" : ""} https://api.stripe.com`,
17-
"frame-src 'self' blob: https://js.stripe.com https://checkout.stripe.com",
1816
"frame-ancestors 'none'",
1917
"object-src 'none'",
2018
"base-uri 'self'",
21-
"form-action 'self' https://checkout.stripe.com https://billing.stripe.com https://accounts.google.com https://login.microsoftonline.com",
22-
...(isDev ? [] : ["upgrade-insecure-requests"]),
19+
"form-action 'self'",
2320
].join("; ")
2421

2522
const securityHeaders = [
26-
{ key: "Content-Security-Policy", value: csp },
2723
{ key: "X-Content-Type-Options", value: "nosniff" },
2824
{ key: "X-Frame-Options", value: "DENY" },
2925
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
@@ -39,15 +35,15 @@ const nextConfig: NextConfig = {
3935
allowedDevOrigins: ["127.0.0.1"],
4036
reactStrictMode: true,
4137
serverExternalPackages: ["imapflow", "mailparser", "nodemailer", "sanitize-html", "postgres"],
42-
images: {
43-
remotePatterns: [{ protocol: "https", hostname: "**" }],
44-
},
38+
// next/image isn't used; without optimization `/_next/image` can't be abused as an open image proxy
39+
images: { unoptimized: true },
4540
experimental: {
4641
serverActions: { bodySizeLimit: "30mb" },
4742
},
4843
async headers() {
4944
return [
5045
{ source: "/:path*", headers: securityHeaders },
46+
{ source: "/api/:path*", headers: [{ key: "Content-Security-Policy", value: apiCsp }] },
5147
// Downloaded attachments / inline previews: never run scripts in our origin.
5248
{
5349
source: "/api/w/:slug/attachments/:id",

‎src/app/api/w/[slug]/ai/draft/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod"
22
import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
3+
import { assertApiScope } from "@/server/conversations/context"
34
import { getConversationAccess } from "@/server/access"
45
import { assertAiAvailable, draftReply, toApiError } from "@/server/ai"
56

@@ -16,6 +17,7 @@ const body = z.object({
1617
/** POST /api/w/[slug]/ai/draft { conversationId, tone?, instructions?, currentDraft? } → { text, html } */
1718
export const POST = route<P>(async (req, { params }) => {
1819
const ctx = await requireApiOrg(req, (await params).slug)
20+
assertApiScope(ctx, "conversations.read")
1921
const input = await parseJson(req, body)
2022
const access = await getConversationAccess(ctx, input.conversationId)
2123
if (!access) throw new ApiError(404, "Conversation not found", "not_found")

‎src/app/api/w/[slug]/ai/summarize/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod"
22
import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
3+
import { assertApiScope } from "@/server/conversations/context"
34
import { getConversationAccess } from "@/server/access"
45
import { assertAiAvailable, cachedSummary, storeSummary, summarizeConversation, toApiError } from "@/server/ai"
56

@@ -10,6 +11,7 @@ const body = z.object({ conversationId: z.uuid(), refresh: z.boolean().optional(
1011
/** POST /api/w/[slug]/ai/summarize { conversationId, refresh? } → { summary, cached } */
1112
export const POST = route<P>(async (req, { params }) => {
1213
const ctx = await requireApiOrg(req, (await params).slug)
14+
assertApiScope(ctx, "conversations.read")
1315
const { conversationId, refresh } = await parseJson(req, body)
1416
const access = await getConversationAccess(ctx, conversationId)
1517
if (!access) throw new ApiError(404, "Conversation not found", "not_found")

‎src/app/api/w/[slug]/contacts/[contactId]/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { and, eq } from "drizzle-orm"
22
import { db, schema } from "@/server/db"
33
import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
4+
import { assertApiScope } from "@/server/conversations/context"
45
import { assertWritable } from "@/server/authz"
56
import { audit } from "@/server/audit"
67
import {
@@ -24,6 +25,7 @@ type P = { slug: string; contactId: string }
2425
async function load(req: Parameters<typeof requireApiOrg>[0], params: Promise<P>) {
2526
const { slug, contactId } = await params
2627
const ctx = await requireApiOrg(req, slug)
28+
assertApiScope(ctx, "contacts.read")
2729
const contact = isUuid(contactId) ? await getContact(ctx, contactId) : null
2830
if (!contact) throw new ApiError(404, "Contact not found", "not_found")
2931
return { ctx, contact }

‎src/app/api/w/[slug]/contacts/export/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod"
22
import { ApiError, parseQuery, requireApiOrg, route } from "@/server/api"
3+
import { assertApiScope } from "@/server/conversations/context"
34
import { searchContacts } from "@/server/contacts"
45

56
type P = { slug: string }
@@ -22,6 +23,7 @@ function csvCell(value: unknown) {
2223
export const GET = route<P>(async (req, { params }) => {
2324
const { slug } = await params
2425
const ctx = await requireApiOrg(req, slug)
26+
assertApiScope(ctx, "contacts.read")
2527
if (!ctx.permissions.has("contacts.manage") && !ctx.permissions.has("conversations.export")) {
2628
throw new ApiError(403, "You don't have permission to export contacts", "forbidden")
2729
}

‎src/app/api/w/[slug]/contacts/import/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod"
22
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"
33
import { db, schema } from "@/server/db"
44
import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
5+
import { assertApiScope } from "@/server/conversations/context"
56
import { assertWritable } from "@/server/authz"
67
import { audit } from "@/server/audit"
78
import { rateLimit } from "@/server/rate-limit"
@@ -30,6 +31,7 @@ const body = z.object({
3031
/** POST /api/w/[slug]/contacts/import → { created, updated, skipped, invalid } */
3132
export const POST = route<P>(async (req, { params }) => {
3233
const ctx = await requireApiOrg(req, (await params).slug)
34+
assertApiScope(ctx, "contacts.read")
3335
assertWritable(ctx)
3436
const input = await parseJson(req, body)
3537
if (!input.isPrivate && !ctx.permissions.has("contacts.manage")) {

‎src/app/api/w/[slug]/contacts/lookup/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod"
22
import { ApiError, json, parseQuery, requireApiOrg, route } from "@/server/api"
3+
import { assertApiScope } from "@/server/conversations/context"
34
import { contactDto, getContactByEmail, isValidEmail, normalizeEmail, recentConversationsFor } from "@/server/contacts"
45

56
type P = { slug: string }
@@ -12,6 +13,7 @@ const query = z.object({ email: z.string().max(254), limit: z.coerce.number().in
1213
*/
1314
export const GET = route<P>(async (req, { params }) => {
1415
const ctx = await requireApiOrg(req, (await params).slug)
16+
assertApiScope(ctx, "contacts.read")
1517
const { email: raw, limit } = parseQuery(req, query)
1618
const email = normalizeEmail(raw)
1719
if (!isValidEmail(email)) throw new ApiError(400, "Invalid email address", "validation_error")

‎src/app/api/w/[slug]/contacts/merge/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod"
22
import { and, eq, inArray } from "drizzle-orm"
33
import { db, schema } from "@/server/db"
44
import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
5+
import { assertApiScope } from "@/server/conversations/context"
56
import { assertWritable } from "@/server/authz"
67
import { audit } from "@/server/audit"
78
import {
@@ -29,6 +30,7 @@ const body = z.object({
2930
*/
3031
export const POST = route<P>(async (req, { params }) => {
3132
const ctx = await requireApiOrg(req, (await params).slug)
33+
assertApiScope(ctx, "contacts.read")
3234
assertWritable(ctx)
3335
const { targetId, sourceIds } = await parseJson(req, body)
3436
const ids = [...new Set([targetId, ...sourceIds])]
@@ -37,7 +39,7 @@ export const POST = route<P>(async (req, { params }) => {
3739
const rows = await db
3840
.select()
3941
.from(schema.contacts)
40-
.where(and(visibleContactsWhere(ctx), inArray(schema.contacts.id, ids)))
42+
.where(and(await visibleContactsWhere(ctx), inArray(schema.contacts.id, ids)))
4143
const target = rows.find((r) => r.id === targetId)
4244
const sources = rows.filter((r) => r.id !== targetId)
4345
if (!target || sources.length !== ids.length - 1) throw new ApiError(404, "Contact not found", "not_found")

‎src/app/api/w/[slug]/contacts/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod"
22
import { db, schema } from "@/server/db"
33
import { ApiError, json, parseJson, parseQuery, requireApiOrg, route } from "@/server/api"
4+
import { assertApiScope } from "@/server/conversations/context"
45
import { assertWritable } from "@/server/authz"
56
import {
67
contactDto,
@@ -32,6 +33,7 @@ const listQuery = z.object({
3233
/** GET /api/w/[slug]/contacts?q&company&tag&scope&sort&limit&offset&facets=1 */
3334
export const GET = route<P>(async (req, { params }) => {
3435
const ctx = await requireApiOrg(req, (await params).slug)
36+
assertApiScope(ctx, "contacts.read")
3537
const query = parseQuery(req, listQuery)
3638
const [result, facets] = await Promise.all([
3739
searchContacts(ctx, query),
@@ -47,6 +49,7 @@ export const GET = route<P>(async (req, { params }) => {
4749
/** POST /api/w/[slug]/contacts → ContactDto */
4850
export const POST = route<P>(async (req, { params }) => {
4951
const ctx = await requireApiOrg(req, (await params).slug)
52+
assertApiScope(ctx, "contacts.read")
5053
assertWritable(ctx)
5154
const input = await parseJson(req, contactInput)
5255
const email = normalizeEmail(input.email)

‎src/app/api/w/[slug]/events/route.ts‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,33 @@ import type { NextRequest } from "next/server"
22
import { and, eq, gt, isNull, or } from "drizzle-orm"
33
import { errorResponse, requireApiOrg } from "@/server/api"
44
import { SESSION_COOKIE, validateSessionToken } from "@/server/auth/session"
5-
import { subscribe } from "@/server/realtime"
5+
import { subscribe, type RealtimeEvent } from "@/server/realtime"
66
import { db, schema } from "@/server/db"
7+
import { getAccountAccess, visibleConversationIds } from "@/server/access"
8+
import { assertApiScope } from "@/server/conversations/context"
79

810
export const dynamic = "force-dynamic"
911
export const runtime = "nodejs"
1012

1113
const HEARTBEAT_MS = 20_000
1214
const RECHECK_MS = 5 * 60_000
15+
const VISIBILITY_TTL_MS = 30_000
1316

1417
/**
1518
* GET /api/w/[slug]/events — Server-Sent Events stream of realtime events
1619
* for this workspace (`data: RealtimeEvent` JSON per message). Events with
17-
* `userIds` are only delivered to those users. Clients refetch data on events;
18-
* visibility is enforced by the regular API. Heartbeat comment every 20s; the
19-
* session and membership are re-validated every 5 minutes.
20+
* `userIds` are only delivered to those users; events about a conversation
21+
* only to members who can see it (cached per connection for 30s). Clients
22+
* refetch data on events; the regular API enforces visibility as well.
23+
* Heartbeat comment every 20s; the session and membership are re-validated
24+
* every 5 minutes.
2025
*/
2126
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
2227
const { slug } = await params
2328
let ctx: Awaited<ReturnType<typeof requireApiOrg>>
2429
try {
2530
ctx = await requireApiOrg(req, slug)
31+
assertApiScope(ctx, "conversations.read")
2632
} catch (err) {
2733
return errorResponse(err)
2834
}
@@ -33,6 +39,31 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ slug
3339
let closed = false
3440
let cleanup = () => {}
3541

42+
const visibility = new Map<string, { visible: boolean; at: number }>()
43+
let accounts: { ids: string[]; at: number } | null = null
44+
const canSee = async (conversationId: string) => {
45+
const now = Date.now()
46+
const hit = visibility.get(conversationId)
47+
if (hit && now - hit.at < VISIBILITY_TTL_MS) return hit.visible
48+
if (!accounts || now - accounts.at >= VISIBILITY_TTL_MS) accounts = { ids: [...(await getAccountAccess(ctx)).keys()], at: now }
49+
const visible = (await visibleConversationIds(ctx, [conversationId], accounts.ids)).has(conversationId)
50+
if (visibility.size >= 5000) visibility.clear()
51+
visibility.set(conversationId, { visible, at: now })
52+
return visible
53+
}
54+
/** Explicitly addressed events and my own always arrive; other conversation events need visibility. */
55+
const deliverable = async (event: RealtimeEvent) => {
56+
if (!event.conversationId) return true
57+
if (event.userIds) {
58+
// Addressed to me about this conversation (assigned, mentioned...): its visibility may have changed.
59+
visibility.delete(event.conversationId)
60+
return true
61+
}
62+
if (event.actorId === me) return true
63+
return canSee(event.conversationId).catch(() => false)
64+
}
65+
let queue = Promise.resolve()
66+
3667
const stream = new ReadableStream<Uint8Array>({
3768
async start(controller) {
3869
const send = (chunk: string) => {
@@ -49,9 +80,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ slug
4980
const unsubscribe = await subscribe((event) => {
5081
if (event.orgId !== orgId) return
5182
if (event.userIds && !event.userIds.includes(me)) return
52-
const { userIds: _recipients, ...payload } = event
53-
void _recipients
54-
send(`data: ${JSON.stringify(payload)}\n\n`)
83+
// Chained to keep the order of events while visibility is checked.
84+
queue = queue.then(async () => {
85+
if (closed || !(await deliverable(event))) return
86+
const { userIds: _recipients, ...payload } = event
87+
void _recipients
88+
send(`data: ${JSON.stringify(payload)}\n\n`)
89+
})
5590
})
5691
const heartbeat = setInterval(() => send(`: ping ${Date.now()}\n\n`), HEARTBEAT_MS)
5792
const recheck = setInterval(async () => {

0 commit comments

Comments
 (0)