fix: Credential Syncing Improvements (#14588)

* Add example app to test credential sync

* Fixes

* Changes to normalize flow of GoogleCalendar and Zoom

* Add unit tests

* PR Feedback

* credential-sync-more-tests-and-more-apps

* Fix yarn.lock

* Clear cache

* Add test

* Fix yarn.lock

* Fix 204 handling

* Fix yarn.lock

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2024-04-25 22:17:17 +05:30
committed by GitHub
parent ebbb1dcce3
commit d47c6b3fdb
44 changed files with 3610 additions and 593 deletions
+1
View File
@@ -299,6 +299,7 @@ E2E_TEST_CALCOM_GCAL_KEYS=
CALCOM_CREDENTIAL_SYNC_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"
# This the endpoint from which the token is fetched
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 24 bytes for AES256 encryption algorithm
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
@@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
const key = minimumTokenResponseSchema.parse(decryptedKey);
const key = OAuth2UniversalSchema.parse(decryptedKey);
const credential = await prisma.credential.update({
where: {
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { HttpError } from "@calcom/lib/http-error";
@@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
const key = minimumTokenResponseSchema.parse(decryptedKey);
const key = OAuth2UniversalSchema.parse(decryptedKey);
// Need to get app type
const app = await prisma.app.findUnique({
+6 -1
View File
@@ -24,7 +24,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ message: "Invalid credential sync secret" });
}
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
if (!reqBodyParsed.success) {
return res.status(400).json({ error: reqBodyParsed.error.issues });
}
const reqBody = reqBodyParsed.data;
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
+15
View File
@@ -0,0 +1,15 @@
CALCOM_TEST_USER_ID=1
GOOGLE_REFRESH_TOKEN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
ZOOM_REFRESH_TOKEN=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
CALCOM_ADMIN_API_KEY=
# Refer to Cal.com env variables as these are set in their env
CALCOM_CREDENTIAL_SYNC_SECRET="";
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret";
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY="";
+9
View File
@@ -0,0 +1,9 @@
# README
This is an example app that acts as the source of truth for Cal.com Apps credentials. This app is capable of generating the access_token itself and then sync those to Cal.com app.
## How to start
`yarn dev` starts the server on port 5100. After this open http://localhost:5100 and from there you would be able to manage the tokens for various Apps.
## Endpoints
http://localhost:5100/api/getToken should be set as the value of env variable CALCOM_CREDENTIAL_SYNC_ENDPOINT in Cal.com
+13
View File
@@ -0,0 +1,13 @@
// How to get it? -> Establish a connection with Google(e.g. through cal.com app) and then copy the refresh_token from there.
export const GOOGLE_REFRESH_TOKEN = process.env.GOOGLE_REFRESH_TOKEN;
export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
export const ZOOM_REFRESH_TOKEN = process.env.ZOOM_REFRESH_TOKEN;
export const ZOOM_CLIENT_ID = process.env.ZOOM_CLIENT_ID;
export const ZOOM_CLIENT_SECRET = process.env.ZOOM_CLIENT_SECRET;
export const CALCOM_ADMIN_API_KEY = process.env.CALCOM_ADMIN_API_KEY;
export const CALCOM_CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET;
export const CALCOM_CREDENTIAL_SYNC_HEADER_NAME = process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME;
export const CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY = process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY;
@@ -0,0 +1,89 @@
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REFRESH_TOKEN,
ZOOM_CLIENT_ID,
ZOOM_CLIENT_SECRET,
ZOOM_REFRESH_TOKEN,
} from "../constants";
export async function generateGoogleCalendarAccessToken() {
const keys = {
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uris: [
"http://localhost:3000/api/integrations/googlecalendar/callback",
"http://localhost:3000/api/auth/callback/google",
],
};
const clientId = keys.client_id;
const clientSecret = keys.client_secret;
const refresh_token = GOOGLE_REFRESH_TOKEN;
const url = "https://oauth2.googleapis.com/token";
const data = {
client_id: clientId,
client_secret: clientSecret,
refresh_token: refresh_token,
grant_type: "refresh_token",
};
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(data),
});
const json = await response.json();
if (json.access_token) {
console.log("Access Token:", json.access_token);
return json.access_token;
} else {
console.error("Failed to retrieve access token:", json);
return null;
}
} catch (error) {
console.error("Error fetching access token:", error);
return null;
}
}
export async function generateZoomAccessToken() {
const client_id = ZOOM_CLIENT_ID; // Replace with your client ID
const client_secret = ZOOM_CLIENT_SECRET; // Replace with your client secret
const refresh_token = ZOOM_REFRESH_TOKEN; // Replace with your refresh token
const url = "https://zoom.us/oauth/token";
const auth = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("refresh_token", refresh_token);
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
const json = await response.json();
if (json.access_token) {
console.log("New Access Token:", json.access_token);
console.log("New Refresh Token:", json.refresh_token); // Save this refresh token securely
return json.access_token; // You might also want to return the new refresh token if applicable
} else {
console.error("Failed to refresh access token:", json);
return null;
}
} catch (error) {
console.error("Error refreshing access token:", error);
return null;
}
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
require("dotenv").config({ path: "../../.env" });
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@calcom/lib"],
};
module.exports = nextConfig;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@calcom/example-app-credential-sync",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "PORT=5100 next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@calcom/atoms": "*",
"@prisma/client": "5.4.2",
"next": "14.0.4",
"prisma": "^5.7.1",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20.3.1",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "14.0.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^4.9.4"
}
}
@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { CALCOM_CREDENTIAL_SYNC_HEADER_NAME, CALCOM_CREDENTIAL_SYNC_SECRET } from "../../constants";
import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const secret = req.headers[CALCOM_CREDENTIAL_SYNC_HEADER_NAME];
console.log("getToken hit");
try {
if (!secret) {
return res.status(403).json({ message: "secret header not set" });
}
if (secret !== CALCOM_CREDENTIAL_SYNC_SECRET) {
return res.status(403).json({ message: "Invalid secret" });
}
const calcomUserId = req.body.calcomUserId;
const appSlug = req.body.appSlug;
console.log("getToken Params", {
calcomUserId,
appSlug,
});
let accessToken;
if (appSlug === "google-calendar") {
accessToken = await generateGoogleCalendarAccessToken();
} else if (appSlug === "zoom") {
accessToken = await generateZoomAccessToken();
} else {
throw new Error("Unhandled values");
}
if (!accessToken) {
throw new Error("Unable to generate token");
}
res.status(200).json({
_1: true,
access_token: accessToken,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
}
@@ -0,0 +1,67 @@
import type { NextApiRequest } from "next";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import {
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY,
CALCOM_CREDENTIAL_SYNC_SECRET,
CALCOM_CREDENTIAL_SYNC_HEADER_NAME,
CALCOM_ADMIN_API_KEY,
} from "../../constants";
import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations";
export default async function handler(req: NextApiRequest, res) {
const isInvalid = req.query["invalid"] === "1";
const userId = parseInt(req.query["userId"] as string);
const appSlug = req.query["appSlug"];
try {
let accessToken;
if (appSlug === "google-calendar") {
accessToken = await generateGoogleCalendarAccessToken();
} else if (appSlug === "zoom") {
accessToken = await generateZoomAccessToken();
} else {
throw new Error(`Unhandled appSlug: ${appSlug}`);
}
if (!accessToken) {
return res.status(500).json({ error: "Could not get access token" });
}
const result = await fetch(
`http://localhost:3002/api/v1/credential-sync?apiKey=${CALCOM_ADMIN_API_KEY}&userId=${userId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[CALCOM_CREDENTIAL_SYNC_HEADER_NAME]: CALCOM_CREDENTIAL_SYNC_SECRET,
},
body: JSON.stringify({
appSlug,
encryptedKey: symmetricEncrypt(
JSON.stringify({
access_token: isInvalid ? "1233231231231" : accessToken,
}),
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
),
}),
}
);
const clonedResult = result.clone();
try {
if (result.ok) {
const json = await result.json();
return res.status(200).json(json);
} else {
return res.status(400).json({ error: await clonedResult.text() });
}
} catch (e) {
return res.status(400).json({ error: await clonedResult.text() });
}
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Internal Server Error", error: error.message });
}
}
@@ -0,0 +1,57 @@
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
export default function Index() {
const [data, setData] = useState("");
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const appSlug = searchParams.get("appSlug");
const userId = searchParams.get("userId");
useEffect(() => {
let isRedirectNeeded = false;
const newSearchParams = new URLSearchParams(new URL(document.URL).searchParams);
if (!userId) {
newSearchParams.set("userId", "1");
isRedirectNeeded = true;
}
if (!appSlug) {
newSearchParams.set("appSlug", "google-calendar");
isRedirectNeeded = true;
}
if (isRedirectNeeded) {
router.push(`${pathname}?${newSearchParams.toString()}`);
}
}, [router, pathname, userId, appSlug]);
async function updateToken({ invalid } = { invalid: false }) {
const res = await fetch(
`/api/setTokenInCalCom?invalid=${invalid ? 1 : 0}&userId=${userId}&appSlug=${appSlug}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
const data = await res.json();
setData(JSON.stringify(data));
}
return (
<div>
<h1>Welcome to Credential Sync Playground</h1>
<p>
You are managing credentials for cal.com <strong>userId={userId}</strong> for{" "}
<strong>appSlug={appSlug}</strong>. Update query params to manage a different user or app{" "}
</p>
<button onClick={() => updateToken({ invalid: true })}>Give an invalid token to Cal.com</button>
<button onClick={() => updateToken()}>Give a valid token to Cal.com</button>
<div>{data}</div>
</div>
);
}
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+2 -1
View File
@@ -11,7 +11,8 @@
"packages/app-store/*",
"packages/app-store/ee/*",
"packages/platform/*",
"packages/platform/examples/base"
"packages/platform/examples/base",
"example-apps/*"
],
"scripts": {
"app-store-cli": "yarn workspace @calcom/app-store-cli",
@@ -1,8 +1,12 @@
import type Zod from "zod";
import type z from "zod";
import getAppKeysFromSlug from "./getAppKeysFromSlug";
export async function getParsedAppKeysFromSlug(slug: string, schema: Zod.Schema) {
export async function getParsedAppKeysFromSlug<T extends Zod.Schema>(
slug: string,
schema: T
): Promise<z.infer<T>> {
const appKeys = await getAppKeysFromSlug(slug);
return schema.parse(appKeys);
}
@@ -0,0 +1,21 @@
import prisma from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";
export const invalidateCredential = async (credentialId: CredentialPayload["id"]) => {
const credential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (credential) {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
invalid: true,
},
});
}
};
@@ -0,0 +1,22 @@
/**
* This class is used to convert axios like response to fetch response
*/
export class AxiosLikeResponseToFetchResponse<
T extends {
status: number;
statusText: string;
data: unknown;
}
> extends Response {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any;
constructor(axiomResponse: T) {
super(JSON.stringify(axiomResponse.data), {
status: axiomResponse.status,
statusText: axiomResponse.statusText,
});
}
async json() {
return super.json() as unknown as T["data"];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,559 @@
/**
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
* It is aware of the credential sync endpoint and can sync the token from the third party source.
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
*
* For a recommended usage example, see Zoom VideoApiAdapter.ts
*/
import type { z } from "zod";
import { CREDENTIAL_SYNC_ENDPOINT } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { AxiosLikeResponseToFetchResponse } from "./AxiosLikeResponseToFetchResponse";
import type { OAuth2TokenResponseInDbWhenExistsSchema, OAuth2UniversalSchema } from "./universalSchema";
import { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
const log = logger.getSubLogger({ prefix: ["app-store/_utils/oauth/OAuthManager"] });
export const enum TokenStatus {
UNUSABLE_TOKEN_OBJECT,
UNUSABLE_ACCESS_TOKEN,
INCONCLUSIVE,
VALID,
}
type ResourceOwner =
| {
id: number | null;
type: "team";
}
| {
id: number | null;
type: "user";
};
type FetchNewTokenObject = ({ refreshToken }: { refreshToken: string | null }) => Promise<Response | null>;
type UpdateTokenObject = (
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) => Promise<void>;
type isTokenObjectUnusable = (response: Response) => Promise<{ reason: string } | null>;
type isAccessTokenUnusable = (response: Response) => Promise<{ reason: string } | null>;
type IsTokenExpired = (token: z.infer<typeof OAuth2UniversalSchema>) => Promise<boolean> | boolean;
type InvalidateTokenObject = () => Promise<void>;
type ExpireAccessToken = () => Promise<void>;
type CredentialSyncVariables = {
/**
* The secret required to access the credential sync endpoint
*/
CREDENTIAL_SYNC_SECRET: string | undefined;
/**
* The header name that the secret should be passed in
*/
CREDENTIAL_SYNC_SECRET_HEADER_NAME: string;
/**
* The endpoint where the credential sync should happen
*/
CREDENTIAL_SYNC_ENDPOINT: string | undefined;
APP_CREDENTIAL_SHARING_ENABLED: boolean;
};
/**
* Manages OAuth2.0 tokens for an app and resourceOwner
* If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled)
* If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired)
*/
export class OAuthManager {
private currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
private resourceOwner: ResourceOwner;
private appSlug: string;
private fetchNewTokenObject: FetchNewTokenObject;
private updateTokenObject: UpdateTokenObject;
private isTokenObjectUnusable: isTokenObjectUnusable;
private isAccessTokenUnusable: isAccessTokenUnusable;
private isTokenExpired: IsTokenExpired;
private invalidateTokenObject: InvalidateTokenObject;
private expireAccessToken: ExpireAccessToken;
private credentialSyncVariables: CredentialSyncVariables;
private useCredentialSync: boolean;
private autoCheckTokenExpiryOnRequest: boolean;
constructor({
resourceOwner,
appSlug,
currentTokenObject,
fetchNewTokenObject,
updateTokenObject,
isTokenObjectUnusable,
isAccessTokenUnusable,
invalidateTokenObject,
expireAccessToken,
credentialSyncVariables,
autoCheckTokenExpiryOnRequest = true,
isTokenExpired = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
log.debug(
"isTokenExpired called",
safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() })
);
return getExpiryDate() <= Date.now();
function isRelativeToEpoch(relativeTimeInSeconds: number) {
return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time
}
function getExpiryDate() {
if (token.expiry_date) {
return token.expiry_date;
}
// It is usually in "seconds since now" but due to some integrations logic converting it to "seconds since epoch"(e.g. Office365Calendar has done that) we need to confirm what is the case here.
// But we for now know that it is in seconds for sure
// If it is not relative to epoch then it would be wrong to use it as it would make the token as non-expired when it could be expired
if (token.expires_in && isRelativeToEpoch(token.expires_in)) {
return token.expires_in * 1000;
}
// 0 means it would be expired as Date.now() is greater than that
return 0;
}
},
}: {
/**
* The resource owner for which the token is being managed
*/
resourceOwner: ResourceOwner;
/**
* Does response for any request contain information that refresh_token became invalid and thus the entire token object become unusable
* Note: Right now, the implementations of this function makes it so that the response is considered invalid(sometimes) even if just access_token is revoked or invalid. In that case, regenerating access token should work. So, we shouldn't mark the token as invalid in that case.
* We should instead mark the token as expired. We could do that by introducing isAccessTokenInvalid function
*
* @param response
* @returns
*/
isTokenObjectUnusable: isTokenObjectUnusable;
/**
*
*/
isAccessTokenUnusable: isAccessTokenUnusable;
/**
* The current token object.
*/
currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
/**
* The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting
*/
appSlug: string;
/**
*
* It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened.
* If credential syncing is still enabled `fetchNewTokenObject` wouldn't be called
*/
fetchNewTokenObject: FetchNewTokenObject;
/**
* update token object
*/
updateTokenObject: UpdateTokenObject;
/**
* Handler to invalidate the token object. It is called when the token object is invalid and credential syncing is disabled
*/
invalidateTokenObject: InvalidateTokenObject;
/*
* Handler to expire the access token. It is called when credential syncing is enabled and when the token object expires
*/
expireAccessToken: ExpireAccessToken;
/**
* The variables required for credential syncing
*/
credentialSyncVariables: CredentialSyncVariables;
/**
* If the token should be checked for expiry before sending a request
*/
autoCheckTokenExpiryOnRequest?: boolean;
/**
* If there is a different way to check if the token is expired(and not the standard way of checking expiry_date)
*/
isTokenExpired?: IsTokenExpired;
}) {
ensureValidResourceOwner(resourceOwner);
this.resourceOwner = resourceOwner;
this.currentTokenObject = currentTokenObject;
this.appSlug = appSlug;
this.fetchNewTokenObject = fetchNewTokenObject;
this.isTokenObjectUnusable = isTokenObjectUnusable;
this.isAccessTokenUnusable = isAccessTokenUnusable;
this.isTokenExpired = isTokenExpired;
this.invalidateTokenObject = invalidateTokenObject;
this.expireAccessToken = expireAccessToken;
this.credentialSyncVariables = credentialSyncVariables;
this.useCredentialSync = !!(
credentialSyncVariables.APP_CREDENTIAL_SHARING_ENABLED &&
credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET
);
this.autoCheckTokenExpiryOnRequest = autoCheckTokenExpiryOnRequest;
this.updateTokenObject = updateTokenObject;
}
private isResponseNotOkay(response: Response) {
return !response.ok || response.status < 200 || response.status >= 300;
}
public async getTokenObjectOrFetch() {
const myLog = log.getSubLogger({
prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`],
});
const isExpired = await this.isTokenExpired(this.currentTokenObject);
myLog.debug(
"getTokenObjectOrFetch called",
safeStringify({
isExpired,
resourceOwner: this.resourceOwner,
})
);
if (!isExpired) {
myLog.debug("Token is not expired. Returning the current token object");
return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false };
} else {
const token = {
// Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar
// It also allows any other properties set to be retained.
// Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent
...this.currentTokenObject,
...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()),
};
myLog.debug("Token is expired. So, returning new token object");
this.currentTokenObject = token;
await this.updateTokenObject(token);
return { token, isUpdated: true };
}
}
public async request(arg: { url: string; options: RequestInit }): Promise<{
tokenStatus: TokenStatus;
json: unknown;
}>;
public async request<T>(
customFetch: () => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>
): Promise<{
tokenStatus: TokenStatus;
json: T;
}>;
/**
* Send request automatically adding the Authorization header with the access token. More importantly, handles token invalidation
*/
public async request<T>(
customFetchOrUrlAndOptions:
| { url: string; options: RequestInit }
| (() => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>)
) {
let response;
const myLog = log.getSubLogger({ prefix: ["request"] });
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
if (typeof customFetchOrUrlAndOptions === "function") {
myLog.debug("Sending request using customFetch");
const customFetch = customFetchOrUrlAndOptions;
try {
response = await customFetch();
} catch (e) {
// Get response from error so that code further down can categorize it into tokenUnusable or access token unusable
// Those methods accept response only
response = handleFetchError(e);
}
} else {
const { url, options } = customFetchOrUrlAndOptions;
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
myLog.debug("Sending request using fetch", safeStringify({ customFetchOrUrlAndOptions, headers }));
// We don't catch fetch error here because such an error would be temporary and we shouldn't take any action on it.
response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
}
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
const { tokenStatus, json } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
// In case of Credential Sync, we expire the token so that through the sync we can refresh the token
// TODO: We should consider sending a special 'reason' query param to toke sync endpoint to convey the reason for getting token
await this.invalidate();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
} else if (tokenStatus === TokenStatus.INCONCLUSIVE) {
await this.onInconclusiveResponse();
}
// We are done categorizing the token status. Now, we can throw back
if ("myFetchError" in (json || {})) {
throw new Error(json.myFetchError);
}
return { tokenStatus: tokenStatus, json };
}
/**
* It doesn't automatically detect the response for tokenObject and accessToken becoming invalid
* Could be used when you expect a possible non JSON response as well.
*/
public async requestRaw({ url, options }: { url: string; options: RequestInit }) {
const myLog = log.getSubLogger({ prefix: ["requestRaw"] });
myLog.debug("Sending request using fetch", safeStringify({ url, options }));
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
const response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
if (this.isResponseNotOkay(response)) {
await this.onInconclusiveResponse();
}
return response;
}
private async onInconclusiveResponse() {
const myLog = log.getSubLogger({ prefix: ["onInconclusiveResponse"] });
myLog.debug("Expiring the access token");
// We can't really take any action on inconclusive response
// But in case of credential sync we should expire the token so that through the sync we can possibly fix the issue by refreshing the token
// It is important because in that cases tokens have an infinite expiry and it is possible that the token is revoked and isAccessUnusable and isTokenObjectUnusable couldn't detect the issue
if (this.useCredentialSync) {
await this.expireAccessToken();
}
}
private async invalidate() {
const myLog = log.getSubLogger({ prefix: ["invalidate"] });
if (this.useCredentialSync) {
myLog.debug("Expiring the access token");
// We are not calling it through refreshOAuthToken flow because the token is refreshed already there
// There is no point expiring the token as we will probably get the same result in that case.
await this.expireAccessToken();
} else {
myLog.debug("Invalidating the token object");
// In case credential sync is enabled there is no point of marking the token as invalid as user doesn't take action on that.
// The third party needs to sync the correct credential back which we get done by marking the token as expired.
await this.invalidateTokenObject();
}
}
private normalizeNewlyReceivedToken(
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) {
if (!token.expiry_date && !token.expires_in) {
// Use a practically infinite expiry(a year) for when Credential Sync is enabled. Token is expected to be refreshed by the API request from the credential source.
// If credential sync is not enabled, we should consider the token as expired otherwise the token could be considered valid forever
token.expiry_date = this.useCredentialSync ? Date.now() + 365 * 24 * 3600 * 1000 : 0;
} else if (token.expires_in !== undefined && token.expiry_date === undefined) {
token.expiry_date = Math.round(Date.now() + token.expires_in * 1000);
// As expires_in could be relative to current time, we can't keep it in the token object as it could endup giving wrong absolute expiry_time if outdated value is used
// That could happen if we merge token objects which we do
delete token.expires_in;
}
return token;
}
// TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working
private async refreshOAuthToken() {
const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] });
let response;
const refreshToken = this.currentTokenObject.refresh_token ?? null;
if (this.resourceOwner.id && this.useCredentialSync) {
if (
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT
) {
throw new Error("Credential syncing is enabled but the required env variables are not set");
}
myLog.debug(
"Refreshing OAuth token from credential sync endpoint",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
endpoint: CREDENTIAL_SYNC_ENDPOINT,
})
);
try {
response = await fetch(`${this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT}`, {
method: "POST",
headers: {
[this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]:
this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET,
},
body: new URLSearchParams({
calcomUserId: this.resourceOwner.id.toString(),
appSlug: this.appSlug,
}),
});
} catch (e) {
myLog.error("Could not refresh the token.", safeStringify(e));
throw new Error(
`Could not refresh the token due to connection issue with the endpoint: ${CREDENTIAL_SYNC_ENDPOINT}`
);
}
} else {
myLog.debug(
"Refreshing OAuth token",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
})
);
try {
response = await this.fetchNewTokenObject({ refreshToken });
} catch (e) {
response = handleFetchError(e);
}
if (!response) {
throw new Error("`fetchNewTokenObject` could not refresh the token");
}
}
const clonedResponse = response.clone();
myLog.debug(
"Response from refreshOAuthToken",
safeStringify({
text: await clonedResponse.text(),
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
})
);
const { json, tokenStatus } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
await this.invalidateTokenObject();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
}
const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json);
if (!parsedToken.success) {
myLog.error("Token parsing error:", safeStringify(parsedToken.error.issues));
throw new Error("Invalid token response");
}
return parsedToken.data;
}
private async getAndValidateOAuth2Response({ response }: { response: Response }) {
const myLog = log.getSubLogger({ prefix: ["getAndValidateOAuth2Response"] });
const clonedResponse = response.clone();
// handle empty response (causes crash otherwise on doing json() as "" is invalid JSON) which is valid in some cases like PATCH calls(with 204 response)
if ((await clonedResponse.text()).trim() === "") {
return { tokenStatus: TokenStatus.VALID, json: null, invalidReason: null } as const;
}
const tokenObjectUsabilityRes = await this.isTokenObjectUnusable(response.clone());
const accessTokenUsabilityRes = await this.isAccessTokenUnusable(response.clone());
const isNotOkay = this.isResponseNotOkay(response);
const json = await response.json();
if (tokenObjectUsabilityRes?.reason) {
myLog.error("Token Object has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
invalidReason: tokenObjectUsabilityRes.reason,
json,
} as const;
}
if (accessTokenUsabilityRes?.reason) {
myLog.error("Access Token has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN,
invalidReason: accessTokenUsabilityRes?.reason,
json,
};
}
// Any handlable not ok response should be handled through isTokenObjectUnusable or isAccessTokenUnusable but if still not handled, we should throw an error
// So, that the caller can handle it. It could be a network error or some other temporary error from the third party App itself.
if (isNotOkay) {
return {
tokenStatus: TokenStatus.INCONCLUSIVE,
invalidReason: response.statusText,
json,
};
}
return { tokenStatus: TokenStatus.VALID, json, invalidReason: null } as const;
}
}
function ensureValidResourceOwner(
resourceOwner: { id: number | null; type: "team" } | { id: number | null; type: "user" }
) {
if (resourceOwner.type === "team") {
throw new Error("Teams are not supported");
} else {
if (!resourceOwner.id) {
throw new Error("resourceOwner should have id set");
}
}
}
/**
* It converts error into a Response
*/
function handleFetchError(e: unknown) {
const myLog = log.getSubLogger({ prefix: ["handleFetchError"] });
myLog.debug("Error", safeStringify(e));
if (e instanceof Error) {
return new Response(JSON.stringify({ myFetchError: e.message }), { status: 500 });
}
return new Response(JSON.stringify({ myFetchError: "UNKNOWN_ERROR" }), { status: 500 });
}
@@ -0,0 +1,24 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { CredentialPayload } from "@calcom/types/Credential";
import { OAuth2TokenResponseInDbSchema } from "./universalSchema";
export function getTokenObjectFromCredential(credential: CredentialPayload) {
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
if (!parsedTokenResponse.success) {
logger.debug(
"GoogleCalendarService-getTokenObjectFromCredential",
safeStringify(parsedTokenResponse.error.issues)
);
throw new Error("Could not parse credential.key");
}
const tokenResponse = parsedTokenResponse.data;
if (!tokenResponse) {
throw new Error("credential.key is not set");
}
return tokenResponse;
}
@@ -0,0 +1,21 @@
import prisma from "@calcom/prisma";
import type { CredentialPayload } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
export const markTokenAsExpired = async (credential: CredentialPayload) => {
const tokenResponse = getTokenObjectFromCredential(credential);
if (credential && credential.key) {
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: {
...tokenResponse,
expiry_date: Date.now() - 3600 * 1000,
},
},
});
}
};
@@ -0,0 +1,26 @@
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { invalidateCredential } from "../invalidateCredential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
import { markTokenAsExpired } from "./markTokenAsExpired";
import { updateTokenObject } from "./updateTokenObject";
export const credentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
};
export const oAuthManagerHelper = {
updateTokenObject,
markTokenAsExpired,
invalidateCredential: invalidateCredential,
getTokenObjectFromCredential,
credentialSyncVariables,
};
@@ -0,0 +1,45 @@
import { z } from "zod";
/**
* We should be able to work with just the access token.
* access_token allows us to access the resources
*/
export const OAuth2BareMinimumUniversalSchema = z
.object({
access_token: z.string(),
/**
* It is usually 'Bearer'
*/
token_type: z.string().optional(),
})
// We want any other property to be passed through and stay there.
.passthrough();
export const OAuth2UniversalSchema = OAuth2BareMinimumUniversalSchema.extend({
/**
* If we aren't sent refresh_token, it means that the party syncing us the credentials don't want us to ever refresh the token.
* They would be responsible to send us the access_token before it expires.
*/
refresh_token: z.string().optional(),
/**
* It is only needed when connecting to the API for the first time. So, it is okay if the party syncing us the credentials don't send it as then it is responsible to provide us the access_token already
*/
scope: z.string().optional(),
/**
* Absolute expiration time in milliseconds
*/
expiry_date: z.number().optional(),
});
export const OAuth2UniversalSchemaWithCalcomBackwardCompatibility = OAuth2UniversalSchema.extend({
/**
* Time in seconds until the token expires
* Either this or expiry_date should be provided
*/
expires_in: z.number().optional(),
});
export const OAuth2TokenResponseInDbWhenExistsSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility;
export const OAuth2TokenResponseInDbSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.nullable();
@@ -0,0 +1,22 @@
import type z from "zod";
import prisma from "@calcom/prisma";
import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
export const updateTokenObject = async ({
tokenObject,
credentialId,
}: {
tokenObject: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>;
credentialId: number;
}) => {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
key: tokenObject,
},
});
};
+43
View File
@@ -0,0 +1,43 @@
export function generateJsonResponse({
json,
status = 200,
statusText = "OK",
}: {
json: unknown;
status?: number;
statusText?: string;
}) {
return new Response(JSON.stringify(json), {
status,
statusText,
});
}
export function internalServerErrorResponse({
json,
}: {
json: unknown;
status?: number;
statusText?: string;
}) {
return generateJsonResponse({ json, status: 500, statusText: "Internal Server Error" });
}
export function generateTextResponse({
text,
status = 200,
statusText = "OK",
}: {
text: string;
status?: number;
statusText?: string;
}) {
return new Response(text, {
status: status,
statusText: statusText,
});
}
export function successResponse({ json }: { json: unknown }) {
return generateJsonResponse({ json });
}
@@ -9,6 +9,12 @@ import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -22,11 +28,16 @@ import type {
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema";
import { metadata } from "../_metadata";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
import { googleCredentialSchema } from "./googleCredentialSchema";
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] });
interface GoogleCalError extends Error {
code?: number;
@@ -64,80 +75,109 @@ function handleMinMax(min: string, max: string) {
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
private auth: { getToken: () => Promise<MyGoogleAuth> };
private auth: ReturnType<typeof this.initGoogleAuth>;
private log: typeof logger;
private credential: CredentialPayload;
private myGoogleAuth!: MyGoogleAuth;
private oAuthManagerInstance!: OAuthManager;
constructor(credential: CredentialPayload) {
this.integrationName = "google_calendar";
this.credential = credential;
this.auth = this.googleAuth(credential);
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.auth = this.initGoogleAuth(credential);
this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.credential = credential;
}
private googleAuth = (credential: CredentialPayload) => {
const googleCredentials = googleCredentialSchema.parse(credential.key);
private async getMyGoogleAuthSingleton() {
const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key);
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
this.myGoogleAuth = this.myGoogleAuth || new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
this.myGoogleAuth.setCredentials(googleCredentials);
return this.myGoogleAuth;
}
async function getGoogleAuth() {
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
}
const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => {
try {
const res = await refreshOAuthTokens(
async () => {
const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
return fetchTokens.res;
},
"google-calendar",
credential.userId
);
const token = res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
const parsedKey: ParseRefreshTokenResponse<typeof googleCredentialSchema> = parseRefreshTokenResponse(
googleCredentials,
googleCredentialSchema
);
await prisma.credential.update({
where: { id: credential.id },
data: { key: { ...parsedKey } as Prisma.InputJsonValue },
private initGoogleAuth = (credential: CredentialPayload) => {
const currentTokenObject = getTokenObjectFromCredential(credential);
const auth = new OAuthManager({
// Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well.
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
currentTokenObject,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
const fetchTokens = await myGoogleAuth.refreshToken(refreshToken);
// Create Response from fetchToken.res
const response = new Response(JSON.stringify(fetchTokens.res?.data ?? null), {
status: fetchTokens.res?.status,
statusText: fetchTokens.res?.statusText,
});
myGoogleAuth.setCredentials(googleCredentials);
} catch (err) {
this.log.error("Error Refreshing Google Token", safeStringify(err));
let message;
if (err instanceof Error) message = err.message;
else message = String(err);
// if not invalid_grant, default behaviour (which admittedly isn't great)
if (message !== "invalid_grant") return myGoogleAuth;
// when the error is invalid grant, it's unrecoverable and the credential marked invalid.
// TODO: Evaluate bubbling up and handling this in the CalendarManager. IMO this should be done
// but this is a bigger refactor.
return response;
},
isTokenExpired: async () => {
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
return myGoogleAuth.isTokenExpiring();
},
isTokenObjectUnusable: async function (response) {
// TODO: Confirm that if this logic should go to isAccessTokenUnusable
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
if (responseBody.error === "invalid_grant") {
return {
reason: "invalid_grant",
};
}
}
return null;
},
isAccessTokenUnusable: async () => {
// As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected
// So, a situation where access_token is invalid but refresh_token is valid should not happen
return null;
},
invalidateTokenObject: () => invalidateCredential(this.credential.id),
expireAccessToken: async () => {
await markTokenAsExpired(this.credential);
},
updateTokenObject: async (token) => {
this.myGoogleAuth.setCredentials(token);
await prisma.credential.update({
where: { id: credential.id },
where: {
id: credential.id,
},
data: {
invalid: true,
key: token,
},
});
}
return myGoogleAuth;
};
},
});
this.oAuthManagerInstance = auth;
return {
getToken: async () => {
const myGoogleAuth = await getGoogleAuth();
const isExpired = () => myGoogleAuth.isTokenExpiring();
return !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(myGoogleAuth);
getMyGoogleAuthWithRefreshedToken: async () => {
// It would automatically update myGoogleAuth with correct token
const { token } = await auth.getTokenObjectOrFetch();
if (!token) {
throw new Error("Invalid grant for Google Calendar app");
}
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
return myGoogleAuth;
},
};
};
public authedCalendar = async () => {
const myGoogleAuth = await this.auth.getToken();
const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
@@ -188,6 +228,7 @@ export default class GoogleCalendarService implements Calendar {
};
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
this.log.debug("Creating event");
const formattedCalEvent = formatCalEvent(calEventRaw);
const payload: calendar_v3.Schema$Event = {
@@ -480,11 +521,14 @@ export default class GoogleCalendarService implements Calendar {
if (!calendarCacheEnabled) {
this.log.warn("Calendar Cache is disabled - Skipping");
const { timeMin, timeMax, items } = args;
const apires = await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
});
freeBusyResult = apires.data;
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
})
)
));
} else {
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
@@ -502,9 +546,14 @@ export default class GoogleCalendarService implements Calendar {
if (cached) {
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
} else {
const apires = await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
});
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
})
)
));
// Skipping await to respond faster
await prisma.calendarCache.upsert({
@@ -515,18 +564,16 @@ export default class GoogleCalendarService implements Calendar {
},
},
update: {
value: JSON.parse(JSON.stringify(apires.data)),
value: JSON.parse(JSON.stringify(freeBusyResult)),
expiresAt: new Date(Date.now() + CACHING_TIME),
},
create: {
value: JSON.parse(JSON.stringify(apires.data)),
value: JSON.parse(JSON.stringify(freeBusyResult)),
credentialId: this.credential.id,
key,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
});
freeBusyResult = apires.data;
}
}
if (!freeBusyResult.calendars) return null;
@@ -548,6 +595,7 @@ export default class GoogleCalendarService implements Calendar {
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
this.log.debug("Getting availability");
const calendar = await this.authedCalendar();
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
@@ -613,11 +661,18 @@ export default class GoogleCalendarService implements Calendar {
}
async listCalendars(): Promise<IntegrationCalendar[]> {
this.log.debug("Listing calendars");
const calendar = await this.authedCalendar();
try {
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
if (!cals.data.items) return [];
return cals.data.items.map(
const { json: cals } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" })
)
);
if (!cals.items) return [];
return cals.items.map(
(cal) =>
({
externalId: cal.id ?? "No id",
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
@@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
scope: scopes.join(" "),
client_id,
prompt: "select_account",
redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`,
state,
};
const query = stringify(params);
@@ -2,7 +2,7 @@ import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-type
import type { NextApiRequest, NextApiResponse } from "next";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { handleErrorsJson } from "@calcom/lib/errors";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
@@ -50,7 +50,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`,
client_secret,
});
@@ -1,12 +1,10 @@
import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta";
import type { DefaultBodyType } from "msw";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type {
Calendar,
@@ -17,10 +15,10 @@ import type {
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { O365AuthCredentials } from "../types/Office365Calendar";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
import metadata from "../_metadata";
import { getOfficeAppKeys } from "./getOfficeAppKeys";
interface IRequest {
@@ -49,26 +47,58 @@ interface BodyValue {
start: { dateTime: string };
}
const refreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z
.number()
.transform((currentTimeOffsetInSeconds) => Math.round(+new Date() / 1000 + currentTimeOffsetInSeconds)),
refresh_token: z.string().optional(),
});
export default class Office365CalendarService implements Calendar {
private url = "";
private integrationName = "";
private log: typeof logger;
private accessToken: string | null = null;
auth: { getToken: () => Promise<string> };
private auth: OAuthManager;
private apiGraphUrl = "https://graph.microsoft.com/v1.0";
private credential: CredentialPayload;
constructor(credential: CredentialPayload) {
this.integrationName = "office365_calendar";
this.auth = this.o365Auth(credential);
const tokenResponse = getTokenObjectFromCredential(credential);
this.auth = new OAuthManager({
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
if (!refreshToken) {
return null;
}
const { client_id, client_secret } = await getOfficeAppKeys();
return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret,
}),
});
},
isTokenObjectUnusable: async function () {
// TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well.
// This is a placeholder for future implementation.
return null;
},
isAccessTokenUnusable: async function () {
// TODO: Implement this
return null;
},
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
updateTokenObject: (tokenObject) =>
oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }),
});
this.credential = credential;
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
@@ -232,58 +262,6 @@ export default class Office365CalendarService implements Calendar {
});
}
private o365Auth = (credential: CredentialPayload) => {
const isExpired = (expiryDate: number) => {
if (!expiryDate) {
return true;
} else {
return expiryDate < Math.round(+new Date() / 1000);
}
};
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
const { client_id, client_secret } = await getOfficeAppKeys();
const response = await refreshOAuthTokens(
async () =>
await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id,
refresh_token: o365AuthCredentials.refresh_token,
grant_type: "refresh_token",
client_secret,
}),
}),
"office365-calendar",
credential.userId
);
const responseJson = await handleErrorsJson(response);
const tokenResponse: ParseRefreshTokenResponse<typeof refreshTokenResponseSchema> =
parseRefreshTokenResponse(responseJson, refreshTokenResponseSchema);
o365AuthCredentials = { ...o365AuthCredentials, ...tokenResponse };
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
});
return o365AuthCredentials.access_token;
};
return {
getToken: () =>
refreshTokenResponseSchema.safeParse(o365AuthCredentials).success &&
!isExpired(o365AuthCredentials.expires_in)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials),
};
};
private translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
@@ -339,14 +317,12 @@ export default class Office365CalendarService implements Calendar {
};
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
this.accessToken = await this.auth.getToken();
return fetch(`${this.apiGraphUrl}${endpoint}`, {
method: "get",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
return this.auth.requestRaw({
url: `${this.apiGraphUrl}${endpoint}`,
options: {
method: "get",
...init,
},
...init,
});
};
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
response_type: "code",
scope: scopes.join(" "),
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`,
state,
};
const query = stringify(params);
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
@@ -47,7 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`,
client_secret,
});
@@ -0,0 +1,282 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { expect, test, vi, describe } from "vitest";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { internalServerErrorResponse, successResponse } from "../../_utils/testUtils";
import config from "../config.json";
import VideoApiAdapter from "./VideoApiAdapter";
const URLS = {
CREATE_MEETING: {
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
method: "POST",
},
UPDATE_MEETING: {
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
method: "POST",
},
};
vi.mock("../../_utils/getParsedAppKeysFromSlug", () => ({
default: vi.fn().mockImplementation((slug) => {
if (slug !== config.slug) {
throw new Error(
`expected to be called with the correct slug. Expected ${config.slug} - Received ${slug}`
);
}
return {
client_id: "FAKE_CLIENT_ID",
client_secret: "FAKE_CLIENT_SECRET",
};
}),
}));
const mockRequestRaw = vi.fn();
vi.mock("../../_utils/oauth/OAuthManager", () => ({
OAuthManager: vi.fn().mockImplementation(() => {
return { requestRaw: mockRequestRaw };
}),
}));
const testCredential = {
appId: config.slug,
id: 1,
invalid: false,
key: {
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: 1625097600000,
access_token: "",
refresh_token: "",
},
type: config.type,
userId: 1,
user: { email: "example@cal.com" },
teamId: 1,
};
describe("createMeeting", () => {
test("Successful `createMeeting` call", async () => {
prismaMock.calendarCache.findUnique;
const videoApi = VideoApiAdapter(testCredential);
mockRequestRaw.mockImplementation(({ url }) => {
if (url === URLS.CREATE_MEETING.url) {
return Promise.resolve(
successResponse({
json: {
id: 1,
joinWebUrl: "https://join_web_url.example.com",
joinUrl: "https://join_url.example.com",
},
})
);
}
throw new Error("Unexpected URL");
});
const event = {
title: "Test Meeting",
description: "Test Description",
startTime: new Date(),
endTime: new Date(),
};
const createdMeeting = await videoApi?.createMeeting(event);
expect(OAuthManager).toHaveBeenCalled();
expect(mockRequestRaw).toHaveBeenCalledWith({
url: URLS.CREATE_MEETING.url,
options: {
method: "POST",
body: JSON.stringify({
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
}),
},
});
expect(createdMeeting).toEqual({
id: 1,
password: "",
type: "office365_video",
url: "https://join_web_url.example.com",
});
});
test(" `createMeeting` when there is no joinWebUrl and only joinUrl", async () => {
prismaMock.calendarCache.findUnique;
const videoApi = VideoApiAdapter(testCredential);
mockRequestRaw.mockImplementation(({ url }) => {
if (url === URLS.CREATE_MEETING.url) {
return Promise.resolve(
successResponse({
json: {
id: 1,
joinUrl: "https://join_url.example.com",
error: {
message: "ERROR",
},
},
})
);
}
throw new Error("Unexpected URL");
});
const event = {
title: "Test Meeting",
description: "Test Description",
startTime: new Date(),
endTime: new Date(),
};
await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError(
"Error creating MS Teams meeting"
);
expect(OAuthManager).toHaveBeenCalled();
expect(mockRequestRaw).toHaveBeenCalledWith({
url: URLS.CREATE_MEETING.url,
options: {
method: "POST",
body: JSON.stringify({
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
}),
},
});
});
test("Failing `createMeeting` call", async () => {
const videoApi = VideoApiAdapter(testCredential);
mockRequestRaw.mockImplementation(({ url }) => {
if (url === URLS.CREATE_MEETING.url) {
return Promise.resolve(
internalServerErrorResponse({
json: {
id: 1,
joinWebUrl: "https://example.com",
joinUrl: "https://example.com",
},
})
);
}
throw new Error("Unexpected URL");
});
const event = {
title: "Test Meeting",
description: "Test Description",
startTime: new Date(),
endTime: new Date(),
};
await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError("Internal Server Error");
expect(OAuthManager).toHaveBeenCalled();
expect(mockRequestRaw).toHaveBeenCalledWith({
url: URLS.CREATE_MEETING.url,
options: {
method: "POST",
body: JSON.stringify({
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
}),
},
});
});
});
describe("updateMeeting", () => {
test("Successful `updateMeeting` call", async () => {
const videoApi = VideoApiAdapter(testCredential);
mockRequestRaw.mockImplementation(({ url }) => {
if (url === URLS.CREATE_MEETING.url) {
return Promise.resolve(
successResponse({
json: {
id: 1,
joinWebUrl: "https://join_web_url.example.com",
joinUrl: "https://join_url.example.com",
},
})
);
}
throw new Error("Unexpected URL");
});
const event = {
title: "Test Meeting",
description: "Test Description",
startTime: new Date(),
endTime: new Date(),
};
const updatedMeeting = await videoApi?.updateMeeting(null, event);
expect(OAuthManager).toHaveBeenCalled();
expect(mockRequestRaw).toHaveBeenCalledWith({
url: URLS.CREATE_MEETING.url,
options: {
method: "POST",
body: JSON.stringify({
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
}),
},
});
expect(updatedMeeting).toEqual({
id: 1,
password: "",
type: config.type,
url: "https://join_web_url.example.com",
});
});
test("Failing `updateMeeting` call", async () => {
const videoApi = VideoApiAdapter(testCredential);
mockRequestRaw.mockImplementation(({ url }) => {
if (url === URLS.CREATE_MEETING.url) {
return Promise.resolve(
internalServerErrorResponse({
json: {
id: 1,
joinWebUrl: "https://join_web_url.example.com",
joinUrl: "https://join_url.example.com",
},
})
);
}
throw new Error("Unexpected URL");
});
const event = {
title: "Test Meeting",
description: "Test Description",
startTime: new Date(),
endTime: new Date(),
};
await expect(() => videoApi?.updateMeeting(null, event)).rejects.toThrowError("Internal Server Error");
expect(OAuthManager).toHaveBeenCalled();
expect(mockRequestRaw).toHaveBeenCalledWith({
url: URLS.CREATE_MEETING.url,
options: {
method: "POST",
body: JSON.stringify({
startDateTime: event.startTime,
endDateTime: event.endTime,
subject: event.title,
}),
},
});
});
});
@@ -1,18 +1,16 @@
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
import { handleErrorsRaw } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
let client_id = "";
let client_secret = "";
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
import config from "../config.json";
/** @link https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings?view=graph-rest-1.0&tabs=http#response */
export interface TeamsEventResult {
@@ -24,90 +22,56 @@ export interface TeamsEventResult {
subject: string;
}
interface O365AuthCredentials {
email: string;
scope: string;
token_type: string;
expiry_date: number;
access_token: string;
refresh_token: string;
ext_expires_in: number;
}
const o365VideoAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
interface ITokenResponse {
expiry_date: number;
expires_in?: number;
token_type: string;
scope: string;
access_token: string;
refresh_token: string;
error?: string;
error_description?: string;
}
// Checks to see if our O365 user token is valid or if we need to refresh
const o365Auth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("msteams");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) throw new HttpError({ statusCode: 400, message: "MS teams client_id missing." });
if (!client_secret) throw new HttpError({ statusCode: 400, message: "MS teams client_secret missing." });
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date());
const o365AuthCredentials = credential.key as unknown as O365AuthCredentials;
const refreshAccessToken = async (refreshToken: string) => {
const response = await refreshOAuthTokens(
async () =>
await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret,
}),
}),
"msteams",
credential.userId
);
const responseBody = await handleErrorsJson<ITokenResponse>(response);
if (responseBody?.error) {
console.error(responseBody);
throw new HttpError({ statusCode: 500, message: `Error contacting MS Teams: ${responseBody.error}` });
}
// set expiry date as offset from current time.
responseBody.expiry_date = Math.round(Date.now() + (responseBody?.expires_in || 0) * 1000);
delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
// @NOTE: prisma doesn't know key its a JSON so do as responseBody
key: responseBody as unknown as Prisma.InputJsonValue,
},
});
o365AuthCredentials.expiry_date = responseBody.expiry_date;
o365AuthCredentials.access_token = responseBody.access_token;
return o365AuthCredentials.access_token;
};
return {
getToken: () =>
isExpired(o365AuthCredentials.expiry_date)
? refreshAccessToken(o365AuthCredentials.refresh_token)
: Promise.resolve(o365AuthCredentials.access_token),
};
const getO365VideoAppKeys = async () => {
return getParsedAppKeysFromSlug(config.slug, o365VideoAppKeysSchema);
};
const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const auth = o365Auth(credential);
const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential);
const auth = new OAuthManager({
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: config.slug,
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
if (!refreshToken) {
return null;
}
const { client_id, client_secret } = await getO365VideoAppKeys();
return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret,
}),
});
},
isTokenObjectUnusable: async function () {
// TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well.
// This is a placeholder for future implementation.
return null;
},
isAccessTokenUnusable: async function () {
// TODO: Implement this
return null;
},
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
updateTokenObject: (tokenObject) =>
oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }),
});
const translateEvent = (event: CalendarEvent) => {
return {
@@ -123,16 +87,15 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
return Promise.resolve([]);
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent) => {
const accessToken = await (await auth).getToken();
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw);
const resultString = await auth
.requestRaw({
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
options: {
method: "POST",
body: JSON.stringify(translateEvent(event)),
},
})
.then(handleErrorsRaw);
const resultObject = JSON.parse(resultString);
@@ -140,23 +103,22 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
type: "office365_video",
id: resultObject.id,
password: "",
url: resultObject.joinUrl,
url: resultObject.joinWebUrl || resultObject.joinUrl,
});
},
deleteMeeting: () => {
return Promise.resolve([]);
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
const accessToken = await (await auth).getToken();
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw);
const resultString = await auth
.requestRaw({
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
options: {
method: "POST",
body: JSON.stringify(translateEvent(event)),
},
})
.then(handleErrorsRaw);
const resultObject = JSON.parse(resultString);
+3 -1
View File
@@ -28,7 +28,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {}
} catch (e) {
errorMessage = await result.clone().text();
}
res.status(400).json({ message: errorMessage });
return;
@@ -1,20 +1,30 @@
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Credential } from "@calcom/prisma/client";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import metadata from "../_metadata";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
import { metadata } from "../_metadata";
import { getZoomAppKeys } from "./getZoomAppKeys";
const log = logger.getSubLogger({ prefix: ["app-store/zoomvideo/lib/VideoApiAdapter"] });
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
const zoomEventResultSchema = z.object({
id: z.number(),
@@ -49,93 +59,6 @@ export const zoomMeetingsSchema = z.object({
),
});
// Successful API response
// @TODO: add link to the docs
const zoomTokenSchema = z.object({
scope: z.string().regex(new RegExp("meeting:write")),
expiry_date: z.number(),
expires_in: z.number().optional(), // deprecated, purely for backwards compatibility; superseeded by expiry_date.
token_type: z.literal("bearer"),
access_token: z.string(),
refresh_token: z.string(),
});
type ZoomToken = z.infer<typeof zoomTokenSchema>;
const isTokenValid = (token: Partial<ZoomToken>) =>
zoomTokenSchema.safeParse(token).success && (token.expires_in || token.expiry_date || 0) > Date.now();
/** @link https://marketplace.zoom.us/docs/guides/auth/oauth/#request */
const zoomRefreshedTokenSchema = z.object({
access_token: z.string(),
token_type: z.literal("bearer"),
refresh_token: z.string(),
expires_in: z.number(),
scope: z.string(),
});
const zoomAuth = (credential: CredentialPayload) => {
const refreshAccessToken = async (refreshToken: string) => {
const { client_id, client_secret } = await getZoomAppKeys();
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
const response = await refreshOAuthTokens(
async () =>
await fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
}),
metadata.slug,
credential.userId
);
const responseBody = await handleZoomResponse(response, credential.id);
if (responseBody.error) {
if (responseBody.error === "invalid_grant") {
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
}
}
// We check the if the new credentials matches the expected response structure
const newTokens: ParseRefreshTokenResponse<typeof zoomRefreshedTokenSchema> = parseRefreshTokenResponse(
responseBody,
zoomRefreshedTokenSchema
);
const key = credential.key as ZoomToken;
key.access_token = newTokens.access_token ?? key.access_token;
key.refresh_token = (newTokens.refresh_token as string) ?? key.refresh_token;
// set expiry date as offset from current time.
key.expiry_date =
typeof newTokens.expires_in === "number"
? Math.round(Date.now() + newTokens.expires_in * 1000)
: key.expiry_date;
// Store new tokens in database.
await prisma.credential.update({
where: { id: credential.id },
data: { key: { ...key, ...newTokens } },
});
return newTokens.access_token;
};
return {
getToken: async () => {
const credentialKey = credential.key as ZoomToken;
return isTokenValid(credentialKey)
? Promise.resolve(credentialKey.access_token)
: refreshAccessToken(credentialKey.refresh_token);
},
};
};
type ZoomRecurrence = {
end_date_time?: string;
type: 1 | 2 | 3;
@@ -146,6 +69,8 @@ type ZoomRecurrence = {
};
const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const tokenResponse = getTokenObjectFromCredential(credential);
const translateEvent = (event: CalendarEvent) => {
const getRecurrence = ({
recurringEvent,
@@ -228,18 +153,90 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
};
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
const auth = zoomAuth(credential);
const accessToken = await auth.getToken();
const response = await fetch(`https://api.zoom.us/v2/${endpoint}`, {
method: "GET",
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
...options?.headers,
const auth = new OAuthManager({
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: metadata.slug,
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
if (!refreshToken) {
return null;
}
const clientCredentials = await getZoomAppKeys();
const { client_id, client_secret } = clientCredentials;
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
return fetch("https://zoom.us/oauth/token", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
},
isTokenObjectUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.debug(safeStringify({ responseBody }));
if (responseBody.error === "invalid_grant") {
return { reason: responseBody.error };
}
}
return null;
},
isAccessTokenUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.debug(safeStringify({ responseBody }));
if (responseBody.code === 124) {
return { reason: responseBody.message ?? "" };
}
}
return null;
},
invalidateTokenObject: () => invalidateCredential(credential.id),
expireAccessToken: () => markTokenAsExpired(credential),
updateTokenObject: async (newTokenObject) => {
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: newTokenObject,
},
});
},
});
const responseBody = await handleZoomResponse(response, credential.id);
return responseBody;
const { json } = await auth.request({
url: `https://api.zoom.us/v2/${endpoint}`,
options: {
method: "GET",
...options,
headers: {
...options?.headers,
},
},
});
return json;
};
return {
@@ -268,12 +265,6 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
},
body: JSON.stringify(translateEvent(event)),
});
if (response.error) {
if (response.error === "invalid_grant") {
await invalidateCredential(credential.id);
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
}
}
const result = zoomEventResultSchema.parse(response);
@@ -319,51 +310,11 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
url: bookingRef.meetingUrl as string,
});
} catch (err) {
log.error("Failed to update meeting", safeStringify(err));
return Promise.reject(new Error("Failed to update meeting"));
}
},
};
};
const handleZoomResponse = async (response: Response, credentialId: Credential["id"]) => {
let _response = response.clone();
const responseClone = response.clone();
if (_response.headers.get("content-encoding") === "gzip") {
const responseString = await response.text();
_response = JSON.parse(responseString);
}
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await _response.json();
if ((response && response.status === 124) || responseBody.error === "invalid_grant") {
await invalidateCredential(credentialId);
}
throw Error(response.statusText);
}
// handle 204 response code with empty response (causes crash otherwise as "" is invalid JSON)
if (response.status === 204) {
return;
}
return responseClone.json();
};
const invalidateCredential = async (credentialId: Credential["id"]) => {
const credential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (credential) {
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
invalid: true,
},
});
}
};
export default ZoomVideoApiAdapter;
+1 -1
View File
@@ -104,7 +104,7 @@ export const getConnectedCalendars = async (
}
}
log.error("getConnectedCalendars failed", safeStringify({ error, item }));
log.error("getConnectedCalendars failed", safeStringify(error), safeStringify({ item }));
return {
integration: cleanIntegrationKeys(item.integration),
+5 -1
View File
@@ -109,7 +109,11 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv
log.debug("created Meeting", safeStringify(returnObject));
} catch (err) {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
log.error(
"createMeeting failed",
safeStringify(err),
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
);
// Default to calVideo
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
+2
View File
@@ -115,6 +115,8 @@ export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET;
export const CREDENTIAL_SYNC_SECRET_HEADER_NAME =
process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret";
export const CREDENTIAL_SYNC_ENDPOINT = process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
export const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
export const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
+2 -1
View File
@@ -12,6 +12,7 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb
}
export async function handleErrorsJson<Type>(response: Response): Promise<Type> {
// FIXME: I don't know why we are handling gzipped case separately. This should be handled by fetch itself.
if (response.headers.get("content-encoding") === "gzip") {
const responseText = await response.text();
return new Promise((resolve) => resolve(JSON.parse(responseText)));
@@ -34,7 +35,7 @@ export function handleErrorsRaw(response: Response) {
console.error({ response });
return "{}";
}
if (!response.ok && response.status < 200 && response.status >= 300) {
if (!response.ok || response.status < 200 || response.status >= 300) {
response.text().then(console.log);
throw Error(response.statusText);
}
+7
View File
@@ -1,5 +1,12 @@
/**
* It stringifies the object which is necessary to ensure that in a logging system(like Axiom) we see the object in context in a single log event
*/
export function safeStringify(obj: unknown) {
try {
if (obj instanceof Error) {
// Errors don't serialize well, so we extract what we want
return obj.stack ?? obj.message;
}
// Avoid crashing on circular references
return JSON.stringify(obj);
} catch (e) {
+1 -1
View File
@@ -225,7 +225,7 @@ model TravelSchedule {
@@index([endDate])
}
// It holds Personal Profiles of a User plus it has email, password and other core things
// It holds Personal Profiles of a User plus it has email, password and other core things..
model User {
id Int @id @default(autoincrement())
username String?
+382 -166
View File
@@ -290,6 +290,25 @@ __metadata:
languageName: node
linkType: hard
"@auth/core@npm:^0.1.4":
version: 0.1.4
resolution: "@auth/core@npm:0.1.4"
dependencies:
"@panva/hkdf": 1.0.2
cookie: 0.5.0
jose: 4.11.1
oauth4webapi: 2.0.5
preact: 10.11.3
preact-render-to-string: 5.2.3
peerDependencies:
nodemailer: 6.8.0
peerDependenciesMeta:
nodemailer:
optional: true
checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79
languageName: node
linkType: hard
"@aw-web-design/x-default-browser@npm:1.4.126":
version: 1.4.126
resolution: "@aw-web-design/x-default-browser@npm:1.4.126"
@@ -4004,7 +4023,7 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/atoms@*, @calcom/atoms@workspace:packages/platform/atoms":
"@calcom/atoms@*, @calcom/atoms@workspace:^, @calcom/atoms@workspace:packages/platform/atoms":
version: 0.0.0-use.local
resolution: "@calcom/atoms@workspace:packages/platform/atoms"
dependencies:
@@ -4017,9 +4036,14 @@ __metadata:
"@types/react": 18.0.26
"@types/react-dom": ^18.0.9
"@vitejs/plugin-react": ^2.2.0
autoprefixer: ^10.4.19
class-variance-authority: ^0.4.0
clsx: ^2.0.0
lucide-react: ^0.364.0
postcss: ^8.4.38
postcss-import: ^16.1.0
postcss-prefixer: ^3.0.0
postcss-prefixwrap: 1.46.0
react-use: ^17.4.2
rollup-plugin-node-builtins: ^2.1.2
tailwind-merge: ^1.13.2
@@ -4031,6 +4055,41 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/auth@workspace:apps/auth":
version: 0.0.0-use.local
resolution: "@calcom/auth@workspace:apps/auth"
dependencies:
"@auth/core": ^0.1.4
"@calcom/app-store": "*"
"@calcom/app-store-cli": "*"
"@calcom/config": "*"
"@calcom/core": "*"
"@calcom/dayjs": "*"
"@calcom/embed-core": "workspace:*"
"@calcom/embed-react": "workspace:*"
"@calcom/embed-snippet": "workspace:*"
"@calcom/features": "*"
"@calcom/lib": "*"
"@calcom/prisma": "*"
"@calcom/trpc": "*"
"@calcom/tsconfig": "*"
"@calcom/types": "*"
"@calcom/ui": "*"
"@types/node": 16.9.1
"@types/react": 18.0.26
"@types/react-dom": ^18.0.9
eslint: ^8.34.0
eslint-config-next: ^13.2.1
next: ^13.5.4
next-auth: ^4.22.1
postcss: ^8.4.18
react: ^18.2.0
react-dom: ^18.2.0
tailwindcss: ^3.3.3
typescript: ^4.9.4
languageName: unknown
linkType: soft
"@calcom/base@workspace:packages/platform/examples/base":
version: 0.0.0-use.local
resolution: "@calcom/base@workspace:packages/platform/examples/base"
@@ -4151,7 +4210,7 @@ __metadata:
chart.js: ^3.7.1
client-only: ^0.0.1
eslint: ^8.34.0
next: ^13.4.6
next: ^13.5.4
next-auth: ^4.22.1
next-i18next: ^13.2.2
postcss: ^8.4.18
@@ -4351,6 +4410,29 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/example-app-credential-sync@workspace:example-apps/credential-sync":
version: 0.0.0-use.local
resolution: "@calcom/example-app-credential-sync@workspace:example-apps/credential-sync"
dependencies:
"@calcom/atoms": "*"
"@prisma/client": 5.4.2
"@types/node": ^20.3.1
"@types/react": ^18
"@types/react-dom": ^18
autoprefixer: ^10.0.1
dotenv: ^16.3.1
eslint: ^8
eslint-config-next: 14.0.4
next: 14.0.4
postcss: ^8
prisma: ^5.7.1
react: ^18
react-dom: ^18
tailwindcss: ^3.3.0
typescript: ^4.9.4
languageName: unknown
linkType: soft
"@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar":
version: 0.0.0-use.local
resolution: "@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar"
@@ -5328,6 +5410,7 @@ __metadata:
dependencies:
"@algora/sdk": ^0.1.2
"@calcom/app-store": "*"
"@calcom/atoms": "workspace:^"
"@calcom/config": "*"
"@calcom/dayjs": "*"
"@calcom/embed-react": "workspace:^"
@@ -5373,6 +5456,7 @@ __metadata:
"@vercel/og": ^0.5.0
autoprefixer: ^10.4.12
bcryptjs: ^2.4.3
class-variance-authority: ^0.7.0
clsx: ^1.2.1
cobe: ^0.4.1
concurrently: ^7.6.0
@@ -5385,6 +5469,7 @@ __metadata:
env-cmd: ^10.1.0
eslint: ^8.34.0
fathom-client: ^3.5.0
framer-motion: ^11.0.25
globby: ^13.1.3
graphql: ^16.8.0
graphql-codegen: ^0.4.0
@@ -5406,7 +5491,7 @@ __metadata:
prism-react-renderer: ^1.3.5
react: ^18.2.0
react-confetti: ^6.0.1
react-datocms: ^3.1.0
react-datocms: ^5.0.3
react-device-detect: ^2.2.2
react-dom: ^18.2.0
react-fast-marquee: ^1.6.4
@@ -5418,6 +5503,7 @@ __metadata:
react-merge-refs: 1.1.0
react-resize-detector: ^9.1.0
react-twemoji: ^0.3.0
react-twitter-embed: ^4.0.4
react-use-measure: ^2.1.1
react-wrap-balancer: ^1.0.0
remark: ^14.0.2
@@ -9117,6 +9203,59 @@ __metadata:
languageName: node
linkType: hard
"@mux/mux-player-react@npm:*":
version: 2.5.0
resolution: "@mux/mux-player-react@npm:2.5.0"
dependencies:
"@mux/mux-player": 2.5.0
"@mux/playback-core": 0.23.0
prop-types: ^15.7.2
peerDependencies:
"@types/react": ^17.0.0 || ^18
react: ^17.0.2 || ^18
react-dom: ^17.0.2 || ^18
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 9b2854793a47928d964fbbd46d4c2e659d1dea29993bf74febcdbd3dfd2729abdb9d9d926e67113376ea7a9d99da9c275705d702996503deade6d35128e7f6ac
languageName: node
linkType: hard
"@mux/mux-player@npm:2.5.0":
version: 2.5.0
resolution: "@mux/mux-player@npm:2.5.0"
dependencies:
"@mux/mux-video": 0.18.0
"@mux/playback-core": 0.23.0
media-chrome: ~3.2.1
checksum: 566bcb3372fb0f6ac4e37ce82707d565693681d010a7ae6d9890ae6b74320d8464af7bb1f8582709a7263c2e09e9e58137c2caf5f2c2eb3db91a137fb625d064
languageName: node
linkType: hard
"@mux/mux-video@npm:0.18.0":
version: 0.18.0
resolution: "@mux/mux-video@npm:0.18.0"
dependencies:
"@mux/playback-core": 0.23.0
castable-video: ~1.0.6
custom-media-element: ~1.2.3
media-tracks: ~0.3.0
checksum: a6a8137cfbfd04304f11434cc8e2eff3c1f3e72fd758cd4b2bf4eea4ce29a29525116a491e4be3ce4c995eba483704a5020ab4db5960017b22c5a913bfb04279
languageName: node
linkType: hard
"@mux/playback-core@npm:0.23.0":
version: 0.23.0
resolution: "@mux/playback-core@npm:0.23.0"
dependencies:
hls.js: ~1.5.8
mux-embed: ~5.2.0
checksum: c5bee62359ee79094ceca839972a7ecbcaf7339cb68ab5f404bbb6b3b7e25d1066c69bc4d92aa9912af5fa6d6a0e4eabe745e3589fffcc92f8b78171a6af58eb
languageName: node
linkType: hard
"@ndelangen/get-tarball@npm:^3.0.7":
version: 3.0.9
resolution: "@ndelangen/get-tarball@npm:3.0.9"
@@ -9386,13 +9525,6 @@ __metadata:
languageName: node
linkType: hard
"@next/env@npm:13.5.6":
version: 13.5.6
resolution: "@next/env@npm:13.5.6"
checksum: 5e8f3f6f987a15dad3cd7b2bcac64a6382c2ec372d95d0ce6ab295eb59c9731222017eebf71ff3005932de2571f7543bce7e5c6a8c90030207fb819404138dc2
languageName: node
linkType: hard
"@next/env@npm:14.0.4":
version: 14.0.4
resolution: "@next/env@npm:14.0.4"
@@ -9432,13 +9564,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-arm64@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-darwin-arm64@npm:13.5.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@next/swc-darwin-arm64@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-darwin-arm64@npm:14.0.4"
@@ -9460,13 +9585,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-x64@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-darwin-x64@npm:13.5.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@next/swc-darwin-x64@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-darwin-x64@npm:14.0.4"
@@ -9488,13 +9606,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-gnu@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-linux-arm64-gnu@npm:13.5.6"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@next/swc-linux-arm64-gnu@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-linux-arm64-gnu@npm:14.0.4"
@@ -9516,13 +9627,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-musl@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-linux-arm64-musl@npm:13.5.6"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@next/swc-linux-arm64-musl@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-linux-arm64-musl@npm:14.0.4"
@@ -9544,13 +9648,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-gnu@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-linux-x64-gnu@npm:13.5.6"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@next/swc-linux-x64-gnu@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-linux-x64-gnu@npm:14.0.4"
@@ -9572,13 +9669,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-musl@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-linux-x64-musl@npm:13.5.6"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@next/swc-linux-x64-musl@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-linux-x64-musl@npm:14.0.4"
@@ -9600,13 +9690,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-arm64-msvc@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-win32-arm64-msvc@npm:13.5.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@next/swc-win32-arm64-msvc@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-win32-arm64-msvc@npm:14.0.4"
@@ -9628,13 +9711,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-ia32-msvc@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-win32-ia32-msvc@npm:13.5.6"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@next/swc-win32-ia32-msvc@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-win32-ia32-msvc@npm:14.0.4"
@@ -9656,13 +9732,6 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-x64-msvc@npm:13.5.6":
version: 13.5.6
resolution: "@next/swc-win32-x64-msvc@npm:13.5.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@next/swc-win32-x64-msvc@npm:14.0.4":
version: 14.0.4
resolution: "@next/swc-win32-x64-msvc@npm:14.0.4"
@@ -10305,6 +10374,13 @@ __metadata:
languageName: node
linkType: hard
"@panva/hkdf@npm:1.0.2":
version: 1.0.2
resolution: "@panva/hkdf@npm:1.0.2"
checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03
languageName: node
linkType: hard
"@panva/hkdf@npm:^1.0.2":
version: 1.0.4
resolution: "@panva/hkdf@npm:1.0.4"
@@ -17259,15 +17335,6 @@ __metadata:
languageName: node
linkType: hard
"@vercel/analytics@npm:^0.1.6":
version: 0.1.11
resolution: "@vercel/analytics@npm:0.1.11"
peerDependencies:
react: ^16.8||^17||^18
checksum: 05b8180ac6e23ebe7c09d74c43f8ee78c408cd0b6546e676389cbf4fba44dfeeae3648c9b52e2421be64fe3aeee8b026e6ea4bdfc0589fb5780670f2b090a167
languageName: node
linkType: hard
"@vercel/edge-config@npm:^0.1.1":
version: 0.1.1
resolution: "@vercel/edge-config@npm:0.1.1"
@@ -19004,6 +19071,24 @@ __metadata:
languageName: node
linkType: hard
"autoprefixer@npm:^10.4.19":
version: 10.4.19
resolution: "autoprefixer@npm:10.4.19"
dependencies:
browserslist: ^4.23.0
caniuse-lite: ^1.0.30001599
fraction.js: ^4.3.7
normalize-range: ^0.1.2
picocolors: ^1.0.0
postcss-value-parser: ^4.2.0
peerDependencies:
postcss: ^8.1.0
bin:
autoprefixer: bin/autoprefixer
checksum: 3a4bc5bace05e057396dca2b306503efc175e90e8f2abf5472d3130b72da1d54d97c0ee05df21bf04fe66a7df93fd8c8ec0f1aca72a165f4701a02531abcbf11
languageName: node
linkType: hard
"available-typed-arrays@npm:^1.0.5":
version: 1.0.5
resolution: "available-typed-arrays@npm:1.0.5"
@@ -20374,6 +20459,13 @@ __metadata:
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001599":
version: 1.0.30001612
resolution: "caniuse-lite@npm:1.0.30001612"
checksum: 2b6ab6a19c72bdf8dccac824944e828a2a1fae52c6dfeb2d64ccecfd60d0466d2e5a392e996da2150d92850188a5034666dceed34a38d978177f6934e0bf106d
languageName: node
linkType: hard
"capital-case@npm:^1.0.4":
version: 1.0.4
resolution: "capital-case@npm:1.0.4"
@@ -20411,6 +20503,15 @@ __metadata:
languageName: node
linkType: hard
"castable-video@npm:~1.0.6":
version: 1.0.6
resolution: "castable-video@npm:1.0.6"
dependencies:
custom-media-element: ~1.2.2
checksum: 873ea75b35c594ed3755bacedd33628c070c751e34e0a64e42f98a4e9e0fda7f988ecac0ec723a4f7a25f332ba4650d4686e826614d81663554b9b31298cc648
languageName: node
linkType: hard
"ccount@npm:^2.0.0":
version: 2.0.1
resolution: "ccount@npm:2.0.1"
@@ -20875,6 +20976,15 @@ __metadata:
languageName: node
linkType: hard
"class-variance-authority@npm:^0.7.0":
version: 0.7.0
resolution: "class-variance-authority@npm:0.7.0"
dependencies:
clsx: 2.0.0
checksum: e7fd1fab433ef06f52a1b7b241b70b4a185864deef199d3b0a2c3412f1cc179517288264c383f3b971a00d76811625fc8f7ffe709e6170219e88cd7368f08a20
languageName: node
linkType: hard
"classnames@npm:^2.2.5, classnames@npm:^2.2.6":
version: 2.3.2
resolution: "classnames@npm:2.3.2"
@@ -21147,6 +21257,13 @@ __metadata:
languageName: node
linkType: hard
"clsx@npm:2.0.0":
version: 2.0.0
resolution: "clsx@npm:2.0.0"
checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e
languageName: node
linkType: hard
"clsx@npm:^1.1.1":
version: 1.1.1
resolution: "clsx@npm:1.1.1"
@@ -22326,6 +22443,16 @@ __metadata:
languageName: node
linkType: hard
"css-selector-tokenizer@npm:^0.7.2":
version: 0.7.3
resolution: "css-selector-tokenizer@npm:0.7.3"
dependencies:
cssesc: ^3.0.0
fastparse: ^1.1.2
checksum: 92560a9616a8bc073b88c678aa04f22c599ac23c5f8587e60f4861069e2d5aeb37b722af581ae3c5fbce453bed7a893d9c3e06830912e6d28badc3b8b99acd24
languageName: node
linkType: hard
"css-to-react-native@npm:^3.0.0":
version: 3.0.0
resolution: "css-to-react-native@npm:3.0.0"
@@ -22442,6 +22569,13 @@ __metadata:
languageName: node
linkType: hard
"custom-media-element@npm:~1.2.2, custom-media-element@npm:~1.2.3":
version: 1.2.3
resolution: "custom-media-element@npm:1.2.3"
checksum: da43680c35c870cd80ea02b9fce3efe25e9502c7088de5ab0e47ef2734aa9ce7e147c95f78a10abd4c13c47a81eae4712a7651ad18f87e023bc371082ba09842
languageName: node
linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
version: 3.2.3
resolution: "d3-array@npm:3.2.3"
@@ -25755,6 +25889,13 @@ __metadata:
languageName: node
linkType: hard
"fastparse@npm:^1.1.2":
version: 1.1.2
resolution: "fastparse@npm:1.1.2"
checksum: c4d199809dc4e8acafeb786be49481cc9144de296e2d54df4540ccfd868d0df73afc649aba70a748925eb32bbc4208b723d6288adf92382275031a8c7e10c0aa
languageName: node
linkType: hard
"fastq@npm:^1.6.0":
version: 1.13.0
resolution: "fastq@npm:1.13.0"
@@ -26483,6 +26624,26 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^11.0.25":
version: 11.1.7
resolution: "framer-motion@npm:11.1.7"
dependencies:
tslib: ^2.4.0
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0
react-dom: ^18.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: ba4f2f0823eec7b78fa87180545cf8c832929ce52de8bab34312a67bfd6dee4ab6ad17143b9df2efcd4ede7cb87fc326843a1101a6cad3031c89a6ad2160e037
languageName: node
linkType: hard
"fresh@npm:0.5.2, fresh@npm:^0.5.2":
version: 0.5.2
resolution: "fresh@npm:0.5.2"
@@ -28164,6 +28325,13 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:~1.5.8":
version: 1.5.8
resolution: "hls.js@npm:1.5.8"
checksum: 93781b38859ce852952244e9671d536a73acb93a20cfbc3a68e372056d024789e48c7d67354b6b9ac35acab4a161d0d15adcf64279a0832f46465233cdbc8be0
languageName: node
linkType: hard
"hmac-drbg@npm:^1.0.1":
version: 1.0.1
resolution: "hmac-drbg@npm:1.0.1"
@@ -30220,15 +30388,6 @@ __metadata:
languageName: node
linkType: hard
"isomorphic-ws@npm:^5.0.0":
version: 5.0.0
resolution: "isomorphic-ws@npm:5.0.0"
peerDependencies:
ws: "*"
checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398
languageName: node
linkType: hard
"isstream@npm:~0.1.2":
version: 0.1.2
resolution: "isstream@npm:0.1.2"
@@ -30987,6 +31146,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:4.11.1":
version: 4.11.1
resolution: "jose@npm:4.11.1"
checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7
languageName: node
linkType: hard
"jose@npm:5.2.1":
version: 5.2.1
resolution: "jose@npm:5.2.1"
@@ -32976,15 +33142,6 @@ __metadata:
languageName: node
linkType: hard
"lucide-react@npm:^0.363.0":
version: 0.363.0
resolution: "lucide-react@npm:0.363.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
checksum: abe8fad469a2f14181eca6f3682403c5d53a4a354f333b0f7d3b575471d451c6f3fd36f59d96745a5a823e4cc55eeb2dcda70774a6fe438834ea3fb6fa5b75af
languageName: node
linkType: hard
"luxon@npm:3.3.0":
version: 3.3.0
resolution: "luxon@npm:3.3.0"
@@ -33544,6 +33701,20 @@ __metadata:
languageName: node
linkType: hard
"media-chrome@npm:~3.2.1":
version: 3.2.1
resolution: "media-chrome@npm:3.2.1"
checksum: 0e7d8a6850d0c4844be7b7975bcf80886aad5f2ae3cff1081aa5a7b7920ddf9f15b2da4df875f83ffb5f083d3fc1d337a8ac204317e40391f8311eb7f2f58c5e
languageName: node
linkType: hard
"media-tracks@npm:~0.3.0":
version: 0.3.0
resolution: "media-tracks@npm:0.3.0"
checksum: 217880bdf566cd070f22fce2cba132cd602ae2838a20f1c75aeb5f928edda283133d474b1f427deb2758cd9b64911f88eb6380921662566bbeb7051adbb81629
languageName: node
linkType: hard
"media-typer@npm:0.3.0":
version: 0.3.0
resolution: "media-typer@npm:0.3.0"
@@ -34898,6 +35069,13 @@ __metadata:
languageName: node
linkType: hard
"mux-embed@npm:~5.2.0":
version: 5.2.0
resolution: "mux-embed@npm:5.2.0"
checksum: c90f6e216a8239ef15534b26feb2299cbac51dff82766e23c89be6e30bf4348fe711fb092968b607fd45d1b509e75882efc2e12e0b34039483c1bf1b7c6690bb
languageName: node
linkType: hard
"mysql2@npm:3.9.1":
version: 3.9.1
resolution: "mysql2@npm:3.9.1"
@@ -35320,61 +35498,6 @@ __metadata:
languageName: node
linkType: hard
"next@npm:^13.4.6":
version: 13.5.6
resolution: "next@npm:13.5.6"
dependencies:
"@next/env": 13.5.6
"@next/swc-darwin-arm64": 13.5.6
"@next/swc-darwin-x64": 13.5.6
"@next/swc-linux-arm64-gnu": 13.5.6
"@next/swc-linux-arm64-musl": 13.5.6
"@next/swc-linux-x64-gnu": 13.5.6
"@next/swc-linux-x64-musl": 13.5.6
"@next/swc-win32-arm64-msvc": 13.5.6
"@next/swc-win32-ia32-msvc": 13.5.6
"@next/swc-win32-x64-msvc": 13.5.6
"@swc/helpers": 0.5.2
busboy: 1.6.0
caniuse-lite: ^1.0.30001406
postcss: 8.4.31
styled-jsx: 5.1.1
watchpack: 2.4.0
peerDependencies:
"@opentelemetry/api": ^1.1.0
react: ^18.2.0
react-dom: ^18.2.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-ia32-msvc":
optional: true
"@next/swc-win32-x64-msvc":
optional: true
peerDependenciesMeta:
"@opentelemetry/api":
optional: true
sass:
optional: true
bin:
next: dist/bin/next
checksum: c869b0014ae921ada3bf22301985027ec320aebcd6aa9c16e8afbded68bb8def5874cca034c680e8c351a79578f1e514971d02777f6f0a5a1d7290f25970ac0d
languageName: node
linkType: hard
"next@npm:^13.5.4":
version: 13.5.5
resolution: "next@npm:13.5.5"
@@ -36029,6 +36152,13 @@ __metadata:
languageName: node
linkType: hard
"oauth4webapi@npm:2.0.5":
version: 2.0.5
resolution: "oauth4webapi@npm:2.0.5"
checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9
languageName: node
linkType: hard
"oauth@npm:^0.9.15":
version: 0.9.15
resolution: "oauth@npm:0.9.15"
@@ -37874,6 +38004,19 @@ __metadata:
languageName: node
linkType: hard
"postcss-import@npm:^16.1.0":
version: 16.1.0
resolution: "postcss-import@npm:16.1.0"
dependencies:
postcss-value-parser: ^4.0.0
read-cache: ^1.0.0
resolve: ^1.1.7
peerDependencies:
postcss: ^8.0.0
checksum: 6d7f2fd649b7c7c3ff58d9d08003a0502466a007176655922ec535c98ab1a6bb42f09f017bb05bd18dd5fb57419df0ed9a06ec7f53b1a286fcb2daf964eec19c
languageName: node
linkType: hard
"postcss-js@npm:^4.0.1":
version: 4.0.1
resolution: "postcss-js@npm:4.0.1"
@@ -37972,6 +38115,26 @@ __metadata:
languageName: node
linkType: hard
"postcss-prefixer@npm:^3.0.0":
version: 3.0.0
resolution: "postcss-prefixer@npm:3.0.0"
dependencies:
css-selector-tokenizer: ^0.7.2
peerDependencies:
postcss: ^8.0.0
checksum: 5083289b671e2b7d1507d8574c9bcf1efe4485117adc98cee34aae2d9edb5633d203947f1d202b0a8dc631e5920247995e414a08fba1bdf822f50a043333c203
languageName: node
linkType: hard
"postcss-prefixwrap@npm:1.46.0":
version: 1.46.0
resolution: "postcss-prefixwrap@npm:1.46.0"
peerDependencies:
postcss: "*"
checksum: a76a391c54b95cca9ec024205ca9526fa5e2cb0e033da8c78aee557b5b604a18d667ac7f5209d2af9d5bb13c7562389af0edab3e9d31ad06109c44ba979fb671
languageName: node
linkType: hard
"postcss-pseudo-companion-classes@npm:^0.1.1":
version: 0.1.1
resolution: "postcss-pseudo-companion-classes@npm:0.1.1"
@@ -38092,6 +38255,17 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.38":
version: 8.4.38
resolution: "postcss@npm:8.4.38"
dependencies:
nanoid: ^3.3.7
picocolors: ^1.0.0
source-map-js: ^1.2.0
checksum: 649f9e60a763ca4b5a7bbec446a069edf07f057f6d780a5a0070576b841538d1ecf7dd888f2fbfd1f76200e26c969e405aeeae66332e6927dbdc8bdcb90b9451
languageName: node
linkType: hard
"postgres-array@npm:~2.0.0":
version: 2.0.0
resolution: "postgres-array@npm:2.0.0"
@@ -38159,6 +38333,17 @@ __metadata:
languageName: node
linkType: hard
"preact-render-to-string@npm:5.2.3":
version: 5.2.3
resolution: "preact-render-to-string@npm:5.2.3"
dependencies:
pretty-format: ^3.8.0
peerDependencies:
preact: ">=10"
checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44
languageName: node
linkType: hard
"preact-render-to-string@npm:^5.1.19":
version: 5.2.6
resolution: "preact-render-to-string@npm:5.2.6"
@@ -38170,7 +38355,7 @@ __metadata:
languageName: node
linkType: hard
"preact@npm:^10.6.3":
"preact@npm:10.11.3, preact@npm:^10.6.3":
version: 10.11.3
resolution: "preact@npm:10.11.3"
checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367
@@ -39190,20 +39375,21 @@ __metadata:
languageName: node
linkType: hard
"react-datocms@npm:^3.1.0":
version: 3.1.4
resolution: "react-datocms@npm:3.1.4"
"react-datocms@npm:^5.0.3":
version: 5.0.3
resolution: "react-datocms@npm:5.0.3"
dependencies:
"@mux/mux-player-react": "*"
datocms-listen: ^0.1.9
datocms-structured-text-generic-html-renderer: ^2.0.1
datocms-structured-text-utils: ^2.0.1
react-intersection-observer: ^8.33.1
react-intersection-observer: ^9.4.3
react-string-replace: ^1.1.0
universal-base64: ^2.1.0
use-deep-compare-effect: ^1.6.1
peerDependencies:
react: ">= 16.12.0"
checksum: 54aba12aef4937175c2011548a8a576c96c8d8a596e84d191826910624c1d596e76a49782689dc236388a10803b02e700ac820cb7500cca7fd147a81f6c544c3
checksum: 22c20152afb54424acfe967a2c8c525cd9f132a33374f2aba0231f16ea64dade389b096e2dac8de9ffded612bc32e9891d725609ee947639fe1cef907cb143f5
languageName: node
linkType: hard
@@ -39445,12 +39631,16 @@ __metadata:
languageName: node
linkType: hard
"react-intersection-observer@npm:^8.33.1":
version: 8.34.0
resolution: "react-intersection-observer@npm:8.34.0"
"react-intersection-observer@npm:^9.4.3":
version: 9.8.2
resolution: "react-intersection-observer@npm:9.8.2"
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0|| ^18.0.0
checksum: 7713fecfd1512c7f5a60f9f0bf15403b8f8bbd4110bcafaeaea6de36a0e0eb60368c3638f99e9c97b75ad8fc787ea48c241dcb5c694f821d7f2976f709082cc5
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
react-dom:
optional: true
checksum: 0e77226cf458499959773da9db9e00ae33a6eb95c2fa1ec3f506bbcba00955c1c27d2320fba8cdd740cadb1c7d6cc396386cbd9c359298cff196cac660dbee49
languageName: node
linkType: hard
@@ -39974,6 +40164,18 @@ __metadata:
languageName: node
linkType: hard
"react-twitter-embed@npm:^4.0.4":
version: 4.0.4
resolution: "react-twitter-embed@npm:4.0.4"
dependencies:
scriptjs: ^2.5.9
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: cdb3c5bd04c4da0efa767476be47c0a3865fb6335f2a1b9e242170167b51615c38164223278cef60c77143c4bac27ba582cbea054d0af3f138104fa5ec537c4c
languageName: node
linkType: hard
"react-universal-interface@npm:^0.6.2":
version: 0.6.2
resolution: "react-universal-interface@npm:0.6.2"
@@ -41801,6 +42003,13 @@ __metadata:
languageName: node
linkType: hard
"scriptjs@npm:^2.5.9":
version: 2.5.9
resolution: "scriptjs@npm:2.5.9"
checksum: fc84cb6b60b6fb9aa6f1b3bc59fc94b233bd5241ed3a04233579014382b5eb60640269c87d8657902acc09f9b785ee33230c218627cea00e653564bda8f5acb6
languageName: node
linkType: hard
"scuid@npm:^1.1.0":
version: 1.1.0
resolution: "scuid@npm:1.1.0"
@@ -42478,6 +42687,13 @@ __metadata:
languageName: node
linkType: hard
"source-map-js@npm:^1.2.0":
version: 1.2.0
resolution: "source-map-js@npm:1.2.0"
checksum: 791a43306d9223792e84293b00458bf102a8946e7188f3db0e4e22d8d530b5f80a4ce468eb5ec0bf585443ad55ebbd630bf379c98db0b1f317fd902500217f97
languageName: node
linkType: hard
"source-map-support@npm:0.5.13":
version: 0.5.13
resolution: "source-map-support@npm:0.5.13"