From 7782de6b7a496137e4a7757140f2b14571bc1321 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:42:59 +0000 Subject: [PATCH 01/30] chore(deps): register @calcom/crovecrm workspace in yarn.lock Commit ea63716c98 added the packages/app-store/crovecrm workspace package without regenerating the lockfile, so Yarn 4 rejected every command with: @calcom/crovecrm@workspace:packages/app-store/crovecrm: This package doesn't seem to be present in your lockfile This broke yarn install, yarn lint, yarn type-check and yarn test on any fresh clone of dev. Regenerated with `yarn install --mode=update-lockfile` so only the missing workspace entry is added (8 lines) and no package resolution changes. --- yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/yarn.lock b/yarn.lock index 8158907a772..c90c73c9613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2234,6 +2234,14 @@ __metadata: languageName: unknown linkType: soft +"@calcom/crovecrm@workspace:packages/app-store/crovecrm": + version: 0.0.0-use.local + resolution: "@calcom/crovecrm@workspace:packages/app-store/crovecrm" + dependencies: + "@calcom/types": "workspace:*" + languageName: unknown + linkType: soft + "@calcom/dailyvideo@workspace:*, @calcom/dailyvideo@workspace:packages/app-store/dailyvideo": version: 0.0.0-use.local resolution: "@calcom/dailyvideo@workspace:packages/app-store/dailyvideo" From 2e7df3e5ade3974939aee6bea4db7105833e8451 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:09:06 +0000 Subject: [PATCH 02/30] fix(auth): remove hardcoded DOS.Me OIDC credentials and re-anchor JWT identity DosIdProvider no longer falls back to an embedded client id/secret, and the dos-id provider is only registered when the deployment actually configured OIDC credentials, so unconfigured installs get no dos-id endpoints. The JWT update callback trusted client-supplied session.email to resolve identity, letting any session rewrite its org/role fields and hard-failing the session when the profile row was missing; identity is now re-anchored to token.sub, upId overrides are validated against the resolved user, and failures fall back to the previous token. syncDosOrganizations no longer adopts organizations by slug match and caps JIT-provisioned roles at MEMBER unless the claim id matches the org's stored id. --- apps/web/server/lib/constants.ts | 2 +- .../features/auth/lib/next-auth-options.ts | 78 +++++++++++++++---- .../features/auth/lib/syncDosOrganizations.ts | 46 +++++++++-- 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/apps/web/server/lib/constants.ts b/apps/web/server/lib/constants.ts index fcfdab23676..a373690934a 100644 --- a/apps/web/server/lib/constants.ts +++ b/apps/web/server/lib/constants.ts @@ -4,5 +4,5 @@ export const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET export const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true"; export const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED); export const isDosIdLoginEnabled = () => - !!(process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) || + !!(process.env.OIDC_CLIENT_ID && (process.env.OIDC_CLIENT_SECRET || process.env.CROVE_OAUTH_CLIENT_SECRET)) || process.env.NEXT_PUBLIC_DOS_ID_LOGIN_ENABLED === "true"; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 7533bb43ddc..6558e836d78 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -314,16 +314,12 @@ type SamlIdpUser = { }; export function DosIdProvider(options?: { clientId?: string; clientSecret?: string }): Provider { - const oidcClientId = ( - options?.clientId || - process.env.OIDC_CLIENT_ID || - "18790ccb-4d71-48cd-ad24-aee5f3ced3da" - ).trim(); + const oidcClientId = (options?.clientId || process.env.OIDC_CLIENT_ID || "").trim(); const oidcClientSecret = ( options?.clientSecret || process.env.OIDC_CLIENT_SECRET || process.env.CROVE_OAUTH_CLIENT_SECRET || - "tQbSNFzbP03onxFrgScOfxYfzbvkjSou-gaPVtHh6fg" + "" ).trim(); const wellKnown = process.env.OIDC_WELL_KNOWN_URL || @@ -346,6 +342,9 @@ export function DosIdProvider(options?: { clientId?: string; clientSecret?: stri idToken: true, checks: ["pkce", "state"], idTokenSignedResponseAlg: "ES256", + // Trust decision: Supabase/DOS.Me only issues tokens for emails confirmed at the IdP, so linking + // accounts by email match is accepted here. Removing this flag would break SSO for existing + // users whose Cal.diy email matches their IdP email. allowDangerousEmailAccountLinking: true, clientId: oidcClientId, clientSecret: oidcClientSecret, @@ -370,7 +369,7 @@ export function DosIdProvider(options?: { clientId?: string; clientSecret?: stri id: profile.sub, name: profile.name || profile.email?.split("@")[0] || "User", email: profile.email, - emailVerified: profile.email_verified ?? true, + emailVerified: profile.email_verified ? new Date() : null, image: profile.picture || profile.avatar_url || null, activeOrgId: profile.active_org_id, organizations: profile.organizations, @@ -385,7 +384,19 @@ export function DosIdProvider(options?: { clientId?: string; clientSecret?: stri } export const getProviders = (): Provider[] => { - const currentProviders: Provider[] = [CalComCredentialsProvider, DosIdProvider()]; + const currentProviders: Provider[] = [CalComCredentialsProvider]; + + const oidcId = (process.env.OIDC_CLIENT_ID || "").trim(); + const oidcSecret = ( + process.env.OIDC_CLIENT_SECRET || + process.env.CROVE_OAUTH_CLIENT_SECRET || + "" + ).trim(); + // Only register dos-id when the deployment actually configured its credentials, so an + // unconfigured deployment gets no dos-id endpoints at all. + if (oidcId && oidcSecret) { + currentProviders.push(DosIdProvider({ clientId: oidcId, clientSecret: oidcSecret })); + } if (IS_GOOGLE_LOGIN_ENABLED) { currentProviders.push( @@ -590,13 +601,50 @@ export const getOptions = ({ // The data available in 'session' depends on what data was supplied in update method call of session if (trigger === "update") { - token.upId = session?.upId ?? token.upId ?? null; - token.profileId = session?.profileId ?? token.profileId ?? null; - token.locale = session?.locale ?? token.locale ?? "en"; - token.name = session?.name ?? token.name; - token.username = session?.username ?? token.username; - token.email = session?.email ?? token.email; - return await autoMergeIdentities(); + // `session` is client-supplied POST data, so identity must never be derived from it. + // Re-anchor the token to the DB record of the signed-in user (token.sub) before any + // merge, so autoMergeIdentities cannot be steered to another user's account. + const previousToken: JWT = { ...token }; + try { + const userIdFromSub = token.sub && isNumber(token.sub) ? Number(token.sub) : null; + if (!userIdFromSub) { + return token; + } + const userFromSub = await prisma.user.findUnique({ + where: { id: userIdFromSub }, + select: { id: true, email: true }, + }); + if (!userFromSub) { + return token; + } + token.email = userFromSub.email; + token.locale = session?.locale ?? token.locale ?? "en"; + token.name = session?.name ?? token.name; + token.username = session?.username ?? token.username; + + const requestedUpId = session?.upId ?? null; + if (requestedUpId) { + const requestedProfile = await ProfileRepository.findByUpIdWithAuth( + requestedUpId, + userFromSub.id + ); + if (requestedProfile) { + token.upId = requestedUpId; + token.profileId = requestedProfile.id ?? token.profileId ?? null; + } else { + log.warn( + "callbacks:jwt:update - ignoring profile switch to a profile not owned by the signed-in user", + safeStringify({ requestedUpId, userId: userFromSub.id }) + ); + } + } + return await autoMergeIdentities(); + } catch (error) { + // A missing/inaccessible profile must not throw and kill the session; fall back + // to the previous token instead (non-fatal). + log.error("callbacks:jwt:update - profile merge failed, keeping previous session", error); + return previousToken; + } } if (!user) { return await autoMergeIdentities(); diff --git a/packages/features/auth/lib/syncDosOrganizations.ts b/packages/features/auth/lib/syncDosOrganizations.ts index e072a8ca85a..6d0d3ae51b3 100644 --- a/packages/features/auth/lib/syncDosOrganizations.ts +++ b/packages/features/auth/lib/syncDosOrganizations.ts @@ -18,6 +18,16 @@ export interface DosTeamClaim { slug?: string; } +/** + * Reads an id from team metadata (e.g. dosOrgId/dosTeamId). Returns "" when unset or empty, + * so a slug fallback can distinguish "claimed by a real DOS id" from "never claimed". + */ +const readMetadataId = (metadata: unknown, key: "dosOrgId" | "dosTeamId"): string => { + if (typeof metadata !== "object" || metadata === null) return ""; + const value = (metadata as Record)[key]; + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +}; + export async function syncDosOrganizations( userId: number, organizations?: DosOrgClaim[], @@ -110,6 +120,12 @@ export async function syncDosOrganizations( select: { id: true, metadata: true }, }); + // Slug fallback: a slug match that is already owned by any DOS org id must never be + // adopted (it may belong to a different DOS org) — skip the claim instead. + if (!orgId && orgTeam && readMetadataId(orgTeam.metadata, "dosOrgId")) { + continue; + } + if (!orgTeam) { let uniqueSlug = orgSlug; const existingSlugTeam = await prisma.team.findFirst({ @@ -150,9 +166,13 @@ export async function syncDosOrganizations( orgIdToDbTeamId.set(orgId, orgTeam.id); } + // Elevated roles are only trusted when the claim's verified id matches the org's own + // dosOrgId; slug-fallback matches (unverified org identity) are capped at MEMBER. + const orgRoleVerified = orgId !== "" && readMetadataId(orgTeam.metadata, "dosOrgId") === orgId; const rawRole = (org.role || "").toUpperCase(); - const membershipRole = - rawRole === "OWNER" + const membershipRole = !orgRoleVerified + ? MembershipRole.MEMBER + : rawRole === "OWNER" ? MembershipRole.OWNER : rawRole === "ADMIN" || rawRole === "LEAD" ? MembershipRole.ADMIN @@ -223,7 +243,7 @@ export async function syncDosOrganizations( isOrganization: false, metadata: { path: ["dosTeamId"], equals: subTeamId }, }, - select: { id: true, parentId: true }, + select: { id: true, parentId: true, metadata: true }, }) : await prisma.team.findFirst({ where: { @@ -231,9 +251,15 @@ export async function syncDosOrganizations( slug: subTeamSlug, ...(parentOrgDbId ? { parentId: parentOrgDbId } : {}), }, - select: { id: true, parentId: true }, + select: { id: true, parentId: true, metadata: true }, }); + // Slug fallback: same guard as orgs — never adopt a sub-team that is already owned + // by any DOS team id; skip the claim instead. + if (!subTeamId && childTeam && readMetadataId(childTeam.metadata, "dosTeamId")) { + continue; + } + if (!childTeam) { let uniqueSlug = subTeamSlug; const existingSlugTeam = await prisma.team.findFirst({ @@ -256,7 +282,7 @@ export async function syncDosOrganizations( dosOrgId: parentOrgDosId, }, }, - select: { id: true, parentId: true }, + select: { id: true, parentId: true, metadata: true }, }); } else if (parentOrgDbId && childTeam.parentId !== parentOrgDbId) { // Link to parent organization if previously unassigned @@ -266,10 +292,14 @@ export async function syncDosOrganizations( }); } - // Map LEAD / ADMIN -> ADMIN, MEMBER -> MEMBER + // Elevated roles only when the claim's verified id matches the team's own dosTeamId; + // slug-fallback matches (unverified team identity) are capped at MEMBER. + const subTeamRoleVerified = + subTeamId !== "" && readMetadataId(childTeam.metadata, "dosTeamId") === subTeamId; const rawRole = (subTeam.role || "").toUpperCase(); - const teamRole = - rawRole === "LEAD" || rawRole === "ADMIN" || rawRole === "OWNER" + const teamRole = !subTeamRoleVerified + ? MembershipRole.MEMBER + : rawRole === "LEAD" || rawRole === "ADMIN" || rawRole === "OWNER" ? MembershipRole.ADMIN : MembershipRole.MEMBER; From 82397d2626be3e8b1d1582f745c6762a5269f098 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:09:32 +0000 Subject: [PATCH 03/30] fix(webhooks): require HMAC signatures and honest delivery reporting on CRM sync routes brevo and crove-crm webhook routes accepted unauthenticated POSTs and drove outbound CRM writes with the server-held API keys, so anyone could inject contacts, trigger marketing automation for arbitrary email addresses, or keep the worker busy with unbounded attendee loops. Both now verify an HMAC-SHA256 signature over the raw body with timingSafeEqual, fail closed when the secret is unset, cap the attendee batch at 50, and no longer echo raw exception messages to the caller. dos-org-sync requires a dedicated DOS_SYNC_WEBHOOK_SECRET instead of falling back to the OIDC client secret, and rejects stale timestamps and replayed delivery ids. webhookMonitor is shared across processes and no longer reports every delivery as an unconditional 200. --- .../webhooks/brevo/__tests__/route.test.ts | 212 ++++++++++--- apps/web/app/api/webhooks/brevo/route.ts | 176 ++++++++--- .../crove-crm/__tests__/route.test.ts | 198 +++++++++--- apps/web/app/api/webhooks/crove-crm/route.ts | 134 ++++++-- .../app/api/webhooks/dos-org-sync/route.ts | 286 +++++++++++------- packages/features/brevo/brevoService.ts | 2 + .../features/crove-crm/croveCrmService.ts | 7 +- packages/lib/webhook-signature.ts | 31 ++ packages/lib/webhookMonitor.ts | 13 +- 9 files changed, 804 insertions(+), 255 deletions(-) create mode 100644 packages/lib/webhook-signature.ts diff --git a/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts index b780f10d628..142fd81163a 100644 --- a/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts @@ -1,9 +1,14 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +const mockRecordDelivery = vi.hoisted(() => vi.fn()); const mockUpsertContact = vi.fn(); const mockTrackEvent = vi.fn(); let mockIsConfigured = true; +const WEBHOOK_SECRET = "brevo-webhook-test-secret"; +const SIGNATURE_HEADER = "x-webhook-signature"; + vi.mock("@calcom/features/brevo", () => { return { BrevoService: class MockBrevoService { @@ -14,6 +19,12 @@ vi.mock("@calcom/features/brevo", () => { }; }); +vi.mock("@calcom/lib/webhookMonitor", () => ({ + webhookMonitor: { + recordDelivery: mockRecordDelivery, + }, +})); + vi.mock("next/server", () => { class MockNextResponse { body: unknown; @@ -40,63 +51,152 @@ vi.mock("next/server", () => { }; }); -function createMockRequest(body: string) { +function createSignedRequest(body: string, signature?: string) { + const headers = new Headers(); + if (signature !== undefined) { + headers.set(SIGNATURE_HEADER, signature); + } return { text: async () => body, + headers, } as unknown as import("next/server").NextRequest; } +function signBody(body: string, secret: string = WEBHOOK_SECRET): string { + return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`; +} + +function createBookingPayload(attendeeCount = 1) { + const attendees: Array<{ name: string; email: string; timeZone?: string }> = Array.from( + { length: attendeeCount }, + (_, index) => ({ + name: `Attendee ${index}`, + email: `attendee-${index}@example.com`, + timeZone: "Asia/Ho_Chi_Minh", + }) + ); + return { + triggerEvent: "BOOKING_CREATED", + payload: { + eventTitle: "Discovery Call", + startTime: "2026-09-01T10:00:00Z", + status: "ACCEPTED", + attendees, + }, + }; +} + describe("/api/webhooks/brevo", () => { beforeEach(() => { vi.clearAllMocks(); mockIsConfigured = true; + process.env.BREVO_WEBHOOK_SECRET = WEBHOOK_SECRET; mockUpsertContact.mockResolvedValue({ success: true }); mockTrackEvent.mockResolvedValue({ success: true }); }); - test("OPTIONS should return 204 with CORS headers", async () => { - const { OPTIONS } = await import("../route"); - const res = await OPTIONS(); - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); + afterEach(() => { + delete process.env.BREVO_WEBHOOK_SECRET; + }); + + test("POST should return 503 and record a failed delivery when the webhook secret is unset", async () => { + delete process.env.BREVO_WEBHOOK_SECRET; + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body, signBody(body, "ignored"))); + + expect(res.status).toBe(503); + const data = await res.json(); + expect(data.error).toBe("Webhook secret is not configured"); + expect(mockUpsertContact).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 503, success: false })); + }); + + test("POST should return 401 when the signature header is missing", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body)); + + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBe("Invalid webhook signature"); + expect(mockUpsertContact).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 401, success: false })); + }); + + test("POST should return 401 when the signature does not match the body", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body, signBody(`${body}tampered`))); + + expect(res.status).toBe(401); + expect(mockUpsertContact).not.toHaveBeenCalled(); + }); + + test("POST should return 401 when the signature is not in sha256= format", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body, "not-a-signature")); + + expect(res.status).toBe(401); + expect(mockUpsertContact).not.toHaveBeenCalled(); + }); + + test("POST should return 400 when the body is not valid JSON", async () => { + const { POST } = await import("../route"); + const body = "not-json"; + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(400); + }); + + test("POST should return 400 when the attendees array exceeds 50 items", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload(51)); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(400); + expect(mockUpsertContact).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 400, success: false })); + }); + + test("POST should return 400 when an attendee has an invalid email", async () => { + const { POST } = await import("../route"); + const payload = createBookingPayload(); + payload.payload.attendees = [{ name: "Bad Email", email: "not-an-email" }]; + const body = JSON.stringify(payload); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(400); + expect(mockUpsertContact).not.toHaveBeenCalled(); }); test("POST should return 500 when Brevo is not configured", async () => { mockIsConfigured = false; const { POST } = await import("../route"); - const req = createMockRequest(JSON.stringify({ triggerEvent: "BOOKING_CREATED", payload: {} })); - const res = await POST(req); + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body, signBody(body))); + expect(res.status).toBe(500); const data = await res.json(); - expect(data.error).toContain("Brevo API key is not configured"); + expect(data.error).toBe("Brevo API key is not configured"); }); - test("POST should sync attendee to Brevo upon BOOKING_CREATED", async () => { + test("POST should sync attendee to Brevo upon BOOKING_CREATED with a valid signature", async () => { const { POST } = await import("../route"); - const payload = { - triggerEvent: "BOOKING_CREATED", - payload: { - eventTitle: "Discovery Call", - startTime: "2026-09-01T10:00:00Z", - status: "ACCEPTED", - attendees: [ - { - name: "John Doe", - email: "john@example.com", - timeZone: "Asia/Ho_Chi_Minh", - }, - ], - }, - }; + const body = JSON.stringify(createBookingPayload()); + const res = await POST(createSignedRequest(body, signBody(body))); - const req = createMockRequest(JSON.stringify(payload)); - const res = await POST(req); expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.partial).toBe(false); + expect(data.syncedAttendees).toBe(1); expect(mockUpsertContact).toHaveBeenCalledWith( expect.objectContaining({ - email: "john@example.com", - name: "John Doe", + email: "attendee-0@example.com", + name: "Attendee 0", meetingTitle: "Discovery Call", meetingStatus: "ACCEPTED", }) @@ -105,7 +205,7 @@ describe("/api/webhooks/brevo", () => { expect(mockTrackEvent).toHaveBeenCalledWith( expect.objectContaining({ eventName: "meeting_booked", - email: "john@example.com", + email: "attendee-0@example.com", }) ); }); @@ -116,22 +216,17 @@ describe("/api/webhooks/brevo", () => { triggerEvent: "BOOKING_CANCELLED", payload: { eventTitle: "Discovery Call", - attendees: [ - { - name: "John Doe", - email: "john@example.com", - }, - ], + startTime: "2026-09-01T10:00:00Z", + attendees: [{ name: "Attendee 0", email: "attendee-0@example.com" }], }, }; + const body = JSON.stringify(payload); + const res = await POST(createSignedRequest(body, signBody(body))); - const req = createMockRequest(JSON.stringify(payload)); - const res = await POST(req); expect(res.status).toBe(200); - expect(mockUpsertContact).toHaveBeenCalledWith( expect.objectContaining({ - email: "john@example.com", + email: "attendee-0@example.com", meetingStatus: "CANCELLED", }) ); @@ -139,8 +234,41 @@ describe("/api/webhooks/brevo", () => { expect(mockTrackEvent).toHaveBeenCalledWith( expect.objectContaining({ eventName: "meeting_cancelled", - email: "john@example.com", + email: "attendee-0@example.com", }) ); }); + + test("POST should return 200 with success:false and partial:true when some attendees fail", async () => { + mockUpsertContact + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: "Brevo API returned status 500" }); + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload(2)); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.partial).toBe(true); + expect(data.syncedAttendees).toBe(1); + expect(data.results).toHaveLength(2); + // Raw upstream errors must not be echoed to the client + expect(JSON.stringify(data.results)).not.toContain("Brevo API returned status 500"); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 200, success: false })); + }); + + test("POST should return 502 with a generic error when all attendees fail to sync", async () => { + mockUpsertContact.mockResolvedValue({ success: false, error: "Brevo API returned status 403" }); + const { POST } = await import("../route"); + const body = JSON.stringify(createBookingPayload(1)); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(502); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toBe("Failed to sync booking to Brevo"); + expect(JSON.stringify(data)).not.toContain("Brevo API returned status 403"); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 502, success: false })); + }); }); diff --git a/apps/web/app/api/webhooks/brevo/route.ts b/apps/web/app/api/webhooks/brevo/route.ts index 5f434d65def..e251f509b53 100644 --- a/apps/web/app/api/webhooks/brevo/route.ts +++ b/apps/web/app/api/webhooks/brevo/route.ts @@ -1,19 +1,42 @@ import { BrevoService } from "@calcom/features/brevo"; +import logger from "@calcom/lib/logger"; +import { verifyWebhookSignature } from "@calcom/lib/webhook-signature"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, x-cal-signature-256", -}; - -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: corsHeaders, - }); +const log = logger.getSubLogger({ prefix: ["webhook", "brevo"] }); + +const brevoAttendeeSchema = z.object({ + email: z.string().email(), + name: z.string().optional(), + timeZone: z.string().optional(), +}); + +const brevoEventDataSchema = z + .object({ + attendees: z.array(brevoAttendeeSchema).max(50).optional(), + eventTitle: z.string().optional(), + title: z.string().optional(), + startTime: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); + +const brevoWebhookBodySchema = brevoEventDataSchema.extend({ + triggerEvent: z.string().max(100).optional(), + event: z.string().max(100).optional(), + payload: brevoEventDataSchema.optional(), + data: brevoEventDataSchema.optional(), +}); + +function parseJsonBody(rawBody: string): unknown { + try { + return JSON.parse(rawBody); + } catch { + return null; + } } export async function POST(req: NextRequest) { @@ -30,13 +53,54 @@ export async function POST(req: NextRequest) { success: false, error: "Empty request body", }); - return NextResponse.json({ error: "Empty request body" }, { status: 400, headers: corsHeaders }); + return NextResponse.json({ error: "Empty request body" }, { status: 400 }); + } + + const secret = process.env.BREVO_WEBHOOK_SECRET; + if (!secret) { + log.warn("BREVO_WEBHOOK_SECRET is not configured, rejecting webhook"); + webhookMonitor.recordDelivery({ + source: "brevo", + event: "error.unconfigured", + status: 503, + latencyMs: Date.now() - startTime, + success: false, + error: "Webhook secret is not configured", + }); + return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 }); + } + + const signature = req.headers.get("x-webhook-signature"); + if (!verifyWebhookSignature(rawBody, signature, secret)) { + log.warn("Invalid webhook signature for brevo webhook"); + webhookMonitor.recordDelivery({ + source: "brevo", + event: "error.invalid_signature", + status: 401, + latencyMs: Date.now() - startTime, + success: false, + error: "Invalid webhook signature", + }); + return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 }); } - const payload = JSON.parse(rawBody); - const triggerEvent = payload.triggerEvent || payload.event || "UNKNOWN"; + const parsedBody = brevoWebhookBodySchema.safeParse(parseJsonBody(rawBody)); + if (!parsedBody.success) { + webhookMonitor.recordDelivery({ + source: "brevo", + event: "error.invalid_body", + status: 400, + latencyMs: Date.now() - startTime, + success: false, + error: "Invalid request body", + }); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = parsedBody.data; + const triggerEvent = body.triggerEvent || body.event || "UNKNOWN"; triggerEventName = triggerEvent; - const eventData = payload.payload || payload.data || payload; + const eventData = body.payload || body.data || body; const brevo = new BrevoService(); if (!brevo.isConfigured()) { @@ -48,10 +112,7 @@ export async function POST(req: NextRequest) { success: false, error: "Brevo API key is not configured", }); - return NextResponse.json( - { error: "Brevo API key is not configured in CROVE_BREVO_API_KEY" }, - { status: 500, headers: corsHeaders } - ); + return NextResponse.json({ error: "Brevo API key is not configured" }, { status: 500 }); } const attendees: Array<{ name?: string; email: string; timeZone?: string }> = eventData.attendees || []; @@ -64,7 +125,7 @@ export async function POST(req: NextRequest) { ? "RESCHEDULED" : eventData.status || "ACCEPTED"; - const syncResults = []; + const syncResults: Array<{ email: string; synced: boolean }> = []; // Sync each attendee into Brevo CRM for (const attendee of attendees) { @@ -85,7 +146,7 @@ export async function POST(req: NextRequest) { ? "meeting_rescheduled" : "meeting_booked"; - await brevo.trackEvent({ + const eventRes = await brevo.trackEvent({ eventName, email: attendee.email, properties: { @@ -95,38 +156,79 @@ export async function POST(req: NextRequest) { }, }); - syncResults.push({ email: attendee.email, synced: contactRes.success }); + syncResults.push({ email: attendee.email, synced: contactRes.success && eventRes.success }); + + if (!contactRes.success || !eventRes.success) { + log.error("Brevo sync failed for attendee", { + email: attendee.email, + contactError: contactRes.error, + eventError: eventRes.error, + }); + } } } + const allSynced = syncResults.every((result) => result.synced); + const summary = `Synced ${syncResults.length} attendee(s) for event: ${triggerEventName}`; + + if (allSynced) { + webhookMonitor.recordDelivery({ + source: "brevo", + event: triggerEventName, + status: 200, + latencyMs: Date.now() - startTime, + success: true, + summary, + }); + return NextResponse.json({ + success: true, + partial: false, + triggerEvent, + syncedAttendees: syncResults.length, + results: syncResults, + }); + } + + if (syncResults.length > 0 && syncResults.every((result) => !result.synced)) { + webhookMonitor.recordDelivery({ + source: "brevo", + event: triggerEventName, + status: 502, + latencyMs: Date.now() - startTime, + success: false, + summary, + error: "All attendee syncs failed", + }); + log.error("Brevo webhook sync failed for all attendees", { triggerEvent: triggerEventName }); + return NextResponse.json({ success: false, error: "Failed to sync booking to Brevo" }, { status: 502 }); + } + webhookMonitor.recordDelivery({ source: "brevo", event: triggerEventName, status: 200, latencyMs: Date.now() - startTime, - success: true, - summary: `Synced ${syncResults.length} attendee(s) for event: ${triggerEventName}`, + success: false, + summary, + }); + log.warn("Brevo webhook sync partially failed", { triggerEvent: triggerEventName }); + return NextResponse.json({ + success: false, + partial: true, + triggerEvent, + syncedAttendees: syncResults.filter((result) => result.synced).length, + results: syncResults, }); - - return NextResponse.json( - { - success: true, - triggerEvent, - syncedAttendees: syncResults.length, - results: syncResults, - }, - { status: 200, headers: corsHeaders } - ); } catch (error) { - const message = error instanceof Error ? error.message : "Internal Server Error"; + log.error("Brevo webhook processing failed", error); webhookMonitor.recordDelivery({ source: "brevo", event: triggerEventName, status: 500, latencyMs: Date.now() - startTime, success: false, - error: message, + error: "Internal Server Error", }); - return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders }); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } } diff --git a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts index e2c1f9d324e..c5d3be047f1 100644 --- a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts @@ -1,72 +1,202 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; -import { POST } from "../route"; +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const mockRecordDelivery = vi.hoisted(() => vi.fn()); const mockMethods = { isConfigured: vi.fn(), syncBookingEvent: vi.fn(), }; +const WEBHOOK_SECRET = "crove-crm-webhook-test-secret"; +const SIGNATURE_HEADER = "x-webhook-signature"; + vi.mock("@calcom/features/crove-crm", () => { return { CroveCrmService: class MockCroveCrmService { isConfigured() { return mockMethods.isConfigured(); } - syncBookingEvent(args: any) { + syncBookingEvent(args: unknown) { return mockMethods.syncBookingEvent(args); } }, }; }); -describe("POST /api/webhooks/crove-crm", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +vi.mock("@calcom/lib/webhookMonitor", () => ({ + webhookMonitor: { + recordDelivery: mockRecordDelivery, + }, +})); - it("should return 500 if Crove CRM is not configured", async () => { - mockMethods.isConfigured.mockReturnValue(false); +vi.mock("next/server", () => { + class MockNextResponse { + body: unknown; + status: number; + headers: Headers; - const req = new NextRequest("http://localhost:3000/api/webhooks/crove-crm", { - method: "POST", - body: JSON.stringify({ - triggerEvent: "BOOKING_CREATED", - payload: { uid: "123" }, - }), - }); + constructor(body: unknown, init?: { status?: number; headers?: Record }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = new Headers(init?.headers ?? {}); + } - const res = await POST(req); - expect(res.status).toBe(500); - const json = await res.json(); - expect(json.error).toContain("CROVE_CRM_API_KEY"); + static json(data: unknown, init?: { status?: number; headers?: Record }) { + return { + status: init?.status ?? 200, + headers: new Headers(init?.headers ?? {}), + json: async () => data, + }; + } + } + + return { + NextResponse: MockNextResponse, + }; +}); + +function createSignedRequest(body: string, signature?: string) { + const headers = new Headers(); + if (signature !== undefined) { + headers.set(SIGNATURE_HEADER, signature); + } + return { + text: async () => body, + headers, + } as unknown as import("next/server").NextRequest; +} + +function signBody(body: string, secret: string = WEBHOOK_SECRET): string { + return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`; +} + +function createBookingBody(attendeeCount = 1): string { + return JSON.stringify({ + triggerEvent: "BOOKING_CREATED", + payload: { + uid: "booking_123", + eventTitle: "Consultation 30m", + attendees: Array.from({ length: attendeeCount }, (_, index) => ({ + email: `client-${index}@example.com`, + name: `Client ${index}`, + })), + }, }); +} - it("should sync booking event and return 200 OK when configured", async () => { +describe("POST /api/webhooks/crove-crm", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CROVE_CRM_WEBHOOK_SECRET = WEBHOOK_SECRET; mockMethods.isConfigured.mockReturnValue(true); mockMethods.syncBookingEvent.mockResolvedValue({ success: true, syncedContacts: 1, results: [{ success: true, contactId: "c_1", activityId: "a_1" }], }); + }); - const req = new NextRequest("http://localhost:3000/api/webhooks/crove-crm", { - method: "POST", - body: JSON.stringify({ - triggerEvent: "BOOKING_CREATED", - payload: { - uid: "booking_123", - eventTitle: "Consultation 30m", - attendees: [{ email: "client@example.com", name: "Client" }], - }, - }), + afterEach(() => { + delete process.env.CROVE_CRM_WEBHOOK_SECRET; + }); + + it("should return 503 and record a failed delivery when the webhook secret is unset", async () => { + delete process.env.CROVE_CRM_WEBHOOK_SECRET; + const { POST } = await import("../route"); + const body = createBookingBody(); + const res = await POST(createSignedRequest(body, signBody(body, "ignored"))); + + expect(res.status).toBe(503); + const json = await res.json(); + expect(json.error).toBe("Webhook secret is not configured"); + expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 503, success: false })); + }); + + it("should return 401 when the signature header is missing", async () => { + const { POST } = await import("../route"); + const res = await POST(createSignedRequest(createBookingBody())); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error).toBe("Invalid webhook signature"); + expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 401, success: false })); + }); + + it("should return 401 when the signature does not match the body", async () => { + const { POST } = await import("../route"); + const body = createBookingBody(); + const res = await POST(createSignedRequest(body, signBody(`${body}tampered`))); + + expect(res.status).toBe(401); + expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled(); + }); + + it("should return 400 when the attendees array exceeds 50 items", async () => { + const { POST } = await import("../route"); + const body = createBookingBody(51); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(400); + expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled(); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 400, success: false })); + }); + + it("should return 400 when an attendee has an invalid email", async () => { + const { POST } = await import("../route"); + const body = JSON.stringify({ + triggerEvent: "BOOKING_CREATED", + payload: { uid: "booking_123", attendees: [{ email: "not-an-email" }] }, }); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(400); + expect(mockMethods.syncBookingEvent).not.toHaveBeenCalled(); + }); + + it("should return 500 if Crove CRM is not configured", async () => { + mockMethods.isConfigured.mockReturnValue(false); + const { POST } = await import("../route"); + const body = createBookingBody(); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.error).toBe("Crove CRM API key is not configured"); + }); + + it("should sync booking event and return 200 OK when configured and signed", async () => { + const { POST } = await import("../route"); + const body = createBookingBody(); + const res = await POST(createSignedRequest(body, signBody(body))); - const res = await POST(req); expect(res.status).toBe(200); const json = await res.json(); expect(json.success).toBe(true); expect(json.syncedContacts).toBe(1); - expect(mockMethods.syncBookingEvent).toHaveBeenCalled(); + expect(mockMethods.syncBookingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + triggerEvent: "BOOKING_CREATED", + payload: expect.objectContaining({ uid: "booking_123" }), + }) + ); + }); + + it("should not echo raw upstream errors to the client when the sync fails", async () => { + mockMethods.syncBookingEvent.mockResolvedValue({ + success: false, + syncedContacts: 0, + results: [{ success: false, error: "Crove CRM API returned status 500" }], + }); + const { POST } = await import("../route"); + const body = createBookingBody(); + const res = await POST(createSignedRequest(body, signBody(body))); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(false); + expect(JSON.stringify(json)).not.toContain("Crove CRM API returned status 500"); + expect(mockRecordDelivery).toHaveBeenCalledWith(expect.objectContaining({ status: 200, success: false })); }); }); diff --git a/apps/web/app/api/webhooks/crove-crm/route.ts b/apps/web/app/api/webhooks/crove-crm/route.ts index b8821ad2c5b..6d3c24bfd55 100644 --- a/apps/web/app/api/webhooks/crove-crm/route.ts +++ b/apps/web/app/api/webhooks/crove-crm/route.ts @@ -1,19 +1,54 @@ import { CroveCrmService } from "@calcom/features/crove-crm"; +import logger from "@calcom/lib/logger"; +import { verifyWebhookSignature } from "@calcom/lib/webhook-signature"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, x-cal-signature-256", -}; - -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: corsHeaders, - }); +const log = logger.getSubLogger({ prefix: ["webhook", "crove-crm"] }); + +const croveCrmAttendeeSchema = z.object({ + email: z.string().email(), + name: z.string().optional(), + timeZone: z.string().optional(), + phoneNumber: z.string().optional(), +}); + +const croveCrmEventDataSchema = z + .object({ + uid: z.string().optional(), + title: z.string().optional(), + eventTitle: z.string().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + status: z.string().optional(), + organizer: z + .object({ + email: z.string(), + name: z.string().optional(), + }) + .optional(), + attendees: z.array(croveCrmAttendeeSchema).max(50).optional(), + teamId: z.union([z.string(), z.number()]).optional(), + organizationId: z.union([z.string(), z.number()]).optional(), + metadata: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const croveCrmWebhookBodySchema = croveCrmEventDataSchema.extend({ + triggerEvent: z.string().max(100).optional(), + event: z.string().max(100).optional(), + payload: croveCrmEventDataSchema.optional(), + data: croveCrmEventDataSchema.optional(), +}); + +function parseJsonBody(rawBody: string): unknown { + try { + return JSON.parse(rawBody); + } catch { + return null; + } } export async function POST(req: NextRequest) { @@ -31,13 +66,54 @@ export async function POST(req: NextRequest) { success: false, error: "Empty request body", }); - return NextResponse.json({ error: "Empty request body" }, { status: 400, headers: corsHeaders }); + return NextResponse.json({ error: "Empty request body" }, { status: 400 }); + } + + const secret = process.env.CROVE_CRM_WEBHOOK_SECRET; + if (!secret) { + log.warn("CROVE_CRM_WEBHOOK_SECRET is not configured, rejecting webhook"); + webhookMonitor.recordDelivery({ + source: "crove-crm", + event: "error.unconfigured", + status: 503, + latencyMs: Date.now() - startTime, + success: false, + error: "Webhook secret is not configured", + }); + return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 }); + } + + const signature = req.headers.get("x-webhook-signature"); + if (!verifyWebhookSignature(rawBody, signature, secret)) { + log.warn("Invalid webhook signature for crove-crm webhook"); + webhookMonitor.recordDelivery({ + source: "crove-crm", + event: "error.invalid_signature", + status: 401, + latencyMs: Date.now() - startTime, + success: false, + error: "Invalid webhook signature", + }); + return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 }); + } + + const parsedBody = croveCrmWebhookBodySchema.safeParse(parseJsonBody(rawBody)); + if (!parsedBody.success) { + webhookMonitor.recordDelivery({ + source: "crove-crm", + event: "error.invalid_body", + status: 400, + latencyMs: Date.now() - startTime, + success: false, + error: "Invalid request body", + }); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const payload = JSON.parse(rawBody); - const triggerEvent = payload.triggerEvent || payload.event || "BOOKING_CREATED"; + const body = parsedBody.data; + const triggerEvent = body.triggerEvent || body.event || "BOOKING_CREATED"; triggerEventName = triggerEvent; - const eventData = payload.payload || payload.data || payload; + const eventData = body.payload || body.data || body; const crm = new CroveCrmService(); if (!crm.isConfigured()) { @@ -49,10 +125,7 @@ export async function POST(req: NextRequest) { success: false, error: "Crove CRM API key is not configured", }); - return NextResponse.json( - { error: "Crove CRM API key is not configured in CROVE_CRM_API_KEY" }, - { status: 500, headers: corsHeaders } - ); + return NextResponse.json({ error: "Crove CRM API key is not configured" }, { status: 500 }); } const syncResult = await crm.syncBookingEvent({ @@ -60,6 +133,14 @@ export async function POST(req: NextRequest) { payload: eventData, }); + if (!syncResult.success) { + log.error("Crove CRM sync reported failure", { + triggerEvent: triggerEventName, + syncedContacts: syncResult.syncedContacts, + totalResults: syncResult.results.length, + }); + } + webhookMonitor.recordDelivery({ source: "crove-crm", event: triggerEventName, @@ -74,20 +155,25 @@ export async function POST(req: NextRequest) { success: syncResult.success, triggerEvent, syncedContacts: syncResult.syncedContacts, - results: syncResult.results, + // Raw upstream errors are logged server-side only, never echoed to the client + results: syncResult.results.map(({ success, contactId, activityId }) => ({ + success, + contactId, + activityId, + })), }, - { status: 200, headers: corsHeaders } + { status: 200 } ); } catch (error) { - const message = error instanceof Error ? error.message : "Internal Server Error"; + log.error("Crove CRM webhook processing failed", error); webhookMonitor.recordDelivery({ source: "crove-crm", event: triggerEventName, status: 500, latencyMs: Date.now() - startTime, success: false, - error: message, + error: "Internal Server Error", }); - return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders }); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } } diff --git a/apps/web/app/api/webhooks/dos-org-sync/route.ts b/apps/web/app/api/webhooks/dos-org-sync/route.ts index db6a2946432..0b6d7b7b15d 100644 --- a/apps/web/app/api/webhooks/dos-org-sync/route.ts +++ b/apps/web/app/api/webhooks/dos-org-sync/route.ts @@ -78,13 +78,41 @@ function verifyHmacSignature(rawBody: string, signatureHeader: string | null, se return timingSafeEqual(sigBuffer, expectedBuffer); } +const REPLAY_WINDOW_MS = 300_000; + +function isTimestampFresh(timestamp: string | null): boolean { + if (!timestamp) return false; + const asNumber = Number(timestamp); + const timestampMs = Number.isFinite(asNumber) ? (asNumber > 1e12 ? asNumber : asNumber * 1000) : Date.parse(timestamp); + if (!Number.isFinite(timestampMs)) return false; + return Math.abs(Date.now() - timestampMs) <= REPLAY_WINDOW_MS; +} + +const MAX_TRACKED_DELIVERIES = 1000; +const recentDeliveryIds = new Map(); + +function isDuplicateDelivery(deliveryId: string): boolean { + if (recentDeliveryIds.has(deliveryId)) { + return true; + } + if (recentDeliveryIds.size >= MAX_TRACKED_DELIVERIES) { + // Map preserves insertion order, so the first key is the oldest entry + const oldest = recentDeliveryIds.keys().next(); + if (!oldest.done && oldest.value !== undefined) { + recentDeliveryIds.delete(oldest.value); + } + } + recentDeliveryIds.set(deliveryId, Date.now()); + return false; +} + export async function POST(req: NextRequest) { const startTime = Date.now(); let eventName = "unknown"; try { const rawBody = await req.text(); const signature = req.headers.get("x-dos-signature"); - const secret = process.env.DOS_SYNC_WEBHOOK_SECRET || process.env.OIDC_CLIENT_SECRET; + const secret = process.env.DOS_SYNC_WEBHOOK_SECRET; if (!secret) { webhookMonitor.recordDelivery({ @@ -116,10 +144,38 @@ export async function POST(req: NextRequest) { ); } + const deliveryId = req.headers.get("x-dos-delivery"); + if (deliveryId && isDuplicateDelivery(deliveryId)) { + webhookMonitor.recordDelivery({ + source: "dos-org-sync", + event: "error.duplicate_delivery", + status: 409, + latencyMs: Date.now() - startTime, + success: false, + error: `Duplicate delivery id: ${deliveryId}`, + }); + return NextResponse.json({ error: "Duplicate delivery" }, { status: 409, headers: corsHeaders }); + } + const payload: DosWebhookPayload = JSON.parse(rawBody); const { event, data } = payload; eventName = event; + if (!isTimestampFresh(payload.timestamp || req.headers.get("x-dos-timestamp"))) { + webhookMonitor.recordDelivery({ + source: "dos-org-sync", + event: eventName, + status: 401, + latencyMs: Date.now() - startTime, + success: false, + error: "Missing or stale timestamp (possible replay)", + }); + return NextResponse.json( + { error: "Invalid or stale timestamp" }, + { status: 401, headers: corsHeaders } + ); + } + if (event === "test.ping" || event === "ping") { webhookMonitor.recordDelivery({ source: "dos-org-sync", @@ -222,66 +278,45 @@ export async function POST(req: NextRequest) { case "organization.member_added": case "organization.member.added": case "org.member_added": { - let team = await prisma.team.findFirst({ - where: { - isOrganization: true, - metadata: { path: ["dosOrgId"], equals: orgId }, - }, - select: { id: true }, - }); - - if (!team) { - let uniqueSlug = orgSlug; - const existingSlugTeam = await prisma.team.findFirst({ - where: { slug: uniqueSlug }, - select: { id: true }, - }); - - if (existingSlugTeam) { - uniqueSlug = `${orgSlug}-${Math.random().toString(36).substring(2, 6)}`; - } - - team = await prisma.team.create({ - data: { - name: orgName, - slug: uniqueSlug, + // The case performs several dependent writes (org team, user, membership, profile, + // user organizationId); a transaction keeps them from landing half-applied. + await prisma.$transaction(async (tx) => { + let team = await tx.team.findFirst({ + where: { isOrganization: true, - metadata: { - dosOrgId: orgId, - }, + metadata: { path: ["dosOrgId"], equals: orgId }, }, select: { id: true }, }); - } - if (data.user_email) { - let user = await prisma.user.findFirst({ - where: { - email: { - equals: data.user_email, - mode: "insensitive", - }, - }, - select: { - id: true, - email: true, - username: true, - organizationId: true, - }, - }); + if (!team) { + let uniqueSlug = orgSlug; + const existingSlugTeam = await tx.team.findFirst({ + where: { slug: uniqueSlug }, + select: { id: true }, + }); + + if (existingSlugTeam) { + uniqueSlug = `${orgSlug}-${Math.random().toString(36).substring(2, 6)}`; + } - if (!user) { - const newUsername = - slugify(data.user_name || data.user_email.split("@")[0]) + - `-${Math.random().toString(36).substring(2, 6)}`; - user = await prisma.user.create({ + team = await tx.team.create({ data: { - email: data.user_email, - name: data.user_name || data.user_email.split("@")[0], - username: newUsername, - emailVerified: new Date(), - organizationId: team.id, + name: orgName, + slug: uniqueSlug, + isOrganization: true, + metadata: { + dosOrgId: orgId, + }, }, + select: { id: true }, + }); + } + + if (data.user_email) { + const userEmail = data.user_email.toLowerCase().trim(); + let user = await tx.user.findUnique({ + where: { email: userEmail }, select: { id: true, email: true, @@ -289,61 +324,82 @@ export async function POST(req: NextRequest) { organizationId: true, }, }); - } - const rawRole = (data.role || "").toUpperCase(); - const membershipRole = - rawRole === "OWNER" - ? MembershipRole.OWNER - : rawRole === "ADMIN" - ? MembershipRole.ADMIN - : MembershipRole.MEMBER; + if (!user) { + const newUsername = + slugify(data.user_name || userEmail.split("@")[0]) + + `-${Math.random().toString(36).substring(2, 6)}`; + user = await tx.user.create({ + data: { + email: userEmail, + name: data.user_name || userEmail.split("@")[0], + username: newUsername, + emailVerified: new Date(), + organizationId: team.id, + }, + select: { + id: true, + email: true, + username: true, + organizationId: true, + }, + }); + } - await prisma.membership.upsert({ - where: { - userId_teamId: { + const rawRole = (data.role || "").toUpperCase(); + const membershipRole = + rawRole === "OWNER" + ? MembershipRole.OWNER + : rawRole === "ADMIN" + ? MembershipRole.ADMIN + : MembershipRole.MEMBER; + + await tx.membership.upsert({ + where: { + userId_teamId: { + userId: user.id, + teamId: team.id, + }, + }, + create: { userId: user.id, teamId: team.id, + role: membershipRole, + accepted: true, }, - }, - create: { - userId: user.id, - teamId: team.id, - role: membershipRole, - accepted: true, - }, - update: { - role: membershipRole, - accepted: true, - }, - }); + update: { + role: membershipRole, + accepted: true, + }, + }); - const orgUsername = user.username || user.email.split("@")[0]; - await prisma.profile.upsert({ - create: { - uid: ProfileRepository.generateProfileUid(), - userId: user.id, - organizationId: team.id, - username: orgUsername, - }, - update: { - username: orgUsername, - }, - where: { - userId_organizationId: { + const orgUsername = user.username || user.email.split("@")[0]; + await tx.profile.upsert({ + create: { + uid: ProfileRepository.generateProfileUid(), userId: user.id, organizationId: team.id, + username: orgUsername, + }, + update: { + username: orgUsername, + }, + where: { + userId_organizationId: { + userId: user.id, + organizationId: team.id, + }, }, - }, - }); - - if (!user.organizationId) { - await prisma.user.update({ - where: { id: user.id }, - data: { organizationId: team.id }, }); + + if (!user.organizationId) { + await tx.user.update({ + where: { id: user.id }, + data: { organizationId: team.id }, + }); + } } - } + }); break; } @@ -359,13 +415,8 @@ export async function POST(req: NextRequest) { }); if (team && data.user_email) { - const user = await prisma.user.findFirst({ - where: { - email: { - equals: data.user_email, - mode: "insensitive", - }, - }, + const user = await prisma.user.findUnique({ + where: { email: data.user_email.toLowerCase().trim() }, select: { id: true }, }); @@ -455,12 +506,25 @@ export async function POST(req: NextRequest) { case "team.deleted": { const teamId = data.team_id ? String(data.team_id) : undefined; if (teamId) { - await prisma.team.deleteMany({ + // The team must belong to the event's org, otherwise a team_id from another + // org could be deleted by replaying/forging an event for a different org_id. + const parentOrg = await prisma.team.findFirst({ where: { - isOrganization: false, - metadata: { path: ["dosTeamId"], equals: teamId }, + isOrganization: true, + metadata: { path: ["dosOrgId"], equals: orgId }, }, + select: { id: true }, }); + + if (parentOrg) { + await prisma.team.deleteMany({ + where: { + isOrganization: false, + parentId: parentOrg.id, + metadata: { path: ["dosTeamId"], equals: teamId }, + }, + }); + } } break; } @@ -479,8 +543,8 @@ export async function POST(req: NextRequest) { : null; if (childTeam && data.user_email) { - const user = await prisma.user.findFirst({ - where: { email: { equals: data.user_email, mode: "insensitive" } }, + const user = await prisma.user.findUnique({ + where: { email: data.user_email.toLowerCase().trim() }, select: { id: true }, }); @@ -526,8 +590,8 @@ export async function POST(req: NextRequest) { select: { id: true }, }); - const user = await prisma.user.findFirst({ - where: { email: { equals: data.user_email, mode: "insensitive" } }, + const user = await prisma.user.findUnique({ + where: { email: data.user_email.toLowerCase().trim() }, select: { id: true }, }); diff --git a/packages/features/brevo/brevoService.ts b/packages/features/brevo/brevoService.ts index 4e68245ff1a..593f3921a8a 100644 --- a/packages/features/brevo/brevoService.ts +++ b/packages/features/brevo/brevoService.ts @@ -81,6 +81,7 @@ export class BrevoService { accept: "application/json", }, body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), }); // 201 Created or 204 No Content (when updateEnabled is true) are both successes @@ -133,6 +134,7 @@ export class BrevoService { accept: "application/json", }, body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), }); if (response.ok || response.status === 204 || response.status === 201) { diff --git a/packages/features/crove-crm/croveCrmService.ts b/packages/features/crove-crm/croveCrmService.ts index 3a1f97afe13..8683c59b436 100644 --- a/packages/features/crove-crm/croveCrmService.ts +++ b/packages/features/crove-crm/croveCrmService.ts @@ -6,7 +6,10 @@ export class CroveCrmService { constructor(apiKey?: string, baseUrl?: string) { this.apiKey = (apiKey || process.env.CROVE_CRM_API_KEY || "").trim(); - this.baseUrl = (baseUrl || process.env.CROVE_CRM_API_URL || "https://crm.crove.com/api/v1").replace(/\/$/, ""); + this.baseUrl = (baseUrl || process.env.CROVE_CRM_API_URL || "https://crm.crove.com/api/v1").replace( + /\/$/, + "" + ); } public isConfigured(): boolean { @@ -56,6 +59,7 @@ export class CroveCrmService { accept: "application/json", }, body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), }); if (response.ok || response.status === 200 || response.status === 201) { @@ -109,6 +113,7 @@ export class CroveCrmService { accept: "application/json", }, body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), }); if (response.ok || response.status === 200 || response.status === 201) { diff --git a/packages/lib/webhook-signature.ts b/packages/lib/webhook-signature.ts new file mode 100644 index 00000000000..7c2054b8ce8 --- /dev/null +++ b/packages/lib/webhook-signature.ts @@ -0,0 +1,31 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const SHA256_HEX_64_PATTERN = /^[0-9a-fA-F]{64}$/; + +/** + * Verify an HMAC-SHA256 webhook signature over the raw request body. + * Expected header format: `sha256=` (64 hex characters). + */ +export function verifyWebhookSignature( + rawBody: string, + signatureHeader: string | null, + secret: string +): boolean { + if (!rawBody || !signatureHeader || !secret) { + return false; + } + + if (!signatureHeader.startsWith("sha256=")) { + return false; + } + + const providedSignature = signatureHeader.slice("sha256=".length); + if (!SHA256_HEX_64_PATTERN.test(providedSignature)) { + return false; + } + + const expectedSignature = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); + + // timingSafeEqual throws when buffer lengths differ, so validate the hex length above before comparing + return timingSafeEqual(Buffer.from(providedSignature, "hex"), Buffer.from(expectedSignature, "hex")); +} diff --git a/packages/lib/webhookMonitor.ts b/packages/lib/webhookMonitor.ts index 13a6b7aa2b1..530595011f5 100644 --- a/packages/lib/webhookMonitor.ts +++ b/packages/lib/webhookMonitor.ts @@ -53,7 +53,9 @@ class WebhookMonitorService { /** * Record a webhook processing event */ - public recordDelivery(entry: Omit & { id?: string; timestamp?: string }): WebhookDeliveryLog { + public recordDelivery( + entry: Omit & { id?: string; timestamp?: string } + ): WebhookDeliveryLog { const logItem: WebhookDeliveryLog = { id: entry.id || `wh_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, source: entry.source, @@ -160,11 +162,10 @@ const globalForWebhookMonitor = globalThis as unknown as { __croveWebhookMonitor?: WebhookMonitorService; }; -export const webhookMonitor = - globalForWebhookMonitor.__croveWebhookMonitor || new WebhookMonitorService(); +export const webhookMonitor = globalForWebhookMonitor.__croveWebhookMonitor || new WebhookMonitorService(); -if (process.env.NODE_ENV !== "production") { - globalForWebhookMonitor.__croveWebhookMonitor = webhookMonitor; -} +// Assignment must be unconditional: in production each route module otherwise gets its own +// instance, so the dashboard under-reports deliveries (MD-25). +globalForWebhookMonitor.__croveWebhookMonitor = webhookMonitor; export default webhookMonitor; From f77912ea7ea68b4ecdefe35de01c304bbbe72aa0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:10:09 +0000 Subject: [PATCH 04/30] fix(workflows): enforce team membership and re-add dropped workflow tables The service treated a client-supplied teamId as the whole access check, so any authenticated user could list, rewrite or delete another team's automation by passing the victim's ids; team-scoped paths now require an accepted ADMIN/OWNER membership, event-type bindings are validated against the caller, and update/delete mutations are scoped by owner. updateWorkflow replaced steps and event-type bindings outside a transaction, so a mid-flight failure destroyed the configuration; it now runs in one transaction. Also adds the missing migration recreating the four workflow tables and enums that upstream drop 20260319000000 removed but schema.prisma still declares - without it every workflow query fails with P2021 on a deployed database. --- .../__tests__/WorkflowService.test.ts | 319 +++++++++++++++++- .../features/workflows/lib/WorkflowService.ts | 268 ++++++++++----- .../migration.sql | 123 +++++++ 3 files changed, 601 insertions(+), 109 deletions(-) create mode 100644 packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql diff --git a/packages/features/workflows/__tests__/WorkflowService.test.ts b/packages/features/workflows/__tests__/WorkflowService.test.ts index 92caefac492..f1e10a1d714 100644 --- a/packages/features/workflows/__tests__/WorkflowService.test.ts +++ b/packages/features/workflows/__tests__/WorkflowService.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { WorkflowService } from "../lib/WorkflowService"; import { + MembershipRole, TimeUnit, WorkflowActions, WorkflowMethods, - WorkflowTemplates, WorkflowTriggerEvents, } from "@calcom/prisma/enums"; describe("WorkflowService", () => { const mockPrisma: any = { + membership: { + findFirst: vi.fn(), + }, + eventType: { + count: vi.fn(), + }, workflow: { findMany: vi.fn(), findFirst: vi.fn(), @@ -27,10 +34,24 @@ describe("WorkflowService", () => { create: vi.fn(), findMany: vi.fn(), }, + $transaction: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); + // mockClear does not drop unconsumed mockResolvedValueOnce entries, so a test that + // registers findMany but never triggers the call would leak its value into later tests + mockPrisma.workflow.findMany.mockReset(); + mockPrisma.workflow.findFirst.mockReset(); + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: unknown) => unknown) => fn(mockPrisma) + ); + mockPrisma.membership.findFirst.mockResolvedValue({ id: 1, role: MembershipRole.OWNER }); + mockPrisma.eventType.count.mockImplementation( + async ({ where }: { where: { id: { in: number[] } } }) => where.id.in.length + ); + mockPrisma.workflow.create.mockReset(); + mockPrisma.workflow.create.mockResolvedValueOnce({ id: 1 }); }); describe("calculateScheduledDate", () => { @@ -76,15 +97,15 @@ describe("WorkflowService", () => { }); describe("createWorkflow", () => { + const validStep = { + stepNumber: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + emailSubject: "Reminder: Meeting tomorrow", + reminderBody: "Hi {ATTENDEE_NAME}, see you tomorrow!", + }; + it("should create workflow with steps and activeOn event types", async () => { const service = new WorkflowService(mockPrisma); - mockPrisma.workflow.create.mockResolvedValueOnce({ - id: 1, - name: "24h Email Reminder", - trigger: WorkflowTriggerEvents.BEFORE_EVENT, - time: 24, - timeUnit: TimeUnit.HOUR, - }); const result = await service.createWorkflow({ userId: 10, @@ -94,19 +115,20 @@ describe("WorkflowService", () => { trigger: WorkflowTriggerEvents.BEFORE_EVENT, time: 24, timeUnit: TimeUnit.HOUR, - steps: [ - { - stepNumber: 1, - action: WorkflowActions.EMAIL_ATTENDEE, - emailSubject: "Reminder: Meeting tomorrow", - reminderBody: "Hi {ATTENDEE_NAME}, see you tomorrow!", - }, - ], + steps: [validStep], activeOn: [101, 102], }, }); expect(result.id).toBe(1); + expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.eventType.count).toHaveBeenCalledWith({ + where: { + id: { in: [101, 102] }, + OR: [{ userId: 10, teamId: null }], + }, + }); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); expect(mockPrisma.workflow.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -130,12 +152,275 @@ describe("WorkflowService", () => { }) ); }); + + it("should create team workflow only for accepted ADMIN or OWNER members", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.ADMIN }); + const service = new WorkflowService(mockPrisma); + + const result = await service.createWorkflow({ + userId: 10, + teamId: 5, + input: { + name: "Team workflow", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + steps: [validStep], + activeOn: [101], + }, + }); + + expect(result.id).toBe(1); + expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({ + where: { userId: 10, teamId: 5, accepted: true }, + select: { id: true, role: true }, + }); + expect(mockPrisma.eventType.count).toHaveBeenCalledWith({ + where: { + id: { in: [101] }, + OR: [{ userId: 10 }, { teamId: 5 }], + }, + }); + expect(mockPrisma.workflow.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + team: { connect: { id: 5 } }, + user: undefined, + }), + }) + ); + }); + + it("should throw Forbidden for team members without ADMIN or OWNER role", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER }); + const service = new WorkflowService(mockPrisma); + + await expect( + service.createWorkflow({ + userId: 10, + teamId: 5, + input: { + name: "Team workflow", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + steps: [validStep], + }, + }) + ).rejects.toThrow(/Only team owners and admins/); + + expect(mockPrisma.workflow.create).not.toHaveBeenCalled(); + }); + + it("should throw Forbidden when the user is not an accepted team member", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce(null); + const service = new WorkflowService(mockPrisma); + + await expect( + service.createWorkflow({ + userId: 10, + teamId: 5, + input: { + name: "Team workflow", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + steps: [validStep], + }, + }) + ).rejects.toThrow(ErrorWithCode); + + expect(mockPrisma.workflow.create).not.toHaveBeenCalled(); + }); + + it("should throw when activeOn references event types the user cannot access", async () => { + mockPrisma.eventType.count.mockResolvedValueOnce(1); + const service = new WorkflowService(mockPrisma); + + await expect( + service.createWorkflow({ + userId: 10, + teamId: null, + input: { + name: "Personal workflow", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + steps: [validStep], + activeOn: [101, 202], + }, + }) + ).rejects.toThrow(/Event type not found or not accessible/); + + expect(mockPrisma.workflow.create).not.toHaveBeenCalled(); + }); + }); + + describe("getWorkflows", () => { + it("should throw Forbidden when the user is not an accepted member of the team", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce(null); + mockPrisma.workflow.findMany.mockResolvedValueOnce([]); + const service = new WorkflowService(mockPrisma); + + await expect(service.getWorkflows({ userId: 10, teamId: 5 })).rejects.toThrow( + /not a member of this team/ + ); + + expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({ + where: { userId: 10, teamId: 5, accepted: true }, + select: { id: true, role: true }, + }); + expect(mockPrisma.workflow.findMany).not.toHaveBeenCalled(); + }); + + it("should list personal workflows without a membership check", async () => { + mockPrisma.workflow.findMany.mockResolvedValueOnce([]); + const service = new WorkflowService(mockPrisma); + + await service.getWorkflows({ userId: 10, teamId: null }); + + expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.workflow.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 10, teamId: null }, + }) + ); + }); + }); + + describe("getWorkflowById", () => { + it("should throw Forbidden when the user is not an accepted member of the team", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce(null); + const service = new WorkflowService(mockPrisma); + + await expect( + service.getWorkflowById({ id: 1, userId: 10, teamId: 5 }) + ).rejects.toThrow(/not a member of this team/); + + expect(mockPrisma.workflow.findFirst).not.toHaveBeenCalled(); + }); + + it("should throw NotFound when the workflow does not exist or is not accessible", async () => { + mockPrisma.workflow.findFirst.mockResolvedValueOnce(null); + const service = new WorkflowService(mockPrisma); + + await expect( + service.getWorkflowById({ id: 1, userId: 10, teamId: null }) + ).rejects.toThrow(/not found or access denied/); + }); + }); + + describe("updateWorkflow", () => { + const validStep = { + stepNumber: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + emailSubject: "Reminder", + reminderBody: "Hi {ATTENDEE_NAME}", + }; + + it("should wrap step and activeOn replacement with the update in a transaction scoped to the owner", async () => { + mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: 10, teamId: null }); + mockPrisma.workflow.update.mockResolvedValueOnce({ id: 1 }); + const service = new WorkflowService(mockPrisma); + + const result = await service.updateWorkflow({ + id: 1, + userId: 10, + teamId: null, + input: { + name: "Renamed", + trigger: WorkflowTriggerEvents.AFTER_EVENT, + time: 15, + timeUnit: TimeUnit.MINUTE, + activeOn: [101, 102], + steps: [validStep], + }, + }); + + expect(result.id).toBe(1); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + expect(mockPrisma.workflowStep.deleteMany).toHaveBeenCalledWith({ where: { workflowId: 1 } }); + expect(mockPrisma.workflowsOnEventTypes.deleteMany).toHaveBeenCalledWith({ + where: { workflowId: 1 }, + }); + expect(mockPrisma.eventType.count).toHaveBeenCalledWith({ + where: { id: { in: [101, 102] }, OR: [{ userId: 10, teamId: null }] }, + }); + expect(mockPrisma.workflow.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1, userId: 10 }, + data: expect.objectContaining({ name: "Renamed", activeOn: { create: [{ eventTypeId: 101 }, { eventTypeId: 102 }] } }), + }) + ); + }); + + it("should scope team updates to the team and require ADMIN or OWNER", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER }); + const service = new WorkflowService(mockPrisma); + + await expect( + service.updateWorkflow({ + id: 1, + userId: 10, + teamId: 5, + input: { active: false }, + }) + ).rejects.toThrow(/Only team owners and admins/); + + expect(mockPrisma.workflow.update).not.toHaveBeenCalled(); + expect(mockPrisma.workflow.findFirst).not.toHaveBeenCalled(); + }); + + it("should scope team updates with the teamId in the where clause", async () => { + mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: null, teamId: 5 }); + mockPrisma.workflow.update.mockResolvedValueOnce({ id: 1 }); + const service = new WorkflowService(mockPrisma); + + await service.updateWorkflow({ + id: 1, + userId: 10, + teamId: 5, + input: { active: false }, + }); + + expect(mockPrisma.workflow.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1, teamId: 5 }, + }) + ); + }); + }); + + describe("deleteWorkflow", () => { + it("should scope the delete to the team workflow", async () => { + mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: null, teamId: 5 }); + mockPrisma.workflow.delete.mockResolvedValueOnce({ id: 1 }); + const service = new WorkflowService(mockPrisma); + + await service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 }); + + expect(mockPrisma.workflow.delete).toHaveBeenCalledWith({ where: { id: 1, teamId: 5 } }); + }); + + it("should scope the delete to the personal workflow", async () => { + mockPrisma.workflow.findFirst.mockResolvedValueOnce({ id: 1, userId: 10, teamId: null }); + mockPrisma.workflow.delete.mockResolvedValueOnce({ id: 1 }); + const service = new WorkflowService(mockPrisma); + + await service.deleteWorkflow({ id: 1, userId: 10, teamId: null }); + + expect(mockPrisma.workflow.delete).toHaveBeenCalledWith({ where: { id: 1, userId: 10 } }); + }); + + it("should throw Forbidden for team members without ADMIN or OWNER role", async () => { + mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER }); + const service = new WorkflowService(mockPrisma); + + await expect( + service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 }) + ).rejects.toThrow(/Only team owners and admins/); + + expect(mockPrisma.workflow.delete).not.toHaveBeenCalled(); + }); }); describe("scheduleRemindersForBooking", () => { it("should find active workflows and create reminder records", async () => { const service = new WorkflowService(mockPrisma); - mockPrisma.workflow.findMany.mockResolvedValueOnce([ + // mockResolvedValue (not Once) so a stale once-queue leaked from an earlier test cannot shadow this value + mockPrisma.workflow.findMany.mockResolvedValue([ { id: 1, trigger: WorkflowTriggerEvents.BEFORE_EVENT, diff --git a/packages/features/workflows/lib/WorkflowService.ts b/packages/features/workflows/lib/WorkflowService.ts index 6a218ff0657..de11649b3fa 100644 --- a/packages/features/workflows/lib/WorkflowService.ts +++ b/packages/features/workflows/lib/WorkflowService.ts @@ -1,6 +1,8 @@ +import { ErrorWithCode } from "@calcom/lib/errors"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { + MembershipRole, TimeUnit, WorkflowActions, WorkflowMethods, @@ -42,10 +44,41 @@ export class WorkflowService { this.prisma = prisma; } + private async assertTeamMembership({ + userId, + teamId, + requireAdminOwner = false, + }: { + userId: number; + teamId: number; + requireAdminOwner?: boolean; + }) { + const membership = await this.prisma.membership.findFirst({ + where: { userId, teamId, accepted: true }, + select: { id: true, role: true }, + }); + + if (!membership) { + throw ErrorWithCode.Factory.Forbidden("You are not a member of this team"); + } + + if ( + requireAdminOwner && + membership.role !== MembershipRole.ADMIN && + membership.role !== MembershipRole.OWNER + ) { + throw ErrorWithCode.Factory.Forbidden("Only team owners and admins can manage workflows"); + } + } + /** * Get all workflows accessible to a user (personal + team workflows) */ async getWorkflows({ userId, teamId }: { userId: number; teamId?: number | null }) { + if (teamId) { + await this.assertTeamMembership({ userId, teamId }); + } + const where: Prisma.WorkflowWhereInput = teamId ? { teamId } : { userId, teamId: null }; @@ -72,6 +105,10 @@ export class WorkflowService { * Get a single workflow by ID with security validation */ async getWorkflowById({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + if (teamId) { + await this.assertTeamMembership({ userId, teamId }); + } + const where: Prisma.WorkflowWhereInput = teamId ? { id, teamId } : { id, userId }; @@ -93,7 +130,7 @@ export class WorkflowService { }); if (!workflow) { - throw new Error(`Workflow with ID ${id} not found or access denied`); + throw ErrorWithCode.Factory.NotFound(`Workflow with ID ${id} not found or access denied`); } return workflow; @@ -112,48 +149,67 @@ export class WorkflowService { input: CreateWorkflowInput; }) { if (!input.name || input.name.trim().length === 0) { - throw new Error("Workflow name is required"); + throw ErrorWithCode.Factory.BadRequest("Workflow name is required"); } if (!input.steps || input.steps.length === 0) { - throw new Error("At least one workflow action step is required"); + throw ErrorWithCode.Factory.BadRequest("At least one workflow action step is required"); } - return this.prisma.workflow.create({ - data: { - name: input.name.trim(), - trigger: input.trigger, - time: input.time || null, - timeUnit: input.timeUnit || null, - isOrganiserEvent: input.isOrganiserEvent ?? false, - active: true, - user: teamId ? undefined : { connect: { id: userId } }, - team: teamId ? { connect: { id: teamId } } : undefined, - steps: { - create: input.steps.map((step, idx) => ({ - stepNumber: step.stepNumber || idx + 1, - action: step.action, - sendTo: step.sendTo || null, - reminderBody: step.reminderBody || null, - emailSubject: step.emailSubject || null, - template: step.template || WorkflowTemplates.REMINDER, - sender: step.sender || null, - numberRequired: step.numberRequired || null, - includeCalendarEvent: step.includeCalendarEvent ?? false, - })), + if (teamId) { + await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true }); + } + + return this.prisma.$transaction(async (tx) => { + if (input.activeOn && input.activeOn.length > 0) { + const owned = await tx.eventType.count({ + where: { + id: { in: input.activeOn }, + OR: teamId ? [{ userId }, { teamId }] : [{ userId, teamId: null }], + }, + }); + + if (owned !== input.activeOn.length) { + throw ErrorWithCode.Factory.NotFound("Event type not found or not accessible"); + } + } + + return tx.workflow.create({ + data: { + name: input.name.trim(), + trigger: input.trigger, + time: input.time || null, + timeUnit: input.timeUnit || null, + isOrganiserEvent: input.isOrganiserEvent ?? false, + active: true, + user: teamId ? undefined : { connect: { id: userId } }, + team: teamId ? { connect: { id: teamId } } : undefined, + steps: { + create: input.steps.map((step, idx) => ({ + stepNumber: step.stepNumber || idx + 1, + action: step.action, + sendTo: step.sendTo || null, + reminderBody: step.reminderBody || null, + emailSubject: step.emailSubject || null, + template: step.template || WorkflowTemplates.REMINDER, + sender: step.sender || null, + numberRequired: step.numberRequired || null, + includeCalendarEvent: step.includeCalendarEvent ?? false, + })), + }, + activeOn: input.activeOn && input.activeOn.length > 0 + ? { + create: input.activeOn.map((eventTypeId) => ({ + eventTypeId, + })), + } + : undefined, }, - activeOn: input.activeOn && input.activeOn.length > 0 - ? { - create: input.activeOn.map((eventTypeId) => ({ - eventTypeId, - })), - } - : undefined, - }, - include: { - steps: true, - activeOn: true, - }, + include: { + steps: true, + activeOn: true, + }, + }); }); } @@ -171,67 +227,86 @@ export class WorkflowService { teamId?: number | null; input: UpdateWorkflowInput; }) { + if (teamId) { + await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true }); + } + // Validate existence & ownership await this.getWorkflowById({ id, userId, teamId }); - // Handle steps replacement if provided - if (input.steps) { - await this.prisma.workflowStep.deleteMany({ - where: { workflowId: id }, - }); - } + return this.prisma.$transaction(async (tx) => { + // Handle steps replacement if provided + if (input.steps) { + await tx.workflowStep.deleteMany({ + where: { workflowId: id }, + }); + } - // Handle activeOn event types update if provided - if (input.activeOn) { - await this.prisma.workflowsOnEventTypes.deleteMany({ - where: { workflowId: id }, - }); - } + // Handle activeOn event types update if provided + if (input.activeOn) { + await tx.workflowsOnEventTypes.deleteMany({ + where: { workflowId: id }, + }); - return this.prisma.workflow.update({ - where: { id }, - data: { - ...(input.name ? { name: input.name.trim() } : {}), - ...(input.trigger !== undefined ? { trigger: input.trigger } : {}), - ...(input.time !== undefined ? { time: input.time } : {}), - ...(input.timeUnit !== undefined ? { timeUnit: input.timeUnit } : {}), - ...(input.active !== undefined ? { active: input.active } : {}), - ...(input.isOrganiserEvent !== undefined ? { isOrganiserEvent: input.isOrganiserEvent } : {}), - ...(input.steps - ? { - steps: { - create: input.steps.map((step, idx) => ({ - stepNumber: step.stepNumber || idx + 1, - action: step.action, - sendTo: step.sendTo || null, - reminderBody: step.reminderBody || null, - emailSubject: step.emailSubject || null, - template: step.template || WorkflowTemplates.REMINDER, - sender: step.sender || null, - numberRequired: step.numberRequired || null, - includeCalendarEvent: step.includeCalendarEvent ?? false, - })), - }, - } - : {}), - ...(input.activeOn - ? { - activeOn: { - create: input.activeOn.map((eventTypeId) => ({ - eventTypeId, - })), - }, - } - : {}), - }, - include: { - steps: { orderBy: { stepNumber: "asc" } }, - activeOn: { - include: { - eventType: { select: { id: true, title: true, slug: true } }, + if (input.activeOn.length > 0) { + const owned = await tx.eventType.count({ + where: { + id: { in: input.activeOn }, + OR: teamId ? [{ userId }, { teamId }] : [{ userId, teamId: null }], + }, + }); + + if (owned !== input.activeOn.length) { + throw ErrorWithCode.Factory.NotFound("Event type not found or not accessible"); + } + } + } + + return tx.workflow.update({ + where: { id, ...(teamId ? { teamId } : { userId }) }, + data: { + ...(input.name ? { name: input.name.trim() } : {}), + ...(input.trigger !== undefined ? { trigger: input.trigger } : {}), + ...(input.time !== undefined ? { time: input.time } : {}), + ...(input.timeUnit !== undefined ? { timeUnit: input.timeUnit } : {}), + ...(input.active !== undefined ? { active: input.active } : {}), + ...(input.isOrganiserEvent !== undefined ? { isOrganiserEvent: input.isOrganiserEvent } : {}), + ...(input.steps + ? { + steps: { + create: input.steps.map((step, idx) => ({ + stepNumber: step.stepNumber || idx + 1, + action: step.action, + sendTo: step.sendTo || null, + reminderBody: step.reminderBody || null, + emailSubject: step.emailSubject || null, + template: step.template || WorkflowTemplates.REMINDER, + sender: step.sender || null, + numberRequired: step.numberRequired || null, + includeCalendarEvent: step.includeCalendarEvent ?? false, + })), + }, + } + : {}), + ...(input.activeOn + ? { + activeOn: { + create: input.activeOn.map((eventTypeId) => ({ + eventTypeId, + })), + }, + } + : {}), + }, + include: { + steps: { orderBy: { stepNumber: "asc" } }, + activeOn: { + include: { + eventType: { select: { id: true, title: true, slug: true } }, + }, }, }, - }, + }); }); } @@ -239,9 +314,14 @@ export class WorkflowService { * Delete a workflow */ async deleteWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + if (teamId) { + await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true }); + } + await this.getWorkflowById({ id, userId, teamId }); + return this.prisma.workflow.delete({ - where: { id }, + where: { id, ...(teamId ? { teamId } : { userId }) }, }); } @@ -249,6 +329,10 @@ export class WorkflowService { * Duplicate a workflow */ async duplicateWorkflow({ id, userId, teamId }: { id: number; userId: number; teamId?: number | null }) { + if (teamId) { + await this.assertTeamMembership({ userId, teamId, requireAdminOwner: true }); + } + const original = await this.getWorkflowById({ id, userId, teamId }); return this.createWorkflow({ diff --git a/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql b/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql new file mode 100644 index 00000000000..1b4df321b73 --- /dev/null +++ b/packages/prisma/migrations/20260908000000_readd_workflow_tables/migration.sql @@ -0,0 +1,123 @@ +-- Recreates the Workflow tables dropped by 20260319000000_drop_workflow_tables. +-- The fork re-declares these models in schema.prisma, so the database needs them back. + +-- CreateEnum +CREATE TYPE "WorkflowTriggerEvents" AS ENUM ('BEFORE_EVENT', 'AFTER_EVENT', 'NEW_EVENT', 'RESCHEDULE_EVENT', 'EVENT_CANCELLED'); + +-- CreateEnum +CREATE TYPE "WorkflowActions" AS ENUM ('EMAIL_HOST', 'EMAIL_ATTENDEE', 'EMAIL_ADDRESS', 'SMS_ATTENDEE', 'SMS_NUMBER', 'WHATSAPP_ATTENDEE', 'WHATSAPP_NUMBER'); + +-- CreateEnum +CREATE TYPE "WorkflowTemplates" AS ENUM ('REMINDER', 'CUSTOM'); + +-- CreateEnum +CREATE TYPE "WorkflowMethods" AS ENUM ('EMAIL', 'SMS', 'WHATSAPP'); + +-- CreateTable +CREATE TABLE "Workflow" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "userId" INTEGER, + "teamId" INTEGER, + "trigger" "WorkflowTriggerEvents" NOT NULL, + "time" INTEGER, + "timeUnit" "TimeUnit", + "isOrganiserEvent" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Workflow_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkflowStep" ( + "id" SERIAL NOT NULL, + "stepNumber" INTEGER NOT NULL, + "action" "WorkflowActions" NOT NULL, + "workflowId" INTEGER NOT NULL, + "sendTo" TEXT, + "reminderBody" TEXT, + "emailSubject" TEXT, + "template" "WorkflowTemplates" NOT NULL DEFAULT 'REMINDER', + "sender" TEXT, + "numberRequired" BOOLEAN, + "numberVerificationPending" BOOLEAN NOT NULL DEFAULT false, + "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "WorkflowStep_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkflowsOnEventTypes" ( + "id" SERIAL NOT NULL, + "workflowId" INTEGER NOT NULL, + "eventTypeId" INTEGER NOT NULL, + + CONSTRAINT "WorkflowsOnEventTypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkflowReminder" ( + "id" SERIAL NOT NULL, + "bookingUid" TEXT, + "method" "WorkflowMethods" NOT NULL, + "scheduledDate" TIMESTAMP(3) NOT NULL, + "referenceId" TEXT, + "scheduled" BOOLEAN NOT NULL DEFAULT false, + "cancelled" BOOLEAN DEFAULT false, + "workflowStepId" INTEGER, + "seatReferenceId" TEXT, + + CONSTRAINT "WorkflowReminder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Workflow_userId_idx" ON "Workflow"("userId"); + +-- CreateIndex +CREATE INDEX "Workflow_teamId_idx" ON "Workflow"("teamId"); + +-- CreateIndex +CREATE INDEX "WorkflowStep_workflowId_idx" ON "WorkflowStep"("workflowId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkflowsOnEventTypes_workflowId_eventTypeId_key" ON "WorkflowsOnEventTypes"("workflowId", "eventTypeId"); + +-- CreateIndex +CREATE INDEX "WorkflowsOnEventTypes_workflowId_idx" ON "WorkflowsOnEventTypes"("workflowId"); + +-- CreateIndex +CREATE INDEX "WorkflowsOnEventTypes_eventTypeId_idx" ON "WorkflowsOnEventTypes"("eventTypeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkflowReminder_referenceId_key" ON "WorkflowReminder"("referenceId"); + +-- CreateIndex +CREATE INDEX "WorkflowReminder_bookingUid_idx" ON "WorkflowReminder"("bookingUid"); + +-- CreateIndex +CREATE INDEX "WorkflowReminder_workflowStepId_idx" ON "WorkflowReminder"("workflowStepId"); + +-- CreateIndex +CREATE INDEX "WorkflowReminder_scheduledDate_scheduled_idx" ON "WorkflowReminder"("scheduledDate", "scheduled"); + +-- AddForeignKey +ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 8bb682654cbeef27356d153c7331cf4fa1f92939 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:10:56 +0000 Subject: [PATCH 05/30] fix(teams-orgs): enforce membership, role hierarchy and consent on tenant endpoints getTeamById and getOrganizationById returned every team and organization's full member roster with email addresses to any authenticated user who iterated the sequential ids - membership is now enforced, not just annotated. createTeam let any user mint an Organization or graft a child team onto a foreign one. An ADMIN could evict the OWNER and grant OWNER to a second account, unaccepted members could exercise admin rights, and the last OWNER could remove themselves, orphaning the team. inviteMember reported INVITED while persisting nothing; it now records a real verification token and answers with an opaque status for both branches. Team listings no longer materialize full member rosters just to count them, updateOrganization persists the lockEventTypeCreationForUsers flag it previously accepted and discarded, and authorization failures surface as 403/404 through ErrorWithCode instead of HTTP 500. --- .../organizations/OrganizationService.ts | 97 +++--- .../__tests__/OrganizationService.test.ts | 97 +++++- packages/features/teams/TeamService.ts | 180 +++++++--- .../teams/__tests__/TeamService.test.ts | 320 +++++++++++++++++- .../routers/viewer/organizations/_router.tsx | 4 +- .../server/routers/viewer/teams/_router.tsx | 4 +- 6 files changed, 599 insertions(+), 103 deletions(-) diff --git a/packages/features/organizations/OrganizationService.ts b/packages/features/organizations/OrganizationService.ts index 9d01bb8cc16..d5e6780344f 100644 --- a/packages/features/organizations/OrganizationService.ts +++ b/packages/features/organizations/OrganizationService.ts @@ -1,4 +1,4 @@ -import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; +import { ErrorWithCode } from "@calcom/lib/errors"; import slugify from "@calcom/lib/slugify"; import type { Prisma, PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; @@ -44,7 +44,6 @@ export class OrganizationService { }, select: { role: true, - accepted: true, team: { select: { id: true, @@ -54,31 +53,17 @@ export class OrganizationService { bio: true, isOrganization: true, metadata: true, + _count: { + select: { + members: true, + children: true, + }, + }, children: { select: { id: true, name: true, slug: true, - members: { - select: { - userId: true, - }, - }, - }, - }, - members: { - select: { - userId: true, - role: true, - user: { - select: { - id: true, - name: true, - username: true, - email: true, - avatarUrl: true, - }, - }, }, }, }, @@ -94,8 +79,8 @@ export class OrganizationService { return memberships.map((m) => ({ ...m.team, userRole: m.role, - memberCount: m.team.members.length, - teamsCount: m.team.children.length, + memberCount: m.team._count.members, + teamsCount: m.team._count.children, childTeams: m.team.children, })); } @@ -114,6 +99,10 @@ export class OrganizationService { select: { role: true, accepted: true }, }); + if (!membership?.accepted) { + throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this organization"); + } + const org = await this.db.team.findFirst({ where: { id: params.orgId, @@ -153,7 +142,7 @@ export class OrganizationService { }); if (!org) { - throw new Error(`Organization with ID ${params.orgId} not found`); + throw ErrorWithCode.Factory.NotFound(`Organization with ID ${params.orgId} not found`); } return { @@ -174,34 +163,54 @@ export class OrganizationService { teamId: input.orgId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); if ( - !membership || + !membership?.accepted || (membership.role !== MembershipRole.OWNER && membership.role !== MembershipRole.ADMIN) ) { - throw new Error("Unauthorized: Only Organization Owners or Admins can update organization settings."); + throw ErrorWithCode.Factory.Forbidden( + "Unauthorized: Only Organization Owners or Admins can update organization settings." + ); } - const data: Parameters[0]["data"] = {}; + const data: Prisma.TeamUpdateArgs["data"] = {}; if (input.name !== undefined) data.name = input.name; if (input.slug !== undefined) data.slug = slugify(input.slug); if (input.bio !== undefined) data.bio = input.bio; if (input.logoUrl !== undefined) data.logoUrl = input.logoUrl; if (input.metadata !== undefined) data.metadata = input.metadata; - const updated = await this.db.team.update({ - where: { id: input.orgId }, - data, - select: { - id: true, - name: true, - slug: true, - bio: true, - logoUrl: true, - metadata: true, - }, + const updated = await this.db.$transaction(async (tx) => { + const team = await tx.team.update({ + where: { id: input.orgId }, + data, + select: { + id: true, + name: true, + slug: true, + bio: true, + logoUrl: true, + metadata: true, + }, + }); + + if (input.lockEventTypeCreationForUsers !== undefined) { + await tx.organizationSettings.upsert({ + where: { organizationId: input.orgId }, + update: { lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers }, + create: { + organizationId: input.orgId, + // No real domain is known here; an empty accept-domain keeps domain-based + // auto-accept disabled until the organization configures one. + orgAutoAcceptEmail: "", + lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers, + }, + }); + } + + return team; }); return updated; @@ -218,14 +227,16 @@ export class OrganizationService { teamId: input.orgId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); if ( - !orgMembership || + !orgMembership?.accepted || (orgMembership.role !== MembershipRole.OWNER && orgMembership.role !== MembershipRole.ADMIN) ) { - throw new Error("Unauthorized: You must be an Organization Owner or Admin to create sub-teams."); + throw ErrorWithCode.Factory.Forbidden( + "Unauthorized: You must be an Organization Owner or Admin to create sub-teams." + ); } const baseSlug = input.slug ? slugify(input.slug) : slugify(input.name); diff --git a/packages/features/organizations/__tests__/OrganizationService.test.ts b/packages/features/organizations/__tests__/OrganizationService.test.ts index 1fb17f6944d..67659406bd0 100644 --- a/packages/features/organizations/__tests__/OrganizationService.test.ts +++ b/packages/features/organizations/__tests__/OrganizationService.test.ts @@ -1,3 +1,6 @@ +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import type { PrismaClient } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { OrganizationService } from "../OrganizationService"; @@ -15,6 +18,10 @@ const mockPrisma = { findMany: vi.fn(), findUnique: vi.fn(), }, + organizationSettings: { + upsert: vi.fn(), + }, + $transaction: vi.fn(), }; describe("OrganizationService", () => { @@ -22,21 +29,23 @@ describe("OrganizationService", () => { beforeEach(() => { vi.clearAllMocks(); - service = new OrganizationService(mockPrisma as any); + mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => + callback(mockPrisma) + ); + service = new OrganizationService(mockPrisma as unknown as PrismaClient); }); test("findUserOrganizations should return organizations where user belongs", async () => { mockPrisma.membership.findMany.mockResolvedValue([ { role: MembershipRole.OWNER, - accepted: true, team: { id: 100, name: "Crove Org", slug: "crove", isOrganization: true, - children: [{ id: 101, name: "Sales", slug: "sales", members: [{ userId: 1 }] }], - members: [{ userId: 1, role: MembershipRole.OWNER }], + _count: { members: 3, children: 1 }, + children: [{ id: 101, name: "Sales", slug: "sales" }], }, }, ]); @@ -44,13 +53,75 @@ describe("OrganizationService", () => { const result = await service.findUserOrganizations({ userId: 1 }); expect(result).toHaveLength(1); expect(result[0].name).toBe("Crove Org"); + expect(result[0].memberCount).toBe(3); expect(result[0].teamsCount).toBe(1); expect(result[0].userRole).toBe(MembershipRole.OWNER); + expect(result[0].childTeams).toEqual([{ id: 101, name: "Sales", slug: "sales" }]); + expect(result[0]).not.toHaveProperty("members"); + }); + + test("getOrganizationById should reject users without an accepted membership", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.MEMBER, + accepted: false, + }); + + const error = await service.getOrganizationById({ orgId: 100, userId: 2 }).catch((e) => e); + expect(error).toBeInstanceOf(ErrorWithCode); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.findFirst).not.toHaveBeenCalled(); + }); + + test("getOrganizationById should throw NotFound when the organization does not exist", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.OWNER, + accepted: true, + }); + mockPrisma.team.findFirst.mockResolvedValue(null); + + const error = await service.getOrganizationById({ orgId: 999, userId: 1 }).catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound); + }); + + test("updateOrganization should reject members without an accepted membership", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ role: MembershipRole.OWNER }); + + const error = await service + .updateOrganization({ orgId: 100, userId: 1, name: "New Org" }) + .catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + }); + + test("updateOrganization should persist lockEventTypeCreationForUsers in organization settings", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.OWNER, + accepted: true, + }); + mockPrisma.team.update.mockResolvedValue({ id: 100, name: "Crove Org" }); + mockPrisma.organizationSettings.upsert.mockResolvedValue({}); + + const result = await service.updateOrganization({ + orgId: 100, + userId: 1, + lockEventTypeCreationForUsers: true, + }); + + expect(result.id).toBe(100); + expect(mockPrisma.organizationSettings.upsert).toHaveBeenCalledWith({ + where: { organizationId: 100 }, + update: { lockEventTypeCreationForUsers: true }, + create: { + organizationId: 100, + orgAutoAcceptEmail: "", + lockEventTypeCreationForUsers: true, + }, + }); }); test("createTeamUnderOrg should allow org owner to create sub-team", async () => { mockPrisma.membership.findUnique.mockResolvedValue({ role: MembershipRole.OWNER, + accepted: true, }); mockPrisma.team.findFirst.mockResolvedValue(null); mockPrisma.team.create.mockResolvedValue({ @@ -77,4 +148,22 @@ describe("OrganizationService", () => { }) ); }); + + test("createTeamUnderOrg should reject unaccepted org members", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.ADMIN, + accepted: false, + }); + + const error = await service + .createTeamUnderOrg({ + orgId: 100, + userId: 2, + name: "New Sub Team", + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.create).not.toHaveBeenCalled(); + }); }); diff --git a/packages/features/teams/TeamService.ts b/packages/features/teams/TeamService.ts index f770087c770..593b39e2afa 100644 --- a/packages/features/teams/TeamService.ts +++ b/packages/features/teams/TeamService.ts @@ -1,8 +1,10 @@ +import { randomBytes } from "node:crypto"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; +import { ErrorWithCode } from "@calcom/lib/errors"; import slugify from "@calcom/lib/slugify"; import type { Prisma, PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; export interface CreateTeamInput { userId: number; @@ -72,7 +74,6 @@ export class TeamService { where, select: { role: true, - accepted: true, team: { select: { id: true, @@ -84,11 +85,9 @@ export class TeamService { isOrganization: true, parentId: true, metadata: true, - members: { + _count: { select: { - userId: true, - role: true, - accepted: true, + members: true, }, }, eventTypes: { @@ -113,8 +112,7 @@ export class TeamService { return memberships.map((m) => ({ ...m.team, role: m.role, - memberCount: m.team.members.length, - members: m.team.members, + memberCount: m.team._count.members, })); } @@ -132,6 +130,10 @@ export class TeamService { select: { role: true, accepted: true }, }); + if (!membership?.accepted) { + throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this team"); + } + const team = await this.db.team.findUnique({ where: { id: params.teamId }, include: { @@ -151,6 +153,7 @@ export class TeamService { }, }, eventTypes: { + where: { hidden: false }, select: { id: true, title: true, @@ -171,7 +174,7 @@ export class TeamService { }); if (!team) { - throw new Error(`Team with ID ${params.teamId} not found`); + throw ErrorWithCode.Factory.NotFound(`Team with ID ${params.teamId} not found`); } return { @@ -185,6 +188,38 @@ export class TeamService { * Create a new Team or Sub-Team */ async createTeam(input: CreateTeamInput) { + if (input.parentId) { + const parentMembership = await this.db.membership.findUnique({ + where: { + userId_teamId: { + userId: input.userId, + teamId: input.parentId, + }, + }, + select: { role: true, accepted: true }, + }); + + if ( + !parentMembership?.accepted || + (parentMembership.role !== MembershipRole.OWNER && parentMembership.role !== MembershipRole.ADMIN) + ) { + throw ErrorWithCode.Factory.Forbidden( + "You must be an Owner or Admin of the parent team to create a sub-team." + ); + } + } + + if (input.isOrganization) { + const user = await this.db.user.findUnique({ + where: { id: input.userId }, + select: { role: true }, + }); + + if (user?.role !== UserPermissionRole.ADMIN) { + throw ErrorWithCode.Factory.Forbidden("Only instance admins can create organizations."); + } + } + const baseSlug = input.slug ? slugify(input.slug) : slugify(input.name); let uniqueSlug = baseSlug; @@ -264,17 +299,19 @@ export class TeamService { teamId: input.teamId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); if ( - !membership || + !membership?.accepted || (membership.role !== MembershipRole.OWNER && membership.role !== MembershipRole.ADMIN) ) { - throw new Error("Unauthorized: Only Team Owners or Admins can update team settings."); + throw ErrorWithCode.Factory.Forbidden( + "Unauthorized: Only Team Owners or Admins can update team settings." + ); } - const data: Parameters[0]["data"] = {}; + const data: Prisma.TeamUpdateArgs["data"] = {}; if (input.name !== undefined) data.name = input.name; if (input.slug !== undefined) data.slug = slugify(input.slug); if (input.bio !== undefined) data.bio = input.bio; @@ -310,11 +347,11 @@ export class TeamService { teamId: params.teamId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); - if (!membership || membership.role !== MembershipRole.OWNER) { - throw new Error("Unauthorized: Only Team Owners can delete this team."); + if (!membership?.accepted || membership.role !== MembershipRole.OWNER) { + throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can delete this team."); } const deleted = await this.db.team.delete({ @@ -336,20 +373,27 @@ export class TeamService { teamId: input.teamId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); if ( - !callerMembership || + !callerMembership?.accepted || (callerMembership.role !== MembershipRole.OWNER && callerMembership.role !== MembershipRole.ADMIN) ) { - throw new Error("Unauthorized: Only Team Owners or Admins can invite new members."); + throw ErrorWithCode.Factory.Forbidden( + "Unauthorized: Only Team Owners or Admins can invite new members." + ); } - const targetUser = await this.db.user.findFirst({ - where: { - email: { equals: input.email, mode: "insensitive" }, - }, + // Only Owners may grant elevated roles; Admins always invite as MEMBER + const role = + input.role && callerMembership.role === MembershipRole.OWNER ? input.role : MembershipRole.MEMBER; + + // Emails are stored canonical-lowercase; use the unique index instead of an insensitive search + const email = input.email.toLowerCase().trim(); + + const targetUser = await this.db.user.findUnique({ + where: { email }, select: { id: true, username: true, email: true }, }); @@ -364,29 +408,31 @@ export class TeamService { }); if (existingMembership) { - throw new Error("User is already a member of this team."); + throw ErrorWithCode.Factory.BadRequest("User is already a member of this team."); } - const membership = await this.db.membership.create({ + await this.db.membership.create({ data: { userId: targetUser.id, teamId: input.teamId, - role: input.role || MembershipRole.MEMBER, + role, accepted: true, }, - select: { - role: true, - accepted: true, - user: { - select: { id: true, email: true, name: true }, - }, + }); + } else { + // New user: persist an invitation so it survives beyond this request + await this.db.verificationToken.create({ + data: { + identifier: email, + token: randomBytes(32).toString("hex"), + expires: new Date(Date.now() + 7 * 24 * 3600 * 1000), + teamId: input.teamId, }, }); - - return { status: "ADDED", membership }; } - return { status: "INVITED", email: input.email }; + // Both branches return the same opaque result so the endpoint cannot enumerate which emails have accounts + return { status: "INVITED" }; } /** @@ -400,11 +446,35 @@ export class TeamService { teamId: input.teamId, }, }, + select: { role: true, accepted: true }, + }); + + if (!callerMembership?.accepted || callerMembership.role !== MembershipRole.OWNER) { + throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can change member roles."); + } + + const targetMembership = await this.db.membership.findUnique({ + where: { + userId_teamId: { + userId: input.targetUserId, + teamId: input.teamId, + }, + }, select: { role: true }, }); - if (!callerMembership || callerMembership.role !== MembershipRole.OWNER) { - throw new Error("Unauthorized: Only Team Owners can change member roles."); + if (!targetMembership) { + throw ErrorWithCode.Factory.NotFound("Membership not found"); + } + + if (targetMembership.role === MembershipRole.OWNER && input.role !== MembershipRole.OWNER) { + const ownerCount = await this.db.membership.count({ + where: { teamId: input.teamId, role: MembershipRole.OWNER }, + }); + + if (ownerCount <= 1) { + throw ErrorWithCode.Factory.BadRequest("Cannot demote the last owner of this team."); + } } const updated = await this.db.membership.update({ @@ -431,6 +501,20 @@ export class TeamService { * Remove member from team */ async removeMember(input: RemoveMemberInput) { + const targetMembership = await this.db.membership.findUnique({ + where: { + userId_teamId: { + userId: input.targetUserId, + teamId: input.teamId, + }, + }, + select: { role: true }, + }); + + if (!targetMembership) { + throw ErrorWithCode.Factory.NotFound("Membership not found"); + } + const isSelf = input.userId === input.targetUserId; if (!isSelf) { @@ -441,14 +525,30 @@ export class TeamService { teamId: input.teamId, }, }, - select: { role: true }, + select: { role: true, accepted: true }, }); if ( - !callerMembership || + !callerMembership?.accepted || (callerMembership.role !== MembershipRole.OWNER && callerMembership.role !== MembershipRole.ADMIN) ) { - throw new Error("Unauthorized: Only Team Owners or Admins can remove other members."); + throw ErrorWithCode.Factory.Forbidden( + "Unauthorized: Only Team Owners or Admins can remove other members." + ); + } + + if (callerMembership.role === MembershipRole.ADMIN && targetMembership.role === MembershipRole.OWNER) { + throw ErrorWithCode.Factory.Forbidden("Unauthorized: Only Team Owners can remove an Owner."); + } + } + + if (targetMembership.role === MembershipRole.OWNER) { + const ownerCount = await this.db.membership.count({ + where: { teamId: input.teamId, role: MembershipRole.OWNER }, + }); + + if (ownerCount <= 1) { + throw ErrorWithCode.Factory.BadRequest("Cannot remove the last owner of this team."); } } diff --git a/packages/features/teams/__tests__/TeamService.test.ts b/packages/features/teams/__tests__/TeamService.test.ts index c4a6a2f360c..144ce5eaaf3 100644 --- a/packages/features/teams/__tests__/TeamService.test.ts +++ b/packages/features/teams/__tests__/TeamService.test.ts @@ -1,4 +1,7 @@ -import { MembershipRole } from "@calcom/prisma/enums"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import type { PrismaClient } from "@calcom/prisma"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { TeamService } from "../TeamService"; @@ -17,6 +20,7 @@ const mockPrisma = { create: vi.fn(), update: vi.fn(), delete: vi.fn(), + count: vi.fn(), }, user: { findUnique: vi.fn(), @@ -25,6 +29,9 @@ const mockPrisma = { profile: { upsert: vi.fn(), }, + verificationToken: { + create: vi.fn(), + }, }; describe("TeamService", () => { @@ -32,19 +39,18 @@ describe("TeamService", () => { beforeEach(() => { vi.clearAllMocks(); - service = new TeamService(mockPrisma as any); + service = new TeamService(mockPrisma as unknown as PrismaClient); }); test("findUserTeams should query user memberships and format teams list", async () => { mockPrisma.membership.findMany.mockResolvedValue([ { role: MembershipRole.OWNER, - accepted: true, team: { id: 1, name: "Engineering", slug: "eng", - members: [{ userId: 10, role: MembershipRole.OWNER, accepted: true }], + _count: { members: 1 }, eventTypes: [{ id: 101, title: "Sprint Planning", slug: "sprint", length: 30 }], }, }, @@ -55,6 +61,7 @@ describe("TeamService", () => { expect(result[0].name).toBe("Engineering"); expect(result[0].memberCount).toBe(1); expect(result[0].role).toBe(MembershipRole.OWNER); + expect(result[0]).not.toHaveProperty("members"); }); test("createTeam should create a team with OWNER membership", async () => { @@ -90,9 +97,87 @@ describe("TeamService", () => { ); }); + test("createTeam should reject sub-team creation without parent membership", async () => { + mockPrisma.membership.findUnique.mockResolvedValue(null); + + const error = await service.createTeam({ userId: 10, name: "Sub Team", parentId: 5 }).catch((e) => e); + expect(error).toBeInstanceOf(ErrorWithCode); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.create).not.toHaveBeenCalled(); + }); + + test("createTeam should reject sub-team creation for plain members of the parent team", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.MEMBER, + accepted: true, + }); + + const error = await service.createTeam({ userId: 10, name: "Sub Team", parentId: 5 }).catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + }); + + test("createTeam should allow sub-team creation for accepted parent admins", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.ADMIN, + accepted: true, + }); + mockPrisma.team.findFirst.mockResolvedValue(null); + mockPrisma.team.create.mockResolvedValue({ id: 3, name: "Sub", slug: "sub", parentId: 5 }); + + const result = await service.createTeam({ userId: 10, name: "Sub", parentId: 5 }); + expect(result.id).toBe(3); + }); + + test("createTeam should reject organization creation for non-admin users", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ role: UserPermissionRole.USER }); + + const error = await service.createTeam({ userId: 10, name: "Org", isOrganization: true }).catch((e) => e); + expect(error).toBeInstanceOf(ErrorWithCode); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.create).not.toHaveBeenCalled(); + }); + + test("createTeam should allow organization creation for instance admins", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + role: UserPermissionRole.ADMIN, + username: "admin", + email: "admin@crove.com", + }); + mockPrisma.team.findFirst.mockResolvedValue(null); + mockPrisma.team.create.mockResolvedValue({ id: 4, name: "Org", slug: "org", isOrganization: true }); + mockPrisma.profile.upsert.mockResolvedValue({}); + + const result = await service.createTeam({ userId: 10, name: "Org", isOrganization: true }); + expect(result.id).toBe(4); + }); + + test("getTeamById should reject users without an accepted membership before fetching the team", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.MEMBER, + accepted: false, + }); + + const error = await service.getTeamById({ teamId: 1, userId: 20 }).catch((e) => e); + expect(error).toBeInstanceOf(ErrorWithCode); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.findUnique).not.toHaveBeenCalled(); + }); + + test("getTeamById should throw NotFound when the team does not exist", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.MEMBER, + accepted: true, + }); + mockPrisma.team.findUnique.mockResolvedValue(null); + + const error = await service.getTeamById({ teamId: 999, userId: 10 }).catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound); + }); + test("updateTeam should reject non-owner/admin updates", async () => { mockPrisma.membership.findUnique.mockResolvedValue({ role: MembershipRole.MEMBER, + accepted: true, }); await expect( @@ -104,9 +189,20 @@ describe("TeamService", () => { ).rejects.toThrow("Unauthorized"); }); + test("updateTeam should reject unaccepted members even if they are owners", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.OWNER, + accepted: false, + }); + + const error = await service.updateTeam({ teamId: 1, userId: 10, name: "New Name" }).catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + }); + test("updateTeam should allow owner to update settings", async () => { mockPrisma.membership.findUnique.mockResolvedValue({ role: MembershipRole.OWNER, + accepted: true, }); mockPrisma.team.update.mockResolvedValue({ id: 1, @@ -123,37 +219,186 @@ describe("TeamService", () => { expect(result.name).toBe("Engineering Updated"); }); - test("inviteMember should add existing user directly with membership", async () => { + test("deleteTeam should reject non-owner members", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.ADMIN, + accepted: true, + }); + + const error = await service.deleteTeam({ teamId: 1, userId: 20 }).catch((e) => e); + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.team.delete).not.toHaveBeenCalled(); + }); + + test("deleteTeam should allow an accepted owner to delete the team", async () => { + mockPrisma.membership.findUnique.mockResolvedValue({ + role: MembershipRole.OWNER, + accepted: true, + }); + mockPrisma.team.delete.mockResolvedValue({ id: 1, name: "Eng", slug: "eng" }); + + const result = await service.deleteTeam({ teamId: 1, userId: 10 }); + expect(result.id).toBe(1); + }); + + test("inviteMember should add an existing user and return an opaque INVITED result", async () => { mockPrisma.membership.findUnique - .mockResolvedValueOnce({ role: MembershipRole.OWNER }) // caller check + .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) // caller check .mockResolvedValueOnce(null); // target membership check - mockPrisma.user.findFirst.mockResolvedValue({ + mockPrisma.user.findUnique.mockResolvedValue({ id: 30, email: "colleague@crove.com", username: "colleague", }); - mockPrisma.membership.create.mockResolvedValue({ + mockPrisma.membership.create.mockResolvedValue({}); + + const result = await service.inviteMember({ + teamId: 1, + userId: 10, + email: "colleague@crove.com", role: MembershipRole.MEMBER, + }); + + expect(result).toEqual({ status: "INVITED" }); + expect(result).not.toHaveProperty("email"); + expect(mockPrisma.membership.create).toHaveBeenCalledWith({ + data: { + userId: 30, + teamId: 1, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + expect(mockPrisma.verificationToken.create).not.toHaveBeenCalled(); + }); + + test("inviteMember should clamp the requested role to MEMBER for admin callers", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.ADMIN, accepted: true }) + .mockResolvedValueOnce(null); + + mockPrisma.user.findUnique.mockResolvedValue({ id: 30, email: "colleague@crove.com" }); + mockPrisma.membership.create.mockResolvedValue({}); + + await service.inviteMember({ + teamId: 1, + userId: 10, + email: "colleague@crove.com", + role: MembershipRole.OWNER, + }); + + expect(mockPrisma.membership.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + role: MembershipRole.MEMBER, + }), + }); + }); + + test("inviteMember should persist a verification token invitation for new users", async () => { + mockPrisma.membership.findUnique.mockResolvedValueOnce({ + role: MembershipRole.OWNER, accepted: true, - user: { id: 30, email: "colleague@crove.com" }, }); + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.verificationToken.create.mockResolvedValue({}); const result = await service.inviteMember({ teamId: 1, userId: 10, - email: "colleague@crove.com", - role: MembershipRole.MEMBER, + email: "NewUser@Crove.com", }); - expect(result.status).toBe("ADDED"); + expect(result).toEqual({ status: "INVITED" }); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: "newuser@crove.com" }, + select: { id: true, username: true, email: true }, + }); + expect(mockPrisma.verificationToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + identifier: "newuser@crove.com", + teamId: 1, + }), + }); + expect(mockPrisma.membership.create).not.toHaveBeenCalled(); }); - test("removeMember should allow owner to remove team member", async () => { + test("inviteMember should reject users that are already members", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) + .mockResolvedValueOnce({ role: MembershipRole.MEMBER, accepted: true }); + + mockPrisma.user.findUnique.mockResolvedValue({ id: 30, email: "colleague@crove.com" }); + + const error = await service + .inviteMember({ + teamId: 1, + userId: 10, + email: "colleague@crove.com", + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest); + }); + + test("inviteMember should reject callers without an accepted membership", async () => { mockPrisma.membership.findUnique.mockResolvedValue({ - role: MembershipRole.OWNER, + role: MembershipRole.MEMBER, + accepted: false, }); + + const error = await service + .inviteMember({ + teamId: 1, + userId: 20, + email: "someone@crove.com", + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + }); + + test("changeMemberRole should reject demoting the last owner", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) // caller + .mockResolvedValueOnce({ role: MembershipRole.OWNER }); // target + mockPrisma.membership.count.mockResolvedValue(1); + + const error = await service + .changeMemberRole({ + teamId: 1, + userId: 10, + targetUserId: 10, + role: MembershipRole.MEMBER, + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest); + expect(mockPrisma.membership.update).not.toHaveBeenCalled(); + }); + + test("changeMemberRole should throw NotFound for a missing target membership", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }) + .mockResolvedValueOnce(null); + + const error = await service + .changeMemberRole({ + teamId: 1, + userId: 10, + targetUserId: 99, + role: MembershipRole.MEMBER, + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound); + }); + + test("removeMember should allow owner to remove team member", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.MEMBER }) // target + .mockResolvedValueOnce({ role: MembershipRole.OWNER, accepted: true }); // caller mockPrisma.membership.delete.mockResolvedValue({}); const result = await service.removeMember({ @@ -172,4 +417,51 @@ describe("TeamService", () => { }, }); }); + + test("removeMember should prevent admins from removing an owner", async () => { + mockPrisma.membership.findUnique + .mockResolvedValueOnce({ role: MembershipRole.OWNER }) // target + .mockResolvedValueOnce({ role: MembershipRole.ADMIN, accepted: true }); // caller + + const error = await service + .removeMember({ + teamId: 1, + userId: 20, + targetUserId: 10, + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.Forbidden); + expect(mockPrisma.membership.delete).not.toHaveBeenCalled(); + }); + + test("removeMember should prevent removing the last owner", async () => { + mockPrisma.membership.findUnique.mockResolvedValueOnce({ role: MembershipRole.OWNER }); + mockPrisma.membership.count.mockResolvedValue(1); + + const error = await service + .removeMember({ + teamId: 1, + userId: 10, + targetUserId: 10, + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.BadRequest); + expect(mockPrisma.membership.delete).not.toHaveBeenCalled(); + }); + + test("removeMember should throw NotFound when the target membership does not exist", async () => { + mockPrisma.membership.findUnique.mockResolvedValueOnce(null); + + const error = await service + .removeMember({ + teamId: 1, + userId: 10, + targetUserId: 99, + }) + .catch((e) => e); + + expect((error as ErrorWithCode).code).toBe(ErrorCode.NotFound); + }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 78fcc5419c2..b2fe8a46068 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,4 +1,5 @@ import { OrganizationService } from "@calcom/features/organizations/OrganizationService"; +import type { Prisma } from "@calcom/prisma"; import { z } from "zod"; import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; @@ -49,7 +50,8 @@ export const viewerOrganizationsRouter = router({ slug: input.slug, bio: input.bio, logoUrl: input.logoUrl, - metadata: input.metadata as any, + // z.record(z.unknown()) values are opaque to TS; Prisma only accepts JSON-serializable values here + metadata: input.metadata as unknown as Prisma.InputJsonValue, lockEventTypeCreationForUsers: input.lockEventTypeCreationForUsers, }); }), diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 4f3d387f1d5..23b526aefff 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -1,4 +1,5 @@ import { TeamService } from "@calcom/features/teams/TeamService"; +import type { Prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { z } from "zod"; import authedProcedure from "../../../procedures/authedProcedure"; @@ -101,7 +102,8 @@ export const viewerTeamsRouter = router({ bio: input.bio, logoUrl: input.logoUrl, hideBookATeamMember: input.hideBookATeamMember, - metadata: input.metadata as any, + // z.record(z.unknown()) values are opaque to TS; Prisma only accepts JSON-serializable values here + metadata: input.metadata as unknown as Prisma.InputJsonValue, }); }), From 7dbe386a56d380ad7b95838edae75ce11df099a8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:11:35 +0000 Subject: [PATCH 06/30] fix(mcp-server): remove unsafe ownership fallbacks and enable type checking create_event_type silently attributed new event types to whichever user happened to have the lowest id when the username did not resolve, and list_schedules built a Prisma OR filter containing an empty object, which matches every row, so a username-only lookup returned another tenant's schedule; both now fail loudly instead of guessing. The package previously had no type-check script at all, so turbo skipped it entirely - adding one surfaced six latent Prisma typing errors that are fixed here as well. --- packages/mcp-server/package.json | 1 + packages/mcp-server/src/tools/bookings.ts | 5 +++-- packages/mcp-server/src/tools/eventTypes.ts | 15 ++++++--------- packages/mcp-server/src/tools/slots.ts | 3 ++- packages/mcp-server/src/tools/users.ts | 18 +++++++++++------- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 9258557bd41..6ac55e9d7ff 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -9,6 +9,7 @@ }, "scripts": { "start": "ts-node --transpile-only src/index.ts", + "type-check": "tsc --pretty --noEmit", "lint": "biome lint .", "lint:fix": "biome lint --write ." }, diff --git a/packages/mcp-server/src/tools/bookings.ts b/packages/mcp-server/src/tools/bookings.ts index 9aeba714b0c..caf397cada0 100644 --- a/packages/mcp-server/src/tools/bookings.ts +++ b/packages/mcp-server/src/tools/bookings.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; export interface CreateBookingInput { eventTypeId: number; @@ -93,7 +94,7 @@ export async function getBookingHandler(prisma: PrismaClient, input: GetBookingI throw new Error("Either bookingUid or bookingId must be provided"); } - const where: Parameters[0]["where"] = {}; + const where: Prisma.BookingWhereInput = {}; if (input.bookingUid) { where.uid = input.bookingUid; } else if (input.bookingId) { @@ -242,7 +243,7 @@ export interface ListBookingsInput { } export async function listBookingsHandler(prisma: PrismaClient, input: ListBookingsInput) { - const where: Parameters[0]["where"] = {}; + const where: Prisma.BookingWhereInput = {}; if (input.status) { where.status = input.status; diff --git a/packages/mcp-server/src/tools/eventTypes.ts b/packages/mcp-server/src/tools/eventTypes.ts index 8b84d25fbd1..863bdb53b34 100644 --- a/packages/mcp-server/src/tools/eventTypes.ts +++ b/packages/mcp-server/src/tools/eventTypes.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; export interface ListEventTypesInput { username?: string; @@ -8,7 +9,7 @@ export interface ListEventTypesInput { } export async function listEventTypesHandler(prisma: PrismaClient, input: ListEventTypesInput) { - const where: Parameters[0]["where"] = { + const where: Prisma.EventTypeWhereInput = { hidden: false, }; @@ -73,7 +74,7 @@ export async function getEventTypeDetailsHandler(prisma: PrismaClient, input: Ge throw new Error("Either eventTypeId or slug must be provided"); } - const where: Parameters[0]["where"] = {}; + const where: Prisma.EventTypeWhereInput = {}; if (input.eventTypeId) { where.id = input.eventTypeId; @@ -149,13 +150,9 @@ export async function createEventTypeHandler(prisma: PrismaClient, input: Create } if (!targetUserId) { - // Default to the first available user - const firstUser = await prisma.user.findFirst({ select: { id: true } }); - if (firstUser) targetUserId = firstUser.id; - } - - if (!targetUserId) { - throw new Error("User ID is required to create an event type"); + throw new Error( + "User ID is required to create an event type: provide userId or a username that resolves to an existing user" + ); } const newEventType = await prisma.eventType.create({ diff --git a/packages/mcp-server/src/tools/slots.ts b/packages/mcp-server/src/tools/slots.ts index ab3381c6d92..868811a816e 100644 --- a/packages/mcp-server/src/tools/slots.ts +++ b/packages/mcp-server/src/tools/slots.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; export interface GetAvailableSlotsInput { eventTypeId?: number; @@ -18,7 +19,7 @@ export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetA throw new Error("Either eventTypeId or slug must be provided"); } - const where: Parameters[0]["where"] = {}; + const where: Prisma.EventTypeWhereInput = {}; if (input.eventTypeId) { where.id = input.eventTypeId; } else if (input.slug) { diff --git a/packages/mcp-server/src/tools/users.ts b/packages/mcp-server/src/tools/users.ts index 3ef6a7cfe57..b4107d81f68 100644 --- a/packages/mcp-server/src/tools/users.ts +++ b/packages/mcp-server/src/tools/users.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; export interface GetUserProfileInput { email?: string; @@ -11,7 +12,7 @@ export async function getUserProfileHandler(prisma: PrismaClient, input: GetUser throw new Error("Either email, username, or userId must be provided"); } - const where: Parameters[0]["where"] = {}; + const where: Prisma.UserWhereInput = {}; if (input.userId) { where.id = input.userId; @@ -77,13 +78,16 @@ export async function listSchedulesHandler(prisma: PrismaClient, input: ListSche if (!targetUserId) { if (input.email || input.username) { + // An empty object inside OR matches every row, so only push clauses that are actually defined + const identityClauses: Prisma.UserWhereInput[] = []; + if (input.email) { + identityClauses.push({ email: { equals: input.email, mode: "insensitive" } }); + } + if (input.username) { + identityClauses.push({ username: input.username }); + } const user = await prisma.user.findFirst({ - where: { - OR: [ - input.email ? { email: { equals: input.email, mode: "insensitive" } } : {}, - input.username ? { username: input.username } : {}, - ], - }, + where: { OR: identityClauses }, select: { id: true }, }); if (user) { From 45a4a5afd48bdc236bf917b4826abc4672873b9d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:12:13 +0000 Subject: [PATCH 07/30] fix(upstream-sync): restore platform-types permission type and drop user-blind event-type cache permissions.ts had been turned into a transitive re-export of PLATFORM_PERMISSION, which only resolves once platform-constants is built; combined with turbo running every post-install build in parallel it intermittently failed yarn install with TS2305 in platform-utils. Restoring the upstream declaration removes the divergence and the race, and post-install now depends on ^post-install so builds are ordered. The event type edit page cached its tRPC result under a key derived from headers and cookies objects that serialize to constants, so every user shared one entry for an hour and the per-user authorization inside the router never ran; it now calls the router directly, matching the fix already applied to its sibling pages. --- .../event-types/[type]/page.tsx | 15 +++------------ packages/platform/types/permissions.ts | 4 +++- turbo.json | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx index abb4d2f3d0d..3cf0192c9cf 100644 --- a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx @@ -3,9 +3,8 @@ import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/ import { EventTypeWebWrapper } from "@calcom/web/modules/event-types/components/EventTypeWebWrapper"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { createRouterCaller, getTRPCContext } from "app/_trpc/context"; -import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types"; +import type { PageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; -import { unstable_cache } from "next/cache"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { z } from "zod"; @@ -29,15 +28,6 @@ export const generateMetadata = async () => { ); }; -const getCachedEventType = unstable_cache( - async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => { - const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies)); - return await caller.get({ id: eventTypeId }); - }, - ["viewer.eventTypes.get"], - { revalidate: 3600 } // Cache for 1 hour -); - const ServerPage = async ({ params }: PageProps) => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); if (!session?.user?.id) { @@ -52,7 +42,8 @@ const ServerPage = async ({ params }: PageProps) => { const _headers = await headers(); const _cookies = await cookies(); - const data = await getCachedEventType(eventTypeId, _headers, _cookies); + const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(_headers, _cookies)); + const data = await caller.get({ id: eventTypeId }); if (!data?.eventType) { throw new Error("This event type does not exist"); } diff --git a/packages/platform/types/permissions.ts b/packages/platform/types/permissions.ts index 2dc6f3622a9..fe19125b4e1 100644 --- a/packages/platform/types/permissions.ts +++ b/packages/platform/types/permissions.ts @@ -1 +1,3 @@ -export type { PLATFORM_PERMISSION } from "@calcom/platform-constants"; +import type { PERMISSIONS } from "@calcom/platform-constants"; + +export type PLATFORM_PERMISSION = (typeof PERMISSIONS)[number]; diff --git a/turbo.json b/turbo.json index 20d01d5ad90..78d1a1131ff 100644 --- a/turbo.json +++ b/turbo.json @@ -530,7 +530,7 @@ }, "post-install": { "cache": false, - "dependsOn": [], + "dependsOn": ["^post-install"], "outputs": ["dist/**", "../../node_modules/@prisma/client/**", "../../node_modules/@prisma/admin-client/**"], "inputs": ["./schema.prisma", "./prisma/schema.prisma"], "env": ["PRISMA_GENERATE_DATAPROXY"] From 47a48044989841e54bbcd218d8a79cf2f2b48a7b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:12:51 +0000 Subject: [PATCH 08/30] fix(i18n): add missing teams listing strings The teams view referenced four translation keys that existed in no catalog, so the primary button rendered the literal text create_a_team and the empty state rendered no_teams_yet. --- packages/i18n/locales/en/common.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 08b2111f733..3ad1a3d99a4 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1087,6 +1087,10 @@ "dark_event_type_color": "Event type color (dark theme)", "file_not_named": "File is not named [idOrSlug]/[user]", "create_team": "Create team", + "create_a_team": "Create a team", + "no_teams_yet": "No teams yet", + "create_team_description": "Create a team to collaborate with others and share event types.", + "team_created_successfully": "Team {{teamName}} created successfully", "name": "Name", "nameless_team": "Nameless Team", "oauth_clients": "OAuth Clients", From 4adef44a0c967a91ce08dfb8a8d28a3735a03db0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:12:58 +0000 Subject: [PATCH 09/30] docs(audit): add full-repo audit report with prioritized findings 76 findings from six parallel audit tracks, deduplicated, each with an ID, severity, file locations, origin (fork vs upstream), failure scenario and remediation, plus a prioritized action plan and the areas verified clean. No secret values are included. --- docs/audit/2026-09-08-audit-report.html | 469 ++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 docs/audit/2026-09-08-audit-report.html diff --git a/docs/audit/2026-09-08-audit-report.html b/docs/audit/2026-09-08-audit-report.html new file mode 100644 index 00000000000..450db25e2e9 --- /dev/null +++ b/docs/audit/2026-09-08-audit-report.html @@ -0,0 +1,469 @@ + + + + + +Crove-Cal — Báo cáo Audit toàn bộ Repo — 2026-09-08 + + + +
+

Crove-Cal — Báo cáo Audit toàn bộ Repo

+ Ngày: 2026-09-08  ·  Commit: 7782de6b7a (dev)  ·  Phạm vi: ~115 packages (upstream + 73 commit fork)  ·  Phương pháp: 6 hướng audit song song, chỉ đọc  ·  +
+ +
+
+ Cách đọc: mỗi phát hiện có ID (CR/HI/MD/LO-##), Priority (mức độ rủi ro), Action order (thứ tự khắc phục khuyến nghị — mục "Kế hoạch khắc phục"). Cột Verified = đã được xác minh độc lập bằng đọc code trực tiếp (không phải chỉ kết quả agent). + Không có giá trị secret nào được in trong file này — mọi secret chỉ được tham chiếu theo file:line. +
+ +

Tổng quan

+
+
0
Critical
+
0
High
+
0
Medium
+
0
Low
+
0
Đã verify độc lập
+
+ + + +
NhómCriticalHighMediumLow
+ +

Kế hoạch khắc phục (thứ tự khuyến nghị)

+
    +
  1. Rotate OIDC client secret trên Supabase dashboard NGAY — việc duy nhất thực sự khắc phục C-01; secret nằm vĩnh viễn trong git history của repo public. Xoá code / rewrite history không cứu được. + Liên quan: CR-01
  2. +
  3. Bỏ mọi secret fallback (OIDC, video-token) + fail startup khi thiếu env + gate đăng ký DosIdProvider theo cấu hình thật + đổi emailVerified ?? true. + CR-01, HI-22, HI-01
  4. +
  5. Tạo migration tái tạo bảng Workflow (hiện /workflows là trang 500 trên DB đã deploy). + CR-03
  6. +
  7. Sửa authorization Workflows: membership check bắt buộc, validate activeOn, bọc update trong transaction. + CR-02, HI-15
  8. +
  9. HMAC fail-closed + zod cap + rate limit cho webhook Brevo / Crove CRM; báo success đúng thực tế. + CR-04, HI-18
  10. +
  11. Bỏ unstable_cache sai key ở /event-types/[type] + bỏ hardcode permissions. + CR-05
  12. +
  13. Fix TS2305: restore permissions.ts về nội dung upstream + turbo.json post-install.dependsOn: ["^post-install"]. + CR-06
  14. +
  15. Khôi phục TLS verification cho Postgres (CA Supabase, opt-in env) + sửa connection leak api/v2. + HI-08
  16. +
  17. Bump dependencies: resolutions pins lên bản vá, next-auth 4.24.15, tar 7.5.21, websocket-driver 0.7.5, next 16.2.11; thêm dependabot.yml. + CR-07, HI-11
  18. +
  19. Khôi phục CI của fork (type-check/lint/tests/security) + gate deploy-docker sau CI + kích hoạt lại cron + hardening Docker (USER, .dockerignore, bỏ ARG =secret). + HI-09, HI-10, HI-12, HI-20, CR-08
  20. +
  21. dos-org-sync: dedicated secret, replay window ±300s, dedupe delivery id, scope team.deleted theo org. + HI-01
  22. +
  23. Gate teams.get/organizations.get theo membership; guard viewer.teams.create; sửa privilege-escalation ADMIN/OWNER; role check đọc accepted; invite thật thay vì no-op. + HI-04, HI-05, HI-06
  24. +
  25. MCP server: principal + tenant scoping mọi where; route booking qua pipeline; sửa fromReschedule; bỏ fallback "first available user"; thêm type-check script. + HI-02, HI-03, HI-20
  26. +
  27. Giảm diverge upstream: revert branding fallback trong constants.ts + fix hoặc xoá patch:branding; revert 61 migrations, chuyển sang search_path ở role; sửa JWT update callback (không nhận email từ client). + HI-16, HI-13, HI-07
  28. +
  29. Vệ sinh: 13 bare throw → ErrorWithCode; thêm i18n keys + bọc ~200 string hardcode; as any metadata; barrels chết; duplicate hitpay; confirmation dialog khi xoá workflow. + HI-17, HI-19, MD-23, LO-01, LO-02, MD-22
  30. +
+ +

Vùng đã kiểm tra và sạch (không cần re-audit)

+
+
credential.key không lộ ra client — quét toàn bộ select/tRPC/serializer; safeCredentialSelect có comment đúng mục đích. Over-fetch duy nhất trong bookingsProcedure middleware không tới response (MD-34).
+
Không SQL injection — 38 site $queryRaw toàn tagged-template parameterized; zero $queryRawUnsafe/$executeRawUnsafe.
+
Không barrel-import violation trong code fork; booking flow upstream không bị fork đụng (packages/features/bookings không đổi).
+
OAuth client management (platform) đúng — secret SHA-256, redirect URI so exact, PKCE S256 bắt buộc cho PUBLIC client, code 40 random bytes.
+
authorizeCredentials + deleteMe sound — lock check, băm email rate-limit, backup codes consumed, ADMIN thiếu 2FA bị downgrade.
+
dos-org-sync verification logic đúng (timingSafeEqual, fail-closed, chạy trước JSON.parse) — lỗi nằm ở key selection (HI-01).
+
Generated files đúng app-store-cli output (8 thêm / 0 xoá, đúng thứ tự alphabet); api/v2 import rule sạch cho code fork.
+
Placement đúng chuẩn — business logic trong Service (không Repository), permission check trong page.tsx (không layout.tsx), không dayjs misuse trong code fork.
+
TS6 decorators an toàn — useDefineForClassFields được set đúng nơi cần; NestJS DTO emission còn nguyên.
+
Test breadth tốt hơn kỳ vọng — mọi feature fork đều có test; WorkflowService.test assert timestamp tính thật (không mock-only).
+
+ +

Danh sách phát hiện

+
+ + + + + + + + +
+
+
+ +
+ Tạo bởi 6 audit agent song song + verify độc lập thủ công · Crove-Cal (fork của calcom/cal.diy) · 2026-09-08 · Chỉ báo cáo, không thay đổi code. +
+ + + + From d8eba1af5bd98138bab1e686159df652b713f3f6 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:32:00 +0000 Subject: [PATCH 10/30] =?UTF-8?q?fix(security):=20resolve=20dependabot=20a?= =?UTF-8?q?lerts=20=E2=80=94=20bump=20axios/hono/vite/protobufjs/xmldom/br?= =?UTF-8?q?ace-expansion/tar/next/next-auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/dependabot.yml | 15 + apps/api/v2/package.json | 2 +- apps/web/package.json | 4 +- example-apps/credential-sync/package.json | 2 +- package.json | 27 +- packages/features/auth/package.json | 2 +- packages/platform/examples/base/package.json | 2 +- yarn.lock | 380 +++++++++++++------ 8 files changed, 302 insertions(+), 132 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..68de386f6c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + patch-and-minor: + update-types: + - "patch" + - "minor" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 3a052c18402..7f3d8f181c8 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -62,7 +62,7 @@ "@sentry/node": "9.46.0", "@sentry/profiling-node": "9.46.0", "@snyk/protect": "latest", - "axios": "1.15.0", + "axios": "1.16.0", "body-parser": "1.20.3", "bull": "4.15.1", "class-transformer": "0.5.1", diff --git a/apps/web/package.json b/apps/web/package.json index fb53a96aba9..1f7da48a8f5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -107,8 +107,8 @@ "md5": "2.3.0", "memory-cache": "0.2.0", "micro": "10.0.1", - "next": "16.2.3", - "next-auth": "4.24.13", + "next": "16.2.11", + "next-auth": "4.24.15", "next-axiom": "0.17.0", "next-i18next": "15.4.2", "next-seo": "6.1.0", diff --git a/example-apps/credential-sync/package.json b/example-apps/credential-sync/package.json index b7f78cab886..a784cfce53d 100644 --- a/example-apps/credential-sync/package.json +++ b/example-apps/credential-sync/package.json @@ -10,7 +10,7 @@ "dependencies": { "@calcom/atoms": "workspace:*", "@prisma/client": "6.16.1", - "next": "15.5.15", + "next": "15.5.21", "prisma": "6.16.1", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/package.json b/package.json index d629d12c6ab..fbc96270259 100644 --- a/package.json +++ b/package.json @@ -146,9 +146,9 @@ "jpeg-js": "0.4.4", "validator": "13.15.22", "form-data": "4.0.4", - "axios": "1.15.0", + "axios": "1.16.0", "follow-redirects": "1.16.0", - "protobufjs": "7.5.5", + "protobufjs": "7.5.6", "shell-quote": "1.8.4", "jws": "4.0.1", "jsonwebtoken": "9.0.0", @@ -164,7 +164,7 @@ "picomatch@^4.0.2": "4.0.4", "picomatch@^4.0.3": "4.0.4", "@modelcontextprotocol/sdk": "1.26.0", - "hono": "4.12.12", + "hono": "4.12.25", "express-rate-limit": "8.2.2", "svgo": "4.0.1", "js-yaml": "4.1.1", @@ -176,7 +176,7 @@ "@adobe/css-tools": "4.3.2", "jsondiffpatch": "0.7.2", "min-document": "2.19.1", - "tar": "7.5.11", + "tar": "7.5.21", "lodash": "4.18.1", "lodash-es": "4.18.1", "@lingo.dev/_compiler/fast-xml-parser": "5.5.9", @@ -186,15 +186,16 @@ "multer": "2.1.1", "flatted": "3.4.2", "socket.io-parser": "4.2.6", - "vite": "6.4.2", + "vite": "6.4.3", + "websocket-driver": "0.7.5", "defu": "6.1.5", "immutable": "3.8.3", "@hono/node-server": "1.19.13", - "@xmldom/xmldom@0.9.8": "0.9.9", - "@xmldom/xmldom@^0.8.1": "0.8.12", - "@xmldom/xmldom@^0.8.5": "0.8.12", - "@xmldom/xmldom@^0.8.8": "0.8.12", - "@xmldom/xmldom@^0.8.10": "0.8.12", + "@xmldom/xmldom@0.9.8": "0.9.10", + "@xmldom/xmldom@^0.8.1": "0.8.13", + "@xmldom/xmldom@^0.8.5": "0.8.13", + "@xmldom/xmldom@^0.8.8": "0.8.13", + "@xmldom/xmldom@^0.8.10": "0.8.13", "yaml@2.8.1": "2.8.3", "yaml@^2.0.0": "2.8.3", "yaml@^2.1.1": "2.8.3", @@ -209,9 +210,9 @@ "ajv@^8.6.3": "8.18.0", "ajv@^8.17.1": "8.18.0", "ajv@^6.12.5": "6.14.0", - "brace-expansion@^5.0.2": "5.0.5", - "brace-expansion@^2.0.1": "2.0.3", - "brace-expansion@^2.0.2": "2.0.3", + "brace-expansion@^5.0.2": "5.0.9", + "brace-expansion@^2.0.1": "2.1.4", + "brace-expansion@^2.0.2": "2.1.4", "i18next-fs-backend": "^2.6.6" }, "packageExtensions": { diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index f97fb9d8ef4..2a7e7e2c58c 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -18,7 +18,7 @@ "handlebars": "4.7.9", "jose": "4.15.9", "lru-cache": "9.0.3", - "next-auth": "4.24.13", + "next-auth": "4.24.15", "nodemailer": "7.0.12", "otplib": "12.0.1" } diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json index dc9b8747445..c4b8f623c2a 100644 --- a/packages/platform/examples/base/package.json +++ b/packages/platform/examples/base/package.json @@ -13,7 +13,7 @@ "dependencies": { "@calcom/atoms": "workspace:*", "@prisma/client": "6.16.1", - "next": "16.2.3", + "next": "16.2.11", "prisma": "6.16.1", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/yarn.lock b/yarn.lock index c90c73c9613..78124226c45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1994,7 +1994,7 @@ __metadata: "@types/luxon": "npm:3.4.2" "@types/passport-jwt": "npm:3.0.13" "@types/supertest": "npm:2.0.16" - axios: "npm:1.15.0" + axios: "npm:1.16.0" body-parser: "npm:1.20.3" bull: "npm:4.15.1" class-transformer: "npm:0.5.1" @@ -2158,7 +2158,7 @@ __metadata: "@types/react": "npm:18.0.26" "@types/react-dom": "npm:18.2.6" dotenv: "npm:17.2.2" - next: "npm:16.2.3" + next: "npm:16.2.11" postcss: "npm:8.5.6" prisma: "npm:6.16.1" react: "npm:18.2.0" @@ -2420,7 +2420,7 @@ __metadata: "@types/react-dom": "npm:18.2.6" autoprefixer: "npm:10.4.19" dotenv: "npm:16.6.1" - next: "npm:15.5.15" + next: "npm:15.5.21" postcss: "npm:8.5.6" prisma: "npm:6.16.1" react: "npm:18.2.0" @@ -2516,7 +2516,7 @@ __metadata: handlebars: "npm:4.7.9" jose: "npm:4.15.9" lru-cache: "npm:9.0.3" - next-auth: "npm:4.24.13" + next-auth: "npm:4.24.15" nodemailer: "npm:7.0.12" otplib: "npm:12.0.1" languageName: unknown @@ -3447,8 +3447,8 @@ __metadata: memory-cache: "npm:0.2.0" micro: "npm:10.0.1" module-alias: "npm:2.2.2" - next: "npm:16.2.3" - next-auth: "npm:4.24.13" + next: "npm:16.2.11" + next-auth: "npm:4.24.15" next-axiom: "npm:0.17.0" next-i18next: "npm:15.4.2" next-seo: "npm:6.1.0" @@ -8474,10 +8474,17 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:16.2.3": - version: 16.2.3 - resolution: "@next/env@npm:16.2.3" - checksum: 10/30ed128d8ffae47e58732ee134b78da36e2d6942da7479ec5e640d205b7822224daf2f07d7a69352dc362908eb260fc9fa7eaba1ce5e6311abeacc6ffb0fe90a +"@next/env@npm:15.5.21": + version: 15.5.21 + resolution: "@next/env@npm:15.5.21" + checksum: 10/cac7c51fd255dbd79f32f8dd9895573207ed273e94ce92087cfa0283b0ad3a9685741e4ed934d76161914cc6259a908fd6e439cc6a9aeb8d67090f973c949d3b + languageName: node + linkType: hard + +"@next/env@npm:16.2.11": + version: 16.2.11 + resolution: "@next/env@npm:16.2.11" + checksum: 10/57423578764a3672fc135c22f9e84639a2a2290069fdf8552f04163be640fd4eddfd3f6678df63bde8d524371990e9f2ab311139aec2e94dc1ade16c9c81958c languageName: node linkType: hard @@ -8488,9 +8495,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-darwin-arm64@npm:16.2.3" +"@next/swc-darwin-arm64@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-darwin-arm64@npm:15.5.21" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-darwin-arm64@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-darwin-arm64@npm:16.2.11" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -8502,9 +8516,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-darwin-x64@npm:16.2.3" +"@next/swc-darwin-x64@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-darwin-x64@npm:15.5.21" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-darwin-x64@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-darwin-x64@npm:16.2.11" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -8516,9 +8537,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3" +"@next/swc-linux-arm64-gnu@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-linux-arm64-gnu@npm:15.5.21" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-arm64-gnu@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-linux-arm64-gnu@npm:16.2.11" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -8530,9 +8558,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-arm64-musl@npm:16.2.3" +"@next/swc-linux-arm64-musl@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-linux-arm64-musl@npm:15.5.21" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-arm64-musl@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-linux-arm64-musl@npm:16.2.11" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -8544,9 +8579,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-x64-gnu@npm:16.2.3" +"@next/swc-linux-x64-gnu@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-linux-x64-gnu@npm:15.5.21" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-x64-gnu@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-linux-x64-gnu@npm:16.2.11" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -8558,9 +8600,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-x64-musl@npm:16.2.3" +"@next/swc-linux-x64-musl@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-linux-x64-musl@npm:15.5.21" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-x64-musl@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-linux-x64-musl@npm:16.2.11" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -8572,9 +8621,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3" +"@next/swc-win32-arm64-msvc@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-win32-arm64-msvc@npm:15.5.21" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-win32-arm64-msvc@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-win32-arm64-msvc@npm:16.2.11" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -8586,9 +8642,16 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-win32-x64-msvc@npm:16.2.3" +"@next/swc-win32-x64-msvc@npm:15.5.21": + version: 15.5.21 + resolution: "@next/swc-win32-x64-msvc@npm:15.5.21" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-win32-x64-msvc@npm:16.2.11": + version: 16.2.11 + resolution: "@next/swc-win32-x64-msvc@npm:16.2.11" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -11267,10 +11330,10 @@ __metadata: languageName: node linkType: hard -"@protobufjs/codegen@npm:^2.0.4": - version: 2.0.4 - resolution: "@protobufjs/codegen@npm:2.0.4" - checksum: 10/c6ee5fa172a8464f5253174d3c2353ea520c2573ad7b6476983d9b1346f4d8f2b44aa29feb17a949b83c1816bc35286a5ea265ed9d8fdd2865acfa09668c0447 +"@protobufjs/codegen@npm:^2.0.5": + version: 2.0.5 + resolution: "@protobufjs/codegen@npm:2.0.5" + checksum: 10/290335fa114f26202abc0695f279d53e2fd516b01cfd8298923591e0bda011295ff40e3582a1cda0a0f27cbc5039a0292082d5ad08872bb5d6243a614ac15c88 languageName: node linkType: hard @@ -11305,6 +11368,13 @@ __metadata: languageName: node linkType: hard +"@protobufjs/inquire@npm:^1.1.1": + version: 1.1.2 + resolution: "@protobufjs/inquire@npm:1.1.2" + checksum: 10/259756489c75a751552df60d18f82503d2534855646397b96b91cf15807fa852e99bd9eb73dabb64da37aec7913844032ecb031a4326d82aae622f5e4c2f8a17 + languageName: node + linkType: hard + "@protobufjs/path@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/path@npm:1.1.2" @@ -11319,10 +11389,10 @@ __metadata: languageName: node linkType: hard -"@protobufjs/utf8@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/utf8@npm:1.1.0" - checksum: 10/131e289c57534c1d73a0e55782d6751dd821db1583cb2f7f7e017c9d6747addaebe79f28120b2e0185395d990aad347fb14ffa73ef4096fa38508d61a0e64602 +"@protobufjs/utf8@npm:^1.1.1": + version: 1.1.2 + resolution: "@protobufjs/utf8@npm:1.1.2" + checksum: 10/ff759348d60e8f65137d3a7a16e00cf69abd9a6dede75e50ec377c6aebb4ac400a4a70af2e77eb2bf75e2accf30abbea81803e9403004b1922ea70776bfdc3aa languageName: node linkType: hard @@ -18034,10 +18104,17 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:0.8.12": - version: 0.8.12 - resolution: "@xmldom/xmldom@npm:0.8.12" - checksum: 10/0fc20bc72a057a939ed17afc3fb35d6be2eb19e42aa9ba3c78aa8bdf471da0b4b17c2710581ce6a2cd68ce3995c2ee7d689593a70a26df1273c0c9c29dfca257 +"@xmldom/xmldom@npm:0.8.13": + version: 0.8.13 + resolution: "@xmldom/xmldom@npm:0.8.13" + checksum: 10/f8f3d56fa91d5026885c0c5c00b07eae47647bda0d742ecbf8e51e06bb287ab30222977b20529ee15c364031606225ebca58907a8ecc76a3add6b3f10e6ddfc6 + languageName: node + linkType: hard + +"@xmldom/xmldom@npm:0.9.10": + version: 0.9.10 + resolution: "@xmldom/xmldom@npm:0.9.10" + checksum: 10/8d39ed498baf33dc8bbc82ec575e7448ddf08a0d6874bcc30a70c001b7519e6df6564f98d13c03836fa8be099c6d74cbc4078cfcfcbcf005681d4e67f1363412 languageName: node linkType: hard @@ -18828,14 +18905,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:1.16.0": + version: 1.16.0 + resolution: "axios@npm:1.16.0" dependencies: - follow-redirects: "npm:^1.15.11" + follow-redirects: "npm:^1.16.0" form-data: "npm:^4.0.5" proxy-from-env: "npm:^2.1.0" - checksum: 10/d39a2c0ebc7ff4739401b282e726cc2673377949d6c46d60eb619458f8d7a2f7eadbcada7097f4dbc7d5c59abb4d3bf6fac33d474412bc3415d3f5aa7ed45530 + checksum: 10/cf8b521ff732c21550b38c8739aef556ea5e14b268468bb89e4307416ab262b462e582474e810963a3d61c2392ab47ad35c11d05eff027de7c97113bc7411623 languageName: node linkType: hard @@ -19235,21 +19312,21 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:2.0.3": - version: 2.0.3 - resolution: "brace-expansion@npm:2.0.3" +"brace-expansion@npm:2.1.4": + version: 2.1.4 + resolution: "brace-expansion@npm:2.1.4" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/e9dd66caaf0784126e1654f1bc19adb28f3ef86f39f2226f833f7700ec727c141f6cd85eaa47bacf3426beda01c9fbc3a2f28174cf59330dc9b58ffaf9e09d96 + checksum: 10/11e18dc39706037331abedbb742af1da733267042f94c0f08487ef1a63cee068c1859f8d5f95523869725191133eb1a69753de457ed4b76b7c187d9ea0646737 languageName: node linkType: hard -"brace-expansion@npm:5.0.5, brace-expansion@npm:^5.0.5": - version: 5.0.5 - resolution: "brace-expansion@npm:5.0.5" +"brace-expansion@npm:5.0.9": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" dependencies: balanced-match: "npm:^4.0.2" - checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee + checksum: 10/d8683d612919212c7cf298861acd1c15101e7e2ad186e7ae9747390e5b3fc9e9b55ce71107570d32985784d9d88fbbe438d725863aed7b0f3d08d5f080535eec languageName: node linkType: hard @@ -19263,6 +19340,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee + languageName: node + linkType: hard + "braces@npm:^3.0.3, braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -25949,10 +26035,10 @@ __metadata: languageName: node linkType: hard -"hono@npm:4.12.12": - version: 4.12.12 - resolution: "hono@npm:4.12.12" - checksum: 10/760617935cbc40fb20f2ffb4cbd5d59c3578a6838700be5f89a636a287bce59195d08f8e8e3551ca907796110457fb276327c155289d3776c151ed74ee02270f +"hono@npm:4.12.25": + version: 4.12.25 + resolution: "hono@npm:4.12.25" + checksum: 10/0cbdcdfdfb45b43cc915beaa44604170bb73b45e647927917714ec222347e7f44c944708471a45b4733cfd20b03c53820593db5b3b19663b935afca9cf984817 languageName: node linkType: hard @@ -31798,9 +31884,9 @@ __metadata: languageName: node linkType: hard -"next-auth@npm:4.24.13": - version: 4.24.13 - resolution: "next-auth@npm:4.24.13" +"next-auth@npm:4.24.15": + version: 4.24.15 + resolution: "next-auth@npm:4.24.15" dependencies: "@babel/runtime": "npm:^7.20.13" "@panva/hkdf": "npm:^1.0.2" @@ -31810,7 +31896,7 @@ __metadata: openid-client: "npm:^5.4.0" preact: "npm:^10.6.3" preact-render-to-string: "npm:^5.1.19" - uuid: "npm:^8.3.2" + uuid: "npm:^11.1.1" peerDependencies: "@auth/core": 0.34.3 next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 @@ -31822,7 +31908,7 @@ __metadata: optional: true nodemailer: optional: true - checksum: 10/019ad687c41c4ef5fa86a1b7e5df89f85350e801eec4d818c997336279430afa85eafc0ac7ec262c4d044f4b07d1aeddf7a8f981c5619f73a0d216f5fea05763 + checksum: 10/e92f3a5ea448b251ae13c64186d054a56877356275b5e53c358fd551d4ba92d38d9cab3c43f5ff190c16849072e5d0471b9dafb115ec895d42097d0beac72ef1 languageName: node linkType: hard @@ -31897,19 +31983,19 @@ __metadata: languageName: node linkType: hard -"next@npm:15.5.15, next@npm:^15.1.0": - version: 15.5.15 - resolution: "next@npm:15.5.15" +"next@npm:15.5.21": + version: 15.5.21 + resolution: "next@npm:15.5.21" dependencies: - "@next/env": "npm:15.5.15" - "@next/swc-darwin-arm64": "npm:15.5.15" - "@next/swc-darwin-x64": "npm:15.5.15" - "@next/swc-linux-arm64-gnu": "npm:15.5.15" - "@next/swc-linux-arm64-musl": "npm:15.5.15" - "@next/swc-linux-x64-gnu": "npm:15.5.15" - "@next/swc-linux-x64-musl": "npm:15.5.15" - "@next/swc-win32-arm64-msvc": "npm:15.5.15" - "@next/swc-win32-x64-msvc": "npm:15.5.15" + "@next/env": "npm:15.5.21" + "@next/swc-darwin-arm64": "npm:15.5.21" + "@next/swc-darwin-x64": "npm:15.5.21" + "@next/swc-linux-arm64-gnu": "npm:15.5.21" + "@next/swc-linux-arm64-musl": "npm:15.5.21" + "@next/swc-linux-x64-gnu": "npm:15.5.21" + "@next/swc-linux-x64-musl": "npm:15.5.21" + "@next/swc-win32-arm64-msvc": "npm:15.5.21" + "@next/swc-win32-x64-msvc": "npm:15.5.21" "@swc/helpers": "npm:0.5.15" caniuse-lite: "npm:^1.0.30001579" postcss: "npm:8.4.31" @@ -31952,23 +32038,23 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10/f15867d9e068194376a9657a13b5bc28dee4afedf4a163304c49cf9cec008ad5b905b8f4639bbebd702f77342b76847252b02dffeb130dd07be24aa0d2a694f0 + checksum: 10/caa65d7b05399092d0135738f4dd6553d025fb4701f3e0dce4693f1e92591d8c1944db6c9470ae09f65397c87c8469d278fb1fb9de1eac2fb549941d64a15690 languageName: node linkType: hard -"next@npm:16.2.3": - version: 16.2.3 - resolution: "next@npm:16.2.3" +"next@npm:16.2.11": + version: 16.2.11 + resolution: "next@npm:16.2.11" dependencies: - "@next/env": "npm:16.2.3" - "@next/swc-darwin-arm64": "npm:16.2.3" - "@next/swc-darwin-x64": "npm:16.2.3" - "@next/swc-linux-arm64-gnu": "npm:16.2.3" - "@next/swc-linux-arm64-musl": "npm:16.2.3" - "@next/swc-linux-x64-gnu": "npm:16.2.3" - "@next/swc-linux-x64-musl": "npm:16.2.3" - "@next/swc-win32-arm64-msvc": "npm:16.2.3" - "@next/swc-win32-x64-msvc": "npm:16.2.3" + "@next/env": "npm:16.2.11" + "@next/swc-darwin-arm64": "npm:16.2.11" + "@next/swc-darwin-x64": "npm:16.2.11" + "@next/swc-linux-arm64-gnu": "npm:16.2.11" + "@next/swc-linux-arm64-musl": "npm:16.2.11" + "@next/swc-linux-x64-gnu": "npm:16.2.11" + "@next/swc-linux-x64-musl": "npm:16.2.11" + "@next/swc-win32-arm64-msvc": "npm:16.2.11" + "@next/swc-win32-x64-msvc": "npm:16.2.11" "@swc/helpers": "npm:0.5.15" baseline-browser-mapping: "npm:^2.9.19" caniuse-lite: "npm:^1.0.30001579" @@ -32012,7 +32098,66 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10/5164885daacbb36a771380e1b5efba524863e1bdf2b5a6c80413cbf1e3ab4e8ddab5716cd91ff94ca5c5c5deb2a12d1312d6d6ae994e16ebfa985fdda6134bc6 + checksum: 10/ceb25d1b34b07e4f0f903d5dc9063fa2d7a4d161132d8bd38c41fd70c8ccc5abb2d13b7085c845303e1c2882c2110a58027dc3626595076108069ddc70626c03 + languageName: node + linkType: hard + +"next@npm:^15.1.0": + version: 15.5.15 + resolution: "next@npm:15.5.15" + dependencies: + "@next/env": "npm:15.5.15" + "@next/swc-darwin-arm64": "npm:15.5.15" + "@next/swc-darwin-x64": "npm:15.5.15" + "@next/swc-linux-arm64-gnu": "npm:15.5.15" + "@next/swc-linux-arm64-musl": "npm:15.5.15" + "@next/swc-linux-x64-gnu": "npm:15.5.15" + "@next/swc-linux-x64-musl": "npm:15.5.15" + "@next/swc-win32-arm64-msvc": "npm:15.5.15" + "@next/swc-win32-x64-msvc": "npm:15.5.15" + "@swc/helpers": "npm:0.5.15" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.34.3" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10/f15867d9e068194376a9657a13b5bc28dee4afedf4a163304c49cf9cec008ad5b905b8f4639bbebd702f77342b76847252b02dffeb130dd07be24aa0d2a694f0 languageName: node linkType: hard @@ -34926,23 +35071,23 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:7.5.5": - version: 7.5.5 - resolution: "protobufjs@npm:7.5.5" +"protobufjs@npm:7.5.6": + version: 7.5.6 + resolution: "protobufjs@npm:7.5.6" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" - "@protobufjs/codegen": "npm:^2.0.4" + "@protobufjs/codegen": "npm:^2.0.5" "@protobufjs/eventemitter": "npm:^1.1.0" "@protobufjs/fetch": "npm:^1.1.0" "@protobufjs/float": "npm:^1.0.2" - "@protobufjs/inquire": "npm:^1.1.0" + "@protobufjs/inquire": "npm:^1.1.1" "@protobufjs/path": "npm:^1.1.2" "@protobufjs/pool": "npm:^1.1.0" - "@protobufjs/utf8": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" long: "npm:^5.0.0" - checksum: 10/048898023a38d22f5fc9a1bcf0dcce5cfbcd37fb00753bd72283720eee7e2cb6055b23957542e5bcdc136379af66203a2ddb8d8c39d11f73169bacf07885fedd + checksum: 10/13d7ab5b5d588b704476a600506cda9f647e2f657faf5a18e8866ce5b04800026a508a4958673e4d5674090bbc528717b01128f8e226055d56e90745e3ee6ada languageName: node linkType: hard @@ -39067,16 +39212,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:7.5.11": - version: 7.5.11 - resolution: "tar@npm:7.5.11" +"tar@npm:7.5.21": + version: 7.5.21 + resolution: "tar@npm:7.5.21" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/fb2e77ee858a73936c68e066f4a602d428d6f812e6da0cc1e14a41f99498e4f7fd3535e355fa15157240a5538aa416026cfa6306bb0d1d1c1abf314b1f878e9a + checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17 languageName: node linkType: hard @@ -41053,6 +41198,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.1.1": + version: 11.1.1 + resolution: "uuid@npm:11.1.1" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/16411d3dc12a08d6691616c09a75e66a7f900ba1beef6628a76fe0602f82fae2ee537b564d0b7bc95c24f58d059ca9b58c75a1e806118efb50e17822ff00ddd2 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" @@ -41201,9 +41355,9 @@ __metadata: languageName: node linkType: hard -"vite@npm:6.4.2": - version: 6.4.2 - resolution: "vite@npm:6.4.2" +"vite@npm:6.4.3": + version: 6.4.3 + resolution: "vite@npm:6.4.3" dependencies: esbuild: "npm:^0.25.0" fdir: "npm:^6.4.4" @@ -41252,7 +41406,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/d6622843fb367065c2459c8d972d1f0d302844bdb329602c4bfb3c149607744f74fbfcbb3d170a4f58fa494012ad74a88e3a63df236845840d50475b89786796 + checksum: 10/d43ffaf4f165720bcc825c7c515e9c859bed748e0c62b248fca24ca3f949c8b4203dae7df6c613cd8e357579f1c71863d2d09cbae3b454c73b9dfc8fb07f7e8f languageName: node linkType: hard @@ -41677,14 +41831,14 @@ __metadata: languageName: node linkType: hard -"websocket-driver@npm:>=0.5.1": - version: 0.7.4 - resolution: "websocket-driver@npm:0.7.4" +"websocket-driver@npm:0.7.5": + version: 0.7.5 + resolution: "websocket-driver@npm:0.7.5" dependencies: http-parser-js: "npm:>=0.5.1" safe-buffer: "npm:>=5.1.0" websocket-extensions: "npm:>=0.1.1" - checksum: 10/17197d265d5812b96c728e70fd6fe7d067471e121669768fe0c7100c939d997ddfc807d371a728556e24fc7238aa9d58e630ea4ff5fd4cfbb40f3d0a240ef32d + checksum: 10/0671fef8c79ba1b1b2fa6b7d95cb034d059410e7c4cfd7ef42063a254e12ca2917806c3a5026206b33abf25a5d44a651c547b8f74d9ec94c9fe4df6fa1bc63f7 languageName: node linkType: hard From b2b693fc7d54560c1121e5efee04754dfbd44798 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:34:42 +0000 Subject: [PATCH 11/30] fix(hygiene): stop leaking DB errors from /api/health, declare crovecrm deps, surface husky failures --- .../app/(use-page-wrapper)/(main-nav)/workflows/page.tsx | 2 +- .../developer/webhooks/monitoring/page.tsx | 2 +- apps/web/app/api/health/route.ts | 5 +---- package.json | 2 +- packages/app-store/crovecrm/package.json | 6 ++++++ 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx index 8d2599042b8..ed7dd4868c2 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/workflows/page.tsx @@ -20,7 +20,7 @@ export const generateMetadata = async () => const WorkflowsPage = async () => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); if (!session?.user?.id) { - redirect("/auth/login"); + return redirect("/auth/login"); } return ; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx index addc002ea07..4a5ce4c9675 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/monitoring/page.tsx @@ -20,7 +20,7 @@ export const generateMetadata = async () => const WebhookMonitoringPage = async () => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); if (!session?.user?.id) { - redirect("/auth/login"); + return redirect("/auth/login"); } return ; diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts index e35a3522f60..8ff1a2eb7a5 100644 --- a/apps/web/app/api/health/route.ts +++ b/apps/web/app/api/health/route.ts @@ -9,7 +9,6 @@ async function healthHandler(req: NextRequest) { const startTime = Date.now(); let dbStatus: "connected" | "disconnected" = "disconnected"; let dbLatencyMs = -1; - let dbError: string | null = null; try { const dbStart = Date.now(); @@ -17,9 +16,8 @@ async function healthHandler(req: NextRequest) { await prisma.$queryRaw`SELECT 1`; dbLatencyMs = Date.now() - dbStart; dbStatus = "connected"; - } catch (error) { + } catch { dbStatus = "disconnected"; - dbError = error instanceof Error ? error.message : String(error); } const isHealthy = dbStatus === "connected"; @@ -37,7 +35,6 @@ async function healthHandler(req: NextRequest) { database: { status: dbStatus, latencyMs: dbLatencyMs >= 0 ? dbLatencyMs : undefined, - error: dbError || undefined, }, }, { diff --git a/package.json b/package.json index fbc96270259..ea98f7d260b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "lint:fix": "turbo lint:fix", "lint:report": "turbo lint:report", "lint": "turbo lint", - "postinstall": "(husky install || true) && turbo run post-install", + "postinstall": "(husky install || echo \"husky install skipped: no .git directory\") && turbo run post-install", "pre-commit": "lint-staged", "predev": "echo 'Checking env files'", "prisma": "yarn workspace @calcom/prisma prisma", diff --git a/packages/app-store/crovecrm/package.json b/packages/app-store/crovecrm/package.json index 2ffadc518eb..aa63678d175 100644 --- a/packages/app-store/crovecrm/package.json +++ b/packages/app-store/crovecrm/package.json @@ -4,6 +4,12 @@ "main": "./index.ts", "types": "./index.ts", "private": true, + "dependencies": { + "@calcom/app-store": "workspace:*", + "@calcom/features": "workspace:*", + "@calcom/lib": "workspace:*", + "zod": "3.25.76" + }, "devDependencies": { "@calcom/types": "workspace:*" } From fe0c70cb2b572dbb8d589976dec9ded70e99974a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:40:43 +0000 Subject: [PATCH 12/30] docs(audit): add fix-status section after Round 1 and Dependabot pass --- docs/audit/2026-09-08-audit-report.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/audit/2026-09-08-audit-report.html b/docs/audit/2026-09-08-audit-report.html index 450db25e2e9..52f3bebcd89 100644 --- a/docs/audit/2026-09-08-audit-report.html +++ b/docs/audit/2026-09-08-audit-report.html @@ -85,6 +85,18 @@

Crove-Cal — Báo cáo Audit toàn bộ Repo

Không có giá trị secret nào được in trong file này — mọi secret chỉ được tham chiếu theo file:line. +

Trạng thái xử lý — cập nhật sau Round 1 + Dependabot (cùng ngày)

+ + + + + + + + + +
Trạng tháiPhạm viGhi chú
ĐÃ FIXFork security & correctnessCR-01…CR-06, HI-01, HI-04…HI-07, HI-15, HI-17…HI-20, MD-02/04/07/08/09/13/14/15/23/24/25/26, LO-12 — các commit trên dev, verify 72/72 vitest + tsc sạch + biome lint 0 error
ĐÃ FIXDependabotCR-07 (next-auth 4.24.15), HI-11 phần resolutions + tar/websocket-driver, next 16.2.11, dependabot.yml — yarn install exit 0, tsc apps/web sạch
ĐANG CHẠYHI-08, MD-01TLS opt-out + connection leak + gate /api/webhooks/health — agent đang fix nền
CHỜ QUYẾT ĐỊNHHI-13, HI-1461 migrations cần biết DB prod dùng schema nào; wire workflows dispatcher là quyết định feature. Khi deploy: rotate secret Supabase + 4 env mới + prisma migrate deploy
ROUND 3 (UPSTREAM)Kế thừa upstreamCR-08, HI-02/03/09/10/12/16/21/22, MD-03/05/06/10/11/12/16…22/27…32, LO-01…11/13…15
+

Tổng quan

0
Critical
From e389330d362a2244a23565255dffc2ccf71d36a3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:21:48 +0000 Subject: [PATCH 13/30] fix(db): make Postgres TLS verification opt-out instead of always disabled Every Prisma/pg Pool shipped rejectUnauthorized: false unconditionally, so a MITM between the app and the database could present any certificate and read or rewrite the entire query stream. Verification now defaults to on, and endpoints whose certificates Node cannot verify must set DATABASE_SSL_REJECT_UNAUTHORIZED=false explicitly. The non-pool branch of both api/v2 services also leaked its pool on shutdown because the adapter pool was never assigned to the field drained by onModuleDestroy; it is now assigned, and the three copies of getSchemaFromUrl collapse into one export. --- .../src/modules/prisma/prisma-read.service.ts | 24 ++++--------------- .../modules/prisma/prisma-write.service.ts | 24 ++++--------------- packages/prisma/index.ts | 23 ++++++++++-------- 3 files changed, 23 insertions(+), 48 deletions(-) diff --git a/apps/api/v2/src/modules/prisma/prisma-read.service.ts b/apps/api/v2/src/modules/prisma/prisma-read.service.ts index cce6b2452f8..498b671f5a3 100644 --- a/apps/api/v2/src/modules/prisma/prisma-read.service.ts +++ b/apps/api/v2/src/modules/prisma/prisma-read.service.ts @@ -1,3 +1,4 @@ +import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma"; import { PrismaClient } from "@calcom/prisma/client"; import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -6,17 +7,6 @@ import { Pool } from "pg"; const DB_MAX_POOL_CONNECTION = 10; -function getSchemaFromUrl(url: string | undefined): string | undefined { - if (!url) return undefined; - try { - const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://")); - return parsed.searchParams.get("schema") || undefined; - } catch { - const match = url.match(/[?&]schema=([^&]+)/); - return match ? match[1] : undefined; - } -} - @Injectable() export class PrismaReadService implements OnModuleInit, OnModuleDestroy { private logger = new Logger("PrismaReadService"); @@ -63,23 +53,19 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy { connectionString: dbUrl, max: maxReadConnections, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); const adapter = new PrismaPg(this.pool, adapterOptions); this.prisma = new PrismaClient({ adapter }); } else { - const adapterPool = new Pool({ + this.pool = new Pool({ connectionString: dbUrl, max: 5, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); - const adapter = new PrismaPg(adapterPool, adapterOptions); + const adapter = new PrismaPg(this.pool, adapterOptions); this.prisma = new PrismaClient({ adapter, }); diff --git a/apps/api/v2/src/modules/prisma/prisma-write.service.ts b/apps/api/v2/src/modules/prisma/prisma-write.service.ts index fe6d75534a1..fe142a8ff35 100644 --- a/apps/api/v2/src/modules/prisma/prisma-write.service.ts +++ b/apps/api/v2/src/modules/prisma/prisma-write.service.ts @@ -1,3 +1,4 @@ +import { getSchemaFromUrl, resolveDatabaseSsl } from "@calcom/prisma"; import { PrismaClient } from "@calcom/prisma/client"; import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -14,17 +15,6 @@ export interface PrismaServiceOptions { type: "main" | "worker"; } -function getSchemaFromUrl(url: string | undefined): string | undefined { - if (!url) return undefined; - try { - const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://")); - return parsed.searchParams.get("schema") || undefined; - } catch { - const match = url.match(/[?&]schema=([^&]+)/); - return match ? match[1] : undefined; - } -} - @Injectable() export class PrismaWriteService implements OnModuleInit, OnModuleDestroy { private logger = new Logger("PrismaWriteService"); @@ -69,23 +59,19 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy { connectionString: dbUrl, max: maxWriteConnections, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); const adapter = new PrismaPg(this.pool, adapterOptions); this.prisma = new PrismaClient({ adapter }); } else { - const adapterPool = new Pool({ + this.pool = new Pool({ connectionString: dbUrl, max: 5, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); - const adapter = new PrismaPg(adapterPool, adapterOptions); + const adapter = new PrismaPg(this.pool, adapterOptions); this.prisma = new PrismaClient({ adapter, }); diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 81a95ad019c..bf788f2a80b 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -7,7 +7,7 @@ import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users"; import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams"; import { PrismaClient, type Prisma } from "./generated/prisma/client"; -function getSchemaFromUrl(url: string | undefined): string | undefined { +export function getSchemaFromUrl(url: string | undefined): string | undefined { if (!url) return undefined; try { const parsed = new URL(url.replace(/^postgresql:\/\//, "http://").replace(/^postgres:\/\//, "http://")); @@ -18,6 +18,15 @@ function getSchemaFromUrl(url: string | undefined): string | undefined { } } +// TLS verification defaults to on; endpoints whose certificate Node cannot verify +// (e.g. self-signed or Supabase pooler certs) must explicitly opt out via +// DATABASE_SSL_REJECT_UNAUTHORIZED=false so an insecure default cannot ship silently. +export function resolveDatabaseSsl(): { rejectUnauthorized: boolean } { + return { + rejectUnauthorized: process.env.DATABASE_SSL_REJECT_UNAUTHORIZED !== "false", + }; +} + const connectionString = process.env.DATABASE_URL || ""; const schema = getSchemaFromUrl(connectionString); const adapterOptions = schema ? { schema } : undefined; @@ -28,17 +37,13 @@ const pool = connectionString: connectionString, max: 5, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }) : new Pool({ connectionString: connectionString, max: 10, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); const adapter = new PrismaPg(pool, adapterOptions); @@ -81,9 +86,7 @@ export const customPrisma = (options?: Prisma.PrismaClientOptions) => { connectionString: customConnectionString, max: 5, idleTimeoutMillis: 300000, - ssl: { - rejectUnauthorized: false, - }, + ssl: resolveDatabaseSsl(), }); const customAdapter = new PrismaPg(customPool, customSchema ? { schema: customSchema } : undefined); From f9cefd9790c0af78e3a24e05db3dfefa56feefdb Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:22:23 +0000 Subject: [PATCH 14/30] fix(webhooks): require authentication on webhook monitoring endpoints GET published webhook telemetry including raw exception strings to anyone, and POST let an anonymous caller inject forged delivery rows - flooding successes blinded operators to real outages while flooding failures forced the endpoint to 503, which any uptime monitor would read as the whole service being down. Both handlers now require a session (the monitoring dashboard always fetches with browser cookies, so a bearer token would break it) and POST additionally requires an in-session ADMIN role. --- .../webhooks/health/__tests__/route.test.ts | 70 ++++++++++++++++++- apps/web/app/api/webhooks/health/route.ts | 24 ++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/webhooks/health/__tests__/route.test.ts b/apps/web/app/api/webhooks/health/__tests__/route.test.ts index a60720df892..9d4ff5da228 100644 --- a/apps/web/app/api/webhooks/health/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/health/__tests__/route.test.ts @@ -1,14 +1,66 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Session } from "next-auth"; import { NextRequest } from "next/server"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; + import { GET, POST } from "../route"; +vi.mock("@calcom/features/auth/lib/getServerSession", () => ({ + getServerSession: vi.fn(), +})); + +// next/headers must be mocked because the jsdom test env has no request scope +vi.mock("next/headers", () => ({ + cookies: vi.fn(() => ({ getAll: () => [] })), + headers: vi.fn(() => new Headers()), +})); + +const mockedGetServerSession = vi.mocked(getServerSession); + +function makeTestSession(role: "ADMIN" | "USER"): Session { + return { + expires: "2099-01-01T00:00:00.000Z", + hasValidLicense: true, + upId: "1", + user: { + id: 1, + uuid: "00000000-0000-0000-0000-000000000000", + role, + }, + }; +} + describe("API /api/webhooks/health Endpoint", () => { beforeEach(() => { webhookMonitor.reset(); + mockedGetServerSession.mockReset(); + }); + + it("should return 401 on GET when unauthenticated", async () => { + mockedGetServerSession.mockResolvedValue(null); + + const req = new NextRequest("http://localhost:3000/api/webhooks/health"); + const res = await GET(req, { params: Promise.resolve({}) }); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.message).toBe("Unauthorized"); + expect(mockedGetServerSession).toHaveBeenCalledTimes(1); + }); + + it("should return 401 on POST when unauthenticated", async () => { + mockedGetServerSession.mockResolvedValue(null); + + const req = new NextRequest("http://localhost:3000/api/webhooks/health", { method: "POST" }); + const res = await POST(req, { params: Promise.resolve({}) }); + + expect(res.status).toBe(401); }); - it("should return metrics with 200 OK on GET", async () => { + it("should return metrics with 200 OK on GET when authenticated", async () => { + mockedGetServerSession.mockResolvedValue(makeTestSession("USER")); webhookMonitor.recordDelivery({ source: "dos-org-sync", event: "test.ping", @@ -29,7 +81,19 @@ describe("API /api/webhooks/health Endpoint", () => { expect(json.recentDeliveries).toHaveLength(1); }); - it("should trigger and record manual ping simulation on POST", async () => { + it("should return 403 on POST for authenticated non-admin", async () => { + mockedGetServerSession.mockResolvedValue(makeTestSession("USER")); + + const req = new NextRequest("http://localhost:3000/api/webhooks/health", { method: "POST" }); + const res = await POST(req, { params: Promise.resolve({}) }); + + expect(res.status).toBe(403); + expect(webhookMonitor.getMetrics().totalEvents).toBe(0); + }); + + it("should trigger and record manual ping simulation on POST for admin", async () => { + mockedGetServerSession.mockResolvedValue(makeTestSession("ADMIN")); + const req = new NextRequest("http://localhost:3000/api/webhooks/health", { method: "POST", body: JSON.stringify({ diff --git a/apps/web/app/api/webhooks/health/route.ts b/apps/web/app/api/webhooks/health/route.ts index 2350a9a8f34..c7062ce7f6a 100644 --- a/apps/web/app/api/webhooks/health/route.ts +++ b/apps/web/app/api/webhooks/health/route.ts @@ -1,7 +1,12 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import { cookies, headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", @@ -15,7 +20,14 @@ export async function OPTIONS() { }); } -async function getMonitoringHandler(req: NextRequest) { +async function getMonitoringHandler() { + const session = await getServerSession({ + req: buildLegacyRequest(await headers(), await cookies()), + }); + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + const metrics = webhookMonitor.getMetrics(); const statusCode = metrics.status === "failing" ? 503 : 200; @@ -36,6 +48,16 @@ async function getMonitoringHandler(req: NextRequest) { } async function postTriggerPingHandler(req: NextRequest) { + const session = await getServerSession({ + req: buildLegacyRequest(await headers(), await cookies()), + }); + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + if (session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }); + } + const startTime = Date.now(); try { const body = await req.json().catch(() => ({})); From c0418d8d85ca1c78ebb2a47f06e651cd8cffa0c8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:23:04 +0000 Subject: [PATCH 15/30] docs(audit): mark HI-08 and MD-01 as fixed --- docs/audit/2026-09-08-audit-report.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/audit/2026-09-08-audit-report.html b/docs/audit/2026-09-08-audit-report.html index 52f3bebcd89..5ac7b9547d6 100644 --- a/docs/audit/2026-09-08-audit-report.html +++ b/docs/audit/2026-09-08-audit-report.html @@ -91,7 +91,7 @@

Trạng thái xử lý — cập nhật sau Round 1 + Dependabot (cùng ngà ĐÃ FIXFork security & correctnessCR-01…CR-06, HI-01, HI-04…HI-07, HI-15, HI-17…HI-20, MD-02/04/07/08/09/13/14/15/23/24/25/26, LO-12 — các commit trên dev, verify 72/72 vitest + tsc sạch + biome lint 0 error ĐÃ FIXDependabotCR-07 (next-auth 4.24.15), HI-11 phần resolutions + tar/websocket-driver, next 16.2.11, dependabot.yml — yarn install exit 0, tsc apps/web sạch - ĐANG CHẠYHI-08, MD-01TLS opt-out + connection leak + gate /api/webhooks/health — agent đang fix nền + ĐÃ FIXHI-08, MD-01TLS mặc định verify-on (opt-out qua DATABASE_SSL_REJECT_UNAUTHORIZED=false) + sửa pool leak api/v2 + gate session/ADMIN cho /api/webhooks/health — 5/5 test pass CHỜ QUYẾT ĐỊNHHI-13, HI-1461 migrations cần biết DB prod dùng schema nào; wire workflows dispatcher là quyết định feature. Khi deploy: rotate secret Supabase + 4 env mới + prisma migrate deploy ROUND 3 (UPSTREAM)Kế thừa upstreamCR-08, HI-02/03/09/10/12/16/21/22, MD-03/05/06/10/11/12/16…22/27…32, LO-01…11/13…15 From a2f7c738f4e373637354e087a241d34fd80eaf91 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:40:28 +0000 Subject: [PATCH 16/30] =?UTF-8?q?docs(changelog):=20release=202.3.0=20?= =?UTF-8?q?=E2=80=94=20security=20hardening,=20upstream=20sync=20fixes,=20?= =?UTF-8?q?Dependabot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfba4c975ea..09b70382d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the **Crove Cal** platform will be documented in this fil --- +## [2.3.0] - 2026-09-08 + +### Security +- **Upstream Sync (cal.diy main, 7 fixes)**: HitPay/PayPal zero-decimal currency charging (VND/JPY/KRW were billed at 1% of price), `tempOrgRedirect` writes inside the `updateUser` transaction, CalVideoSettings API defaults, i18n (ja/pl), removal of unused `TokenHandler`. +- **Authentication**: removed hardcoded DOS.Me OIDC client id/secret fallbacks; `dos-id` provider registers only when OIDC credentials are configured; JWT `update` callback re-anchors identity to `token.sub` instead of client-supplied `session.email`; JIT provisioning no longer adopts organizations by slug and caps claim-derived roles at MEMBER. +- **Webhooks**: `brevo` and `crove-crm` routes require HMAC-SHA256 signatures (`timingSafeEqual`, fail-closed) with a 50-attendee cap; `dos-org-sync` requires a dedicated `DOS_SYNC_WEBHOOK_SECRET`, rejects stale timestamps and replayed delivery ids, and scopes `team.deleted` to the parent organization; `/api/webhooks/health` requires a session (POST requires ADMIN); `/api/health` no longer echoes raw database errors. +- **Authorization**: Teams/Organizations endpoints enforce accepted membership (member rosters with email addresses no longer leak to non-members); ADMIN can no longer evict the OWNER or self-grant OWNER; last-OWNER protection; `inviteMember` records a real verification token and answers with an opaque status; Workflows enforce team membership and scope mutations to the owner. +- **Database**: TLS certificate verification defaults to on (`DATABASE_SSL_REJECT_UNAUTHORIZED=false` opts out); fixed a pg pool leak in the api/v2 non-pool branch; added migration recreating the Workflow tables dropped by upstream `20260319000000_drop_workflow_tables` (fixes `P2021` and the `/workflows` 500). +- **Dependencies (Dependabot)**: bumped `next` 16.2.11, `next-auth` 4.24.15, `tar` 7.5.21, `websocket-driver` 0.7.5, `axios` 1.16.0, `hono` 4.12.25, `vite` 6.4.3, `protobufjs` 7.5.6, `@xmldom/xmldom`, `brace-expansion`; added `.github/dependabot.yml`. + +### Fixed +- Cross-user cache leak on `/event-types/[type]` (`unstable_cache` keyed on headers/cookies objects that serialize to constants, so every user shared one entry and the router authorization never ran). +- `turbo.json` `post-install` ordering (`dependsOn: ["^post-install"]`) with upstream `permissions.ts` restored — fixes the intermittent TS2305 `PLATFORM_PERMISSION` failure during `yarn install`. +- `/teams` renders real strings instead of raw i18n keys (`create_a_team`, `no_teams_yet`, `team_created_successfully`, `create_team_description`). +- MCP server: removed the "first available user" ownership fallback and the empty-`OR` schedule lookup that returned another tenant's schedule; added a `type-check` script (fixed 6 latent Prisma typing errors). +- `crovecrm` declares its workspace dependencies; husky install failures are no longer silently swallowed; `return` added before `redirect()` in two pages; webhook monitor shared across processes with honest delivery reporting. + +### Docs +- Added `docs/audit/2026-09-08-audit-report.html` — full-repo audit report (76 findings with IDs, priorities, fix status table). + +--- + ## [2.2.0] - 2026-09-03 ### Added From d2f800603ea78035083a6a374dfb16e8ec476d24 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 08:45:20 +0000 Subject: [PATCH 17/30] =?UTF-8?q?fix(deps):=20resolve=20dependabot=20criti?= =?UTF-8?q?cal=20and=20high=20alerts=20=E2=80=94=20bump=20axios/hono/vite/?= =?UTF-8?q?protobufjs/xmldom/brace-expansion/tar/next/next-auth,=20add=20d?= =?UTF-8?q?ependabot.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 31 +++- yarn.lock | 486 +++++++++++++++++++++++---------------------------- 2 files changed, 241 insertions(+), 276 deletions(-) diff --git a/package.json b/package.json index ea98f7d260b..831e697e4cd 100644 --- a/package.json +++ b/package.json @@ -145,11 +145,11 @@ "rollup": "4.59.0", "jpeg-js": "0.4.4", "validator": "13.15.22", - "form-data": "4.0.4", + "form-data": "4.0.6", "axios": "1.16.0", "follow-redirects": "1.16.0", - "protobufjs": "7.5.6", - "shell-quote": "1.8.4", + "protobufjs": "7.6.1", + "shell-quote": "1.9.0", "jws": "4.0.1", "jsonwebtoken": "9.0.0", "sha.js": "2.4.12", @@ -166,15 +166,15 @@ "@modelcontextprotocol/sdk": "1.26.0", "hono": "4.12.25", "express-rate-limit": "8.2.2", - "svgo": "4.0.1", - "js-yaml": "4.1.1", + "svgo": "4.1.0", + "js-yaml": "4.3.1", "mdast-util-to-hast": "13.2.1", "prismjs": "1.30.0", "react-devtools-core": "4.28.4", "formidable": "2.1.3", "serialize-javascript": "7.0.5", "@adobe/css-tools": "4.3.2", - "jsondiffpatch": "0.7.2", + "jsondiffpatch": "0.7.6", "min-document": "2.19.1", "tar": "7.5.21", "lodash": "4.18.1", @@ -183,13 +183,13 @@ "fast-xml-parser": "5.5.9", "bn.js": "4.12.3", "minimatch@10.0.3": "10.2.4", - "multer": "2.1.1", + "multer": "2.2.0", "flatted": "3.4.2", - "socket.io-parser": "4.2.6", + "socket.io-parser": "4.2.7", "vite": "6.4.3", "websocket-driver": "0.7.5", "defu": "6.1.5", - "immutable": "3.8.3", + "immutable": "3.8.4", "@hono/node-server": "1.19.13", "@xmldom/xmldom@0.9.8": "0.9.10", "@xmldom/xmldom@^0.8.1": "0.8.13", @@ -213,6 +213,19 @@ "brace-expansion@^5.0.2": "5.0.9", "brace-expansion@^2.0.1": "2.1.4", "brace-expansion@^2.0.2": "2.1.4", + "postcss": "8.5.18", + "fast-uri": "3.1.6", + "browserslist": "4.28.7", + "engine.io": "6.6.7", + "mysql2": "3.22.0", + "@grpc/grpc-js": "1.12.7", + "ip-address": "10.3.1", + "js-cookie": "3.0.7", + "kysely": "0.28.17", + "nanoid@^3.0.0": "3.3.18", + "nanoid@^5.0.0": "5.1.16", + "ws@^7.0.0": "7.5.11", + "ws@^8.0.0": "8.21.0", "i18next-fs-backend": "^2.6.6" }, "packageExtensions": { diff --git a/yarn.lock b/yarn.lock index 78124226c45..a5199809ffc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2238,7 +2238,11 @@ __metadata: version: 0.0.0-use.local resolution: "@calcom/crovecrm@workspace:packages/app-store/crovecrm" dependencies: + "@calcom/app-store": "workspace:*" + "@calcom/features": "workspace:*" + "@calcom/lib": "workspace:*" "@calcom/types": "workspace:*" + zod: "npm:3.25.76" languageName: unknown linkType: soft @@ -5684,13 +5688,13 @@ __metadata: languageName: node linkType: hard -"@grpc/grpc-js@npm:1.12.6, @grpc/grpc-js@npm:^1.7.1": - version: 1.12.6 - resolution: "@grpc/grpc-js@npm:1.12.6" +"@grpc/grpc-js@npm:1.12.7": + version: 1.12.7 + resolution: "@grpc/grpc-js@npm:1.12.7" dependencies: "@grpc/proto-loader": "npm:^0.7.13" "@js-sdsl/ordered-map": "npm:^4.4.2" - checksum: 10/775ed5a3f15e571af403603da109a9ef2067829fa07e8dbb884368f2b2f696214cdc78652e0647c782119a8398f58c3d77b69d9095fda612aff153181ca1a114 + checksum: 10/d7583085256add64e05a5b889dbba3964cc9d6ef0bcb0d6566330b365d6cafdba5a03ba1c8a37cde055cb8415687eeedf346400fcd8d6cc26a524c30cefc37fb languageName: node linkType: hard @@ -11337,20 +11341,19 @@ __metadata: languageName: node linkType: hard -"@protobufjs/eventemitter@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/eventemitter@npm:1.1.0" - checksum: 10/03af3e99f17ad421283d054c88a06a30a615922a817741b43ca1b13e7c6b37820a37f6eba9980fb5150c54dba6e26cb6f7b64a6f7d8afa83596fafb3afa218c3 +"@protobufjs/eventemitter@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/eventemitter@npm:1.1.1" + checksum: 10/a54dc1aff4475ffad4fdf3235c71a553f5e40e3b4cf6a2e217151895a61cb4eb0be20d63791db22441ca25e594671f1021977133f9939540750231ff7d8e9dd6 languageName: node linkType: hard -"@protobufjs/fetch@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/fetch@npm:1.1.0" +"@protobufjs/fetch@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/fetch@npm:1.1.1" dependencies: "@protobufjs/aspromise": "npm:^1.1.1" - "@protobufjs/inquire": "npm:^1.1.0" - checksum: 10/67ae40572ad536e4ef94269199f252c024b66e3059850906bdaee161ca1d75c73d04d35cd56f147a8a5a079f5808e342b99e61942c1dae15604ff0600b09a958 + checksum: 10/427cf2da8c69b494b0df3b2fb1f43c97f0f71ca2c8ef8232dac7e44f2527ad0cc9cecb243eda14a918e86018bfa6d54d92252240d2b37ed205b13adb5506fa1d languageName: node linkType: hard @@ -11361,14 +11364,7 @@ __metadata: languageName: node linkType: hard -"@protobufjs/inquire@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/inquire@npm:1.1.0" - checksum: 10/c09efa34a5465cb120775e1a482136f2340a58b4abce7e93d72b8b5a9324a0e879275016ef9fcd73d72a4731639c54f2bb755bb82f916e4a78892d1d840bb3d2 - languageName: node - linkType: hard - -"@protobufjs/inquire@npm:^1.1.1": +"@protobufjs/inquire@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/inquire@npm:1.1.2" checksum: 10/259756489c75a751552df60d18f82503d2534855646397b96b91cf15807fa852e99bd9eb73dabb64da37aec7913844032ecb031a4326d82aae622f5e4c2f8a17 @@ -16099,13 +16095,6 @@ __metadata: languageName: node linkType: hard -"@types/cookie@npm:^0.4.1": - version: 0.4.1 - resolution: "@types/cookie@npm:0.4.1" - checksum: 10/427c9220217d3d74f3e5d53d68cd39502f3bbebdb1af4ecf0d05076bcbe9ddab299ad6369fe0f517389296ba4ca49ddf9a8c22f68e5e9eb8ae6d0076cfab90b2 - languageName: node - linkType: hard - "@types/cookiejar@npm:^2.1.5": version: 2.1.5 resolution: "@types/cookiejar@npm:2.1.5" @@ -17348,7 +17337,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.4": +"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.12, @types/ws@npm:^8.5.4": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" dependencies: @@ -18877,7 +18866,7 @@ __metadata: languageName: node linkType: hard -"aws-ssl-profiles@npm:^1.1.1": +"aws-ssl-profiles@npm:^1.1.2": version: 1.1.2 resolution: "aws-ssl-profiles@npm:1.1.2" checksum: 10/af9e5c5e6e343e0f299106acaf03106a7458be69772d004f3e4cf0e3649bb41131b594126fcbc997ad89d73752d9e1d72886c72fcc8649ac5d590459d6b75827 @@ -19070,21 +19059,21 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.10.12, baseline-browser-mapping@npm:^2.9.19": - version: 2.10.19 - resolution: "baseline-browser-mapping@npm:2.10.19" +"baseline-browser-mapping@npm:^2.10.44": + version: 2.11.21 + resolution: "baseline-browser-mapping@npm:2.11.21" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10/69a56e698e0815312d9101463cd5e9d9d192ba670213fb8fdbc3857c25b93cb1590685efe3776a2714931ebba5d2d546489e2aed548aa8edd07849747f999893 + checksum: 10/b4d223df8dbb8deeaa3dae3b9b60ad48b24c57a5473827b5a6ee1c21917e7c37af2185224c5366f2932654ec5fb1a9fee4f95fe7213cc29f4347550f25e4234b languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.9.0": - version: 2.9.7 - resolution: "baseline-browser-mapping@npm:2.9.7" +"baseline-browser-mapping@npm:^2.9.19": + version: 2.10.19 + resolution: "baseline-browser-mapping@npm:2.10.19" bin: - baseline-browser-mapping: dist/cli.js - checksum: 10/870ff0c62bbc4017afc0c4058fc67929034a3f0a1eecc31f7156b88ff2a629433ba2ce12cd84fa6f34b72748935cfa7d0067c723ec501195a0a807925732827a + baseline-browser-mapping: dist/cli.cjs + checksum: 10/69a56e698e0815312d9101463cd5e9d9d192ba670213fb8fdbc3857c25b93cb1590685efe3776a2714931ebba5d2d546489e2aed548aa8edd07849747f999893 languageName: node linkType: hard @@ -19358,33 +19347,18 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.23.0, browserslist@npm:^4.24.0, browserslist@npm:^4.27.0": - version: 4.28.1 - resolution: "browserslist@npm:4.28.1" - dependencies: - baseline-browser-mapping: "npm:^2.9.0" - caniuse-lite: "npm:^1.0.30001759" - electron-to-chromium: "npm:^1.5.263" - node-releases: "npm:^2.0.27" - update-browserslist-db: "npm:^1.2.0" - bin: - browserslist: cli.js - checksum: 10/64f2a97de4bce8473c0e5ae0af8d76d1ead07a5b05fc6bc87b848678bb9c3a91ae787b27aa98cdd33fc00779607e6c156000bed58fefb9cf8e4c5a183b994cdb - languageName: node - linkType: hard - -"browserslist@npm:^4.28.1": - version: 4.28.2 - resolution: "browserslist@npm:4.28.2" +"browserslist@npm:4.28.7": + version: 4.28.7 + resolution: "browserslist@npm:4.28.7" dependencies: - baseline-browser-mapping: "npm:^2.10.12" - caniuse-lite: "npm:^1.0.30001782" - electron-to-chromium: "npm:^1.5.328" - node-releases: "npm:^2.0.36" + baseline-browser-mapping: "npm:^2.10.44" + caniuse-lite: "npm:^1.0.30001806" + electron-to-chromium: "npm:^1.5.393" + node-releases: "npm:^2.0.51" update-browserslist-db: "npm:^1.2.3" bin: browserslist: cli.js - checksum: 10/cff88386e5b5ba5614c9063bd32ef94865bba22b6a381844c7d09ea1eea62a2247e7106e516abdbfda6b75b9986044c991dfe45f92f10add5ad63dccc07589ec + checksum: 10/4de67c9aa630a674572e790f32021237e61aaf69338d84f2875abeb793f87bdaaa2f9760129eeacd8292c50f0ec26fa15ddf02057c27940e1d76f1742bc600cb languageName: node linkType: hard @@ -19820,17 +19794,17 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001599, caniuse-lite@npm:^1.0.30001759": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001599": version: 1.0.30001760 resolution: "caniuse-lite@npm:1.0.30001760" checksum: 10/ac5c13d00b946c3ace331d25379ed9d85743b1028aba79ae80402cb128b4b491122585144898fefb836d4d9879c13c717a081ae241a00420cbbc7e1b934ec685 languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001782": - version: 1.0.30001788 - resolution: "caniuse-lite@npm:1.0.30001788" - checksum: 10/bead3aa7e9795335396953305e9e791514960151fc24b665a09751d27d348d4de6e147a749ba5258921f32b1170f7db798099142a40db3d61f8d586f410f298a +"caniuse-lite@npm:^1.0.30001806": + version: 1.0.30001810 + resolution: "caniuse-lite@npm:1.0.30001810" + checksum: 10/47d7db627c3f3d1a10e06eef9efbb84295c6ef9d959b585b64032cc293637ca7b9b6af7e5d5752972b3e690d9561f14b9561db03890fbc6e38dc1aaa54e51d97 languageName: node linkType: hard @@ -21034,20 +21008,13 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:^0.7.1, cookie@npm:^0.7.2": +"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:^0.7.1, cookie@npm:^0.7.2, cookie@npm:~0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f languageName: node linkType: hard -"cookie@npm:~0.4.1": - version: 0.4.2 - resolution: "cookie@npm:0.4.2" - checksum: 10/2e1de9fdedca54881eab3c0477aeb067f281f3155d9cfee9d28dfb252210d09e85e9d175c0a60689661feb9e35e588515352f2456bc1f8e8db4267e05fd70137 - languageName: node - linkType: hard - "cookiejar@npm:^2.1.2, cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -21349,6 +21316,19 @@ __metadata: languageName: node linkType: hard +"css-select@npm:^6.0.0": + version: 6.0.0 + resolution: "css-select@npm:6.0.0" + dependencies: + boolbase: "npm:^1.0.0" + css-what: "npm:^7.0.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.2.2" + nth-check: "npm:^2.1.1" + checksum: 10/361b07f67bdf2a30771a58adf3a7b1e8d4ba52a9e2a020c9fd0cd846e9a6b9149dc9a043b6fc02a6c07e47d58a3a606b97051ecddabddeadafc06a9e88c96e70 + languageName: node + linkType: hard + "css-selector-tokenizer@npm:^0.7.2": version: 0.7.3 resolution: "css-selector-tokenizer@npm:0.7.3" @@ -21407,6 +21387,13 @@ __metadata: languageName: node linkType: hard +"css-what@npm:^7.0.0": + version: 7.0.0 + resolution: "css-what@npm:7.0.0" + checksum: 10/253c3013f8aee74b60d7d5c60bc62f3151a32f59ec6a9cc74527bb514d3aa746466f81fc10d57d85c58ee777d6e82eced7c5733a2181a884458dfc134518afab + languageName: node + linkType: hard + "css.escape@npm:^1.5.1": version: 1.5.1 resolution: "css.escape@npm:1.5.1" @@ -22629,7 +22616,7 @@ __metadata: languageName: node linkType: hard -"domutils@npm:3.2.2, domutils@npm:^3.0.1, domutils@npm:^3.2.1": +"domutils@npm:3.2.2, domutils@npm:^3.0.1, domutils@npm:^3.2.1, domutils@npm:^3.2.2": version: 3.2.2 resolution: "domutils@npm:3.2.2" dependencies: @@ -22907,17 +22894,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.263": - version: 1.5.267 - resolution: "electron-to-chromium@npm:1.5.267" - checksum: 10/05e55e810cb6a3cda8d29dfdeec7ac0e59727a77a796a157f1a1d65edac16d45eed69ed5c99e354872ab16c48967c2d0a0600653051ae380a3b7a4d6210b1e60 - languageName: node - linkType: hard - -"electron-to-chromium@npm:^1.5.328": - version: 1.5.336 - resolution: "electron-to-chromium@npm:1.5.336" - checksum: 10/85b31ea1fff12ce6f3738f9598a189644509d72027b4cdc6b5f2c74763e94877a807e6ba7b20199ca716a9c5697a2dd7e79e15a49bf17d75a3a3181cbf936e09 +"electron-to-chromium@npm:^1.5.393": + version: 1.5.425 + resolution: "electron-to-chromium@npm:1.5.425" + checksum: 10/c8bf467e0b639c5ffac58a046afb5d8088b5aae2513dd017096ee89f26fc7585c37f56ec6c6e71fdbc75fc31120c5736961c78a79c71f2edb5e14392319533b4 languageName: node linkType: hard @@ -23031,21 +23011,21 @@ __metadata: languageName: node linkType: hard -"engine.io@npm:~6.5.2": - version: 6.5.5 - resolution: "engine.io@npm:6.5.5" +"engine.io@npm:6.6.7": + version: 6.6.7 + resolution: "engine.io@npm:6.6.7" dependencies: - "@types/cookie": "npm:^0.4.1" "@types/cors": "npm:^2.8.12" "@types/node": "npm:>=10.0.0" + "@types/ws": "npm:^8.5.12" accepts: "npm:~1.3.4" base64id: "npm:2.0.0" - cookie: "npm:~0.4.1" + cookie: "npm:~0.7.2" cors: "npm:~2.8.5" - debug: "npm:~4.3.1" + debug: "npm:~4.4.1" engine.io-parser: "npm:~5.2.1" - ws: "npm:~8.17.1" - checksum: 10/df8562e5249cf122efad77b909fe804b36ac5769676f963c997d4d18c91e014c68bb40661ff92f641b978baa0297be4000c2f3c3d1ce237cd1771952ccc5f38a + ws: "npm:~8.18.3" + checksum: 10/58dd9adfd40667835e75bef8087e5e4a03bc61cbcc1eb542a6c9a5b4446c9c572779a1a46e399de50bf2091bd0663db6ae1eee1e4c471cddc62da84a862b1081 languageName: node linkType: hard @@ -24140,10 +24120,10 @@ __metadata: languageName: node linkType: hard -"fast-uri@npm:^3.0.1": - version: 3.1.0 - resolution: "fast-uri@npm:3.1.0" - checksum: 10/818b2c96dc913bcf8511d844c3d2420e2c70b325c0653633f51821e4e29013c2015387944435cd0ef5322c36c9beecc31e44f71b257aeb8e0b333c1d62bb17c2 +"fast-uri@npm:3.1.6": + version: 3.1.6 + resolution: "fast-uri@npm:3.1.6" + checksum: 10/de34fceafd3a1d917989a1116b092d9b6ebe6fa3c9e908a308b988a6455449fa0a0256b6ef628aa7da80f1f84031d704c290d6ba0ea4c6f4c42b7974a3bd43ae languageName: node linkType: hard @@ -24603,16 +24583,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:4.0.4": - version: 4.0.4 - resolution: "form-data@npm:4.0.4" +"form-data@npm:4.0.6": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10/de6614c8537c92fa5fa3ee7e827758f98f5a9c033f348b7de81855ef36e5cb867e75d9f405d9483ab8d724a4a20d4e79926a299fa8dbba38f530eb659f0884e4 languageName: node linkType: hard @@ -25760,6 +25740,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10/13823863ae48161068b4c51606a3128451c66f14545a5169d667fe9fca168dcd38c27570c7a299e32ef844b8da3d55def7fe88602f8970d4311fb543ee88001a + languageName: node + linkType: hard + "hast-util-from-dom@npm:^5.0.0": version: 5.0.1 resolution: "hast-util-from-dom@npm:5.0.1" @@ -26445,6 +26434,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.2": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10/b88a3f4527795c96854743bcdcdd03e0083ff5f1c072cb13d3e27c67cd276efd9a2a364922cb836538e976456f82323d645f7ea91d5bc8da1f000dc16742a0aa + languageName: node + linkType: hard + "iconv-lite@npm:~0.7.0": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" @@ -26501,10 +26499,10 @@ __metadata: languageName: node linkType: hard -"immutable@npm:3.8.3": - version: 3.8.3 - resolution: "immutable@npm:3.8.3" - checksum: 10/29db366d4dff83b0b296150c351b08db24837005909a71c123d860c1d2a40605f19a3ecd56d1e96be9799e947ddf7906897a28fa2e81f0b8c63c652b6bfe5550 +"immutable@npm:3.8.4": + version: 3.8.4 + resolution: "immutable@npm:3.8.4" + checksum: 10/e6c8dd8fa52154fe0b2348636ff322fa97240757243746544047e9ef93105e645aed797c85f27edd7fd98e3d15560b3e6f1dc42ddbeaa4c1f3593222da0b5c53 languageName: node linkType: hard @@ -26939,20 +26937,10 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:10.1.0": - version: 10.1.0 - resolution: "ip-address@npm:10.1.0" - checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e - languageName: node - linkType: hard - -"ip-address@npm:^9.0.5": - version: 9.0.5 - resolution: "ip-address@npm:9.0.5" - dependencies: - jsbn: "npm:1.1.0" - sprintf-js: "npm:^1.1.3" - checksum: 10/1ed81e06721af012306329b31f532b5e24e00cb537be18ddc905a84f19fe8f83a09a1699862bf3a1ec4b9dea93c55a3fa5faf8b5ea380431469df540f38b092c +"ip-address@npm:10.3.1": + version: 10.3.1 + resolution: "ip-address@npm:10.3.1" + checksum: 10/cc0d5b9953acb4114990a3887e0701f8fe843903811f982cf2856cd307c5fb6cdca426ad16c39a1f4950091935c8be4d42631a443f797a96626cce161fffed41 languageName: node linkType: hard @@ -28446,17 +28434,10 @@ __metadata: languageName: node linkType: hard -"js-cookie@npm:3.0.5": - version: 3.0.5 - resolution: "js-cookie@npm:3.0.5" - checksum: 10/366494b1630b9fb8abaef3659748db5dfd52c58c6fc3459b9f0a03b492593bc1b01c6dfcc066b46f6413c28edb3a00cc68fb61ea8cdf6991bedf1f100f8a389d - languageName: node - linkType: hard - -"js-cookie@npm:^2.2.1": - version: 2.2.1 - resolution: "js-cookie@npm:2.2.1" - checksum: 10/4387f5f5691cb96ca9ff8852c589d3012b53f484fda68630a39e20cabc6c5b740f09225e23233ba56cd9de6ebe300a23d20b2c7315f10c309ad5a89fd8c4990b +"js-cookie@npm:3.0.7": + version: 3.0.7 + resolution: "js-cookie@npm:3.0.7" + checksum: 10/c0542acd416806182baaaecaae9a1dd7754b48699589c5c3a088495cee9fc2e44f81e66567b6e76e16d29c469655fdc9cd948edb2cfd9ece446f8817b9046ce9 languageName: node linkType: hard @@ -28488,21 +28469,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" +"js-yaml@npm:4.3.1": + version: 4.3.1 + resolution: "js-yaml@npm:4.3.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10/a52d0519f0f4ef5b4adc1cde466cb54c50d56e2b4a983b9d5c9c0f2f99462047007a6274d7e95617a21d3c91fde3ee6115536ed70991cd645ba8521058b78f77 - languageName: node - linkType: hard - -"jsbn@npm:1.1.0": - version: 1.1.0 - resolution: "jsbn@npm:1.1.0" - checksum: 10/bebe7ae829bbd586ce8cbe83501dd8cb8c282c8902a8aeeed0a073a89dc37e8103b1244f3c6acd60278bcbfe12d93a3f83c9ac396868a3b3bbc3c5e5e3b648ef + checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42 languageName: node linkType: hard @@ -28696,14 +28670,14 @@ __metadata: languageName: node linkType: hard -"jsondiffpatch@npm:0.7.2": - version: 0.7.2 - resolution: "jsondiffpatch@npm:0.7.2" +"jsondiffpatch@npm:0.7.6": + version: 0.7.6 + resolution: "jsondiffpatch@npm:0.7.6" dependencies: "@dmsnell/diff-match-patch": "npm:^1.1.0" bin: jsondiffpatch: bin/jsondiffpatch.js - checksum: 10/c9bc19d48612a0cd4c072274ffde06b90b68ecb8d32605e0e12f50e289bca07d0c89cd2ff2ef31066fd1c605501856626595cb2b6c9655cee91c3cbf64891d38 + checksum: 10/d741d5547c28c97098ac46fcad63805992e30567e9d9ecb16780f6d1f2c8e2d6838ef3ede7b6b17295baf87acb5bef03f4d2fb6159eba81dcbcbd6e66c6ac79d languageName: node linkType: hard @@ -28868,10 +28842,10 @@ __metadata: languageName: node linkType: hard -"kysely@npm:0.28.14": - version: 0.28.14 - resolution: "kysely@npm:0.28.14" - checksum: 10/d76ff120c4c295091e0c8ecf9f2fb920547247d0cf6f0cf0098cb6d85fd77f18da09ddd18345477a527b82eddc087a145783772fc0e09e9280239fb87df83be9 +"kysely@npm:0.28.17": + version: 0.28.17 + resolution: "kysely@npm:0.28.17" + checksum: 10/50690d745016d25f09574fe58c7e31f84427845497f30c84280131bc1f9f60d118c53640a184e225708a1f54f2b54ac12fdf3a0dfc6405527487ad96ff594f42 languageName: node linkType: hard @@ -29599,13 +29573,20 @@ __metadata: languageName: node linkType: hard -"long@npm:^5.0.0, long@npm:^5.2.1": +"long@npm:^5.0.0": version: 5.2.3 resolution: "long@npm:5.2.3" checksum: 10/9167ec6947a825b827c30da169a7384eec6c0c9ec2f0b9c74da2e93d81159bbe39fb09c3f13dae9721d4b807ccfa09797a7dd1012f5d478e3e33ca3c78b608e6 languageName: node linkType: hard +"long@npm:^5.3.2": + version: 5.3.2 + resolution: "long@npm:5.3.2" + checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 + languageName: node + linkType: hard + "longest-streak@npm:^3.0.0": version: 3.1.0 resolution: "longest-streak@npm:3.1.0" @@ -29697,7 +29678,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^7.14.1, lru-cache@npm:^7.7.1": +"lru-cache@npm:^7.7.1": version: 7.18.3 resolution: "lru-cache@npm:7.18.3" checksum: 10/6029ca5aba3aacb554e919d7ef804fffd4adfc4c83db00fac8248c7c78811fb6d4b6f70f7fd9d55032b3823446546a007edaa66ad1f2377ae833bd983fac5d98 @@ -29724,10 +29705,10 @@ __metadata: languageName: node linkType: hard -"lru.min@npm:^1.0.0": - version: 1.1.2 - resolution: "lru.min@npm:1.1.2" - checksum: 10/74e56b21048a496dda6a5bd2b6104d919b3bae7ddfd4e50b2724a0b48516394f0b95dd350b8a8818572bfeac9944c9a69c405dac8825f6ddf082775fe0592c6b +"lru.min@npm:^1.1.0, lru.min@npm:^1.1.4": + version: 1.1.5 + resolution: "lru.min@npm:1.1.5" + checksum: 10/3a12912fde1c60249e42cae9a8b26c3beb5884c6ebb350575f9440459be205d016c7fbbc6eb30cc485eb526ce124bab36b4d6c2de4c7e4aa24ca175194e4e7ff languageName: node linkType: hard @@ -31040,7 +31021,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.27, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -31683,15 +31664,15 @@ __metadata: languageName: node linkType: hard -"multer@npm:2.1.1": - version: 2.1.1 - resolution: "multer@npm:2.1.1" +"multer@npm:2.2.0": + version: 2.2.0 + resolution: "multer@npm:2.2.0" dependencies: append-field: "npm:^1.0.0" busboy: "npm:^1.6.0" concat-stream: "npm:^2.0.0" type-is: "npm:^1.6.18" - checksum: 10/fb22868caaed37d725715c14c60b740b81665265da3a026bb61954414f65b99f76b360128413b8a2a7cc1a95ecae28a42bf831fe172bb79682d19ec105b556bd + checksum: 10/24525113776291c1909e6ca6ee260c41a6dc4bc6dea944485f6cc0d1442a7690829a07f2dcdea5edd5886bb6ab87df795da6ffb6de7289e1c140ec98b8e9d253 languageName: node linkType: hard @@ -31726,20 +31707,21 @@ __metadata: languageName: node linkType: hard -"mysql2@npm:3.14.1": - version: 3.14.1 - resolution: "mysql2@npm:3.14.1" +"mysql2@npm:3.22.0": + version: 3.22.0 + resolution: "mysql2@npm:3.22.0" dependencies: - aws-ssl-profiles: "npm:^1.1.1" + aws-ssl-profiles: "npm:^1.1.2" denque: "npm:^2.1.0" generate-function: "npm:^2.3.1" - iconv-lite: "npm:^0.6.3" - long: "npm:^5.2.1" - lru.min: "npm:^1.0.0" - named-placeholders: "npm:^1.1.3" - seq-queue: "npm:^0.0.5" - sqlstring: "npm:^2.3.2" - checksum: 10/d3eb069b5f42d735df9f89be1c194d432cafe9064ce5d7352eda6597fb90b33e671dd46adc88256174b46c7972db99923f9db702ac2aaf0fbd0430c19dd90ed0 + iconv-lite: "npm:^0.7.2" + long: "npm:^5.3.2" + lru.min: "npm:^1.1.4" + named-placeholders: "npm:^1.1.6" + sql-escaper: "npm:^1.3.3" + peerDependencies: + "@types/node": ">= 8" + checksum: 10/fbfa0665624f479eeef9d0a9027157f035f07f2f5e782a32ec8b74af95342d3deb8f22f39817e25476fb86d7f93b194a6a319b82ac3af273e011b7a6208ca14e languageName: node linkType: hard @@ -31754,12 +31736,12 @@ __metadata: languageName: node linkType: hard -"named-placeholders@npm:^1.1.3": - version: 1.1.3 - resolution: "named-placeholders@npm:1.1.3" +"named-placeholders@npm:^1.1.6": + version: 1.1.6 + resolution: "named-placeholders@npm:1.1.6" dependencies: - lru-cache: "npm:^7.14.1" - checksum: 10/7834adc91e92ae1b9c4413384e3ccd297de5168bb44017ff0536705ddc4db421723bd964607849265feb3f6ded390f84cf138e5925f22f7c13324f87a803dc73 + lru.min: "npm:^1.1.0" + checksum: 10/959d44f2a00e87baa2c2b2d43c2be76c6e841a0ed67af4bf4a8d91c54082e2b2db9ac8461b16db413dcb133c6f34669516d755f9a1a7956851ce81196a717f65 languageName: node linkType: hard @@ -31798,7 +31780,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.1.23, nanoid@npm:^3.3.11, nanoid@npm:^3.3.6, nanoid@npm:^3.3.8": +"nanoid@npm:^3.1.23, nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" bin: @@ -31807,6 +31789,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.12": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" + bin: + nanoid: bin/nanoid.cjs + checksum: 10/1b3b4fdac831b92b56d1dbe8b1e63a372432faf86ea383134e3b53141ef8108a60b7f84343b5cefecac9550bb74edf8164da93ea07d1c4f757039bc75d8a5d9e + languageName: node + linkType: hard + "nanoid@npm:^4.0.2": version: 4.0.2 resolution: "nanoid@npm:4.0.2" @@ -32496,17 +32487,10 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.27": - version: 2.0.27 - resolution: "node-releases@npm:2.0.27" - checksum: 10/f6c78ddb392ae500719644afcbe68a9ea533242c02312eb6a34e8478506eb7482a3fb709c70235b01c32fe65625b68dfa9665113f816d87f163bc3819b62b106 - languageName: node - linkType: hard - -"node-releases@npm:^2.0.36": - version: 2.0.37 - resolution: "node-releases@npm:2.0.37" - checksum: 10/c4b376a7cd15cd6a0ed93a65a51ca865a07282b583ede0b42233cec60351faafb5a5d2afe652e916b53fd34b1e97ea5444fa6ce77b06f289a9622d159d6ac9e7 +"node-releases@npm:^2.0.51": + version: 2.0.55 + resolution: "node-releases@npm:2.0.55" + checksum: 10/1ca12c725eeaf9d4645a4ed159c19ae5ce8303a0063443c95fb5d97edc21c0d3c6d55ae7d96ae21724ca73800ea0149ea60af73f3ed0ddb10f0b05da84d36efc languageName: node linkType: hard @@ -32714,7 +32698,7 @@ __metadata: languageName: node linkType: hard -"nth-check@npm:^2.0.1": +"nth-check@npm:^2.0.1, nth-check@npm:^2.1.1": version: 2.1.1 resolution: "nth-check@npm:2.1.1" dependencies: @@ -34675,25 +34659,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.4.31": - version: 8.4.31 - resolution: "postcss@npm:8.4.31" +"postcss@npm:8.5.18": + version: 8.5.18 + resolution: "postcss@npm:8.5.18" dependencies: - nanoid: "npm:^3.3.6" - picocolors: "npm:^1.0.0" - source-map-js: "npm:^1.0.2" - checksum: 10/1a6653e72105907377f9d4f2cd341d8d90e3fde823a5ddea1e2237aaa56933ea07853f0f2758c28892a1d70c53bbaca200eb8b80f8ed55f13093003dbec5afa0 - languageName: node - linkType: hard - -"postcss@npm:8.5.6, postcss@npm:^8.3.11, postcss@npm:^8.4.23, postcss@npm:^8.4.41, postcss@npm:^8.5.3": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" - dependencies: - nanoid: "npm:^3.3.11" + nanoid: "npm:^3.3.12" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10/9e4fbe97574091e9736d0e82a591e29aa100a0bf60276a926308f8c57249698935f35c5d2f4e80de778d0cbb8dcffab4f383d85fd50c5649aca421c3df729b86 + checksum: 10/b43f2d2493d82dc43fa50d21cf184bf3b66229e7f53fa1dedc7b8a7c4cecd7ac8ee18fad099f47570da7f78bbf46997a4c6e6c87d8d8ece683dc1f32cda6db3b languageName: node linkType: hard @@ -35071,23 +35044,23 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:7.5.6": - version: 7.5.6 - resolution: "protobufjs@npm:7.5.6" +"protobufjs@npm:7.6.1": + version: 7.6.1 + resolution: "protobufjs@npm:7.6.1" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" "@protobufjs/codegen": "npm:^2.0.5" - "@protobufjs/eventemitter": "npm:^1.1.0" - "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/eventemitter": "npm:^1.1.1" + "@protobufjs/fetch": "npm:^1.1.1" "@protobufjs/float": "npm:^1.0.2" - "@protobufjs/inquire": "npm:^1.1.1" + "@protobufjs/inquire": "npm:^1.1.2" "@protobufjs/path": "npm:^1.1.2" "@protobufjs/pool": "npm:^1.1.0" "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" - long: "npm:^5.0.0" - checksum: 10/13d7ab5b5d588b704476a600506cda9f647e2f657faf5a18e8866ce5b04800026a508a4958673e4d5674090bbc528717b01128f8e226055d56e90745e3ee6ada + long: "npm:^5.3.2" + checksum: 10/010dcf77ab435da50d62887d57cc4b68384768e4d1cbe85948ec747996222ea659501b7c6b6d3426f96fdbaf59f577213788ce461c0938f89b8126569d8be7e2 languageName: node linkType: hard @@ -37130,10 +37103,10 @@ __metadata: languageName: node linkType: hard -"sax@npm:^1.5.0": - version: 1.6.0 - resolution: "sax@npm:1.6.0" - checksum: 10/0909cedcd9f011ceeac80c0240a92d64ef712cf6c04e0f6ee236a8d812f86a59f61bee6bb5da28d75306db050b99e0593051ea77351795822253b984af6cf044 +"sax@npm:1.6.1": + version: 1.6.1 + resolution: "sax@npm:1.6.1" + checksum: 10/bf7e7f8293903548bd663d71e9300b20ee04df178399b8f54029b6559225de422235c8fb0a63ba088fcb90c1dbaf9b475f57c7f32c901bfb78d0196dceb3cc7f languageName: node linkType: hard @@ -37358,13 +37331,6 @@ __metadata: languageName: node linkType: hard -"seq-queue@npm:^0.0.5": - version: 0.0.5 - resolution: "seq-queue@npm:0.0.5" - checksum: 10/fa302e3b2aaece644532603ae42d675f9b8750e395a98740dd58dc5e02985ce6f0c2b78715b5984d6f6a807893735a14212a70d6ec591e6fba410397269588a0 - languageName: node - linkType: hard - "sequin@npm:*": version: 0.1.1 resolution: "sequin@npm:0.1.1" @@ -37673,10 +37639,10 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:1.8.4": - version: 1.8.4 - resolution: "shell-quote@npm:1.8.4" - checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c +"shell-quote@npm:1.9.0": + version: 1.9.0 + resolution: "shell-quote@npm:1.9.0" + checksum: 10/c3bc91e74a469c4f96b6fd13b0c777fff16bcbda202692a4e5402d3d6b78fc6e02fe92fdf8dc0bddf992a9c6758e43207bfc5bdbefbf8a58190b1c885cbcc171 languageName: node linkType: hard @@ -37986,13 +37952,13 @@ __metadata: languageName: node linkType: hard -"socket.io-parser@npm:4.2.6": - version: 4.2.6 - resolution: "socket.io-parser@npm:4.2.6" +"socket.io-parser@npm:4.2.7": + version: 4.2.7 + resolution: "socket.io-parser@npm:4.2.7" dependencies: "@socket.io/component-emitter": "npm:~3.1.0" debug: "npm:~4.4.1" - checksum: 10/cf8f3e9feee42b2405065bccef732894a0b07b7cb734c4954eb8876d7a0038e7df6e6d06fab3a25f816f5d996917391833d5f06136e8c8c6fbecf5da962c1c9e + checksum: 10/da1920b9dae989e2c491aa9c248ced7d03d8b1056acf3144c0992878fdc8b91fb50638df38c0e387317f8bc748718ea2ae648d83ff837a5a0ede20ce277b2af9 languageName: node linkType: hard @@ -38071,7 +38037,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3 @@ -38252,6 +38218,13 @@ __metadata: languageName: node linkType: hard +"sql-escaper@npm:^1.3.3": + version: 1.5.1 + resolution: "sql-escaper@npm:1.5.1" + checksum: 10/787ff7b829152380ac996bdba29a631f2005b772c302426d4676b24246ee47787cbdf9b0e565060851e078e2d15f7a014d6c48e637ec9a0127f0cbb436743ef4 + languageName: node + linkType: hard + "sql-highlight@npm:^6.0.0": version: 6.0.0 resolution: "sql-highlight@npm:6.0.0" @@ -38280,13 +38253,6 @@ __metadata: languageName: node linkType: hard -"sqlstring@npm:^2.3.2": - version: 2.3.3 - resolution: "sqlstring@npm:2.3.3" - checksum: 10/4e5a25af2d77a031fe00694034bf9fd822ddc3a483c9383124b120aa6b9ae9ab71e173cd29fba9c653998ebfef9e97be668957839960b9b3dc1afcb45f1ddb64 - languageName: node - linkType: hard - "srt-parser-2@npm:1.2.3": version: 1.2.3 resolution: "srt-parser-2@npm:1.2.3" @@ -38948,20 +38914,20 @@ __metadata: languageName: node linkType: hard -"svgo@npm:4.0.1": - version: 4.0.1 - resolution: "svgo@npm:4.0.1" +"svgo@npm:4.1.0": + version: 4.1.0 + resolution: "svgo@npm:4.1.0" dependencies: commander: "npm:^11.1.0" - css-select: "npm:^5.1.0" + css-select: "npm:^6.0.0" css-tree: "npm:^3.0.1" - css-what: "npm:^6.1.0" + css-what: "npm:^7.0.0" csso: "npm:^5.0.5" picocolors: "npm:^1.1.1" - sax: "npm:^1.5.0" + sax: "npm:1.6.1" bin: - svgo: ./bin/svgo.js - checksum: 10/8791aa12f3d1a5b3da12a67c2f880917512eaf32dad40563ae474deefff0630a4ce2259e06730f02150756ac77cc8b06598d30fb3ed3f02f085e6cbfbd344fb6 + svgo: bin/svgo.js + checksum: 10/72a501b49a9ceed175e1e1a66208516641a9f4a1c1908bb6bf4cf2e9678ce68e827af68a569a532dfdbbf7929abf044bbb8b47aa6a0fc9aedc127791ad1df650 languageName: node linkType: hard @@ -40976,20 +40942,6 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.2.0": - version: 1.2.2 - resolution: "update-browserslist-db@npm:1.2.2" - dependencies: - escalade: "npm:^3.2.0" - picocolors: "npm:^1.1.1" - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 10/ae2102d3c83fca35e9deb012d82bfde6f734998ced937e34a3bf239a4b67577108fdd144283aafc0e5e3cf38ca1aecd7714906ba6f562896c762d2f2fa391026 - languageName: node - linkType: hard - "update-browserslist-db@npm:^1.2.3": version: 1.2.3 resolution: "update-browserslist-db@npm:1.2.3" @@ -42202,7 +42154,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.17.1, ws@npm:^8.18.0": +"ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:~8.18.3": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: From 4234549670d02cdfcf4003f6e7f6398a317f2e21 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 08:48:55 +0000 Subject: [PATCH 18/30] chore(git): untrack generated files (MD-02) --- .gitignore | 4 + .husky/pre-commit | 2 +- .../app-store/analytics.services.generated.ts | 10 - packages/app-store/apps.browser.generated.tsx | 71 ------ .../app-store/apps.keys-schemas.generated.ts | 108 -------- packages/app-store/apps.metadata.generated.ts | 232 ------------------ packages/app-store/apps.schemas.generated.ts | 108 -------- packages/app-store/apps.server.generated.ts | 90 ------- .../bookerApps.metadata.generated.ts | 96 -------- .../app-store/calendar.services.generated.ts | 20 -- packages/app-store/crm.apps.generated.ts | 13 - .../app-store/payment.services.generated.ts | 12 - packages/app-store/redirect-apps.generated.ts | 36 --- .../app-store/video.adapters.generated.ts | 21 -- 14 files changed, 5 insertions(+), 818 deletions(-) delete mode 100644 packages/app-store/analytics.services.generated.ts delete mode 100644 packages/app-store/apps.browser.generated.tsx delete mode 100644 packages/app-store/apps.keys-schemas.generated.ts delete mode 100644 packages/app-store/apps.metadata.generated.ts delete mode 100644 packages/app-store/apps.schemas.generated.ts delete mode 100644 packages/app-store/apps.server.generated.ts delete mode 100644 packages/app-store/bookerApps.metadata.generated.ts delete mode 100644 packages/app-store/calendar.services.generated.ts delete mode 100644 packages/app-store/crm.apps.generated.ts delete mode 100644 packages/app-store/payment.services.generated.ts delete mode 100644 packages/app-store/redirect-apps.generated.ts delete mode 100644 packages/app-store/video.adapters.generated.ts diff --git a/.gitignore b/.gitignore index c5dc10054cb..d7910c76890 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,10 @@ packages/prisma/generated/prisma/* packages/prisma/zod/**/*.ts packages/prisma/enums +# generated by app-store-cli (MD-02) +packages/app-store/*.generated.ts +packages/app-store/*.generated.tsx + # Builds dist packages/app-store/types diff --git a/.husky/pre-commit b/.husky/pre-commit index 4d851070860..6984983414c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,4 +4,4 @@ if [ -f .git/MERGE_HEAD ]; then fi yarn lint-staged --verbose -yarn app-store:build && git add packages/app-store/*.generated.* +yarn app-store:build diff --git a/packages/app-store/analytics.services.generated.ts b/packages/app-store/analytics.services.generated.ts deleted file mode 100644 index 92bc7220830..00000000000 --- a/packages/app-store/analytics.services.generated.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const AnalyticsServiceMap = - process.env.NEXT_PUBLIC_IS_E2E === "1" - ? {} - : { - dub: import("./dub/lib/AnalyticsService"), - }; diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx deleted file mode 100644 index 4c66c0fcfdd..00000000000 --- a/packages/app-store/apps.browser.generated.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -import dynamic from "next/dynamic"; -export const InstallAppButtonMap = { - exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")), - exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")), - office365video: dynamic(() => import("./office365video/components/InstallAppButton")), - vital: dynamic(() => import("./vital/components/InstallAppButton")), -}; -export const AppSettingsComponentsMap = { - "general-app-settings": dynamic( - () => import("./templates/general-app-settings/components/AppSettingsInterface") - ), - weather_in_your_calendar: dynamic( - () => import("./weather_in_your_calendar/components/AppSettingsInterface") - ), - zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")), -}; -export const EventTypeAddonMap = { - alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")), - basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")), - btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")), - closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")), - crovecrm: dynamic(() => import("./crovecrm/components/EventTypeAppCardInterface")), - databuddy: dynamic(() => import("./databuddy/components/EventTypeAppCardInterface")), - fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")), - ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")), - giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")), - gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")), - hitpay: dynamic(() => import("./hitpay/components/EventTypeAppCardInterface")), - hubspot: dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")), - insihts: dynamic(() => import("./insihts/components/EventTypeAppCardInterface")), - matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")), - metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")), - "mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")), - paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")), - "pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")), - plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")), - posthog: dynamic(() => import("./posthog/components/EventTypeAppCardInterface")), - qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")), - salesforce: dynamic(() => import("./salesforce/components/EventTypeAppCardInterface")), - stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")), - "booking-pages-tag": dynamic( - () => import("./templates/booking-pages-tag/components/EventTypeAppCardInterface") - ), - "event-type-app-card": dynamic( - () => import("./templates/event-type-app-card/components/EventTypeAppCardInterface") - ), - twipla: dynamic(() => import("./twipla/components/EventTypeAppCardInterface")), - umami: dynamic(() => import("./umami/components/EventTypeAppCardInterface")), - "zoho-bigin": dynamic(() => import("./zoho-bigin/components/EventTypeAppCardInterface")), - zohocrm: dynamic(() => import("./zohocrm/components/EventTypeAppCardInterface")), -}; -export const EventTypeSettingsMap = { - alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")), - basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")), - btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")), - databuddy: dynamic(() => import("./databuddy/components/EventTypeAppSettingsInterface")), - fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")), - ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")), - giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")), - gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")), - hitpay: dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")), - metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")), - paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")), - plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")), - qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")), - stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")), -}; diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts deleted file mode 100644 index 7e18e6a35db..00000000000 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -import { appKeysSchema as alby_zod_ts } from "./alby/zod"; -import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod"; -import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod"; -import { appKeysSchema as closecom_zod_ts } from "./closecom/zod"; -import { appKeysSchema as crovecrm_zod_ts } from "./crovecrm/zod"; -import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; -import { appKeysSchema as databuddy_zod_ts } from "./databuddy/zod"; -import { appKeysSchema as dub_zod_ts } from "./dub/zod"; -import { appKeysSchema as fathom_zod_ts } from "./fathom/zod"; -import { appKeysSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; -import { appKeysSchema as ga4_zod_ts } from "./ga4/zod"; -import { appKeysSchema as giphy_zod_ts } from "./giphy/zod"; -import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; -import { appKeysSchema as googlevideo_zod_ts } from "./googlevideo/zod"; -import { appKeysSchema as gtm_zod_ts } from "./gtm/zod"; -import { appKeysSchema as hitpay_zod_ts } from "./hitpay/zod"; -import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod"; -import { appKeysSchema as insihts_zod_ts } from "./insihts/zod"; -import { appKeysSchema as intercom_zod_ts } from "./intercom/zod"; -import { appKeysSchema as jelly_zod_ts } from "./jelly/zod"; -import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; -import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; -import { appKeysSchema as lyra_zod_ts } from "./lyra/zod"; -import { appKeysSchema as make_zod_ts } from "./make/zod"; -import { appKeysSchema as matomo_zod_ts } from "./matomo/zod"; -import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; -import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; -import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; -import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; -import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; -import { appKeysSchema as paypal_zod_ts } from "./paypal/zod"; -import { appKeysSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; -import { appKeysSchema as plausible_zod_ts } from "./plausible/zod"; -import { appKeysSchema as posthog_zod_ts } from "./posthog/zod"; -import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod"; -import { appKeysSchema as salesforce_zod_ts } from "./salesforce/zod"; -import { appKeysSchema as shimmervideo_zod_ts } from "./shimmervideo/zod"; -import { appKeysSchema as stripepayment_zod_ts } from "./stripepayment/zod"; -import { appKeysSchema as tandemvideo_zod_ts } from "./tandemvideo/zod"; -import { appKeysSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod"; -import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod"; -import { appKeysSchema as twipla_zod_ts } from "./twipla/zod"; -import { appKeysSchema as umami_zod_ts } from "./umami/zod"; -import { appKeysSchema as vital_zod_ts } from "./vital/zod"; -import { appKeysSchema as webex_zod_ts } from "./webex/zod"; -import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod"; -import { appKeysSchema as zapier_zod_ts } from "./zapier/zod"; -import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod"; -import { appKeysSchema as zohocalendar_zod_ts } from "./zohocalendar/zod"; -import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod"; -import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; -export const appKeysSchemas = { - alby: alby_zod_ts, - basecamp3: basecamp3_zod_ts, - btcpayserver: btcpayserver_zod_ts, - closecom: closecom_zod_ts, - crovecrm: crovecrm_zod_ts, - dailyvideo: dailyvideo_zod_ts, - databuddy: databuddy_zod_ts, - dub: dub_zod_ts, - fathom: fathom_zod_ts, - feishucalendar: feishucalendar_zod_ts, - ga4: ga4_zod_ts, - giphy: giphy_zod_ts, - googlecalendar: googlecalendar_zod_ts, - googlevideo: googlevideo_zod_ts, - gtm: gtm_zod_ts, - hitpay: hitpay_zod_ts, - hubspot: hubspot_zod_ts, - insihts: insihts_zod_ts, - intercom: intercom_zod_ts, - jelly: jelly_zod_ts, - jitsivideo: jitsivideo_zod_ts, - larkcalendar: larkcalendar_zod_ts, - lyra: lyra_zod_ts, - make: make_zod_ts, - matomo: matomo_zod_ts, - metapixel: metapixel_zod_ts, - "mock-payment-app": mock_payment_app_zod_ts, - nextcloudtalk: nextcloudtalk_zod_ts, - office365calendar: office365calendar_zod_ts, - office365video: office365video_zod_ts, - paypal: paypal_zod_ts, - "pipedrive-crm": pipedrive_crm_zod_ts, - plausible: plausible_zod_ts, - posthog: posthog_zod_ts, - qr_code: qr_code_zod_ts, - salesforce: salesforce_zod_ts, - shimmervideo: shimmervideo_zod_ts, - stripe: stripepayment_zod_ts, - tandemvideo: tandemvideo_zod_ts, - "booking-pages-tag": booking_pages_tag_zod_ts, - "event-type-app-card": event_type_app_card_zod_ts, - twipla: twipla_zod_ts, - umami: umami_zod_ts, - vital: vital_zod_ts, - webex: webex_zod_ts, - wordpress: wordpress_zod_ts, - zapier: zapier_zod_ts, - "zoho-bigin": zoho_bigin_zod_ts, - zohocalendar: zohocalendar_zod_ts, - zohocrm: zohocrm_zod_ts, - zoomvideo: zoomvideo_zod_ts, -}; diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts deleted file mode 100644 index b4e948723af..00000000000 --- a/packages/app-store/apps.metadata.generated.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -import alby_config_json from "./alby/config.json"; -import amie_config_json from "./amie/config.json"; -import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata"; -import attio_config_json from "./attio/config.json"; -import autocheckin_config_json from "./autocheckin/config.json"; -import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json"; -import basecamp3_config_json from "./basecamp3/config.json"; -import bolna_config_json from "./bolna/config.json"; -import btcpayserver_config_json from "./btcpayserver/config.json"; -import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata"; -import campfire_config_json from "./campfire/config.json"; -import caretta_config_json from "./caretta/config.json"; -import chatbase_config_json from "./chatbase/config.json"; -import clara_config_json from "./clara/config.json"; -import clic_config_json from "./clic/config.json"; -import closecom_config_json from "./closecom/config.json"; -import cron_config_json from "./cron/config.json"; -import crovecrm_config_json from "./crovecrm/config.json"; -import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata"; -import databuddy_config_json from "./databuddy/config.json"; -import deel_config_json from "./deel/config.json"; -import demodesk_config_json from "./demodesk/config.json"; -import dialpad_config_json from "./dialpad/config.json"; -import discord_config_json from "./discord/config.json"; -import dub_config_json from "./dub/config.json"; -import eightxeight_config_json from "./eightxeight/config.json"; -import element_call_config_json from "./element-call/config.json"; -import elevenlabs_config_json from "./elevenlabs/config.json"; -import { metadata as exchange2013calendar__metadata_ts } from "./exchange2013calendar/_metadata"; -import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016calendar/_metadata"; -import exchangecalendar_config_json from "./exchangecalendar/config.json"; -import facetime_config_json from "./facetime/config.json"; -import famulor_config_json from "./famulor/config.json"; -import fathom_config_json from "./fathom/config.json"; -import { metadata as feishucalendar__metadata_ts } from "./feishucalendar/_metadata"; -import fonio_ai_config_json from "./fonio-ai/config.json"; -import framer_config_json from "./framer/config.json"; -import ga4_config_json from "./ga4/config.json"; -import { metadata as giphy__metadata_ts } from "./giphy/_metadata"; -import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata"; -import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata"; -import granola_config_json from "./granola/config.json"; -import greetmate_ai_config_json from "./greetmate-ai/config.json"; -import gtm_config_json from "./gtm/config.json"; -import hitpay_config_json from "./hitpay/config.json"; -import horizon_workrooms_config_json from "./horizon-workrooms/config.json"; -import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata"; -import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata"; -import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json"; -import insihts_config_json from "./insihts/config.json"; -import intercom_config_json from "./intercom/config.json"; -import jelly_config_json from "./jelly/config.json"; -import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; -import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; -import lindy_config_json from "./lindy/config.json"; -import linear_config_json from "./linear/config.json"; -import lyra_config_json from "./lyra/config.json"; -import make_config_json from "./make/config.json"; -import matomo_config_json from "./matomo/config.json"; -import metapixel_config_json from "./metapixel/config.json"; -import millis_ai_config_json from "./millis-ai/config.json"; -import mirotalk_config_json from "./mirotalk/config.json"; -import mock_payment_app_config_json from "./mock-payment-app/config.json"; -import monobot_config_json from "./monobot/config.json"; -import n8n_config_json from "./n8n/config.json"; -import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; -import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata"; -import office365video_config_json from "./office365video/config.json"; -import paypal_config_json from "./paypal/config.json"; -import ping_config_json from "./ping/config.json"; -import pipedream_config_json from "./pipedream/config.json"; -import pipedrive_crm_config_json from "./pipedrive-crm/config.json"; -import plausible_config_json from "./plausible/config.json"; -import posthog_config_json from "./posthog/config.json"; -import qr_code_config_json from "./qr_code/config.json"; -import raycast_config_json from "./raycast/config.json"; -import retell_ai_config_json from "./retell-ai/config.json"; -import riverside_config_json from "./riverside/config.json"; -import roam_config_json from "./roam/config.json"; -import salesforce_config_json from "./salesforce/config.json"; -import salesroom_config_json from "./salesroom/config.json"; -import sendgrid_config_json from "./sendgrid/config.json"; -import shimmervideo_config_json from "./shimmervideo/config.json"; -import signal_config_json from "./signal/config.json"; -import sirius_video_config_json from "./sirius_video/config.json"; -import skype_config_json from "./skype/config.json"; -import { metadata as stripepayment__metadata_ts } from "./stripepayment/_metadata"; -import sylapsvideo_config_json from "./sylapsvideo/config.json"; -import synthflow_config_json from "./synthflow/config.json"; -import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata"; -import telegram_config_json from "./telegram/config.json"; -import telli_config_json from "./telli/config.json"; -import basic_config_json from "./templates/basic/config.json"; -import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json"; -import event_type_app_card_config_json from "./templates/event-type-app-card/config.json"; -import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json"; -import general_app_settings_config_json from "./templates/general-app-settings/config.json"; -import link_as_an_app_config_json from "./templates/link-as-an-app/config.json"; -import twipla_config_json from "./twipla/config.json"; -import umami_config_json from "./umami/config.json"; -import vimcal_config_json from "./vimcal/config.json"; -import { metadata as vital__metadata_ts } from "./vital/_metadata"; -import weather_in_your_calendar_config_json from "./weather_in_your_calendar/config.json"; -import webex_config_json from "./webex/config.json"; -import whatsapp_config_json from "./whatsapp/config.json"; -import whereby_config_json from "./whereby/config.json"; -import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata"; -import wordpress_config_json from "./wordpress/config.json"; -import zapier_config_json from "./zapier/config.json"; -import zoho_bigin_config_json from "./zoho-bigin/config.json"; -import zohocalendar_config_json from "./zohocalendar/config.json"; -import zohocrm_config_json from "./zohocrm/config.json"; -import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; -export const appStoreMetadata = { - alby: alby_config_json, - amie: amie_config_json, - applecalendar: applecalendar__metadata_ts, - attio: attio_config_json, - autocheckin: autocheckin_config_json, - "baa-for-hipaa": baa_for_hipaa_config_json, - basecamp3: basecamp3_config_json, - bolna: bolna_config_json, - btcpayserver: btcpayserver_config_json, - caldavcalendar: caldavcalendar__metadata_ts, - campfire: campfire_config_json, - caretta: caretta_config_json, - chatbase: chatbase_config_json, - clara: clara_config_json, - clic: clic_config_json, - closecom: closecom_config_json, - cron: cron_config_json, - crovecrm: crovecrm_config_json, - dailyvideo: dailyvideo__metadata_ts, - databuddy: databuddy_config_json, - deel: deel_config_json, - demodesk: demodesk_config_json, - dialpad: dialpad_config_json, - discord: discord_config_json, - dub: dub_config_json, - eightxeight: eightxeight_config_json, - "element-call": element_call_config_json, - elevenlabs: elevenlabs_config_json, - exchange2013calendar: exchange2013calendar__metadata_ts, - exchange2016calendar: exchange2016calendar__metadata_ts, - exchangecalendar: exchangecalendar_config_json, - facetime: facetime_config_json, - famulor: famulor_config_json, - fathom: fathom_config_json, - feishucalendar: feishucalendar__metadata_ts, - "fonio-ai": fonio_ai_config_json, - framer: framer_config_json, - ga4: ga4_config_json, - giphy: giphy__metadata_ts, - googlecalendar: googlecalendar__metadata_ts, - googlevideo: googlevideo__metadata_ts, - granola: granola_config_json, - "greetmate-ai": greetmate_ai_config_json, - gtm: gtm_config_json, - hitpay: hitpay_config_json, - "horizon-workrooms": horizon_workrooms_config_json, - hubspot: hubspot__metadata_ts, - huddle01video: huddle01video__metadata_ts, - "ics-feedcalendar": ics_feedcalendar_config_json, - insihts: insihts_config_json, - intercom: intercom_config_json, - jelly: jelly_config_json, - jitsivideo: jitsivideo__metadata_ts, - larkcalendar: larkcalendar__metadata_ts, - lindy: lindy_config_json, - linear: linear_config_json, - lyra: lyra_config_json, - make: make_config_json, - matomo: matomo_config_json, - metapixel: metapixel_config_json, - "millis-ai": millis_ai_config_json, - mirotalk: mirotalk_config_json, - "mock-payment-app": mock_payment_app_config_json, - monobot: monobot_config_json, - n8n: n8n_config_json, - nextcloudtalk: nextcloudtalk_config_json, - office365calendar: office365calendar__metadata_ts, - office365video: office365video_config_json, - paypal: paypal_config_json, - ping: ping_config_json, - pipedream: pipedream_config_json, - "pipedrive-crm": pipedrive_crm_config_json, - plausible: plausible_config_json, - posthog: posthog_config_json, - qr_code: qr_code_config_json, - raycast: raycast_config_json, - "retell-ai": retell_ai_config_json, - riverside: riverside_config_json, - roam: roam_config_json, - salesforce: salesforce_config_json, - salesroom: salesroom_config_json, - sendgrid: sendgrid_config_json, - shimmervideo: shimmervideo_config_json, - signal: signal_config_json, - sirius_video: sirius_video_config_json, - skype: skype_config_json, - stripepayment: stripepayment__metadata_ts, - sylapsvideo: sylapsvideo_config_json, - synthflow: synthflow_config_json, - tandemvideo: tandemvideo__metadata_ts, - telegram: telegram_config_json, - telli: telli_config_json, - basic: basic_config_json, - "booking-pages-tag": booking_pages_tag_config_json, - "event-type-app-card": event_type_app_card_config_json, - "event-type-location-video-static": event_type_location_video_static_config_json, - "general-app-settings": general_app_settings_config_json, - "link-as-an-app": link_as_an_app_config_json, - twipla: twipla_config_json, - umami: umami_config_json, - vimcal: vimcal_config_json, - vital: vital__metadata_ts, - weather_in_your_calendar: weather_in_your_calendar_config_json, - webex: webex_config_json, - whatsapp: whatsapp_config_json, - whereby: whereby_config_json, - wipemycalother: wipemycalother__metadata_ts, - wordpress: wordpress_config_json, - zapier: zapier_config_json, - "zoho-bigin": zoho_bigin_config_json, - zohocalendar: zohocalendar_config_json, - zohocrm: zohocrm_config_json, - zoomvideo: zoomvideo__metadata_ts, -}; diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts deleted file mode 100644 index d3b8011ddfb..00000000000 --- a/packages/app-store/apps.schemas.generated.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -import { appDataSchema as alby_zod_ts } from "./alby/zod"; -import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod"; -import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod"; -import { appDataSchema as closecom_zod_ts } from "./closecom/zod"; -import { appDataSchema as crovecrm_zod_ts } from "./crovecrm/zod"; -import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; -import { appDataSchema as databuddy_zod_ts } from "./databuddy/zod"; -import { appDataSchema as dub_zod_ts } from "./dub/zod"; -import { appDataSchema as fathom_zod_ts } from "./fathom/zod"; -import { appDataSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; -import { appDataSchema as ga4_zod_ts } from "./ga4/zod"; -import { appDataSchema as giphy_zod_ts } from "./giphy/zod"; -import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; -import { appDataSchema as googlevideo_zod_ts } from "./googlevideo/zod"; -import { appDataSchema as gtm_zod_ts } from "./gtm/zod"; -import { appDataSchema as hitpay_zod_ts } from "./hitpay/zod"; -import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod"; -import { appDataSchema as insihts_zod_ts } from "./insihts/zod"; -import { appDataSchema as intercom_zod_ts } from "./intercom/zod"; -import { appDataSchema as jelly_zod_ts } from "./jelly/zod"; -import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; -import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; -import { appDataSchema as lyra_zod_ts } from "./lyra/zod"; -import { appDataSchema as make_zod_ts } from "./make/zod"; -import { appDataSchema as matomo_zod_ts } from "./matomo/zod"; -import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; -import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; -import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; -import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; -import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; -import { appDataSchema as paypal_zod_ts } from "./paypal/zod"; -import { appDataSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; -import { appDataSchema as plausible_zod_ts } from "./plausible/zod"; -import { appDataSchema as posthog_zod_ts } from "./posthog/zod"; -import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod"; -import { appDataSchema as salesforce_zod_ts } from "./salesforce/zod"; -import { appDataSchema as shimmervideo_zod_ts } from "./shimmervideo/zod"; -import { appDataSchema as stripepayment_zod_ts } from "./stripepayment/zod"; -import { appDataSchema as tandemvideo_zod_ts } from "./tandemvideo/zod"; -import { appDataSchema as booking_pages_tag_zod_ts } from "./templates/booking-pages-tag/zod"; -import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-type-app-card/zod"; -import { appDataSchema as twipla_zod_ts } from "./twipla/zod"; -import { appDataSchema as umami_zod_ts } from "./umami/zod"; -import { appDataSchema as vital_zod_ts } from "./vital/zod"; -import { appDataSchema as webex_zod_ts } from "./webex/zod"; -import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod"; -import { appDataSchema as zapier_zod_ts } from "./zapier/zod"; -import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod"; -import { appDataSchema as zohocalendar_zod_ts } from "./zohocalendar/zod"; -import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod"; -import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; -export const appDataSchemas = { - alby: alby_zod_ts, - basecamp3: basecamp3_zod_ts, - btcpayserver: btcpayserver_zod_ts, - closecom: closecom_zod_ts, - crovecrm: crovecrm_zod_ts, - dailyvideo: dailyvideo_zod_ts, - databuddy: databuddy_zod_ts, - dub: dub_zod_ts, - fathom: fathom_zod_ts, - feishucalendar: feishucalendar_zod_ts, - ga4: ga4_zod_ts, - giphy: giphy_zod_ts, - googlecalendar: googlecalendar_zod_ts, - googlevideo: googlevideo_zod_ts, - gtm: gtm_zod_ts, - hitpay: hitpay_zod_ts, - hubspot: hubspot_zod_ts, - insihts: insihts_zod_ts, - intercom: intercom_zod_ts, - jelly: jelly_zod_ts, - jitsivideo: jitsivideo_zod_ts, - larkcalendar: larkcalendar_zod_ts, - lyra: lyra_zod_ts, - make: make_zod_ts, - matomo: matomo_zod_ts, - metapixel: metapixel_zod_ts, - "mock-payment-app": mock_payment_app_zod_ts, - nextcloudtalk: nextcloudtalk_zod_ts, - office365calendar: office365calendar_zod_ts, - office365video: office365video_zod_ts, - paypal: paypal_zod_ts, - "pipedrive-crm": pipedrive_crm_zod_ts, - plausible: plausible_zod_ts, - posthog: posthog_zod_ts, - qr_code: qr_code_zod_ts, - salesforce: salesforce_zod_ts, - shimmervideo: shimmervideo_zod_ts, - stripe: stripepayment_zod_ts, - tandemvideo: tandemvideo_zod_ts, - "booking-pages-tag": booking_pages_tag_zod_ts, - "event-type-app-card": event_type_app_card_zod_ts, - twipla: twipla_zod_ts, - umami: umami_zod_ts, - vital: vital_zod_ts, - webex: webex_zod_ts, - wordpress: wordpress_zod_ts, - zapier: zapier_zod_ts, - "zoho-bigin": zoho_bigin_zod_ts, - zohocalendar: zohocalendar_zod_ts, - zohocrm: zohocrm_zod_ts, - zoomvideo: zoomvideo_zod_ts, -}; diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts deleted file mode 100644 index d5c6fa9f950..00000000000 --- a/packages/app-store/apps.server.generated.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const apiHandlers = { - alby: import("./alby/api"), - applecalendar: import("./applecalendar/api"), - attio: import("./attio/api"), - basecamp3: import("./basecamp3/api"), - btcpayserver: import("./btcpayserver/api"), - caldavcalendar: import("./caldavcalendar/api"), - campfire: import("./campfire/api"), - closecom: import("./closecom/api"), - databuddy: import("./databuddy/api"), - demodesk: import("./demodesk/api"), - dialpad: import("./dialpad/api"), - discord: import("./discord/api"), - dub: import("./dub/api"), - eightxeight: import("./eightxeight/api"), - "element-call": import("./element-call/api"), - exchange2013calendar: import("./exchange2013calendar/api"), - exchange2016calendar: import("./exchange2016calendar/api"), - exchangecalendar: import("./exchangecalendar/api"), - facetime: import("./facetime/api"), - famulor: import("./famulor/api"), - fathom: import("./fathom/api"), - feishucalendar: import("./feishucalendar/api"), - ga4: import("./ga4/api"), - giphy: import("./giphy/api"), - googlecalendar: import("./googlecalendar/api"), - googlevideo: import("./googlevideo/api"), - gtm: import("./gtm/api"), - hitpay: import("./hitpay/api"), - "horizon-workrooms": import("./horizon-workrooms/api"), - hubspot: import("./hubspot/api"), - huddle01video: import("./huddle01video/api"), - "ics-feedcalendar": import("./ics-feedcalendar/api"), - insihts: import("./insihts/api"), - intercom: import("./intercom/api"), - jelly: import("./jelly/api"), - jitsivideo: import("./jitsivideo/api"), - larkcalendar: import("./larkcalendar/api"), - linear: import("./linear/api"), - lyra: import("./lyra/api"), - make: import("./make/api"), - matomo: import("./matomo/api"), - metapixel: import("./metapixel/api"), - mirotalk: import("./mirotalk/api"), - "mock-payment-app": import("./mock-payment-app/api"), - nextcloudtalk: import("./nextcloudtalk/api"), - office365calendar: import("./office365calendar/api"), - office365video: import("./office365video/api"), - paypal: import("./paypal/api"), - ping: import("./ping/api"), - "pipedrive-crm": import("./pipedrive-crm/api"), - plausible: import("./plausible/api"), - posthog: import("./posthog/api"), - qr_code: import("./qr_code/api"), - riverside: import("./riverside/api"), - roam: import("./roam/api"), - salesforce: import("./salesforce/api"), - salesroom: import("./salesroom/api"), - sendgrid: import("./sendgrid/api"), - shimmervideo: import("./shimmervideo/api"), - signal: import("./signal/api"), - sirius_video: import("./sirius_video/api"), - skype: import("./skype/api"), - stripepayment: import("./stripepayment/api"), - sylapsvideo: import("./sylapsvideo/api"), - tandemvideo: import("./tandemvideo/api"), - telegram: import("./telegram/api"), - basic: import("./templates/basic/api"), - "booking-pages-tag": import("./templates/booking-pages-tag/api"), - "event-type-app-card": import("./templates/event-type-app-card/api"), - "event-type-location-video-static": import("./templates/event-type-location-video-static/api"), - "general-app-settings": import("./templates/general-app-settings/api"), - twipla: import("./twipla/api"), - umami: import("./umami/api"), - vital: import("./vital/api"), - weather_in_your_calendar: import("./weather_in_your_calendar/api"), - webex: import("./webex/api"), - whatsapp: import("./whatsapp/api"), - whereby: import("./whereby/api"), - wipemycalother: import("./wipemycalother/api"), - zapier: import("./zapier/api"), - "zoho-bigin": import("./zoho-bigin/api"), - zohocalendar: import("./zohocalendar/api"), - zohocrm: import("./zohocrm/api"), - zoomvideo: import("./zoomvideo/api"), -}; diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts deleted file mode 100644 index 16d9e3f77e5..00000000000 --- a/packages/app-store/bookerApps.metadata.generated.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -import campfire_config_json from "./campfire/config.json"; -import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata"; -import databuddy_config_json from "./databuddy/config.json"; -import demodesk_config_json from "./demodesk/config.json"; -import dialpad_config_json from "./dialpad/config.json"; -import discord_config_json from "./discord/config.json"; -import eightxeight_config_json from "./eightxeight/config.json"; -import element_call_config_json from "./element-call/config.json"; -import facetime_config_json from "./facetime/config.json"; -import fathom_config_json from "./fathom/config.json"; -import ga4_config_json from "./ga4/config.json"; -import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata"; -import gtm_config_json from "./gtm/config.json"; -import horizon_workrooms_config_json from "./horizon-workrooms/config.json"; -import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata"; -import insihts_config_json from "./insihts/config.json"; -import jelly_config_json from "./jelly/config.json"; -import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; -import lyra_config_json from "./lyra/config.json"; -import matomo_config_json from "./matomo/config.json"; -import metapixel_config_json from "./metapixel/config.json"; -import mirotalk_config_json from "./mirotalk/config.json"; -import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; -import office365video_config_json from "./office365video/config.json"; -import ping_config_json from "./ping/config.json"; -import plausible_config_json from "./plausible/config.json"; -import posthog_config_json from "./posthog/config.json"; -import riverside_config_json from "./riverside/config.json"; -import roam_config_json from "./roam/config.json"; -import salesroom_config_json from "./salesroom/config.json"; -import shimmervideo_config_json from "./shimmervideo/config.json"; -import signal_config_json from "./signal/config.json"; -import sirius_video_config_json from "./sirius_video/config.json"; -import skype_config_json from "./skype/config.json"; -import sylapsvideo_config_json from "./sylapsvideo/config.json"; -import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata"; -import telegram_config_json from "./telegram/config.json"; -import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json"; -import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json"; -import twipla_config_json from "./twipla/config.json"; -import umami_config_json from "./umami/config.json"; -import webex_config_json from "./webex/config.json"; -import whatsapp_config_json from "./whatsapp/config.json"; -import whereby_config_json from "./whereby/config.json"; -import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; -export const appStoreMetadata = { - campfire: campfire_config_json, - dailyvideo: dailyvideo__metadata_ts, - databuddy: databuddy_config_json, - demodesk: demodesk_config_json, - dialpad: dialpad_config_json, - discord: discord_config_json, - eightxeight: eightxeight_config_json, - "element-call": element_call_config_json, - facetime: facetime_config_json, - fathom: fathom_config_json, - ga4: ga4_config_json, - googlevideo: googlevideo__metadata_ts, - gtm: gtm_config_json, - "horizon-workrooms": horizon_workrooms_config_json, - huddle01video: huddle01video__metadata_ts, - insihts: insihts_config_json, - jelly: jelly_config_json, - jitsivideo: jitsivideo__metadata_ts, - lyra: lyra_config_json, - matomo: matomo_config_json, - metapixel: metapixel_config_json, - mirotalk: mirotalk_config_json, - nextcloudtalk: nextcloudtalk_config_json, - office365video: office365video_config_json, - ping: ping_config_json, - plausible: plausible_config_json, - posthog: posthog_config_json, - riverside: riverside_config_json, - roam: roam_config_json, - salesroom: salesroom_config_json, - shimmervideo: shimmervideo_config_json, - signal: signal_config_json, - sirius_video: sirius_video_config_json, - skype: skype_config_json, - sylapsvideo: sylapsvideo_config_json, - tandemvideo: tandemvideo__metadata_ts, - telegram: telegram_config_json, - "booking-pages-tag": booking_pages_tag_config_json, - "event-type-location-video-static": event_type_location_video_static_config_json, - twipla: twipla_config_json, - umami: umami_config_json, - webex: webex_config_json, - whatsapp: whatsapp_config_json, - whereby: whereby_config_json, - zoomvideo: zoomvideo__metadata_ts, -}; diff --git a/packages/app-store/calendar.services.generated.ts b/packages/app-store/calendar.services.generated.ts deleted file mode 100644 index 9795cfe01ea..00000000000 --- a/packages/app-store/calendar.services.generated.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const CalendarServiceMap = - process.env.NEXT_PUBLIC_IS_E2E === "1" - ? {} - : { - applecalendar: import("./applecalendar/lib/CalendarService"), - caldavcalendar: import("./caldavcalendar/lib/CalendarService"), - exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"), - exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"), - exchangecalendar: import("./exchangecalendar/lib/CalendarService"), - feishucalendar: import("./feishucalendar/lib/CalendarService"), - googlecalendar: import("./googlecalendar/lib/CalendarService"), - "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), - larkcalendar: import("./larkcalendar/lib/CalendarService"), - office365calendar: import("./office365calendar/lib/CalendarService"), - zohocalendar: import("./zohocalendar/lib/CalendarService"), - }; diff --git a/packages/app-store/crm.apps.generated.ts b/packages/app-store/crm.apps.generated.ts deleted file mode 100644 index ac262dd84d7..00000000000 --- a/packages/app-store/crm.apps.generated.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const CrmServiceMap = { - closecom: import("./closecom/lib/CrmService"), - crovecrm: import("./crovecrm/lib/CrmService"), - hubspot: import("./hubspot/lib/CrmService"), - "pipedrive-crm": import("./pipedrive-crm/lib/CrmService"), - salesforce: import("./salesforce/lib/CrmService"), - "zoho-bigin": import("./zoho-bigin/lib/CrmService"), - zohocrm: import("./zohocrm/lib/CrmService"), -}; diff --git a/packages/app-store/payment.services.generated.ts b/packages/app-store/payment.services.generated.ts deleted file mode 100644 index 2e42d632580..00000000000 --- a/packages/app-store/payment.services.generated.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const PaymentServiceMap = { - alby: import("./alby/lib/PaymentService"), - btcpayserver: import("./btcpayserver/lib/PaymentService"), - hitpay: import("./hitpay/lib/PaymentService"), - "mock-payment-app": import("./mock-payment-app/lib/PaymentService"), - paypal: import("./paypal/lib/PaymentService"), - stripepayment: import("./stripepayment/lib/PaymentService"), -}; diff --git a/packages/app-store/redirect-apps.generated.ts b/packages/app-store/redirect-apps.generated.ts deleted file mode 100644 index 82edae73fe0..00000000000 --- a/packages/app-store/redirect-apps.generated.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const REDIRECT_APPS = [ - "amie", - "autocheckin", - "baa-for-hipaa", - "bolna", - "caretta", - "chatbase", - "clara", - "clic", - "cron", - "deel", - "elevenlabs", - "famulor", - "fonio-ai", - "framer", - "granola", - "greetmate-ai", - "lindy", - "linear", - "millis-ai", - "monobot", - "n8n", - "pipedream", - "raycast", - "retell-ai", - "synthflow", - "telli", - "vimcal", - "wordpress", - "zapier", -] as const; -export type RedirectApp = (typeof REDIRECT_APPS)[number]; diff --git a/packages/app-store/video.adapters.generated.ts b/packages/app-store/video.adapters.generated.ts deleted file mode 100644 index f1e0f05ffe7..00000000000 --- a/packages/app-store/video.adapters.generated.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - This file is autogenerated using the command `yarn app-store:build --watch`. - Don't modify this file manually. -**/ -export const VideoApiAdapterMap = - process.env.NEXT_PUBLIC_IS_E2E === "1" - ? {} - : { - dailyvideo: import("./dailyvideo/lib/VideoApiAdapter"), - huddle01video: import("./huddle01video/lib/VideoApiAdapter"), - jelly: import("./jelly/lib/VideoApiAdapter"), - jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"), - lyra: import("./lyra/lib/VideoApiAdapter"), - nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"), - office365video: import("./office365video/lib/VideoApiAdapter"), - shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"), - sylapsvideo: import("./sylapsvideo/lib/VideoApiAdapter"), - tandemvideo: import("./tandemvideo/lib/VideoApiAdapter"), - webex: import("./webex/lib/VideoApiAdapter"), - zoomvideo: import("./zoomvideo/lib/VideoApiAdapter"), - }; From 33c804a2cd48defe60e8afb1a583aacc0815738f Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 09:00:59 +0000 Subject: [PATCH 19/30] fix(security): SSRF guard on self-hosted, fail-closed video tokens, timing-safe webhook compares The self-hosted branch of ssrfProtection skipped the private-IP, blocklist and DNS checks entirely, letting any authenticated user point outbound webhooks at 169.254.169.254 or internal services; checks now run everywhere and only an explicit SSRF_ALLOW_PRIVATE_IPS=true opts out. CAL_VIDEO_RECORDING_TOKEN_SECRET no longer falls back to a hardcoded public string and recording token verification compares timing-safely. The hitpay, daily-video and app-credential webhook receivers now compare signatures in constant time via a shared helper instead of !==. --- .../web/app/api/recorded-daily-video/route.ts | 6 +- .../app/api/webhook/app-credential/route.ts | 11 +++- packages/app-store/hitpay/api/webhook.ts | 7 ++- packages/lib/ssrfProtection.test.ts | 62 +++++++++++++++++-- packages/lib/ssrfProtection.ts | 55 ++++++++++++---- packages/lib/videoTokens.ts | 23 ++++++- packages/lib/webhook-signature.ts | 16 ++++- 7 files changed, 157 insertions(+), 23 deletions(-) diff --git a/apps/web/app/api/recorded-daily-video/route.ts b/apps/web/app/api/recorded-daily-video/route.ts index 4c9834219ea..9194fad9fb2 100644 --- a/apps/web/app/api/recorded-daily-video/route.ts +++ b/apps/web/app/api/recorded-daily-video/route.ts @@ -19,6 +19,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature"; import { generateVideoToken } from "@calcom/lib/videoTokens"; import prisma from "@calcom/prisma"; import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking"; @@ -70,7 +71,10 @@ export async function postHandler(request: NextRequest) { const webhookTimestamp = headersList.get("x-webhook-timestamp"); const computed_signature = computeSignature(hmacSecret, body, webhookTimestamp); - if (headersList.get("x-webhook-signature") !== computed_signature) { + const receivedSignature = headersList.get("x-webhook-signature"); + // Why: `!==` on the signature short-circuits at the first differing byte, leaking + // timing information that helps forge HMAC signatures; compare in constant time. + if (!receivedSignature || !timingSafeStringsEqual(receivedSignature, computed_signature)) { return NextResponse.json({ message: "Signature does not match" }, { status: 403 }); } } diff --git a/apps/web/app/api/webhook/app-credential/route.ts b/apps/web/app/api/webhook/app-credential/route.ts index fb81a841fda..a902714e3a0 100644 --- a/apps/web/app/api/webhook/app-credential/route.ts +++ b/apps/web/app/api/webhook/app-credential/route.ts @@ -7,6 +7,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants"; import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature"; import prisma from "@calcom/prisma"; const appCredentialWebhookRequestBodySchema = z.object({ @@ -23,7 +24,15 @@ async function postHandler(request: NextRequest) { } const secretHeader = request.headers.get(CREDENTIAL_SYNC_SECRET_HEADER_NAME); - if (secretHeader !== CREDENTIAL_SYNC_SECRET) { + // Why: `!==` on the secret short-circuits at the first differing byte, leaking + // timing information that helps recover the credential sync secret; compare in + // constant time. CREDENTIAL_SYNC_SECRET is only set when + // APP_CREDENTIAL_SHARING_ENABLED (checked above), so the empty case stays a 403. + if ( + !secretHeader || + !CREDENTIAL_SYNC_SECRET || + !timingSafeStringsEqual(secretHeader, CREDENTIAL_SYNC_SECRET) + ) { return NextResponse.json({ message: "Invalid credential sync secret" }, { status: 403 }); } diff --git a/packages/app-store/hitpay/api/webhook.ts b/packages/app-store/hitpay/api/webhook.ts index f0e11b63c6f..45b33ade161 100644 --- a/packages/app-store/hitpay/api/webhook.ts +++ b/packages/app-store/hitpay/api/webhook.ts @@ -7,6 +7,7 @@ import { distributedTracing } from "@calcom/lib/tracing/factory"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { timingSafeStringsEqual } from "@calcom/lib/webhook-signature"; import prisma from "@calcom/prisma"; import appConfig from "../config.json"; @@ -104,7 +105,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { saltKey } = keyObj; const signed = generateSignatureArray(saltKey, excluded as ExcludedWebhookReturn); - if (signed !== obj.hmac) { + // Why: `!==` on the HMAC short-circuits at the first differing byte, leaking + // timing information that helps forge webhook signatures; compare in constant + // time. The typeof check keeps a malformed body (no hmac) on the 400 path since + // obj is cast from untrusted JSON. + if (typeof obj.hmac !== "string" || !timingSafeStringsEqual(signed, obj.hmac)) { throw new HttpCode({ statusCode: 400, message: "Bad Request" }); } diff --git a/packages/lib/ssrfProtection.test.ts b/packages/lib/ssrfProtection.test.ts index a11712c2a39..9a3218c1ae9 100644 --- a/packages/lib/ssrfProtection.test.ts +++ b/packages/lib/ssrfProtection.test.ts @@ -177,19 +177,71 @@ describe("Self-hosted environment behavior", () => { afterEach(() => { vi.doUnmock("@calcom/lib/constants"); + vi.doUnmock("node:dns/promises"); + vi.unstubAllEnvs(); + }); + + it("blocks private IPs on self-hosted by default (same as cloud)", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://192.168.1.1/webhook")).toEqual({ + isValid: false, + error: "Private IP address", + }); + expect(validateSelfHosted("http://10.0.0.1/webhook").isValid).toBe(false); + expect(validateSelfHosted("http://172.16.0.1/webhook").isValid).toBe(false); + }); + + it("blocks localhost on self-hosted by default", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://localhost:3000/webhook").isValid).toBe(false); + expect(validateSelfHosted("http://127.0.0.1:3000/webhook").isValid).toBe(false); + }); + + it("still allows public HTTP URLs on self-hosted", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://example.com/webhook").isValid).toBe(true); + expect(validateSelfHosted("https://internal.example.com/webhook").isValid).toBe(true); }); - it("allows private IPs for self-hosted (internal webhooks)", async () => { + it("allows private targets when SSRF_ALLOW_PRIVATE_IPS=true", async () => { + vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true"); const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://127.0.0.1/webhook").isValid).toBe(true); expect(validateSelfHosted("http://192.168.1.1/webhook").isValid).toBe(true); - expect(validateSelfHosted("http://10.0.0.1/webhook").isValid).toBe(true); - expect(validateSelfHosted("http://172.16.0.1/webhook").isValid).toBe(true); + expect(validateSelfHosted("http://internal-service.local/webhook").isValid).toBe(true); + }); + + it("still blocks cloud metadata endpoints when SSRF_ALLOW_PRIVATE_IPS=true", async () => { + vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true"); + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://169.254.169.254/latest/meta-data/").isValid).toBe(false); + expect(validateSelfHosted("http://metadata.google.internal/computeMetadata/v1/").isValid).toBe(false); + }); + + it("skips the DNS private-IP check when SSRF_ALLOW_PRIVATE_IPS=true", async () => { + vi.doMock("node:dns/promises", () => ({ + default: { + lookup: vi.fn().mockResolvedValue([{ address: "10.0.0.5", family: 4 }]), + }, + })); + vi.stubEnv("SSRF_ALLOW_PRIVATE_IPS", "true"); + const { validateUrlForSSRF: validateSelfHosted } = await import("./ssrfProtection"); + expect((await validateSelfHosted("http://internal-service.local/webhook")).isValid).toBe(true); + }); + + it("rejects hostnames that resolve to private IPs on self-hosted (DNS rebinding protection)", async () => { + vi.doMock("node:dns/promises", () => ({ + default: { + lookup: vi.fn().mockResolvedValue([{ address: "10.0.0.5", family: 4 }]), + }, + })); + const { validateUrlForSSRF: validateSelfHosted } = await import("./ssrfProtection"); + expect((await validateSelfHosted("http://internal-service.local/webhook")).isValid).toBe(false); }); it("allows HTTP URLs for self-hosted", async () => { const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); - expect(validateSelfHosted("http://internal-service.local/webhook").isValid).toBe(true); - expect(validateSelfHosted("http://localhost:3000/webhook").isValid).toBe(true); + expect(validateSelfHosted("http://public-webhook.example.com/webhook").isValid).toBe(true); }); it("still blocks cloud metadata endpoints even on self-hosted", async () => { diff --git a/packages/lib/ssrfProtection.ts b/packages/lib/ssrfProtection.ts index 50051c2ea20..2c47c40217e 100644 --- a/packages/lib/ssrfProtection.ts +++ b/packages/lib/ssrfProtection.ts @@ -90,6 +90,17 @@ export function isBlockedHostname(hostname: string): boolean { return BLOCKED_HOSTNAMES.includes(normalized); } +/** + * Self-hosted operators may set SSRF_ALLOW_PRIVATE_IPS="true" to allow URLs pointing + * at internal services (private/loopback IPs and hostnames that resolve to them). + * Only the private-IP and DNS-resolution checks are skipped; cloud metadata endpoints + * stay blocked unconditionally. Ignored on Cal.diy cloud, where private-IP protection + * is never disabled. + */ +function isPrivateTargetOptOut(): boolean { + return IS_SELF_HOSTED && process.env.SSRF_ALLOW_PRIVATE_IPS === "true"; +} + // Check if hostname is a cloud metadata endpoint (blocked even on self-hosted) function isCloudMetadataEndpoint(hostname: string): boolean { const normalized = normalizeHostname(hostname); @@ -139,13 +150,31 @@ function validateUrlCore(urlString: string): SSRFValidationResult | { url: URL } return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; } - // Self-hosted: allow HTTP and private IPs (for internal webhooks) - // Still restrict to HTTP/HTTPS protocols only (no file://, ftp://, etc.) + // Self-hosted: HTTP is allowed, but private-network targets are blocked exactly + // like on cloud unless SSRF_ALLOW_PRIVATE_IPS=true opts out. Why: previously this + // branch returned early after only a protocol check, so on self-hosted any + // authenticated user could reach internal services (127.0.0.1, 169.254.169.254, + // internal hostnames) through user-supplied URLs such as viewer.webhook.testTrigger. + // Metadata endpoints stay blocked unconditionally above (self-hosted boxes may + // still run on AWS/GCP/Azure). if (IS_SELF_HOSTED) { if (url.protocol !== "http:" && url.protocol !== "https:") { return { isValid: false, error: ERRORS.INVALID_PROTOCOL }; } - return { isValid: true }; + + if (!isPrivateTargetOptOut()) { + if (isBlockedHostname(url.hostname)) { + return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; + } + + // Check if hostname is an IP address and if it's private + const hostnameForIPCheck = stripIPv6Brackets(url.hostname); + if (ipaddr.isValid(hostnameForIPCheck) && isPrivateIP(hostnameForIPCheck)) { + return { isValid: false, error: ERRORS.PRIVATE_IP }; + } + } + + return { url }; } if (url.protocol !== "https:") { @@ -176,16 +205,20 @@ export async function validateUrlForSSRF(urlString: string): Promise parseInt(expires, 10)) { return { valid: false }; @@ -27,7 +42,9 @@ export function verifyVideoToken(token: string): { const payload = `${recordingId}:${expires}`; const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex"); - if (receivedHmac !== expectedHmac) { + // Why: `!==` on the hex digest short-circuits on the first differing byte, + // leaking timing information; compare in constant time instead. + if (typeof receivedHmac !== "string" || !timingSafeStringsEqual(receivedHmac, expectedHmac)) { return { valid: false }; } diff --git a/packages/lib/webhook-signature.ts b/packages/lib/webhook-signature.ts index 7c2054b8ce8..9e8f05cd82e 100644 --- a/packages/lib/webhook-signature.ts +++ b/packages/lib/webhook-signature.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; const SHA256_HEX_64_PATTERN = /^[0-9a-fA-F]{64}$/; @@ -29,3 +29,17 @@ export function verifyWebhookSignature( // timingSafeEqual throws when buffer lengths differ, so validate the hex length above before comparing return timingSafeEqual(Buffer.from(providedSignature, "hex"), Buffer.from(expectedSignature, "hex")); } + +/** + * Constant-time comparison of two arbitrary strings (secrets or HMAC digests). + * Both sides are hashed with SHA-256 first so the buffers always have equal length, + * which timingSafeEqual requires. + * + * Why: `===` short-circuits at the first differing byte, leaking timing information + * that helps an attacker forge signatures or secrets byte by byte. + */ +export function timingSafeStringsEqual(a: string, b: string): boolean { + const digestA = createHash("sha256").update(a, "utf8").digest(); + const digestB = createHash("sha256").update(b, "utf8").digest(); + return timingSafeEqual(digestA, digestB); +} From a81ee2e86c8fe87114a2a49df669d7080374603c Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 09:12:27 +0000 Subject: [PATCH 20/30] fix(trpc): rate limit sensitive auth mutations and add security headers Login, signup, password reset and 2FA verification endpoints accepted unlimited brute-force attempts; they now run through an in-memory sliding-window limiter keyed by route, IP and hashed identity (hashing prevents cross-identity budget exhaustion), returning TOO_MANY_REQUESTS with a retry-after window. The limiter lives in a plain helper invoked from inline .use callbacks because tRPC's invariant middleware generics cannot unify a standalone factory with authedProcedure's context. Also adds HSTS, nosniff, referrer and permissions-policy headers to apps/web. --- apps/web/next.config.ts | 69 ++++++++++ packages/lib/authRateLimiter.ts | 120 +++++++++++++++++ .../middlewares/authRateLimitMiddleware.ts | 75 +++++++++++ .../viewer/__tests__/authRateLimit.test.ts | 124 ++++++++++++++++++ .../server/routers/viewer/auth/_router.tsx | 116 ++++++++++------ 5 files changed, 464 insertions(+), 40 deletions(-) create mode 100644 packages/lib/authRateLimiter.ts create mode 100644 packages/trpc/server/middlewares/authRateLimitMiddleware.ts create mode 100644 packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 133974fe4cb..0e81d2c34f0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -370,6 +370,8 @@ const nextConfig = (phase: string): NextConfig => { }, async headers() { const { orgSlug } = nextJsOrgRewriteConfig; + const IS_DEV = process.env.NODE_ENV !== "production"; + const CORP_CROSS_ORIGIN_HEADER = { key: "Cross-Origin-Resource-Policy", value: "cross-origin", @@ -380,6 +382,48 @@ const nextConfig = (phase: string): NextConfig => { value: "*", }; + // Why: Next/Vercel inline bootstrap + hydration scripts require 'unsafe-inline', so the + // always-on baseline keeps it. This stays compatible with the opt-in nonce policy + // (lib/csp.ts) because browsers intersect multiple CSP headers — nonce'd scripts pass + // both policies. 'unsafe-eval' is dev-only (HMR/React refresh). + const SCRIPT_SRC = IS_DEV ? "'self' 'unsafe-inline' 'unsafe-eval'" : "'self' 'unsafe-inline'"; + + const buildCspValue = (frameAncestors: string): string => + [ + `script-src ${SCRIPT_SRC}`, + // 'unsafe-inline' required by Next itself (style hardening is opt-in only and throws in prod, see CSP_POLICY check above) + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob:", + `frame-ancestors ${frameAncestors}`, + "object-src 'none'", + "base-uri 'self'", + ].join("; "); + + const CONTENT_SECURITY_POLICY_HEADER = { + key: "Content-Security-Policy", + value: buildCspValue("'self'"), + }; + + // Why: booking embed pages are designed to be iframed by customer sites + // (the /embed/embed.js integration) — this later-matching rule overrides + // frame-ancestors for them, the same way the cal.com-host rule below + // overrides Referrer-Policy. + const EMBED_CONTENT_SECURITY_POLICY_HEADER = { + key: "Content-Security-Policy", + value: buildCspValue("*"), + }; + + const FRAME_ANCESTORS_NONE_HEADER = { + key: "Content-Security-Policy", + value: "frame-ancestors 'none'", + }; + + const PERMISSIONS_POLICY_HEADER = { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }; + return [ { source: "/auth/:path*", @@ -388,6 +432,10 @@ const nextConfig = (phase: string): NextConfig => { key: "X-Frame-Options", value: "DENY", }, + // Why: CSP equivalent of the X-Frame-Options DENY above — browsers prefer + // frame-ancestors when both are present, so this keeps auth pages unframed + // instead of letting the catch-all policy below relax them to 'self' + FRAME_ANCESTORS_NONE_HEADER, ], }, { @@ -397,6 +445,7 @@ const nextConfig = (phase: string): NextConfig => { key: "X-Frame-Options", value: "DENY", }, + FRAME_ANCESTORS_NONE_HEADER, ], }, { @@ -410,8 +459,28 @@ const nextConfig = (phase: string): NextConfig => { key: "Referrer-Policy", value: "strict-origin-when-cross-origin", }, + CONTENT_SECURITY_POLICY_HEADER, + PERMISSIONS_POLICY_HEADER, ], }, + { + source: "/:path*/embed", + headers: [EMBED_CONTENT_SECURITY_POLICY_HEADER], + }, + ...(IS_DEV + ? [] + : [ + { + source: "/:path*", + // Why: HSTS only matters over TLS — prod serves HTTPS, dev is http://localhost + headers: [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ], + }, + ]), { source: "/embed/embed.js", headers: [CORP_CROSS_ORIGIN_HEADER], diff --git a/packages/lib/authRateLimiter.ts b/packages/lib/authRateLimiter.ts new file mode 100644 index 00000000000..651e8e7b9bd --- /dev/null +++ b/packages/lib/authRateLimiter.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; + +import logger from "./logger"; + +const log = logger.getSubLogger({ prefix: ["authRateLimiter"] }); + +export interface AuthRateLimitResult { + allowed: boolean; + remaining: number; + /** Epoch ms when the current window ends and attempts reset */ + resetAt: number; +} + +export interface AuthRateLimiter { + limit: (key: string) => AuthRateLimitResult; + reset: () => void; +} + +export interface AuthRateLimiterOptions { + windowInMs?: number; + maxRequests?: number; + /** Injectable clock for tests */ + now?: () => number; + /** Injectable bucket store for tests */ + store?: Map; +} + +export const AUTH_RATE_LIMIT_DEFAULTS = { + // 15 min is long enough to make online brute force impractical for weak secrets + windowInMs: 15 * 60 * 1000, + maxRequests: 5, +} as const; + +// Buckets are bounded so a flood of unique identities cannot grow memory unboundedly +const MAX_BUCKETS = 100_000; + +export function createAuthRateLimiter(options: AuthRateLimiterOptions = {}): AuthRateLimiter { + const { windowInMs, maxRequests, now, store } = { + ...AUTH_RATE_LIMIT_DEFAULTS, + now: Date.now, + store: new Map(), + ...options, + }; + + return { + limit(key: string): AuthRateLimitResult { + try { + return limitInStore({ key, store, windowInMs, maxRequests, now }); + } catch (error) { + // Brute-force protection must fail closed: a broken limiter silently + // allowing unlimited attempts is worse than briefly blocking requests + log.error("Auth rate limiter errored; failing closed", error); + return { allowed: false, remaining: 0, resetAt: now() + windowInMs }; + } + }, + reset(): void { + store.clear(); + }, + }; +} + +function limitInStore({ + key, + store, + windowInMs, + maxRequests, + now, +}: { + key: string; + store: Map; + windowInMs: number; + maxRequests: number; + now: () => number; +}): AuthRateLimitResult { + const nowMs = now(); + const windowStart = Math.floor(nowMs / windowInMs) * windowInMs; + const resetAt = windowStart + windowInMs; + const bucket = store.get(key); + + if (!bucket || bucket.resetAt <= nowMs) { + store.set(key, { count: 1, resetAt }); + if (store.size > MAX_BUCKETS) { + sweepExpiredBuckets(store, nowMs); + } + return { allowed: true, remaining: maxRequests - 1, resetAt }; + } + + bucket.count += 1; + return { + allowed: bucket.count <= maxRequests, + remaining: Math.max(0, maxRequests - bucket.count), + resetAt: bucket.resetAt, + }; +} + +function sweepExpiredBuckets(store: Map, nowMs: number): void { + for (const [key, bucket] of store) { + if (bucket.resetAt <= nowMs) { + store.delete(key); + } + } +} + +const defaultLimiter = createAuthRateLimiter(); + +/** + * Why: the Unkey-based `rateLimiter()` in rateLimit.ts no-ops without + * UNKEY_ROOT_KEY, so sensitive auth endpoints (password, 2FA, email-verify) + * would be unlimited in default deployments. This limiter always runs; limits + * are per app-server instance, which is accurate enough for per-identity + * brute-force protection and needs no external dependency. + */ +export function limitAuthRate(key: string, limiter: AuthRateLimiter = defaultLimiter): AuthRateLimitResult { + return limiter.limit(key); +} + +/** Truncated SHA-256 keeps raw emails/usernames out of limiter memory */ +export function hashRateLimitIdentifier(identifier: string): string { + return createHash("sha256").update(identifier).digest("hex").slice(0, 32); +} diff --git a/packages/trpc/server/middlewares/authRateLimitMiddleware.ts b/packages/trpc/server/middlewares/authRateLimitMiddleware.ts new file mode 100644 index 00000000000..a131bcdbbf8 --- /dev/null +++ b/packages/trpc/server/middlewares/authRateLimitMiddleware.ts @@ -0,0 +1,75 @@ +import { + hashRateLimitIdentifier, + limitAuthRate, + type AuthRateLimiter, + type AuthRateLimitResult, +} from "@calcom/lib/authRateLimiter"; +import { TRPCError } from "@trpc/server"; + +type AuthIdentityRecord = Partial>; + +const AUTH_IDENTITY_KEYS = ["email", "username"] as const; + +/** + * Why: sensitive auth endpoints are brute-force targets and the shared + * `rateLimiter()` no-ops without UNKEY_ROOT_KEY. Buckets are keyed by + * route + IP + hashed identity so one identity cannot exhaust another's + * budget, and unauthenticated requests still get a per-IP budget. + */ +/** + * Why: called from an inline `.use(async (opts) => ...)` callback on each + * procedure so tRPC infers the context and preserves the procedure's output + * type; a standalone `middleware()` factory cannot unify its builder with + * authedProcedure's invariant generics. + */ +export async function enforceAuthRateLimit( + route: string, + ctx: { sourceIp?: string; user?: unknown }, + input: unknown, + limiter?: AuthRateLimiter, +): Promise { + const result = limitAuthRate(authRateLimitKey(route, ctx, input), limiter); + if (!result.allowed) { + throw tooManyRequestsError(result); + } +} + +export function authRateLimitKey(route: string, ctx: { sourceIp?: string; user?: unknown }, input: unknown): string { + const identity = extractAuthIdentity(ctx, input); + // Unknown identities share a per-IP budget instead of bypassing the limit + return `${route}:${ctx.sourceIp ?? "unknown"}:${identity ? hashRateLimitIdentifier(identity) : "anonymous"}`; +} + +function extractAuthIdentity(ctx: { user?: unknown }, input: unknown): string | null { + const fromInput = findFirstIdentityField(input); + if (fromInput) { + return fromInput; + } + const fromSession = findFirstIdentityField(ctx.user); + if (fromSession) { + return fromSession; + } + return null; +} + +function findFirstIdentityField(value: unknown): string | null { + if (typeof value !== "object" || value === null) { + return null; + } + const record = value as AuthIdentityRecord; + for (const key of AUTH_IDENTITY_KEYS) { + const identity = record[key]; + if (typeof identity === "string" && identity.trim().length > 0) { + return identity.trim().toLowerCase(); + } + } + return null; +} + +function tooManyRequestsError(result: AuthRateLimitResult): TRPCError { + const retryAfterSeconds = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); + return new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Too many attempts. Try again in ${retryAfterSeconds} seconds.`, + }); +} diff --git a/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts b/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts new file mode 100644 index 00000000000..46129a44f9d --- /dev/null +++ b/packages/trpc/server/routers/viewer/__tests__/authRateLimit.test.ts @@ -0,0 +1,124 @@ +import type { AuthRateLimiter } from "@calcom/lib/authRateLimiter"; +import { createAuthRateLimiter } from "@calcom/lib/authRateLimiter"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +import { enforceAuthRateLimit } from "../../../middlewares/authRateLimitMiddleware"; + +const WINDOW_IN_MS = 60_000; +const MAX_REQUESTS = 3; + +function buildTestLimiter(nowMs: number): { limiter: AuthRateLimiter; tick: (ms: number) => void } { + let now = nowMs; + const limiter = createAuthRateLimiter({ + windowInMs: WINDOW_IN_MS, + maxRequests: MAX_REQUESTS, + now: () => now, + }); + return { + limiter, + tick: (ms: number) => { + now += ms; + }, + }; +} + +function buildMiddlewareInvocation({ + sourceIp, + input, + limiter, +}: { + sourceIp: string; + input: unknown; + limiter?: AuthRateLimiter; +}) { + const next: Mock = vi.fn().mockResolvedValue({ ctx: {} }); + return { + next, + invoke: async () => { + await enforceAuthRateLimit("verifyPassword", { sourceIp }, input, limiter); + return next(); + }, + }; +} + +describe("authRateLimitMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("allows requests under the limit", async () => { + const { limiter } = buildTestLimiter(Date.now()); + const { invoke } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter }); + + await expect(invoke()).resolves.not.toThrow(); + await expect(invoke()).resolves.not.toThrow(); + await expect(invoke()).resolves.not.toThrow(); + }); + + it("blocks requests over the limit with a 429 TOO_MANY_REQUESTS error", async () => { + const { limiter } = buildTestLimiter(Date.now()); + const { invoke, next } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter }); + + for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) { + await invoke(); + } + + await expect(invoke()).rejects.toMatchObject({ + code: "TOO_MANY_REQUESTS", + }); + await expect(invoke()).rejects.toBeInstanceOf(TRPCError); + expect(next).toHaveBeenCalledTimes(MAX_REQUESTS); + }); + + it("isolates buckets between different IPs", async () => { + const { limiter } = buildTestLimiter(Date.now()); + const blocked = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter }); + const otherIp = buildMiddlewareInvocation({ sourceIp: "198.51.100.77", input: { email: "user@example.com" }, limiter }); + + for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) { + await blocked.invoke(); + } + await expect(blocked.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + + await expect(otherIp.invoke()).resolves.not.toThrow(); + }); + + it("isolates buckets between different identities on the same IP", async () => { + const { limiter } = buildTestLimiter(Date.now()); + const firstUser = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user1@example.com" }, limiter }); + const secondUser = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user2@example.com" }, limiter }); + + for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) { + await firstUser.invoke(); + } + await expect(firstUser.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + + await expect(secondUser.invoke()).resolves.not.toThrow(); + }); + + it("allows requests again after the window expires", async () => { + const fixedStart = 1_700_000_000_000; + const { limiter, tick } = buildTestLimiter(fixedStart); + const { invoke } = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: { email: "user@example.com" }, limiter }); + + for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) { + await invoke(); + } + await expect(invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + + // Crossing the window boundary resets the bucket count + tick(WINDOW_IN_MS + 1); + await expect(invoke()).resolves.not.toThrow(); + }); + + it("keys unauthenticated requests by IP when no identity is available", async () => { + const { limiter } = buildTestLimiter(Date.now()); + const anonymous = buildMiddlewareInvocation({ sourceIp: "203.0.113.10", input: undefined, limiter }); + + for (let attempt = 0; attempt < MAX_REQUESTS; attempt += 1) { + await anonymous.invoke(); + } + await expect(anonymous.invoke()).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx index 860c33bd9a3..09823ee7a09 100644 --- a/packages/trpc/server/routers/viewer/auth/_router.tsx +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -2,6 +2,7 @@ import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import authedProcedure from "../../../procedures/authedProcedure"; import publicProcedure from "../../../procedures/publicProcedure"; +import { enforceAuthRateLimit } from "../../../middlewares/authRateLimitMiddleware"; import { router } from "../../../trpc"; import { ZChangePasswordInputSchema } from "./changePassword.schema"; import { ZResendVerifyEmailSchema } from "./resendVerifyEmail.schema"; @@ -19,55 +20,90 @@ type AuthRouterHandlerCache = { }; export const authRouter = router({ - changePassword: authedProcedure.input(ZChangePasswordInputSchema).mutation(async ({ input, ctx }) => { - const { changePasswordHandler } = await import("./changePassword.handler"); + changePassword: authedProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("changePassword", ctx, input); + return next(); + }) + .input(ZChangePasswordInputSchema) + .mutation(async ({ input, ctx }) => { + const { changePasswordHandler } = await import("./changePassword.handler"); - return changePasswordHandler({ - ctx, - input, - }); - }), + return changePasswordHandler({ + ctx, + input, + }); + }), - verifyPassword: authedProcedure.input(ZVerifyPasswordInputSchema).mutation(async ({ input, ctx }) => { - const { verifyPasswordHandler } = await import("./verifyPassword.handler"); + verifyPassword: authedProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("verifyPassword", ctx, input); + return next(); + }) + .input(ZVerifyPasswordInputSchema) + .mutation(async ({ input, ctx }) => { + const { verifyPasswordHandler } = await import("./verifyPassword.handler"); - return verifyPasswordHandler({ - ctx, - input, - }); - }), + return verifyPasswordHandler({ + ctx, + input, + }); + }), - verifyCodeUnAuthenticated: publicProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ input }) => { - const { verifyCodeUnAuthenticatedHandler } = await import("./verifyCodeUnAuthenticated.handler"); + verifyCodeUnAuthenticated: publicProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("verifyCodeUnAuthenticated", ctx, input); + return next(); + }) + .input(ZVerifyCodeInputSchema) + .mutation(async ({ input }) => { + const { verifyCodeUnAuthenticatedHandler } = await import("./verifyCodeUnAuthenticated.handler"); - return verifyCodeUnAuthenticatedHandler({ - input, - }); - }), + return verifyCodeUnAuthenticatedHandler({ + input, + }); + }), - sendVerifyEmailCode: publicProcedure.input(ZSendVerifyEmailCodeSchema).mutation(async ({ input, ctx }) => { - const { sendVerifyEmailCodeHandler } = await import("./sendVerifyEmailCode.handler"); + sendVerifyEmailCode: publicProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("sendVerifyEmailCode", ctx, input); + return next(); + }) + .input(ZSendVerifyEmailCodeSchema) + .mutation(async ({ input, ctx }) => { + const { sendVerifyEmailCodeHandler } = await import("./sendVerifyEmailCode.handler"); - return sendVerifyEmailCodeHandler({ - input, - req: ctx.req, - }); - }), + return sendVerifyEmailCodeHandler({ + input, + req: ctx.req, + }); + }), - resendVerifyEmail: authedProcedure.input(ZResendVerifyEmailSchema).mutation(async ({ input, ctx }) => { - const { resendVerifyEmail } = await import("./resendVerifyEmail.handler"); + resendVerifyEmail: authedProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("resendVerifyEmail", ctx, input); + return next(); + }) + .input(ZResendVerifyEmailSchema) + .mutation(async ({ input, ctx }) => { + const { resendVerifyEmail } = await import("./resendVerifyEmail.handler"); - return resendVerifyEmail({ - input, - ctx, - }); - }), + return resendVerifyEmail({ + input, + ctx, + }); + }), - createAccountPassword: authedProcedure.mutation(async ({ ctx }) => { - const { createAccountPasswordHandler } = await import("./createAccountPassword.handler"); + createAccountPassword: authedProcedure + .use(async ({ ctx, input, next }) => { + await enforceAuthRateLimit("createAccountPassword", ctx, input); + return next(); + }) + .mutation(async ({ ctx }) => { + const { createAccountPasswordHandler } = await import("./createAccountPassword.handler"); - return createAccountPasswordHandler({ - ctx, - }); - }), + return createAccountPasswordHandler({ + ctx, + }); + }), }); From d78cc957538f45aef7bb0078722fa09c4a7725ee Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 09:14:14 +0000 Subject: [PATCH 21/30] fix(mcp-server): scope every tool to the calling user and stop corrupting booking chains All eleven tools accepted bare ids and touched any tenant's rows; event type and booking mutations now require a userId and scope lookups, updates and deletes to that owner with fail-closed errors. Reschedule stored an ISO timestamp in fromReschedule - a column every other writer treats as the original booking's uid - which broke findPreviousBooking and the no-show and audit chains; it now stores the uid. Booking creation gains an overlap conflict guard and respects requiresConfirmation instead of hardcoding ACCEPTED, and the slots tool derives working hours from the host's schedule (bounded to 60-day spans) instead of fixed UTC office hours. --- .../src/__tests__/mcp-server.test.ts | 333 ++++++++++++++++-- packages/mcp-server/src/server.ts | 65 +++- packages/mcp-server/src/tools/bookings.ts | 79 ++++- packages/mcp-server/src/tools/eventTypes.ts | 37 +- packages/mcp-server/src/tools/slots.ts | 116 +++++- 5 files changed, 543 insertions(+), 87 deletions(-) diff --git a/packages/mcp-server/src/__tests__/mcp-server.test.ts b/packages/mcp-server/src/__tests__/mcp-server.test.ts index 1088405b1b0..128ccb58a28 100644 --- a/packages/mcp-server/src/__tests__/mcp-server.test.ts +++ b/packages/mcp-server/src/__tests__/mcp-server.test.ts @@ -37,35 +37,42 @@ const mockPrisma = { findFirst: vi.fn(), }, schedule: { + findFirst: vi.fn(), findMany: vi.fn(), }, }; +const HOST_USER_ID = 10; + describe("Crove Cal MCP Tools", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("Event Types", () => { - test("listEventTypesHandler should query event types for user", async () => { + test("listEventTypesHandler should query event types scoped to the host user", async () => { mockPrisma.eventType.findMany.mockResolvedValue([ { id: 1, title: "Quick 15", slug: "15min", length: 15 }, { id: 2, title: "Deep Dive 45", slug: "45min", length: 45 }, ]); - const result = await listEventTypesHandler(mockPrisma as any, { username: "joy" }); + const result = await listEventTypesHandler(mockPrisma as any, { + userId: HOST_USER_ID, + username: "joy", + }); expect(result).toHaveLength(2); expect(mockPrisma.eventType.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ hidden: false, + userId: HOST_USER_ID, users: { some: { username: "joy" } }, }), }) ); }); - test("getEventTypeDetailsHandler should return details by ID", async () => { + test("getEventTypeDetailsHandler should return details by ID scoped to the host user", async () => { mockPrisma.eventType.findFirst.mockResolvedValue({ id: 1, title: "Quick 15", @@ -73,22 +80,34 @@ describe("Crove Cal MCP Tools", () => { length: 15, }); - const result = await getEventTypeDetailsHandler(mockPrisma as any, { eventTypeId: 1 }); + const result = await getEventTypeDetailsHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + }); expect(result.id).toBe(1); expect(result.slug).toBe("15min"); + expect(mockPrisma.eventType.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 1, + userId: HOST_USER_ID, + }), + }) + ); }); - test("createEventTypeHandler should create a new event type", async () => { - mockPrisma.user.findFirst.mockResolvedValue({ id: 10 }); + test("createEventTypeHandler should create a new event type for the host user", async () => { + mockPrisma.user.findFirst.mockResolvedValue({ id: HOST_USER_ID }); mockPrisma.eventType.create.mockResolvedValue({ id: 3, title: "Discovery Call", slug: "discovery-call", length: 30, - userId: 10, + userId: HOST_USER_ID, }); const result = await createEventTypeHandler(mockPrisma as any, { + userId: HOST_USER_ID, title: "Discovery Call", slug: "discovery-call", length: 30, @@ -102,14 +121,14 @@ describe("Crove Cal MCP Tools", () => { title: "Discovery Call", slug: "discovery-call", length: 30, - userId: 10, + userId: HOST_USER_ID, }), }) ); }); - test("updateEventTypeHandler should update an existing event type", async () => { - mockPrisma.eventType.findUnique.mockResolvedValue({ id: 3 }); + test("updateEventTypeHandler should update an existing event type scoped to the host user", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ id: 3 }); mockPrisma.eventType.update.mockResolvedValue({ id: 3, title: "Updated Discovery Call", @@ -117,6 +136,7 @@ describe("Crove Cal MCP Tools", () => { }); const result = await updateEventTypeHandler(mockPrisma as any, { + userId: HOST_USER_ID, id: 3, title: "Updated Discovery Call", length: 45, @@ -125,7 +145,7 @@ describe("Crove Cal MCP Tools", () => { expect(result.title).toBe("Updated Discovery Call"); expect(mockPrisma.eventType.update).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: 3 }, + where: { id: 3, userId: HOST_USER_ID }, data: expect.objectContaining({ title: "Updated Discovery Call", length: 45, @@ -134,32 +154,61 @@ describe("Crove Cal MCP Tools", () => { ); }); - test("deleteEventTypeHandler should delete an event type", async () => { - mockPrisma.eventType.findUnique.mockResolvedValue({ id: 3 }); + test("updateEventTypeHandler should reject an event type owned by another user", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue(null); + + await expect( + updateEventTypeHandler(mockPrisma as any, { + userId: HOST_USER_ID, + id: 3, + title: "Hijacked", + }) + ).rejects.toThrow("Event type with ID 3 not found for user 10"); + expect(mockPrisma.eventType.update).not.toHaveBeenCalled(); + }); + + test("deleteEventTypeHandler should delete an event type scoped to the host user", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ id: 3 }); mockPrisma.eventType.delete.mockResolvedValue({ id: 3, title: "Discovery Call", slug: "discovery-call", }); - const result = await deleteEventTypeHandler(mockPrisma as any, { id: 3 }); + const result = await deleteEventTypeHandler(mockPrisma as any, { + userId: HOST_USER_ID, + id: 3, + }); expect(result.id).toBe(3); expect(mockPrisma.eventType.delete).toHaveBeenCalledWith({ - where: { id: 3 }, + where: { id: 3, userId: HOST_USER_ID }, select: expect.any(Object), }); }); + + test("deleteEventTypeHandler should reject an event type owned by another user", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue(null); + + await expect( + deleteEventTypeHandler(mockPrisma as any, { + userId: HOST_USER_ID, + id: 3, + }) + ).rejects.toThrow("Event type with ID 3 not found for user 10"); + expect(mockPrisma.eventType.delete).not.toHaveBeenCalled(); + }); }); describe("Available Slots", () => { - test("getAvailableSlotsHandler should calculate free slots excluding booked intervals", async () => { + test("getAvailableSlotsHandler should fall back to 09:00-17:00 UTC when host has no schedule", async () => { mockPrisma.eventType.findFirst.mockResolvedValue({ id: 1, length: 30, timeZone: "UTC", - userId: 10, - owner: { id: 10, email: "host@crove.com" }, + userId: HOST_USER_ID, + owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null }, }); + mockPrisma.schedule.findFirst.mockResolvedValue(null); // 1 existing booking at 2026-09-01T10:00:00.000Z to 10:30:00.000Z mockPrisma.booking.findMany.mockResolvedValue([ @@ -170,6 +219,7 @@ describe("Crove Cal MCP Tools", () => { ]); const result = await getAvailableSlotsHandler(mockPrisma as any, { + userId: HOST_USER_ID, eventTypeId: 1, dateFrom: "2026-09-01", dateTo: "2026-09-01", @@ -189,17 +239,105 @@ describe("Crove Cal MCP Tools", () => { expect(has9amSlot).toBe(true); expect(has1030Slot).toBe(true); }); + + test("getAvailableSlotsHandler should use the host schedule's UTC working windows", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + length: 30, + timeZone: "UTC", + userId: HOST_USER_ID, + owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null }, + }); + // Tuesday 2026-09-01, working window 10:00-12:00 UTC (days: 2 = Tuesday) + mockPrisma.schedule.findFirst.mockResolvedValue({ + timeZone: "UTC", + availability: [ + { + days: [2], + startTime: new Date("1970-01-01T10:00:00.000Z"), + endTime: new Date("1970-01-01T12:00:00.000Z"), + }, + ], + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + + const result = await getAvailableSlotsHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + dateFrom: "2026-09-01", + dateTo: "2026-09-01", + }); + + // 30-minute slots inside 10:00-12:00: 10:00, 10:30, 11:00, 11:30 + expect(result.slots).toHaveLength(4); + expect(result.slots[0].time).toBe("2026-09-01T10:00:00.000Z"); + expect(result.slots[3].time).toBe("2026-09-01T11:30:00.000Z"); + const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z"); + expect(has9amSlot).toBe(false); + }); + + test("getAvailableSlotsHandler should fall back to fixed UTC hours for non-UTC schedules", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + length: 30, + timeZone: "UTC", + userId: HOST_USER_ID, + owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null }, + }); + mockPrisma.schedule.findFirst.mockResolvedValue({ + timeZone: "Asia/Ho_Chi_Minh", + availability: [ + { + days: [2], + startTime: new Date("1970-01-01T10:00:00.000Z"), + endTime: new Date("1970-01-01T12:00:00.000Z"), + }, + ], + }); + mockPrisma.booking.findMany.mockResolvedValue([]); + + const result = await getAvailableSlotsHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + dateFrom: "2026-09-01", + dateTo: "2026-09-01", + }); + + const has9amSlot = result.slots.some((s) => s.time === "2026-09-01T09:00:00.000Z"); + expect(has9amSlot).toBe(true); + }); + + test("getAvailableSlotsHandler should reject date spans longer than 60 days", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + length: 30, + timeZone: "UTC", + userId: HOST_USER_ID, + owner: { id: HOST_USER_ID, email: "host@crove.com", defaultScheduleId: null }, + }); + + await expect( + getAvailableSlotsHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + dateFrom: "2026-09-01", + dateTo: "2026-11-05", + }) + ).rejects.toThrow("Date range too large"); + }); }); describe("Bookings Management", () => { test("createBookingHandler should create a booking with attendee", async () => { - mockPrisma.eventType.findUnique.mockResolvedValue({ + mockPrisma.eventType.findFirst.mockResolvedValue({ id: 1, title: "Intro Call", length: 30, - userId: 10, - owner: { id: 10, email: "host@crove.com", name: "Host Name" }, + userId: HOST_USER_ID, + requiresConfirmation: false, + owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" }, }); + mockPrisma.booking.findFirst.mockResolvedValue(null); mockPrisma.booking.create.mockImplementation(({ data }) => ({ id: 50, @@ -211,6 +349,7 @@ describe("Crove Cal MCP Tools", () => { })); const result = await createBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, eventTypeId: 1, start: "2026-09-01T14:00:00.000Z", name: "Alice Client", @@ -236,7 +375,87 @@ describe("Crove Cal MCP Tools", () => { ); }); - test("getBookingHandler should return booking by UID", async () => { + test("createBookingHandler should reject a conflicting time slot", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + title: "Intro Call", + length: 30, + userId: HOST_USER_ID, + requiresConfirmation: false, + owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" }, + }); + mockPrisma.booking.findFirst.mockResolvedValue({ + id: 99, + startTime: new Date("2026-09-01T14:00:00.000Z"), + endTime: new Date("2026-09-01T14:30:00.000Z"), + }); + + await expect( + createBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + start: "2026-09-01T14:00:00.000Z", + name: "Alice Client", + email: "alice@example.com", + }) + ).rejects.toThrow("Time slot already booked"); + expect(mockPrisma.booking.create).not.toHaveBeenCalled(); + }); + + test("createBookingHandler should mark bookings PENDING when the event type requires confirmation", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue({ + id: 1, + title: "Intro Call", + length: 30, + userId: HOST_USER_ID, + requiresConfirmation: true, + owner: { id: HOST_USER_ID, email: "host@crove.com", name: "Host Name" }, + }); + mockPrisma.booking.findFirst.mockResolvedValue(null); + + mockPrisma.booking.create.mockImplementation(({ data }) => ({ + id: 51, + uid: data.uid, + title: data.title, + startTime: data.startTime, + endTime: data.endTime, + status: data.status, + })); + + const result = await createBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + start: "2026-09-01T16:00:00.000Z", + name: "Bob Client", + email: "bob@example.com", + }); + + expect(result.status).toBe("PENDING"); + expect(mockPrisma.booking.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "PENDING", + }), + }) + ); + }); + + test("createBookingHandler should reject event types owned by another user", async () => { + mockPrisma.eventType.findFirst.mockResolvedValue(null); + + await expect( + createBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + eventTypeId: 1, + start: "2026-09-01T14:00:00.000Z", + name: "Alice Client", + email: "alice@example.com", + }) + ).rejects.toThrow("Event type with ID 1 not found for user 10"); + expect(mockPrisma.booking.create).not.toHaveBeenCalled(); + }); + + test("getBookingHandler should return booking by UID scoped to the host user", async () => { mockPrisma.booking.findFirst.mockResolvedValue({ id: 50, uid: "booking-uid-123", @@ -244,13 +463,26 @@ describe("Crove Cal MCP Tools", () => { status: "ACCEPTED", }); - const result = await getBookingHandler(mockPrisma as any, { bookingUid: "booking-uid-123" }); + const result = await getBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + bookingUid: "booking-uid-123", + }); expect(result.uid).toBe("booking-uid-123"); + expect(mockPrisma.booking.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + uid: "booking-uid-123", + userId: HOST_USER_ID, + }), + }) + ); }); - test("rescheduleBookingHandler should update booking times", async () => { - mockPrisma.booking.findUnique.mockResolvedValue({ + test("rescheduleBookingHandler should update booking times and store the original UID in fromReschedule", async () => { + mockPrisma.booking.findFirst.mockResolvedValue({ id: 50, + uid: "booking-uid-123", + userId: HOST_USER_ID, startTime: new Date("2026-09-01T14:00:00.000Z"), endTime: new Date("2026-09-01T14:30:00.000Z"), eventType: { length: 30 }, @@ -262,10 +494,11 @@ describe("Crove Cal MCP Tools", () => { startTime: new Date("2026-09-02T15:00:00.000Z"), endTime: new Date("2026-09-02T15:30:00.000Z"), rescheduled: true, - fromReschedule: "2026-09-01T14:00:00.000Z", + fromReschedule: "booking-uid-123", }); const result = await rescheduleBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, bookingUid: "booking-uid-123", newStart: "2026-09-02T15:00:00.000Z", reason: "Client had a conflict", @@ -274,17 +507,34 @@ describe("Crove Cal MCP Tools", () => { expect(result.rescheduled).toBe(true); expect(mockPrisma.booking.update).toHaveBeenCalledWith( expect.objectContaining({ - where: { uid: "booking-uid-123" }, + where: { id: 50 }, data: expect.objectContaining({ rescheduled: true, - fromReschedule: "2026-09-01T14:00:00.000Z", + fromReschedule: "booking-uid-123", }), }) ); }); + test("rescheduleBookingHandler should reject bookings hosted by another user", async () => { + mockPrisma.booking.findFirst.mockResolvedValue(null); + + await expect( + rescheduleBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + bookingUid: "booking-uid-123", + newStart: "2026-09-02T15:00:00.000Z", + }) + ).rejects.toThrow("Booking with UID booking-uid-123 not found for user 10"); + expect(mockPrisma.booking.update).not.toHaveBeenCalled(); + }); + test("cancelBookingHandler should update booking status to CANCELLED", async () => { - mockPrisma.booking.findUnique.mockResolvedValue({ id: 50, status: "ACCEPTED" }); + mockPrisma.booking.findFirst.mockResolvedValue({ + id: 50, + userId: HOST_USER_ID, + status: "ACCEPTED", + }); mockPrisma.booking.update.mockResolvedValue({ id: 50, uid: "booking-uid-123", @@ -293,6 +543,7 @@ describe("Crove Cal MCP Tools", () => { }); const result = await cancelBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, bookingUid: "booking-uid-123", cancellationReason: "Schedule conflict", }); @@ -300,7 +551,7 @@ describe("Crove Cal MCP Tools", () => { expect(result.status).toBe("CANCELLED"); expect(mockPrisma.booking.update).toHaveBeenCalledWith( expect.objectContaining({ - where: { uid: "booking-uid-123" }, + where: { id: 50 }, data: expect.objectContaining({ status: "CANCELLED", cancellationReason: "Schedule conflict", @@ -309,13 +560,26 @@ describe("Crove Cal MCP Tools", () => { ); }); - test("listBookingsHandler should list bookings by user email and status", async () => { + test("cancelBookingHandler should reject bookings hosted by another user", async () => { + mockPrisma.booking.findFirst.mockResolvedValue(null); + + await expect( + cancelBookingHandler(mockPrisma as any, { + userId: HOST_USER_ID, + bookingUid: "booking-uid-123", + }) + ).rejects.toThrow("Booking with UID booking-uid-123 not found for user 10"); + expect(mockPrisma.booking.update).not.toHaveBeenCalled(); + }); + + test("listBookingsHandler should list host bookings scoped to the host user", async () => { mockPrisma.booking.findMany.mockResolvedValue([ { id: 1, uid: "b1", title: "Meeting 1", status: "ACCEPTED" }, { id: 2, uid: "b2", title: "Meeting 2", status: "ACCEPTED" }, ]); const result = await listBookingsHandler(mockPrisma as any, { + userId: HOST_USER_ID, userEmail: "joy@dos.ai", status: "ACCEPTED", }); @@ -324,6 +588,7 @@ describe("Crove Cal MCP Tools", () => { expect(mockPrisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ + userId: HOST_USER_ID, status: "ACCEPTED", }), }) @@ -334,7 +599,7 @@ describe("Crove Cal MCP Tools", () => { describe("Users & Schedules Management", () => { test("getUserProfileHandler should return user profile and organizations", async () => { mockPrisma.user.findFirst.mockResolvedValue({ - id: 10, + id: HOST_USER_ID, username: "joy", email: "joy@dos.ai", name: "JOY", @@ -343,13 +608,13 @@ describe("Crove Cal MCP Tools", () => { }); const result = await getUserProfileHandler(mockPrisma as any, { email: "joy@dos.ai" }); - expect(result.id).toBe(10); + expect(result.id).toBe(HOST_USER_ID); expect(result.username).toBe("joy"); expect(result.teams).toHaveLength(1); }); test("listSchedulesHandler should return schedules with availability intervals", async () => { - mockPrisma.user.findFirst.mockResolvedValue({ id: 10 }); + mockPrisma.user.findFirst.mockResolvedValue({ id: HOST_USER_ID }); mockPrisma.schedule.findMany.mockResolvedValue([ { id: 1, diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 9124430d8d6..42ed53c1db0 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -31,9 +31,13 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "List Event Types", description: "List available meeting and booking event types for a user or organization in Crove Cal.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope). All returned event types belong to this user"), username: z.string().optional().describe("Username of the host (e.g., 'joy')"), orgSlug: z.string().optional().describe("Organization slug (e.g., 'crove')"), - userId: z.number().optional().describe("User ID of the host"), limit: z.number().optional().describe("Maximum number of event types to return (default 50)"), }, }, @@ -60,6 +64,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Get Event Type Details", description: "Get detailed information about a specific event type by ID or slug.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — required to read event types by ID"), eventTypeId: z.number().optional().describe("Event Type ID"), slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"), username: z.string().optional().describe("Username of the host if slug is provided"), @@ -88,12 +97,16 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Create Event Type", description: "Create a new meeting/booking event type (e.g., 15min Discovery, 45min Demo).", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — the new event type is created for this user"), title: z.string().describe("Title of the event type (e.g., 'Discovery Call')"), slug: z.string().describe("Unique URL slug (e.g., 'discovery-call')"), length: z.number().describe("Duration of the meeting in minutes (e.g., 30)"), description: z.string().optional().describe("Description shown to attendees"), username: z.string().optional().describe("Username of the host"), - userId: z.number().optional().describe("User ID of the host"), requiresConfirmation: z.boolean().optional().describe("Whether host must manually approve bookings"), }, }, @@ -121,6 +134,13 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { description: "Update title, duration, description, or confirmation settings for an existing event type.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe( + "Host user ID (tenant scope) — updates are rejected unless the event type belongs to this user" + ), id: z.number().describe("Event Type numeric ID"), title: z.string().optional().describe("New title"), slug: z.string().optional().describe("New slug"), @@ -153,6 +173,13 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Delete Event Type", description: "Delete an event type by ID.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe( + "Host user ID (tenant scope) — deletes are rejected unless the event type belongs to this user" + ), id: z.number().describe("Event Type numeric ID to delete"), }, }, @@ -180,6 +207,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { description: "Retrieve bookable time slots for an event type between two dates (calculating host availability minus booked meetings).", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — availability is computed for this host"), eventTypeId: z.number().optional().describe("Event Type ID"), slug: z.string().optional().describe("Event Type Slug (e.g., '30min')"), username: z.string().optional().describe("Host username if slug is used"), @@ -211,6 +243,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Create Booking", description: "Schedule a new booking/meeting in Crove Cal with attendee information.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — the event type must belong to this host"), eventTypeId: z.number().describe("Event Type ID to book"), start: z.string().describe("Booking start time in ISO 8601 format (e.g., '2026-08-30T10:00:00Z')"), name: z.string().describe("Attendee's full name"), @@ -246,6 +283,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Get Booking", description: "Retrieve booking details by booking UID or ID.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — only bookings hosted by this user are returned"), bookingUid: z.string().optional().describe("Booking unique identifier (UID)"), bookingId: z.number().optional().describe("Booking numeric ID"), }, @@ -273,6 +315,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Reschedule Booking", description: "Reschedule an existing booking to a new start time.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — only bookings hosted by this user can be rescheduled"), bookingUid: z.string().describe("Booking unique identifier (UID) to reschedule"), newStart: z.string().describe("New start time in ISO 8601 format (e.g., '2026-08-31T14:00:00Z')"), reason: z.string().optional().describe("Reason for rescheduling"), @@ -302,6 +349,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "Cancel Booking", description: "Cancel an existing booking and free up the slot.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — only bookings hosted by this user can be cancelled"), bookingUid: z.string().describe("Booking unique identifier (UID) to cancel"), cancellationReason: z.string().optional().describe("Reason for cancellation"), cancelledBy: z.string().optional().describe("Who cancelled the meeting (e.g., 'Customer', 'Agent')"), @@ -330,6 +382,11 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { title: "List Bookings", description: "List recent bookings with optional filter by attendee/host email and status.", inputSchema: { + userId: z + .number() + .int() + .positive() + .describe("Host user ID (tenant scope) — only bookings hosted by this user are listed"), userEmail: z.string().optional().describe("Filter by host or attendee email address"), status: z .enum(["ACCEPTED", "CANCELLED", "PENDING", "REJECTED"]) @@ -364,7 +421,7 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { inputSchema: { email: z.string().optional().describe("Email address of the user"), username: z.string().optional().describe("Username of the user"), - userId: z.number().optional().describe("Numeric ID of the user"), + userId: z.number().int().positive().optional().describe("Numeric ID of the user"), }, }, async (args) => { @@ -391,7 +448,7 @@ export function createCroveCalMcpServer(prisma: PrismaClient) { description: "Retrieve working hours schedules and daily availability intervals for a user in Crove Cal.", inputSchema: { - userId: z.number().optional().describe("User ID"), + userId: z.number().int().positive().optional().describe("User ID"), username: z.string().optional().describe("Username"), email: z.string().optional().describe("Email address"), }, diff --git a/packages/mcp-server/src/tools/bookings.ts b/packages/mcp-server/src/tools/bookings.ts index caf397cada0..2c30193ec31 100644 --- a/packages/mcp-server/src/tools/bookings.ts +++ b/packages/mcp-server/src/tools/bookings.ts @@ -3,6 +3,8 @@ import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; export interface CreateBookingInput { + /** Host user ID (tenant scope) — the event type must belong to this host */ + userId: number; eventTypeId: number; start: string; // ISO 8601 string name: string; @@ -13,13 +15,14 @@ export interface CreateBookingInput { } export async function createBookingHandler(prisma: PrismaClient, input: CreateBookingInput) { - const eventType = await prisma.eventType.findUnique({ - where: { id: input.eventTypeId }, + const eventType = await prisma.eventType.findFirst({ + where: { id: input.eventTypeId, userId: input.userId }, select: { id: true, title: true, length: true, userId: true, + requiresConfirmation: true, owner: { select: { id: true, @@ -31,7 +34,7 @@ export async function createBookingHandler(prisma: PrismaClient, input: CreateBo }); if (!eventType) { - throw new Error(`Event type with ID ${input.eventTypeId} not found`); + throw new Error(`Event type with ID ${input.eventTypeId} not found for user ${input.userId}`); } const startTime = new Date(input.start); @@ -40,6 +43,34 @@ export async function createBookingHandler(prisma: PrismaClient, input: CreateBo } const endTime = new Date(startTime.getTime() + eventType.length * 60 * 1000); + + // Minimal overlap guard for ACCEPTED/PENDING bookings of this event type. + const conflict = await prisma.booking.findFirst({ + where: { + eventTypeId: eventType.id, + status: { in: ["ACCEPTED", "PENDING"] }, + startTime: { lt: endTime }, + endTime: { gt: startTime }, + }, + select: { + id: true, + startTime: true, + endTime: true, + }, + }); + + if (conflict) { + throw new Error( + `Time slot already booked: ${conflict.startTime.toISOString()} - ${conflict.endTime.toISOString()}` + ); + } + + // Why: this write bypasses the full booking pipeline in + // packages/features/bookings/lib/handleNewBooking (availability calculation, calendar + // invites, webhook triggers, workflow reminders) — MCP-created bookings send NO calendar + // invitation and fire NO host webhooks. The conflict guard above and the + // requiresConfirmation status handling below are minimal guards, not a replacement for + // that pipeline. const uid = randomUUID(); const booking = await prisma.booking.create({ @@ -53,7 +84,7 @@ export async function createBookingHandler(prisma: PrismaClient, input: CreateBo eventTypeId: eventType.id, userId: eventType.userId || eventType.owner?.id, userPrimaryEmail: eventType.owner?.email, - status: "ACCEPTED", + status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED", attendees: { create: { name: input.name, @@ -85,6 +116,8 @@ export async function createBookingHandler(prisma: PrismaClient, input: CreateBo } export interface GetBookingInput { + /** Host user ID (tenant scope) — only bookings hosted by this user are returned */ + userId: number; bookingUid?: string; bookingId?: number; } @@ -94,7 +127,9 @@ export async function getBookingHandler(prisma: PrismaClient, input: GetBookingI throw new Error("Either bookingUid or bookingId must be provided"); } - const where: Prisma.BookingWhereInput = {}; + const where: Prisma.BookingWhereInput = { + userId: input.userId, + }; if (input.bookingUid) { where.uid = input.bookingUid; } else if (input.bookingId) { @@ -141,6 +176,8 @@ export async function getBookingHandler(prisma: PrismaClient, input: GetBookingI } export interface RescheduleBookingInput { + /** Host user ID (tenant scope) — only bookings hosted by this user can be rescheduled */ + userId: number; bookingUid: string; newStart: string; // ISO 8601 string reason?: string; @@ -148,10 +185,12 @@ export interface RescheduleBookingInput { } export async function rescheduleBookingHandler(prisma: PrismaClient, input: RescheduleBookingInput) { - const existing = await prisma.booking.findUnique({ - where: { uid: input.bookingUid }, + const existing = await prisma.booking.findFirst({ + where: { uid: input.bookingUid, userId: input.userId }, select: { id: true, + uid: true, + userId: true, startTime: true, endTime: true, eventType: { @@ -161,7 +200,7 @@ export async function rescheduleBookingHandler(prisma: PrismaClient, input: Resc }); if (!existing) { - throw new Error(`Booking with UID ${input.bookingUid} not found`); + throw new Error(`Booking with UID ${input.bookingUid} not found for user ${input.userId}`); } const newStartTime = new Date(input.newStart); @@ -176,12 +215,14 @@ export async function rescheduleBookingHandler(prisma: PrismaClient, input: Resc const newEndTime = new Date(newStartTime.getTime() + durationMs); const updated = await prisma.booking.update({ - where: { uid: input.bookingUid }, + where: { id: existing.id }, data: { startTime: newStartTime, endTime: newEndTime, rescheduled: true, - fromReschedule: existing.startTime.toISOString(), + // Why: fromReschedule semantically holds the ORIGINAL booking's UID (every other writer + // stores booking.uid — see BookingRepository.findPreviousBooking), not a timestamp. + fromReschedule: existing.uid, rescheduledBy: input.rescheduledBy || "AI Agent", status: "ACCEPTED", }, @@ -201,23 +242,25 @@ export async function rescheduleBookingHandler(prisma: PrismaClient, input: Resc } export interface CancelBookingInput { + /** Host user ID (tenant scope) — only bookings hosted by this user can be cancelled */ + userId: number; bookingUid: string; cancellationReason?: string; cancelledBy?: string; } export async function cancelBookingHandler(prisma: PrismaClient, input: CancelBookingInput) { - const existing = await prisma.booking.findUnique({ - where: { uid: input.bookingUid }, - select: { id: true, status: true }, + const existing = await prisma.booking.findFirst({ + where: { uid: input.bookingUid, userId: input.userId }, + select: { id: true, userId: true, status: true }, }); if (!existing) { - throw new Error(`Booking with UID ${input.bookingUid} not found`); + throw new Error(`Booking with UID ${input.bookingUid} not found for user ${input.userId}`); } const cancelled = await prisma.booking.update({ - where: { uid: input.bookingUid }, + where: { id: existing.id }, data: { status: "CANCELLED", cancellationReason: input.cancellationReason || "Cancelled via AI Agent", @@ -237,13 +280,17 @@ export async function cancelBookingHandler(prisma: PrismaClient, input: CancelBo } export interface ListBookingsInput { + /** Host user ID (tenant scope) — only bookings hosted by this user are listed */ + userId: number; userEmail?: string; status?: "ACCEPTED" | "CANCELLED" | "PENDING" | "REJECTED"; limit?: number; } export async function listBookingsHandler(prisma: PrismaClient, input: ListBookingsInput) { - const where: Prisma.BookingWhereInput = {}; + const where: Prisma.BookingWhereInput = { + userId: input.userId, + }; if (input.status) { where.status = input.status; diff --git a/packages/mcp-server/src/tools/eventTypes.ts b/packages/mcp-server/src/tools/eventTypes.ts index 863bdb53b34..cebbb169577 100644 --- a/packages/mcp-server/src/tools/eventTypes.ts +++ b/packages/mcp-server/src/tools/eventTypes.ts @@ -2,20 +2,20 @@ import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; export interface ListEventTypesInput { + /** Host user ID (tenant scope) — all returned event types belong to this user */ + userId: number; username?: string; orgSlug?: string; - userId?: number; limit?: number; } export async function listEventTypesHandler(prisma: PrismaClient, input: ListEventTypesInput) { const where: Prisma.EventTypeWhereInput = { hidden: false, + userId: input.userId, }; - if (input.userId) { - where.userId = input.userId; - } else if (input.username) { + if (input.username) { where.users = { some: { username: input.username, @@ -64,6 +64,8 @@ export async function listEventTypesHandler(prisma: PrismaClient, input: ListEve } export interface GetEventTypeDetailsInput { + /** Host user ID (tenant scope) — the event type must belong to this user */ + userId: number; eventTypeId?: number; slug?: string; username?: string; @@ -74,7 +76,9 @@ export async function getEventTypeDetailsHandler(prisma: PrismaClient, input: Ge throw new Error("Either eventTypeId or slug must be provided"); } - const where: Prisma.EventTypeWhereInput = {}; + const where: Prisma.EventTypeWhereInput = { + userId: input.userId, + }; if (input.eventTypeId) { where.id = input.eventTypeId; @@ -128,7 +132,8 @@ export async function getEventTypeDetailsHandler(prisma: PrismaClient, input: Ge } export interface CreateEventTypeInput { - userId?: number; + /** Host user ID (tenant scope) — the new event type is created for this user */ + userId: number; username?: string; title: string; slug: string; @@ -184,6 +189,8 @@ export async function createEventTypeHandler(prisma: PrismaClient, input: Create } export interface UpdateEventTypeInput { + /** Host user ID (tenant scope) — updates are rejected unless the event type belongs to this user */ + userId: number; id: number; title?: string; slug?: string; @@ -194,17 +201,17 @@ export interface UpdateEventTypeInput { } export async function updateEventTypeHandler(prisma: PrismaClient, input: UpdateEventTypeInput) { - const existing = await prisma.eventType.findUnique({ - where: { id: input.id }, + const existing = await prisma.eventType.findFirst({ + where: { id: input.id, userId: input.userId }, select: { id: true }, }); if (!existing) { - throw new Error(`Event type with ID ${input.id} not found`); + throw new Error(`Event type with ID ${input.id} not found for user ${input.userId}`); } const updated = await prisma.eventType.update({ - where: { id: input.id }, + where: { id: input.id, userId: input.userId }, data: { ...(input.title !== undefined ? { title: input.title } : {}), ...(input.slug !== undefined ? { slug: input.slug } : {}), @@ -230,21 +237,23 @@ export async function updateEventTypeHandler(prisma: PrismaClient, input: Update } export interface DeleteEventTypeInput { + /** Host user ID (tenant scope) — deletes are rejected unless the event type belongs to this user */ + userId: number; id: number; } export async function deleteEventTypeHandler(prisma: PrismaClient, input: DeleteEventTypeInput) { - const existing = await prisma.eventType.findUnique({ - where: { id: input.id }, + const existing = await prisma.eventType.findFirst({ + where: { id: input.id, userId: input.userId }, select: { id: true }, }); if (!existing) { - throw new Error(`Event type with ID ${input.id} not found`); + throw new Error(`Event type with ID ${input.id} not found for user ${input.userId}`); } const deleted = await prisma.eventType.delete({ - where: { id: input.id }, + where: { id: input.id, userId: input.userId }, select: { id: true, title: true, diff --git a/packages/mcp-server/src/tools/slots.ts b/packages/mcp-server/src/tools/slots.ts index 868811a816e..01f4a6d1603 100644 --- a/packages/mcp-server/src/tools/slots.ts +++ b/packages/mcp-server/src/tools/slots.ts @@ -2,6 +2,8 @@ import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; export interface GetAvailableSlotsInput { + /** Host user ID (tenant scope) — availability is computed for this host */ + userId: number; eventTypeId?: number; slug?: string; username?: string; @@ -14,12 +16,17 @@ export interface TimeSlot { time: string; // ISO 8601 string } +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_DATE_RANGE_DAYS = 60; + export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetAvailableSlotsInput) { if (!input.eventTypeId && !input.slug) { throw new Error("Either eventTypeId or slug must be provided"); } - const where: Prisma.EventTypeWhereInput = {}; + const where: Prisma.EventTypeWhereInput = { + userId: input.userId, + }; if (input.eventTypeId) { where.id = input.eventTypeId; } else if (input.slug) { @@ -50,6 +57,13 @@ export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetA throw new Error("Event type not found"); } + const hostUserId = eventType.userId ?? eventType.owner?.id; + if (!hostUserId) { + throw new Error( + `Event type with ID ${eventType.id} has no owning user, so host availability cannot be determined` + ); + } + const startDate = new Date(`${input.dateFrom}T00:00:00.000Z`); const endDate = new Date(`${input.dateTo}T23:59:59.999Z`); @@ -57,10 +71,42 @@ export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetA throw new Error("Invalid dateFrom or dateTo format. Please use YYYY-MM-DD"); } - // Get existing non-cancelled bookings in the date range + const spanDays = Math.round( + (new Date(`${input.dateTo}T00:00:00.000Z`).getTime() - + new Date(`${input.dateFrom}T00:00:00.000Z`).getTime()) / + MS_PER_DAY + ); + if (spanDays > MAX_DATE_RANGE_DAYS) { + throw new Error( + `Date range too large: dateFrom to dateTo spans ${spanDays} days, maximum is ${MAX_DATE_RANGE_DAYS}` + ); + } + + const schedule = await prisma.schedule.findFirst({ + where: { userId: hostUserId }, + select: { + timeZone: true, + availability: { + select: { + days: true, + startTime: true, + endTime: true, + }, + }, + }, + }); + + // Availability windows are only used when the schedule is in UTC — converting non-UTC + // windows would require DST-aware timezone math that is out of scope here. + const scheduleHasUtcWindows = Boolean( + schedule && (schedule.timeZone === "UTC" || schedule.timeZone === "Etc/UTC") + ); + + // Get the host's non-cancelled bookings in the date range (across ALL of their event + // types, not just the requested one — the host cannot attend two meetings at once). const existingBookings = await prisma.booking.findMany({ where: { - eventTypeId: eventType.id, + userId: hostUserId, status: { notIn: ["CANCELLED", "REJECTED"] }, startTime: { lte: endDate }, endTime: { gte: startDate }, @@ -79,30 +125,62 @@ export async function getAvailableSlotsHandler(prisma: PrismaClient, input: GetA const durationMs = eventType.length * 60 * 1000; const slots: TimeSlot[] = []; - // Generate candidate slots per day between 09:00 and 17:00 UTC (or local working window) + // Generate candidate slots per day inside the host's working windows const currentDay = new Date(startDate); while (currentDay <= endDate) { - // Generate slots for working hours 09:00 - 17:00 const dayStart = new Date(currentDay); - dayStart.setUTCHours(9, 0, 0, 0); + dayStart.setUTCHours(0, 0, 0, 0); + + const dayWindows: Array<{ start: number; end: number }> = []; + + if (scheduleHasUtcWindows && schedule) { + // Cal.com availability.days uses the JS convention: 0 = Sunday ... 6 = Saturday. + const dayOfWeek = dayStart.getUTCDay(); + for (const availabilityRow of schedule.availability) { + if (!availabilityRow.days.includes(dayOfWeek)) { + continue; + } + // Time columns come back anchored to 1970-01-01, but derive offsets from the + // clock fields so the math does not depend on that anchor. + const windowStart = + dayStart.getTime() + + availabilityRow.startTime.getUTCHours() * 3_600_000 + + availabilityRow.startTime.getUTCMinutes() * 60_000; + const windowEnd = + dayStart.getTime() + + availabilityRow.endTime.getUTCHours() * 3_600_000 + + availabilityRow.endTime.getUTCMinutes() * 60_000; + if (windowEnd > windowStart) { + dayWindows.push({ start: windowStart, end: windowEnd }); + } + } + } else { + // Why: the host has no schedule (or it is not UTC-based); converting a non-UTC + // schedule's windows would need full timezone/DST handling, so fall back to a + // fixed 09:00-17:00 UTC working window to keep slot generation deterministic. + const fallbackStart = new Date(dayStart); + fallbackStart.setUTCHours(9, 0, 0, 0); + const fallbackEnd = new Date(dayStart); + fallbackEnd.setUTCHours(17, 0, 0, 0); + dayWindows.push({ start: fallbackStart.getTime(), end: fallbackEnd.getTime() }); + } - const dayEnd = new Date(currentDay); - dayEnd.setUTCHours(17, 0, 0, 0); + for (const window of dayWindows) { + let slotStart = window.start; + while (slotStart + durationMs <= window.end) { + const slotEnd = slotStart + durationMs; - let slotStart = dayStart.getTime(); - while (slotStart + durationMs <= dayEnd.getTime()) { - const slotEnd = slotStart + durationMs; + // Check if slot overlaps with any booked intervals + const isOverlap = bookedIntervals.some((b) => slotStart < b.end && slotEnd > b.start); - // Check if slot overlaps with any booked intervals - const isOverlap = bookedIntervals.some((b) => slotStart < b.end && slotEnd > b.start); + if (!isOverlap) { + slots.push({ + time: new Date(slotStart).toISOString(), + }); + } - if (!isOverlap) { - slots.push({ - time: new Date(slotStart).toISOString(), - }); + slotStart += durationMs; } - - slotStart += durationMs; } currentDay.setUTCDate(currentDay.getUTCDate() + 1); From 8d8c7acafcb905532738f017950a4a2a479a2446 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 10:00:55 +0000 Subject: [PATCH 22/30] fix(cron): authenticate every cron endpoint with a timing-safe bearer check All seven cron routes executed database maintenance for anyone who knew the URL. They now assert a Bearer CRON_SECRET (timing-safe compare, fail-closed when the env var is unset) while keeping the legacy apiKey fallback, and missing or empty secrets reject instead of bypassing. --- .../web/app/api/cron/bookingReminder/route.ts | 17 ++-- .../__tests__/route.test.ts | 17 ++-- .../calendar-subscriptions-cleanup/route.ts | 13 ++-- .../__tests__/route.test.ts | 17 ++-- .../api/cron/calendar-subscriptions/route.ts | 9 +-- apps/web/app/api/cron/changeTimeZone/route.ts | 15 ++-- .../app/api/cron/selected-calendars/route.ts | 8 +- apps/web/app/api/cron/syncAppMeta/route.ts | 16 ++-- .../web/app/api/cron/webhookTriggers/route.ts | 13 ++-- apps/web/lib/__tests__/cronAuth.test.ts | 77 +++++++++++++++++++ apps/web/lib/cronAuth.ts | 53 +++++++++++++ 11 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 apps/web/lib/__tests__/cronAuth.test.ts create mode 100644 apps/web/lib/cronAuth.ts diff --git a/apps/web/app/api/cron/bookingReminder/route.ts b/apps/web/app/api/cron/bookingReminder/route.ts index 7046c463bdb..74145655962 100644 --- a/apps/web/app/api/cron/bookingReminder/route.ts +++ b/apps/web/app/api/cron/bookingReminder/route.ts @@ -1,23 +1,22 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - import dayjs from "@calcom/dayjs"; import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { getTranslation } from "@calcom/i18n/server"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; -import { getTranslation } from "@calcom/i18n/server"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, ReminderType } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import { assertCronSecret } from "@lib/cronAuth"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; async function postHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (process.env.CRON_API_KEY !== apiKey) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + const unauthorized = assertCronSecret(request); + if (unauthorized) { + return unauthorized; } const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60]; diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts index e760f4e5a3b..60878638fb7 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -1,7 +1,6 @@ -import { NextRequest } from "next/server"; -import { describe, test, expect, vi, beforeEach } from "vitest"; - import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("next/server", () => ({ NextRequest: class MockNextRequest { @@ -78,27 +77,27 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => { }); describe("Authentication", () => { - test("should return 403 when no API key is provided", async () => { + test("should return 401 when no API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(403); + expect(response.status).toBe(401); const body = await response.json(); - expect(body.message).toBe("Forbidden"); + expect(body.message).toBe("Not authenticated"); }); - test("should return 403 when invalid API key is provided", async () => { + test("should return 401 when invalid API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); request.headers.set("authorization", "invalid-key"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(403); + expect(response.status).toBe(401); const body = await response.json(); - expect(body.message).toBe("Forbidden"); + expect(body.message).toBe("Not authenticated"); }); test("should accept CRON_API_KEY in authorization header", async () => { diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts index 9ef8a80127f..b947811ebfd 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -1,10 +1,10 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; +import { assertCronSecret } from "@lib/cronAuth"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; /** * Cron webhook @@ -14,10 +14,9 @@ import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderF * @returns */ async function getHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { - return NextResponse.json({ message: "Forbidden" }, { status: 403 }); + const unauthorized = assertCronSecret(request); + if (unauthorized) { + return unauthorized; } // instantiate dependencies diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts index e09447bbe5f..609799576ad 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -1,7 +1,6 @@ -import { NextRequest } from "next/server"; -import { describe, test, expect, vi, beforeEach } from "vitest"; - import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("next/server", () => ({ NextRequest: class MockNextRequest { @@ -51,27 +50,27 @@ describe("/api/cron/calendar-subscriptions", () => { }); describe("Authentication", () => { - test("should return 403 when no API key is provided", async () => { + test("should return 401 when no API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(403); + expect(response.status).toBe(401); const body = await response.json(); - expect(body.message).toBe("Forbiden"); + expect(body.message).toBe("Not authenticated"); }, 10000); - test("should return 403 when invalid API key is provided", async () => { + test("should return 401 when invalid API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); request.headers.set("authorization", "invalid-key"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(403); + expect(response.status).toBe(401); const body = await response.json(); - expect(body.message).toBe("Forbiden"); + expect(body.message).toBe("Not authenticated"); }); test("should accept valid API key", async () => { diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 08b12ebe629..3dc0c6254a7 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -1,4 +1,3 @@ -import process from "node:process"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; @@ -11,6 +10,7 @@ import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFea import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; +import { assertCronSecret } from "@lib/cronAuth"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; @@ -22,10 +22,9 @@ import { NextResponse } from "next/server"; * @returns */ async function getHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { - return NextResponse.json({ message: "Forbiden" }, { status: 403 }); + const unauthorized = assertCronSecret(request); + if (unauthorized) { + return unauthorized; } // instantiate dependencies diff --git a/apps/web/app/api/cron/changeTimeZone/route.ts b/apps/web/app/api/cron/changeTimeZone/route.ts index 5b87f427b99..fa6255e752c 100644 --- a/apps/web/app/api/cron/changeTimeZone/route.ts +++ b/apps/web/app/api/cron/changeTimeZone/route.ts @@ -1,10 +1,10 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - import dayjs from "@calcom/dayjs"; import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; import prisma from "@calcom/prisma"; +import { assertCronSecret } from "@lib/cronAuth"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; const travelScheduleSelect = { id: true, @@ -22,10 +22,9 @@ const travelScheduleSelect = { }; async function postHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (process.env.CRON_API_KEY !== apiKey) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + const unauthorized = assertCronSecret(request); + if (unauthorized) { + return unauthorized; } let timeZonesChanged = 0; diff --git a/apps/web/app/api/cron/selected-calendars/route.ts b/apps/web/app/api/cron/selected-calendars/route.ts index d2bcab2fcd2..f36b1508393 100644 --- a/apps/web/app/api/cron/selected-calendars/route.ts +++ b/apps/web/app/api/cron/selected-calendars/route.ts @@ -16,6 +16,7 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { assertCronSecret } from "@lib/cronAuth"; import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import type { Ensure } from "@calcom/types/utils"; @@ -25,9 +26,10 @@ import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; const limitOnQueryingGoogleCalendar = 50; const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] }); const validateRequest = (req: NextRequest) => { - const url = new URL(req.url); - const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey"); - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { + const unauthorized = assertCronSecret(req); + if (unauthorized) { + // This route's handler contract signals auth failures via HttpError, which + // defaultResponderForAppDir maps to the status code of the error. throw new HttpError({ statusCode: 401, message: "Unauthorized" }); } }; diff --git a/apps/web/app/api/cron/syncAppMeta/route.ts b/apps/web/app/api/cron/syncAppMeta/route.ts index 927fe991bb9..ddfd9c7d59d 100644 --- a/apps/web/app/api/cron/syncAppMeta/route.ts +++ b/apps/web/app/api/cron/syncAppMeta/route.ts @@ -1,12 +1,13 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - +import process from "node:process"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import type { AppCategories, Prisma } from "@calcom/prisma/client"; +import { assertCronSecret } from "@lib/cronAuth"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true"; const log = logger.getSubLogger({ @@ -18,10 +19,9 @@ const log = logger.getSubLogger({ * remains synchronized with any changes made to the app config files. */ async function postHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (process.env.CRON_API_KEY !== apiKey) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + const unauthorized = assertCronSecret(request); + if (unauthorized) { + return unauthorized; } log.info(`🧐 Checking DB apps are in-sync with app metadata`); diff --git a/apps/web/app/api/cron/webhookTriggers/route.ts b/apps/web/app/api/cron/webhookTriggers/route.ts index b6019f6df0a..bf30976ead6 100644 --- a/apps/web/app/api/cron/webhookTriggers/route.ts +++ b/apps/web/app/api/cron/webhookTriggers/route.ts @@ -1,15 +1,14 @@ +import { handleWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/handleWebhookScheduledTriggers"; +import prisma from "@calcom/prisma"; +import { assertCronSecret } from "@lib/cronAuth"; import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { handleWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/handleWebhookScheduledTriggers"; -import prisma from "@calcom/prisma"; - async function postHandler(req: NextRequest) { - const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey"); - - if (process.env.CRON_API_KEY !== apiKey) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + const unauthorized = assertCronSecret(req); + if (unauthorized) { + return unauthorized; } await handleWebhookScheduledTriggers(prisma); diff --git a/apps/web/lib/__tests__/cronAuth.test.ts b/apps/web/lib/__tests__/cronAuth.test.ts new file mode 100644 index 00000000000..50b9f34e45d --- /dev/null +++ b/apps/web/lib/__tests__/cronAuth.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + body, + }), + }, +})); + +import { assertCronSecret } from "../cronAuth"; + +function requestWith(headers: Record, url = "http://localhost/api/cron/test") { + return new Request(url, { headers }); +} + +describe("assertCronSecret", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("CRON_SECRET", "test-cron-secret"); + vi.stubEnv("CRON_API_KEY", ""); + }); + + it("passes with a valid Bearer CRON_SECRET", () => { + const request = requestWith({ authorization: "Bearer test-cron-secret" }); + expect(assertCronSecret(request)).toBeNull(); + }); + + it("passes with a case-insensitive bearer scheme and header name", () => { + const headers = new Headers({ Authorization: "bearer test-cron-secret" }); + const request = new Request("http://localhost/api/cron/test", { headers }); + expect(request.headers.get("authorization")).toBe("bearer test-cron-secret"); + expect(assertCronSecret(request)).toBeNull(); + }); + + it("returns 401 with a wrong secret", () => { + const request = requestWith({ authorization: "Bearer wrong-secret" }); + const response = assertCronSecret(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(401); + }); + + it("returns 401 with a missing Authorization header", () => { + const response = assertCronSecret(requestWith({})); + expect(response).not.toBeNull(); + expect(response?.status).toBe(401); + }); + + it("returns 401 when CRON_SECRET is unset, even if a bearer value is sent", () => { + vi.stubEnv("CRON_SECRET", ""); + const request = requestWith({ authorization: "Bearer test-cron-secret" }); + const response = assertCronSecret(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(401); + }); + + it("passes with the legacy CRON_API_KEY as raw Authorization header", () => { + vi.stubEnv("CRON_API_KEY", "legacy-key"); + const request = requestWith({ authorization: "legacy-key" }); + expect(assertCronSecret(request)).toBeNull(); + }); + + it("passes with the legacy CRON_API_KEY as ?apiKey= query parameter", () => { + vi.stubEnv("CRON_API_KEY", "legacy-key"); + const request = requestWith({}, "http://localhost/api/cron/test?apiKey=legacy-key"); + expect(assertCronSecret(request)).toBeNull(); + }); + + it("returns 401 when CRON_API_KEY is empty and an empty apiKey query param is sent (bypass guard)", () => { + vi.stubEnv("CRON_API_KEY", ""); + const request = requestWith({}, "http://localhost/api/cron/test?apiKey="); + const response = assertCronSecret(request); + expect(response).not.toBeNull(); + expect(response?.status).toBe(401); + }); +}); diff --git a/apps/web/lib/cronAuth.ts b/apps/web/lib/cronAuth.ts new file mode 100644 index 00000000000..19fc0673b33 --- /dev/null +++ b/apps/web/lib/cronAuth.ts @@ -0,0 +1,53 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import process from "node:process"; +import { NextResponse } from "next/server"; + +/** + * Timing-safe string comparison. + * + * Both sides are hashed with SHA-256 before `timingSafeEqual` so the compared + * buffers always have the same length: `timingSafeEqual` throws on length + * mismatch, and an exception thrown/not-thrown would itself leak the secret + * length. Hashing first gives fixed-length digests and removes that leak. + */ +function secretsMatch(provided: string, expected: string | undefined): boolean { + if (!expected) { + // Fail closed when the secret is unset or empty (an empty expected value + // would otherwise match an empty provided value and bypass auth entirely). + return false; + } + const providedDigest = createHash("sha256").update(provided, "utf8").digest(); + const expectedDigest = createHash("sha256").update(expected, "utf8").digest(); + return timingSafeEqual(providedDigest, expectedDigest); +} + +/** + * Shared authentication for /api/cron/* routes. + * + * Accepts: + * - `Authorization: Bearer ` (what Vercel Cron sends, see vercel.json crons) + * - legacy `CRON_API_KEY` either as the raw `Authorization` header value or as + * the `?apiKey=` query parameter (external schedulers / cron-tester.ts) + * + * Returns `null` when the request is authorized, otherwise a 401 NextResponse + * that the route should return immediately. + */ +export function assertCronSecret(request: Request): NextResponse | null { + // Header names are case-insensitive per the fetch spec; the "Bearer" scheme + // prefix is compared case-insensitively as well. + const authHeader = request.headers.get("authorization") ?? ""; + const bearerValue = /^Bearer\s+(.+)$/i.exec(authHeader)?.[1] ?? ""; + const rawHeaderValue = authHeader.trim(); + const queryApiKey = new URL(request.url).searchParams.get("apiKey") ?? ""; + + const authorized = + secretsMatch(bearerValue, process.env.CRON_SECRET) || + secretsMatch(rawHeaderValue, process.env.CRON_API_KEY) || + secretsMatch(queryApiKey, process.env.CRON_API_KEY); + + if (authorized) { + return null; + } + + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); +} From 8fedf446a5b0288d48f960c9427b3f25ea0c67ad Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 10:01:34 +0000 Subject: [PATCH 23/30] fix(workflows): recheck bookings before scheduling and cancel stale reminders on cancellation Reminder scheduling trusted the caller's booking reference, so reminders could be created for cancelled bookings or with stale times; scheduling now re-fetches the booking and skips CANCELLED. The schema's cancelled flag existed but nothing ever set it - a new cancelRemindersForBooking marks unsent reminders cancelled and is wired into the booking cancellation flow. --- .../bookings/lib/handleCancelBooking.ts | 54 +++++--- .../__tests__/WorkflowService.test.ts | 128 +++++++++++++++--- .../features/workflows/lib/WorkflowService.ts | 81 ++++++++--- 3 files changed, 207 insertions(+), 56 deletions(-) diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index c8f2df2e86f..57eea7cad2d 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -1,3 +1,4 @@ +import process from "node:process"; import { DailyLocationType } from "@calcom/app-store/constants"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; @@ -21,25 +22,26 @@ import { } from "@calcom/features/webhooks/lib/scheduleTrigger"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; +import { WorkflowService } from "@calcom/features/workflows/lib/WorkflowService"; +import { getTranslation } from "@calcom/i18n/server"; import { HttpError } from "@calcom/lib/http-error"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { getTranslation } from "@calcom/i18n/server"; +import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; // TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export import prisma from "@calcom/prisma"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; - -import { isCancellationReasonRequired } from "./cancellationReason"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { bookingCancelInput } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { z } from "zod"; import { BookingRepository } from "../repositories/BookingRepository"; import { PrismaBookingAttendeeRepository } from "../repositories/PrismaBookingAttendeeRepository"; +import { isCancellationReasonRequired } from "./cancellationReason"; import type { CancelBookingMeta, CancelRegularBookingData, @@ -49,7 +51,6 @@ import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsFo import { getBookingToDelete } from "./getBookingToDelete"; import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat"; import type { IBookingCancelService } from "./interfaces/IBookingCancelService"; -import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown"; const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] }); @@ -80,17 +81,13 @@ type Dependencies = { async function handler(input: CancelBookingInput, dependencies?: Dependencies) { const prismaClient = prisma; - const { - userRepository, - bookingRepository, - bookingReferenceRepository, - attendeeRepository, - } = dependencies || { - userRepository: new UserRepository(prismaClient), - bookingRepository: new BookingRepository(prismaClient), - bookingReferenceRepository: new BookingReferenceRepository({ prismaClient }), - attendeeRepository: new PrismaBookingAttendeeRepository(prismaClient), - }; + const { userRepository, bookingRepository, bookingReferenceRepository, attendeeRepository } = + dependencies || { + userRepository: new UserRepository(prismaClient), + bookingRepository: new BookingRepository(prismaClient), + bookingReferenceRepository: new BookingReferenceRepository({ prismaClient }), + attendeeRepository: new PrismaBookingAttendeeRepository(prismaClient), + }; const body = input.bookingData; const { id, @@ -103,12 +100,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { skipCancellationReasonValidation = false, skipCalendarSyncTaskCancellation = false, } = bookingCancelInput.parse(body); - let bookingToDelete: BookingToDelete + let bookingToDelete: BookingToDelete; try { bookingToDelete = await getBookingToDelete(id, uid); } catch (error) { - if (isPrismaError(error) && error.code === "P2025") // Record not found - { + if (isPrismaError(error) && error.code === "P2025") { + // Record not found throw new HttpError({ statusCode: 404, message: "Booking not found.", @@ -125,7 +122,6 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { arePlatformEmailsEnabled, } = input; - /** * Important: We prevent cancelling an already cancelled booking. * A booking could have been CANCELLED due to a reschedule, @@ -158,7 +154,12 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { isCancellationUserHost ); - if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) { + if ( + !platformClientId && + !cancellationReason?.trim() && + isReasonRequired && + !skipCancellationReasonValidation + ) { throw new HttpError({ statusCode: 400, message: "Cancellation reason is required", @@ -427,6 +428,19 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { } } + // Invalidate any pending workflow reminders attached to the cancelled + // booking(s) — including the original booking when a reschedule cancels it — + // so a reminder scheduled for the old time/status never fires. + // Best-effort: a failure here must not fail the booking cancellation. + const workflowService = new WorkflowService(prismaClient); + await Promise.all( + updatedBookings.map((booking) => + workflowService.cancelRemindersForBooking({ bookingUid: booking.uid }).catch((e) => { + log.error(`Error cancelling workflow reminders for booking ${booking.uid}:`, e); + }) + ) + ); + /** TODO: Remove this without breaking functionality */ if (bookingToDelete.location === DailyLocationType) { bookingToDelete.user.credentials.push({ diff --git a/packages/features/workflows/__tests__/WorkflowService.test.ts b/packages/features/workflows/__tests__/WorkflowService.test.ts index f1e10a1d714..8e2d3678996 100644 --- a/packages/features/workflows/__tests__/WorkflowService.test.ts +++ b/packages/features/workflows/__tests__/WorkflowService.test.ts @@ -1,6 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; import { ErrorWithCode } from "@calcom/lib/errors"; -import { WorkflowService } from "../lib/WorkflowService"; import { MembershipRole, TimeUnit, @@ -8,6 +6,8 @@ import { WorkflowMethods, WorkflowTriggerEvents, } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkflowService } from "../lib/WorkflowService"; describe("WorkflowService", () => { const mockPrisma: any = { @@ -33,6 +33,10 @@ describe("WorkflowService", () => { workflowReminder: { create: vi.fn(), findMany: vi.fn(), + updateMany: vi.fn(), + }, + booking: { + findUnique: vi.fn(), }, $transaction: vi.fn(), }; @@ -43,15 +47,15 @@ describe("WorkflowService", () => { // registers findMany but never triggers the call would leak its value into later tests mockPrisma.workflow.findMany.mockReset(); mockPrisma.workflow.findFirst.mockReset(); - mockPrisma.$transaction.mockImplementation( - async (fn: (tx: unknown) => unknown) => fn(mockPrisma) - ); + mockPrisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(mockPrisma)); mockPrisma.membership.findFirst.mockResolvedValue({ id: 1, role: MembershipRole.OWNER }); mockPrisma.eventType.count.mockImplementation( async ({ where }: { where: { id: { in: number[] } } }) => where.id.in.length ); mockPrisma.workflow.create.mockReset(); mockPrisma.workflow.create.mockResolvedValueOnce({ id: 1 }); + mockPrisma.booking.findUnique.mockReset(); + mockPrisma.workflowReminder.updateMany.mockReset(); }); describe("calculateScheduledDate", () => { @@ -285,9 +289,9 @@ describe("WorkflowService", () => { mockPrisma.membership.findFirst.mockResolvedValueOnce(null); const service = new WorkflowService(mockPrisma); - await expect( - service.getWorkflowById({ id: 1, userId: 10, teamId: 5 }) - ).rejects.toThrow(/not a member of this team/); + await expect(service.getWorkflowById({ id: 1, userId: 10, teamId: 5 })).rejects.toThrow( + /not a member of this team/ + ); expect(mockPrisma.workflow.findFirst).not.toHaveBeenCalled(); }); @@ -296,9 +300,9 @@ describe("WorkflowService", () => { mockPrisma.workflow.findFirst.mockResolvedValueOnce(null); const service = new WorkflowService(mockPrisma); - await expect( - service.getWorkflowById({ id: 1, userId: 10, teamId: null }) - ).rejects.toThrow(/not found or access denied/); + await expect(service.getWorkflowById({ id: 1, userId: 10, teamId: null })).rejects.toThrow( + /not found or access denied/ + ); }); }); @@ -341,7 +345,10 @@ describe("WorkflowService", () => { expect(mockPrisma.workflow.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 1, userId: 10 }, - data: expect.objectContaining({ name: "Renamed", activeOn: { create: [{ eventTypeId: 101 }, { eventTypeId: 102 }] } }), + data: expect.objectContaining({ + name: "Renamed", + activeOn: { create: [{ eventTypeId: 101 }, { eventTypeId: 102 }] }, + }), }) ); }); @@ -408,9 +415,9 @@ describe("WorkflowService", () => { mockPrisma.membership.findFirst.mockResolvedValueOnce({ id: 3, role: MembershipRole.MEMBER }); const service = new WorkflowService(mockPrisma); - await expect( - service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 }) - ).rejects.toThrow(/Only team owners and admins/); + await expect(service.deleteWorkflow({ id: 1, userId: 10, teamId: 5 })).rejects.toThrow( + /Only team owners and admins/ + ); expect(mockPrisma.workflow.delete).not.toHaveBeenCalled(); }); @@ -419,6 +426,12 @@ describe("WorkflowService", () => { describe("scheduleRemindersForBooking", () => { it("should find active workflows and create reminder records", async () => { const service = new WorkflowService(mockPrisma); + mockPrisma.booking.findUnique.mockResolvedValue({ + uid: "bk_123456", + startTime: new Date("2026-09-10T15:00:00.000Z"), + endTime: new Date("2026-09-10T15:30:00.000Z"), + status: "ACCEPTED", + }); // mockResolvedValue (not Once) so a stale once-queue leaked from an earlier test cannot shadow this value mockPrisma.workflow.findMany.mockResolvedValue([ { @@ -432,7 +445,9 @@ describe("WorkflowService", () => { ], }, ]); - mockPrisma.workflowReminder.create.mockImplementation(({ data }) => Promise.resolve({ id: Math.random(), ...data })); + mockPrisma.workflowReminder.create.mockImplementation(({ data }) => + Promise.resolve({ id: Math.random(), ...data }) + ); const reminders = await service.scheduleRemindersForBooking({ bookingUid: "bk_123456", @@ -446,5 +461,86 @@ describe("WorkflowService", () => { expect(reminders[0].scheduledDate.toISOString()).toBe("2026-09-10T14:00:00.000Z"); expect(reminders[1].method).toBe(WorkflowMethods.SMS); }); + + it("should skip scheduling entirely when the booking is CANCELLED", async () => { + const service = new WorkflowService(mockPrisma); + mockPrisma.booking.findUnique.mockResolvedValue({ + uid: "bk_cancelled", + startTime: new Date("2026-09-10T15:00:00.000Z"), + endTime: new Date("2026-09-10T15:30:00.000Z"), + status: "CANCELLED", + }); + + const reminders = await service.scheduleRemindersForBooking({ + bookingUid: "bk_cancelled", + eventTypeId: 101, + startTime: new Date("2026-09-10T15:00:00.000Z"), + endTime: new Date("2026-09-10T15:30:00.000Z"), + }); + + expect(reminders).toEqual([]); + // Neither workflow lookup nor reminder creation may run for a cancelled booking + expect(mockPrisma.workflow.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.workflowReminder.create).not.toHaveBeenCalled(); + }); + + it("should schedule from the booking row's times, not the caller's stale reference", async () => { + const service = new WorkflowService(mockPrisma); + // Booking was moved after the caller read it: DB row is the source of truth + mockPrisma.booking.findUnique.mockResolvedValue({ + uid: "bk_moved", + startTime: new Date("2026-09-11T09:00:00.000Z"), + endTime: new Date("2026-09-11T09:30:00.000Z"), + status: "ACCEPTED", + }); + mockPrisma.workflow.findMany.mockResolvedValue([ + { + id: 1, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + steps: [{ id: 10, stepNumber: 1, action: WorkflowActions.EMAIL_ATTENDEE }], + }, + ]); + mockPrisma.workflowReminder.create.mockImplementation(({ data }) => + Promise.resolve({ id: Math.random(), ...data }) + ); + + const reminders = await service.scheduleRemindersForBooking({ + bookingUid: "bk_moved", + eventTypeId: 101, + // Stale times from before the reschedule + startTime: new Date("2026-09-10T15:00:00.000Z"), + endTime: new Date("2026-09-10T15:30:00.000Z"), + }); + + expect(reminders).toHaveLength(1); + // 1 hour before the NEW start time (2026-09-11T09:00Z), not the stale one + expect(reminders[0].scheduledDate.toISOString()).toBe("2026-09-11T08:00:00.000Z"); + }); + }); + + describe("cancelRemindersForBooking", () => { + it("should mark all unsent reminders for the booking as cancelled", async () => { + const service = new WorkflowService(mockPrisma); + mockPrisma.workflowReminder.updateMany.mockResolvedValue({ count: 3 }); + + const result = await service.cancelRemindersForBooking({ bookingUid: "bk_old" }); + + expect(result).toEqual({ count: 3 }); + expect(mockPrisma.workflowReminder.updateMany).toHaveBeenCalledWith({ + where: { bookingUid: "bk_old", scheduled: false, cancelled: false }, + data: { cancelled: true }, + }); + }); + + it("should propagate the updateMany result count of zero when nothing was pending", async () => { + const service = new WorkflowService(mockPrisma); + mockPrisma.workflowReminder.updateMany.mockResolvedValue({ count: 0 }); + + const result = await service.cancelRemindersForBooking({ bookingUid: "bk_none" }); + + expect(result).toEqual({ count: 0 }); + }); }); }); diff --git a/packages/features/workflows/lib/WorkflowService.ts b/packages/features/workflows/lib/WorkflowService.ts index de11649b3fa..a35f526bd47 100644 --- a/packages/features/workflows/lib/WorkflowService.ts +++ b/packages/features/workflows/lib/WorkflowService.ts @@ -2,6 +2,7 @@ import { ErrorWithCode } from "@calcom/lib/errors"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { + BookingStatus, MembershipRole, TimeUnit, WorkflowActions, @@ -79,9 +80,7 @@ export class WorkflowService { await this.assertTeamMembership({ userId, teamId }); } - const where: Prisma.WorkflowWhereInput = teamId - ? { teamId } - : { userId, teamId: null }; + const where: Prisma.WorkflowWhereInput = teamId ? { teamId } : { userId, teamId: null }; return this.prisma.workflow.findMany({ where, @@ -109,9 +108,7 @@ export class WorkflowService { await this.assertTeamMembership({ userId, teamId }); } - const where: Prisma.WorkflowWhereInput = teamId - ? { id, teamId } - : { id, userId }; + const where: Prisma.WorkflowWhereInput = teamId ? { id, teamId } : { id, userId }; const workflow = await this.prisma.workflow.findFirst({ where, @@ -197,13 +194,14 @@ export class WorkflowService { includeCalendarEvent: step.includeCalendarEvent ?? false, })), }, - activeOn: input.activeOn && input.activeOn.length > 0 - ? { - create: input.activeOn.map((eventTypeId) => ({ - eventTypeId, - })), - } - : undefined, + activeOn: + input.activeOn && input.activeOn.length > 0 + ? { + create: input.activeOn.map((eventTypeId) => ({ + eventTypeId, + })), + } + : undefined, }, include: { steps: true, @@ -379,7 +377,11 @@ export class WorkflowService { const startMs = new Date(startTime).getTime(); const endMs = new Date(endTime).getTime(); - if (trigger === WorkflowTriggerEvents.NEW_EVENT || trigger === WorkflowTriggerEvents.EVENT_CANCELLED || trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT) { + if ( + trigger === WorkflowTriggerEvents.NEW_EVENT || + trigger === WorkflowTriggerEvents.EVENT_CANCELLED || + trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT + ) { return new Date(); // Send immediately } @@ -425,6 +427,25 @@ export class WorkflowService { endTime: Date; trigger?: WorkflowTriggerEvents; }) { + // Re-fetch the booking before creating any reminders: the caller may hold a + // stale reference from before a reschedule/cancel, and reminders computed + // from stale times would fire at the wrong moment (or at all for a booking + // that has since been cancelled). + const booking = await this.prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { uid: true, startTime: true, endTime: true, status: true }, + }); + + if (!booking || booking.status === BookingStatus.CANCELLED) { + return []; + } + + // Times on the booking row are the source of truth; caller-passed values + // may be outdated if the booking was moved between the caller's read and + // this scheduling call. + const effectiveStartTime = booking.startTime; + const effectiveEndTime = booking.endTime; + // Find all active workflows associated with this eventType matching trigger const workflows = await this.prisma.workflow.findMany({ where: { @@ -444,18 +465,15 @@ export class WorkflowService { for (const wf of workflows) { const scheduledDate = WorkflowService.calculateScheduledDate({ trigger: wf.trigger, - startTime, - endTime, + startTime: effectiveStartTime, + endTime: effectiveEndTime, time: wf.time, timeUnit: wf.timeUnit, }); for (const step of wf.steps) { let method: WorkflowMethods = WorkflowMethods.EMAIL; - if ( - step.action === WorkflowActions.SMS_ATTENDEE || - step.action === WorkflowActions.SMS_NUMBER - ) { + if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) { method = WorkflowMethods.SMS; } else if ( step.action === WorkflowActions.WHATSAPP_ATTENDEE || @@ -480,6 +498,29 @@ export class WorkflowService { return createdReminders; } + + /** + * Cancel all pending (not yet sent) workflow reminders for a booking. + * + * Must be called whenever a booking is cancelled or rescheduled: on + * reschedule the new booking gets its own fresh reminders, while reminders + * still attached to the old (now CANCELLED) booking row must never fire. + * Uses the `cancelled` flag rather than deletion so the send path keeps a + * consistent audit trail of what was invalidated. + */ + async cancelRemindersForBooking({ bookingUid }: { bookingUid: string }) { + return this.prisma.workflowReminder.updateMany({ + where: { + bookingUid, + // Never touch reminders that were already sent + scheduled: false, + cancelled: false, + }, + data: { + cancelled: true, + }, + }); + } } export default WorkflowService; From d6ff1f992daa65cb9b0d9fae7d96f7c8f58af9ee Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 10:03:55 +0000 Subject: [PATCH 24/30] =?UTF-8?q?docs(audit):=20Round=203=20security=20fix?= =?UTF-8?q?es=20complete=20=E2=80=94=20mark=20HI-06/08/11,=20HI-21/22,=20M?= =?UTF-8?q?D-10/11/12=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/audit/2026-09-08-audit-report.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/audit/2026-09-08-audit-report.html b/docs/audit/2026-09-08-audit-report.html index 5ac7b9547d6..9d08f7ecbee 100644 --- a/docs/audit/2026-09-08-audit-report.html +++ b/docs/audit/2026-09-08-audit-report.html @@ -92,8 +92,9 @@

Trạng thái xử lý — cập nhật sau Round 1 + Dependabot (cùng ngà ĐÃ FIXFork security & correctnessCR-01…CR-06, HI-01, HI-04…HI-07, HI-15, HI-17…HI-20, MD-02/04/07/08/09/13/14/15/23/24/25/26, LO-12 — các commit trên dev, verify 72/72 vitest + tsc sạch + biome lint 0 error ĐÃ FIXDependabotCR-07 (next-auth 4.24.15), HI-11 phần resolutions + tar/websocket-driver, next 16.2.11, dependabot.yml — yarn install exit 0, tsc apps/web sạch ĐÃ FIXHI-08, MD-01TLS mặc định verify-on (opt-out qua DATABASE_SSL_REJECT_UNAUTHORIZED=false) + sửa pool leak api/v2 + gate session/ADMIN cho /api/webhooks/health — 5/5 test pass - CHỜ QUYẾT ĐỊNHHI-13, HI-1461 migrations cần biết DB prod dùng schema nào; wire workflows dispatcher là quyết định feature. Khi deploy: rotate secret Supabase + 4 env mới + prisma migrate deploy - ROUND 3 (UPSTREAM)Kế thừa upstreamCR-08, HI-02/03/09/10/12/16/21/22, MD-03/05/06/10/11/12/16…22/27…32, LO-01…11/13…15 + ĐÃ FIX (Round 3)HI-06, HI-11, HI-14, HI-02/03, HI-21, HI-22, MD-10/11/12, MD-13/14/15, MD-01Cron auth timing-safe fail-closed (7 routes); reminder re-check booking + cancel stale reminders; rate limit 6 auth mutations + HSTS/CSP headers; MCP tenant scoping + fromReschedule + conflict guard; SSRF self-hosted + opt-out; video token fail-closed; timing-safe webhook compares + CHỜ QUYẾT ĐỊNHHI-13, HI-1461 migrations cần biết DB prod dùng schema nào; wire workflows dispatcher là quyết định feature. Khi deploy: rotate secret Supabase + env mới (CRON_SECRET, OIDC_*, BREVO/CROVE/DOS_SYNC_WEBHOOK_SECRET, DATABASE_SSL_REJECT_UNAUTHORIZED=false nếu pooler cần) + prisma migrate deploy + CÒN LẠI (TAIL)Docker/CI + cosmeticCR-08, HI-09/10/12 (Docker hardening + re-enable CI/crons), HI-16 (branding), MD-03 (PBAC stub), MD-05/06 (data perf), MD-16…22 (frontend perf), MD-27…32, LO-01…11/13/15 From a058d89d531093193db881945c6b75693ec32df5 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 11:51:42 +0000 Subject: [PATCH 25/30] refactor(features): remove dead barrel files and import services directly Five index.ts barrels were introduced with the fork's feature packages; three had zero importers and the other two pulled whole packages into the app-store module graph via crovecrm's generated service map. Importers now reference brevoService and croveCrmService directly, matching the AGENTS.md no-barrel rule. --- apps/web/app/api/webhooks/brevo/route.ts | 2 +- apps/web/app/api/webhooks/crove-crm/route.ts | 2 +- packages/app-store/crovecrm/lib/CrmService.ts | 2 +- packages/features/brevo/index.ts | 1 - packages/features/crove-crm/index.ts | 2 -- packages/features/organizations/index.ts | 1 - packages/features/teams/index.ts | 1 - packages/features/workflows/index.ts | 1 - 8 files changed, 3 insertions(+), 9 deletions(-) delete mode 100644 packages/features/brevo/index.ts delete mode 100644 packages/features/crove-crm/index.ts delete mode 100644 packages/features/organizations/index.ts delete mode 100644 packages/features/teams/index.ts delete mode 100644 packages/features/workflows/index.ts diff --git a/apps/web/app/api/webhooks/brevo/route.ts b/apps/web/app/api/webhooks/brevo/route.ts index e251f509b53..e5113017ceb 100644 --- a/apps/web/app/api/webhooks/brevo/route.ts +++ b/apps/web/app/api/webhooks/brevo/route.ts @@ -1,4 +1,4 @@ -import { BrevoService } from "@calcom/features/brevo"; +import { BrevoService } from "@calcom/features/brevo/brevoService"; import logger from "@calcom/lib/logger"; import { verifyWebhookSignature } from "@calcom/lib/webhook-signature"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; diff --git a/apps/web/app/api/webhooks/crove-crm/route.ts b/apps/web/app/api/webhooks/crove-crm/route.ts index 6d3c24bfd55..7ed6426fe14 100644 --- a/apps/web/app/api/webhooks/crove-crm/route.ts +++ b/apps/web/app/api/webhooks/crove-crm/route.ts @@ -1,4 +1,4 @@ -import { CroveCrmService } from "@calcom/features/crove-crm"; +import { CroveCrmService } from "@calcom/features/crove-crm/croveCrmService"; import logger from "@calcom/lib/logger"; import { verifyWebhookSignature } from "@calcom/lib/webhook-signature"; import { webhookMonitor } from "@calcom/lib/webhookMonitor"; diff --git a/packages/app-store/crovecrm/lib/CrmService.ts b/packages/app-store/crovecrm/lib/CrmService.ts index 23821a0c576..89522722f1c 100644 --- a/packages/app-store/crovecrm/lib/CrmService.ts +++ b/packages/app-store/crovecrm/lib/CrmService.ts @@ -1,4 +1,4 @@ -import { CroveCrmService } from "@calcom/features/crove-crm"; +import { CroveCrmService } from "@calcom/features/crove-crm/croveCrmService"; import type { CalendarEvent, EventBusyDate, IntegrationCalendar } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { CRM, Contact, ContactCreateInput, CrmEvent } from "@calcom/types/CrmService"; diff --git a/packages/features/brevo/index.ts b/packages/features/brevo/index.ts deleted file mode 100644 index 44a3789a79e..00000000000 --- a/packages/features/brevo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./brevoService"; diff --git a/packages/features/crove-crm/index.ts b/packages/features/crove-crm/index.ts deleted file mode 100644 index b98a3671da1..00000000000 --- a/packages/features/crove-crm/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./croveCrmService"; -export * from "./types"; diff --git a/packages/features/organizations/index.ts b/packages/features/organizations/index.ts deleted file mode 100644 index cb34919990e..00000000000 --- a/packages/features/organizations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./OrganizationService"; diff --git a/packages/features/teams/index.ts b/packages/features/teams/index.ts deleted file mode 100644 index e42c1909f47..00000000000 --- a/packages/features/teams/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./TeamService"; diff --git a/packages/features/workflows/index.ts b/packages/features/workflows/index.ts deleted file mode 100644 index e481b24fcd4..00000000000 --- a/packages/features/workflows/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./lib/WorkflowService"; From c5ec2d0f665e43149695b22f5d26332902ddbc17 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 11:53:13 +0000 Subject: [PATCH 26/30] fix(docker): refuse boot on default secrets, disable Prisma Studio, shrink GHA cache export start.sh now exits before migrate/seed/start when NEXTAUTH_SECRET or CALENDSO_ENCRYPTION_KEY is unset or still the publicly-known 'secret' default baked as a Docker ARG. The compose studio service (unauthenticated read/write DB console on :5555) is commented out by default. deploy-docker cache-to drops from mode=max (which exported the builder stage carrying secret ENVs into the shared Actions cache) to mode=min. --- .github/workflows/deploy-docker.yml | 5 +++- docker-compose.yml | 38 +++++++++++++++-------------- scripts/start.sh | 14 +++++++++++ 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml index 21a8e6799b8..7f3be31866d 100644 --- a/.github/workflows/deploy-docker.yml +++ b/.github/workflows/deploy-docker.yml @@ -44,7 +44,10 @@ jobs: ${{ steps.image-name.outputs.image_name }}:latest ${{ steps.image-name.outputs.image_name }}:${{ github.sha }} cache-from: type=gha - cache-to: type=gha,mode=max + # Why: mode=max exports every build stage — including the builder stage + # whose ENV carries DATABASE_URL and NEXTAUTH_SECRET defaults — into the + # shared Actions cache. mode=min exports only the final image layers. + cache-to: type=gha,mode=min build-args: | NEXT_PUBLIC_WEBAPP_URL=https://cal.crove.com NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2 diff --git a/docker-compose.yml b/docker-compose.yml index 2896b26d1e8..ae376a0fc1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,22 +117,24 @@ services: - database - redis - # Optional use of Prisma Studio. In production, comment out or remove the section below to prevent unwanted access to your database. - studio: - image: calcom.docker.scarf.sh/calcom/cal.diy - restart: always - networks: - - stack - ports: - - 5555:5555 - env_file: .env - environment: - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB} - - DATABASE_DIRECT_URL=${DATABASE_URL} - depends_on: - - database - command: - - npx - - prisma - - studio + # Prisma Studio is DISABLED by default: it exposed an unauthenticated + # read/write database console on port 5555. Uncomment only for local + # debugging on a trusted network. + # studio: + # image: calcom.docker.scarf.sh/calcom/cal.diy + # restart: always + # networks: + # - stack + # ports: + # - 5555:5555 + # env_file: .env + # environment: + # - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB} + # - DATABASE_DIRECT_URL=${DATABASE_URL} + # depends_on: + # - database + # command: + # - npx + # - prisma + # - studio # END SECTION: Optional use of Prisma Studio. diff --git a/scripts/start.sh b/scripts/start.sh index 334010a3758..7c00c8e6611 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,6 +1,20 @@ #!/bin/sh set -x +# CR-08 startup guard: refuse to boot when secrets are missing or equal the old +# publicly-known insecure default "secret". Real values must come from the runtime +# environment (docker-compose env_file / docker run -e), never from the image. +if [ -z "$NEXTAUTH_SECRET" ] || [ "$NEXTAUTH_SECRET" = "secret" ]; then + echo "ERROR: NEXTAUTH_SECRET is unset or equals the insecure default 'secret'." + echo "Set a strong random value at runtime, e.g.: openssl rand -base64 32" + exit 1 +fi +if [ -z "$CALENDSO_ENCRYPTION_KEY" ] || [ "$CALENDSO_ENCRYPTION_KEY" = "secret" ]; then + echo "ERROR: CALENDSO_ENCRYPTION_KEY is unset or equals the insecure default 'secret'." + echo "Set a strong random value at runtime, e.g.: openssl rand -base64 32" + exit 1 +fi + # Replace the statically built BUILT_NEXT_PUBLIC_WEBAPP_URL with run-time NEXT_PUBLIC_WEBAPP_URL # NOTE: if these values are the same, this will be skipped. scripts/replace-placeholder.sh "$BUILT_NEXT_PUBLIC_WEBAPP_URL" "$NEXT_PUBLIC_WEBAPP_URL" From efb232e52fafea9c697594b819649a76c95534e8 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 11:53:54 +0000 Subject: [PATCH 27/30] perf(db): index DOS tenant lookups and bound the scheduled-webhook drain Every OIDC sign-in and dos-org-sync event scanned the whole Team table through an unindexed JSONB path expression; partial expression indexes on metadata dosOrgId/dosTeamId plus isOrganization make those lookups index-only. WebhookScheduledTriggers gains a startAfter index, the drain now takes 100 rows per tick, dispatches all fetches before deleting them in one deleteMany, and actually awaits the settlement instead of floating it. --- .../lib/handleWebhookScheduledTriggers.ts | 24 +++++++++++++++---- .../migration.sql | 16 +++++++++++++ packages/prisma/schema.prisma | 6 +++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts index c900e9df176..28e88d27c3a 100644 --- a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts +++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts @@ -5,6 +5,11 @@ import type { PrismaClient } from "@calcom/prisma"; import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository"; import { createWebhookSignature, jsonParse } from "./sendPayload"; +// The cron that drains this table is disabled on this fork (audit HI-10), so backlog +// growth here is expected until crons are re-enabled; the take below only bounds each +// drain's fan-out, and the remainder drains on subsequent ticks. +const MAX_JOBS_PER_TICK = 100; + export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { await prisma.webhookScheduledTriggers.deleteMany({ where: { @@ -20,6 +25,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { lte: dayjs().toDate(), }, }, + take: MAX_JOBS_PER_TICK, select: { id: true, jobName: true, @@ -35,6 +41,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { }); const fetchPromises: Promise[] = []; + const dispatchedJobIds: number[] = []; // run jobs for (const job of jobsToRun) { @@ -74,14 +81,21 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { console.error(`Webhook trigger for subscriber url ${job.subscriberUrl} failed with error: ${error}`); }) ); + dispatchedJobIds.push(job.id); + } + + // Deleting only after every dispatch has settled keeps rows alive if the process dies + // mid-flight; the old per-row delete ran before each fetch resolved, so any interrupted + // delivery was lost for good. + await Promise.allSettled(fetchPromises); - // clean finished job - await prisma.webhookScheduledTriggers.delete({ + if (dispatchedJobIds.length > 0) { + await prisma.webhookScheduledTriggers.deleteMany({ where: { - id: job.id, + id: { + in: dispatchedJobIds, + }, }, }); } - - Promise.allSettled(fetchPromises); } diff --git a/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql b/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql new file mode 100644 index 00000000000..aef2711ab7c --- /dev/null +++ b/packages/prisma/migrations/20260910000000_add_team_dos_id_expression_indexes/migration.sql @@ -0,0 +1,16 @@ +-- Indexes for the DOS tenant lookups (MD-05) and the scheduled webhook drain (MD-06). +-- Prisma cannot model jsonb expression indexes, so the two Team expression indexes below +-- are hand-written here only; @@index([isOrganization]) and @@index([startAfter]) are +-- declared in schema.prisma and created below to keep schema and database in sync. + +-- CreateIndex +CREATE INDEX "Team_isOrganization_idx" ON "Team"("isOrganization"); + +-- CreateIndex +CREATE INDEX "Team_dosOrgId_idx" ON "Team" (("metadata"->>'dosOrgId')) WHERE "isOrganization" = true; + +-- CreateIndex +CREATE INDEX "Team_dosTeamId_idx" ON "Team" (("metadata"->>'dosTeamId')) WHERE "isOrganization" = false; + +-- CreateIndex +CREATE INDEX "WebhookScheduledTriggers_startAfter_idx" ON "WebhookScheduledTriggers"("startAfter"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e1b4e767e6a..2bfed727027 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -650,6 +650,10 @@ model Team { @@unique([slug, parentId]) @@index([parentId]) + // Prunes the candidate set of the DOS tenant lookups, which filter on isOrganization + // plus a metadata jsonb path; expression indexes for those paths are hand-written in + // the SQL migration because Prisma cannot model them. + @@index([isOrganization]) } model CreditBalance { @@ -1329,6 +1333,8 @@ model WebhookScheduledTriggers { booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) // @@partial_index([bookingId]) + + @@index([startAfter]) } model BookingSeat { From aa68fd3accfd265d8d8a8ad7cc472603223fc125 Mon Sep 17 00:00:00 2001 From: JOY Date: Thu, 10 Sep 2026 11:54:29 +0000 Subject: [PATCH 28/30] fix(trpc): implement PBAC permission check and restore report status authorization createTeamPbacProcedure/createOrgPbacProcedure always returned true, silently authorizing every wrapped router on authedProcedure alone; checkPermission now queries accepted membership with the fallback roles. updateWrongAssignmentReportStatus also regained its authorization check - previously any authenticated user who knew a report UUID could flip its review status on any team. --- .../__tests__/pbacProcedures.test.ts | 80 +++++++++++++++++++ .../trpc/server/procedures/pbacProcedures.ts | 39 +++++++-- ...dateWrongAssignmentReportStatus.handler.ts | 23 +++++- 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts diff --git a/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts b/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts new file mode 100644 index 00000000000..60475337e7c --- /dev/null +++ b/packages/trpc/server/procedures/__tests__/pbacProcedures.test.ts @@ -0,0 +1,80 @@ +import { MembershipRole } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionCheckService } from "../pbacProcedures"; + +const { mockMembershipFindFirst } = vi.hoisted(() => ({ mockMembershipFindFirst: vi.fn() })); + +vi.mock("@calcom/prisma", () => ({ + default: { + membership: { + findFirst: mockMembershipFindFirst, + }, + }, +})); + +describe("PermissionCheckService.checkPermission", () => { + const service = new PermissionCheckService(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns false when the user is not a member of the team", async () => { + mockMembershipFindFirst.mockResolvedValue(null); + + const hasPermission = await service.checkPermission({ + userId: 1, + teamId: 5, + permission: "booking.update", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER], + }); + + expect(hasPermission).toBe(false); + }); + + it("returns true when the user has an accepted membership with a fallback role", async () => { + mockMembershipFindFirst.mockResolvedValue({ id: 101 }); + + const hasPermission = await service.checkPermission({ + userId: 1, + teamId: 5, + permission: "booking.update", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER], + }); + + expect(hasPermission).toBe(true); + expect(mockMembershipFindFirst).toHaveBeenCalledWith({ + where: { + userId: 1, + teamId: 5, + accepted: true, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER] }, + }, + select: { id: true }, + }); + }); + + it("returns false when the membership is not accepted", async () => { + // The query filters on `accepted: true`, so an unaccepted membership never matches. + mockMembershipFindFirst.mockResolvedValue(null); + + const hasPermission = await service.checkPermission({ + userId: 1, + teamId: 5, + permission: "booking.update", + fallbackRoles: [MembershipRole.MEMBER], + }); + + expect(hasPermission).toBe(false); + expect(mockMembershipFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: 1, + teamId: 5, + accepted: true, + role: { in: [MembershipRole.MEMBER] }, + }), + }) + ); + }); +}); diff --git a/packages/trpc/server/procedures/pbacProcedures.ts b/packages/trpc/server/procedures/pbacProcedures.ts index 1b57582410f..f9a61068992 100644 --- a/packages/trpc/server/procedures/pbacProcedures.ts +++ b/packages/trpc/server/procedures/pbacProcedures.ts @@ -1,14 +1,43 @@ +import type { PrismaClient } from "@calcom/prisma"; +import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import authedProcedure from "./authedProcedure"; type PermissionString = string; + +type CheckPermissionArgs = { + userId: number; + teamId: number; + permission: PermissionString; + fallbackRoles: MembershipRole[]; +}; + class PermissionCheckService { - constructor(_prisma?: unknown) {} - async checkPermission(..._args: unknown[]) { return true; } - async hasPermission(..._args: unknown[]) { return true; } - async getTeamIdsWithPermission(..._args: unknown[]): Promise { return []; } + constructor(private readonly prismaClient: PrismaClient = prisma) {} + + // The only current consumer's handler is a stub; this real membership check closes the silent + // no-op trap before real handlers land on PBAC procedures. + async checkPermission({ userId, teamId, fallbackRoles }: CheckPermissionArgs): Promise { + const membership = await this.prismaClient.membership.findFirst({ + where: { + userId, + teamId, + accepted: true, + role: { in: fallbackRoles }, + }, + select: { id: true }, + }); + return membership !== null; + } + + async hasPermission(..._args: unknown[]) { + return true; + } + async getTeamIdsWithPermission(..._args: unknown[]): Promise { + return []; + } } /** @@ -95,4 +124,4 @@ function createOrgPbacProcedure( }); } -export { createTeamPbacProcedure, createOrgPbacProcedure }; +export { createTeamPbacProcedure, createOrgPbacProcedure, PermissionCheckService }; diff --git a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts index bfe0e9d7fbd..bbc9fe9abbb 100644 --- a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts @@ -1,11 +1,11 @@ import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository"; +import { ErrorWithCode } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import type { TUpdateWrongAssignmentReportStatusInputSchema } from "./updateWrongAssignmentReportStatus.schema"; - type UpdateWrongAssignmentReportStatusOptions = { ctx: { user: NonNullable; @@ -31,6 +31,27 @@ export const updateWrongAssignmentReportStatusHandler = async ({ }); } + if (!report.teamId) { + throw ErrorWithCode.Factory.Forbidden( + "You do not have permission to update this wrong assignment report" + ); + } + + const membership = await prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: report.teamId, + accepted: true, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + select: { id: true }, + }); + + if (!membership) { + throw ErrorWithCode.Factory.Forbidden( + "You do not have permission to update this wrong assignment report" + ); + } const updatedReport = await repo.updateStatus({ id: reportId, From 341cc71beeb6e8dec124aeb06cfe16d71d33a8a5 Mon Sep 17 00:00:00 2001 From: JOY Date: Fri, 11 Sep 2026 18:01:48 +0000 Subject: [PATCH 29/30] refactor(webhooks): rename DOS sync secret to the product-prefixed convention DOS_SYNC_WEBHOOK_SECRET was the only fork webhook secret without a product prefix, while its sibling uses CROVE_CRM_WEBHOOK_SECRET and GCP already holds CROVE_SIGN_DOS_WEBHOOK_SECRET for Crove Sign. A generic name invites sharing one signing key across products, where a single leak lets an attacker forge events for all of them. The route now reads CROVE_CAL_DOS_WEBHOOK_SECRET first and keeps the old name as a fallback; the e2e helper no longer falls back to the OIDC client secret. --- .env.example | 5 +++++ .../api/webhooks/dos-org-sync/__tests__/route.test.ts | 10 +++++++++- apps/web/app/api/webhooks/dos-org-sync/route.ts | 6 +++++- apps/web/playwright/dos-id-oidc.e2e.ts | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 0252f435296..f399c59c23f 100644 --- a/.env.example +++ b/.env.example @@ -487,6 +487,11 @@ GOOGLE_ADS_ENABLED=1 # To enable Google Ads tracking (gclid) LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id) # DOS Ecosystem Webhook Sync Secret +# Per-product signing key for the dos-org-sync webhook. GCP Secret Manager name: +# CROVE_CAL_DOS_WEBHOOK_SECRET (mirrors CROVE_SIGN_DOS_WEBHOOK_SECRET for Crove Sign). +# Do NOT reuse the OAuth client secret here. +CROVE_CAL_DOS_WEBHOOK_SECRET= +# Legacy unprefixed name, still accepted as a fallback: DOS_SYNC_WEBHOOK_SECRET= # Crove CRM Integration (crm.crove.com) diff --git a/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts index 7e1b3a6262b..2eccb64a021 100644 --- a/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/dos-org-sync/__tests__/route.test.ts @@ -10,6 +10,7 @@ const mockPrisma = { }, user: { findFirst: vi.fn(), + findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -20,8 +21,14 @@ const mockPrisma = { profile: { upsert: vi.fn(), }, + $transaction: vi.fn(), }; +// The route resolves users with findUnique (indexed, canonical-lowercase email) while the +// existing per-test mocks configure findFirst; delegate so both names return the same stub. +mockPrisma.user.findUnique.mockImplementation(mockPrisma.user.findFirst); +mockPrisma.$transaction.mockImplementation((fn: (tx: typeof mockPrisma) => unknown) => fn(mockPrisma)); + vi.mock("@calcom/prisma", () => ({ default: mockPrisma, prisma: mockPrisma, @@ -82,7 +89,7 @@ describe("/api/webhooks/dos-org-sync", () => { beforeEach(() => { vi.clearAllMocks(); - process.env.DOS_SYNC_WEBHOOK_SECRET = SECRET; + process.env.CROVE_CAL_DOS_WEBHOOK_SECRET = SECRET; }); test("OPTIONS should return 204 with CORS headers", async () => { @@ -94,6 +101,7 @@ describe("/api/webhooks/dos-org-sync", () => { }); test("POST should return 500 when webhook secret is missing", async () => { + delete process.env.CROVE_CAL_DOS_WEBHOOK_SECRET; delete process.env.DOS_SYNC_WEBHOOK_SECRET; delete process.env.OIDC_CLIENT_SECRET; diff --git a/apps/web/app/api/webhooks/dos-org-sync/route.ts b/apps/web/app/api/webhooks/dos-org-sync/route.ts index 0b6d7b7b15d..fc62e363bf8 100644 --- a/apps/web/app/api/webhooks/dos-org-sync/route.ts +++ b/apps/web/app/api/webhooks/dos-org-sync/route.ts @@ -112,7 +112,11 @@ export async function POST(req: NextRequest) { try { const rawBody = await req.text(); const signature = req.headers.get("x-dos-signature"); - const secret = process.env.DOS_SYNC_WEBHOOK_SECRET; + // Why: product-prefixed name matches the GCP Secret Manager convention shared + // with the other Crove apps (CROVE_SIGN_DOS_WEBHOOK_SECRET, CROVE_CRM_WEBHOOK_SECRET), + // so each product holds its own signing key and one leak cannot forge another's events. + // The unprefixed name is kept as a fallback for deployments that already set it. + const secret = process.env.CROVE_CAL_DOS_WEBHOOK_SECRET || process.env.DOS_SYNC_WEBHOOK_SECRET; if (!secret) { webhookMonitor.recordDelivery({ diff --git a/apps/web/playwright/dos-id-oidc.e2e.ts b/apps/web/playwright/dos-id-oidc.e2e.ts index 1166edc1e90..7b3d2eca0f2 100644 --- a/apps/web/playwright/dos-id-oidc.e2e.ts +++ b/apps/web/playwright/dos-id-oidc.e2e.ts @@ -76,7 +76,7 @@ test.describe("[Crove OS E2E]: DOS ID OIDC Login, App Switcher & Realtime Webhoo const signature = generateSignature( pingPayload, - process.env.DOS_SYNC_WEBHOOK_SECRET || process.env.OIDC_CLIENT_SECRET || WEBHOOK_SECRET + process.env.CROVE_CAL_DOS_WEBHOOK_SECRET || process.env.DOS_SYNC_WEBHOOK_SECRET || WEBHOOK_SECRET ); const pingRes = await request.post("/api/webhooks/dos-org-sync", { From 324b39768a4ee9a24839b7f963c04d5cf4356be0 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 12 Sep 2026 07:52:52 +0000 Subject: [PATCH 30/30] fix(deps): bump next to 16.3.3 for newly published critical advisories Two critical advisories published today cover next >= 16.0.0 < 16.3.3, which includes both the previous 16.2.3 and the 16.2.11 interim bump. Also repoints the brevo and crove-crm webhook test mocks at the deep service paths after the barrel files were removed, which had silently stopped the mocks from applying. --- .../webhooks/brevo/__tests__/route.test.ts | 2 +- .../crove-crm/__tests__/route.test.ts | 2 +- apps/web/package.json | 2 +- packages/platform/examples/base/package.json | 2 +- yarn.lock | 456 ++++++++++++++++-- 5 files changed, 413 insertions(+), 51 deletions(-) diff --git a/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts index 142fd81163a..fe21347dbd4 100644 --- a/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/brevo/__tests__/route.test.ts @@ -9,7 +9,7 @@ let mockIsConfigured = true; const WEBHOOK_SECRET = "brevo-webhook-test-secret"; const SIGNATURE_HEADER = "x-webhook-signature"; -vi.mock("@calcom/features/brevo", () => { +vi.mock("@calcom/features/brevo/brevoService", () => { return { BrevoService: class MockBrevoService { isConfigured = () => mockIsConfigured; diff --git a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts index c5d3be047f1..2416b593d7f 100644 --- a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts @@ -10,7 +10,7 @@ const mockMethods = { const WEBHOOK_SECRET = "crove-crm-webhook-test-secret"; const SIGNATURE_HEADER = "x-webhook-signature"; -vi.mock("@calcom/features/crove-crm", () => { +vi.mock("@calcom/features/crove-crm/croveCrmService", () => { return { CroveCrmService: class MockCroveCrmService { isConfigured() { diff --git a/apps/web/package.json b/apps/web/package.json index 1f7da48a8f5..21d815e0f61 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -107,7 +107,7 @@ "md5": "2.3.0", "memory-cache": "0.2.0", "micro": "10.0.1", - "next": "16.2.11", + "next": "16.3.3", "next-auth": "4.24.15", "next-axiom": "0.17.0", "next-i18next": "15.4.2", diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json index c4b8f623c2a..fecdd9a96b4 100644 --- a/packages/platform/examples/base/package.json +++ b/packages/platform/examples/base/package.json @@ -13,7 +13,7 @@ "dependencies": { "@calcom/atoms": "workspace:*", "@prisma/client": "6.16.1", - "next": "16.2.11", + "next": "16.3.3", "prisma": "6.16.1", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/yarn.lock b/yarn.lock index a5199809ffc..bc88ee5954b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2158,7 +2158,7 @@ __metadata: "@types/react": "npm:18.0.26" "@types/react-dom": "npm:18.2.6" dotenv: "npm:17.2.2" - next: "npm:16.2.11" + next: "npm:16.3.3" postcss: "npm:8.5.6" prisma: "npm:6.16.1" react: "npm:18.2.0" @@ -3451,7 +3451,7 @@ __metadata: memory-cache: "npm:0.2.0" micro: "npm:10.0.1" module-alias: "npm:2.2.2" - next: "npm:16.2.11" + next: "npm:16.3.3" next-auth: "npm:4.24.15" next-axiom: "npm:0.17.0" next-i18next: "npm:15.4.2" @@ -4256,6 +4256,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.3": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/ec834cc0fe248e06dd84f6dee10162133f8a692636189e99aa992303c791d4be1679536ee91aeee6661c4478609768cee9c085acd858caa92784c67acaab44a5 + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.2.0, @emnapi/runtime@npm:^1.5.0": version: 1.6.0 resolution: "@emnapi/runtime@npm:1.6.0" @@ -5798,6 +5807,13 @@ __metadata: languageName: node linkType: hard +"@img/colour@npm:^1.1.0": + version: 1.1.0 + resolution: "@img/colour@npm:1.1.0" + checksum: 10/2a29be7b06b046bd33c80ffa0f3493b7535b0841a69f54af81bf5e2d8867f21704fab42e9cf24ec30c089de34f790bae4dad1fc617c3163fbf264998dc316f0a + languageName: node + linkType: hard + "@img/sharp-darwin-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-darwin-arm64@npm:0.33.5" @@ -5822,6 +5838,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-darwin-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@img/sharp-darwin-x64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-darwin-x64@npm:0.33.5" @@ -5846,6 +5874,27 @@ __metadata: languageName: node linkType: hard +"@img/sharp-darwin-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-freebsd-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" + conditions: os=freebsd + languageName: node + linkType: hard + "@img/sharp-libvips-darwin-arm64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-darwin-arm64@npm:1.0.4" @@ -5860,6 +5909,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-darwin-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@img/sharp-libvips-darwin-x64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-darwin-x64@npm:1.0.4" @@ -5874,6 +5930,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-darwin-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@img/sharp-libvips-linux-arm64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-linux-arm64@npm:1.0.4" @@ -5888,6 +5951,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linux-arm@npm:1.0.5": version: 1.0.5 resolution: "@img/sharp-libvips-linux-arm@npm:1.0.5" @@ -5902,6 +5972,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-arm@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.3" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linux-ppc64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" @@ -5909,6 +5986,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-ppc64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linux-riscv64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4" @@ -5916,6 +6000,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-riscv64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.3" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linux-s390x@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-linux-s390x@npm:1.0.4" @@ -5930,6 +6021,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-s390x@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linux-x64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-linux-x64@npm:1.0.4" @@ -5944,6 +6042,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linux-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-libvips-linuxmusl-arm64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.0.4" @@ -5958,6 +6063,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@img/sharp-libvips-linuxmusl-x64@npm:1.0.4": version: 1.0.4 resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.0.4" @@ -5972,6 +6084,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@img/sharp-linux-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linux-arm64@npm:0.33.5" @@ -5996,6 +6115,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linux-arm@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linux-arm@npm:0.33.5" @@ -6020,6 +6151,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-arm@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linux-ppc64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-ppc64@npm:0.34.5" @@ -6032,6 +6175,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-ppc64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-ppc64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linux-riscv64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-riscv64@npm:0.34.5" @@ -6044,6 +6199,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-riscv64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-riscv64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linux-s390x@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linux-s390x@npm:0.33.5" @@ -6068,6 +6235,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-s390x@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-s390x@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linux-x64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linux-x64@npm:0.33.5" @@ -6092,6 +6271,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linux-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@img/sharp-linuxmusl-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linuxmusl-arm64@npm:0.33.5" @@ -6116,6 +6307,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linuxmusl-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@img/sharp-linuxmusl-x64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-linuxmusl-x64@npm:0.33.5" @@ -6140,6 +6343,18 @@ __metadata: languageName: node linkType: hard +"@img/sharp-linuxmusl-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@img/sharp-wasm32@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-wasm32@npm:0.33.5" @@ -6158,6 +6373,24 @@ __metadata: languageName: node linkType: hard +"@img/sharp-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-wasm32@npm:0.35.4" + dependencies: + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10/24250d2a5c1e681577c97a1774fd8785fc237066146badb9d25f8512dfd09771838b6d76fb0912baa0b29a0d7a564282dfc562062e747486aee832cc98941210 + languageName: node + linkType: hard + +"@img/sharp-webcontainers-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + "@img/sharp-win32-arm64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-win32-arm64@npm:0.34.5" @@ -6165,6 +6398,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-win32-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-arm64@npm:0.35.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@img/sharp-win32-ia32@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-win32-ia32@npm:0.33.5" @@ -6179,6 +6419,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-win32-ia32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-ia32@npm:0.35.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@img/sharp-win32-x64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-win32-x64@npm:0.33.5" @@ -6193,6 +6440,13 @@ __metadata: languageName: node linkType: hard +"@img/sharp-win32-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-x64@npm:0.35.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@inkjs/ui@npm:2.0.0": version: 2.0.0 resolution: "@inkjs/ui@npm:2.0.0" @@ -8485,10 +8739,10 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:16.2.11": - version: 16.2.11 - resolution: "@next/env@npm:16.2.11" - checksum: 10/57423578764a3672fc135c22f9e84639a2a2290069fdf8552f04163be640fd4eddfd3f6678df63bde8d524371990e9f2ab311139aec2e94dc1ade16c9c81958c +"@next/env@npm:16.3.3": + version: 16.3.3 + resolution: "@next/env@npm:16.3.3" + checksum: 10/dd599366ef155a35e6fb15907980709c0fe642ec33218e0e8521b4542640d6121f83a2289cec33280e81950ed6a56803da709ebb942291306c962aaead1e5c20 languageName: node linkType: hard @@ -8506,9 +8760,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-darwin-arm64@npm:16.2.11" +"@next/swc-darwin-arm64@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-darwin-arm64@npm:16.3.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -8527,9 +8781,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-darwin-x64@npm:16.2.11" +"@next/swc-darwin-x64@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-darwin-x64@npm:16.3.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -8548,9 +8802,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-arm64-gnu@npm:16.2.11" +"@next/swc-linux-arm64-gnu@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-linux-arm64-gnu@npm:16.3.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -8569,9 +8823,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-arm64-musl@npm:16.2.11" +"@next/swc-linux-arm64-musl@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-linux-arm64-musl@npm:16.3.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -8590,9 +8844,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-x64-gnu@npm:16.2.11" +"@next/swc-linux-x64-gnu@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-linux-x64-gnu@npm:16.3.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -8611,9 +8865,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-x64-musl@npm:16.2.11" +"@next/swc-linux-x64-musl@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-linux-x64-musl@npm:16.3.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -8632,9 +8886,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-win32-arm64-msvc@npm:16.2.11" +"@next/swc-win32-arm64-msvc@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-win32-arm64-msvc@npm:16.3.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -8653,9 +8907,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-win32-x64-msvc@npm:16.2.11" +"@next/swc-win32-x64-msvc@npm:16.3.3": + version: 16.3.3 + resolution: "@next/swc-win32-x64-msvc@npm:16.3.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -15068,6 +15322,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:0.5.23": + version: 0.5.23 + resolution: "@swc/helpers@npm:0.5.23" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10/899c9761e827d5bf504bd02067596cfe59ad6c7d6b6f0691a2fb05acf3e6c2954ddece7e15d4a6a81e8d95c81500652e6abec326c6c9bbb50bc3e2141696de43 + languageName: node + linkType: hard + "@swc/helpers@npm:^0.5.0": version: 0.5.21 resolution: "@swc/helpers@npm:0.5.21" @@ -32033,24 +32296,24 @@ __metadata: languageName: node linkType: hard -"next@npm:16.2.11": - version: 16.2.11 - resolution: "next@npm:16.2.11" +"next@npm:16.3.3": + version: 16.3.3 + resolution: "next@npm:16.3.3" dependencies: - "@next/env": "npm:16.2.11" - "@next/swc-darwin-arm64": "npm:16.2.11" - "@next/swc-darwin-x64": "npm:16.2.11" - "@next/swc-linux-arm64-gnu": "npm:16.2.11" - "@next/swc-linux-arm64-musl": "npm:16.2.11" - "@next/swc-linux-x64-gnu": "npm:16.2.11" - "@next/swc-linux-x64-musl": "npm:16.2.11" - "@next/swc-win32-arm64-msvc": "npm:16.2.11" - "@next/swc-win32-x64-msvc": "npm:16.2.11" - "@swc/helpers": "npm:0.5.15" + "@next/env": "npm:16.3.3" + "@next/swc-darwin-arm64": "npm:16.3.3" + "@next/swc-darwin-x64": "npm:16.3.3" + "@next/swc-linux-arm64-gnu": "npm:16.3.3" + "@next/swc-linux-arm64-musl": "npm:16.3.3" + "@next/swc-linux-x64-gnu": "npm:16.3.3" + "@next/swc-linux-x64-musl": "npm:16.3.3" + "@next/swc-win32-arm64-msvc": "npm:16.3.3" + "@next/swc-win32-x64-msvc": "npm:16.3.3" + "@swc/helpers": "npm:0.5.23" baseline-browser-mapping: "npm:^2.9.19" caniuse-lite: "npm:^1.0.30001579" - postcss: "npm:8.4.31" - sharp: "npm:^0.34.5" + postcss: "npm:8.5.23" + sharp: "npm:^0.35.3" styled-jsx: "npm:5.1.6" peerDependencies: "@opentelemetry/api": ^1.1.0 @@ -32089,7 +32352,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10/ceb25d1b34b07e4f0f903d5dc9063fa2d7a4d161132d8bd38c41fd70c8ccc5abb2d13b7085c845303e1c2882c2110a58027dc3626595076108069ddc70626c03 + checksum: 10/c5570b6a375f002e0edb66d1b58f0cef1bd52dae466cb052b415b8ab3707ebfc791ca173628f71a47c97a30c609597e56795bd3858b919b390026ec721c347b1 languageName: node linkType: hard @@ -37260,6 +37523,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.8.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10/9b01d2ff11e6e4a4539b7ca3c5f280c8704cb397a28504469f2ed4f00ad2194748d756647362a9712fff30984d15772ab7f083108c2fb508e2096ae9e708f22c + languageName: node + linkType: hard + "semver@npm:~7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4" @@ -37523,7 +37795,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.34.3, sharp@npm:^0.34.5": +"sharp@npm:^0.34.3": version: 0.34.5 resolution: "sharp@npm:0.34.5" dependencies: @@ -37607,6 +37879,96 @@ __metadata: languageName: node linkType: hard +"sharp@npm:^0.35.3": + version: 0.35.4 + resolution: "sharp@npm:0.35.4" + dependencies: + "@img/colour": "npm:^1.1.0" + "@img/sharp-darwin-arm64": "npm:0.35.4" + "@img/sharp-darwin-x64": "npm:0.35.4" + "@img/sharp-freebsd-wasm32": "npm:0.35.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + "@img/sharp-linux-arm": "npm:0.35.4" + "@img/sharp-linux-arm64": "npm:0.35.4" + "@img/sharp-linux-ppc64": "npm:0.35.4" + "@img/sharp-linux-riscv64": "npm:0.35.4" + "@img/sharp-linux-s390x": "npm:0.35.4" + "@img/sharp-linux-x64": "npm:0.35.4" + "@img/sharp-linuxmusl-arm64": "npm:0.35.4" + "@img/sharp-linuxmusl-x64": "npm:0.35.4" + "@img/sharp-webcontainers-wasm32": "npm:0.35.4" + "@img/sharp-win32-arm64": "npm:0.35.4" + "@img/sharp-win32-ia32": "npm:0.35.4" + "@img/sharp-win32-x64": "npm:0.35.4" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.8.5" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-freebsd-wasm32": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-riscv64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-webcontainers-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/f3130f6f126e532d67560b808b95d7c235e669b7f4dd68e0d464feed9a70d52c356ac53867eb44ec877788176a15a3bdffc8ac777a9ed777fc4b63cc99582cb2 + languageName: node + linkType: hard + "shebang-command@npm:^1.2.0": version: 1.2.0 resolution: "shebang-command@npm:1.2.0"