mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
fix: Add authorisation for API Key server actions (#741)
* poc: use server session and api key validation on deletion * feat: use server session and api key validation on deletion and creation * feat: packages/lib/apiKey for apiKey services and auth * shubham/auth-for-api-key * fix: caching * fix: club caching methods and use authzn errors * feat: add caching in canUserAccessApiKey
This commit is contained in:
committed by
GitHub
parent
532d66bb34
commit
c470c024c5
@@ -5,17 +5,18 @@ import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import FormbricksClient from "../../FormbricksClient";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export default async function EnvironmentLayout({ children, params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user, params.environmentId);
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new Error("User does not have access to this environment");
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import EditApiKeys from "./EditApiKeys";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { getApiKeys } from "@formbricks/lib/services/apiKey";
|
||||
import { getApiKeys } from "@formbricks/lib/apiKey/service";
|
||||
import { getEnvironments } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function ApiKeyList({
|
||||
|
||||
@@ -34,19 +34,29 @@ export default function EditAPIKeys({
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
await deleteApiKeyAction(activeKey.id);
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenDeleteKeyModal(false);
|
||||
toast.success("API Key deleted");
|
||||
try {
|
||||
await deleteApiKeyAction(activeKey.id);
|
||||
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success("API Key deleted");
|
||||
} catch (e) {
|
||||
toast.error("Unable to delete API Key");
|
||||
} finally {
|
||||
setOpenDeleteKeyModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAPIKey = async (data) => {
|
||||
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
|
||||
const updatedApiKeys = [...apiKeysLocal!, apiKey];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
setOpenAddAPIKeyModal(false);
|
||||
toast.success("API key created");
|
||||
try {
|
||||
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
|
||||
const updatedApiKeys = [...apiKeysLocal!, apiKey];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
toast.success("API key created");
|
||||
} catch (e) {
|
||||
toast.error("Unable to create API Key");
|
||||
} finally {
|
||||
setOpenAddAPIKeyModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { deleteApiKey, createApiKey } from "@formbricks/lib/apiKey/service";
|
||||
import { canUserAccessApiKey } from "@formbricks/lib/apiKey/auth";
|
||||
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthorizationError } from "@formbricks/types/v1/errors";
|
||||
|
||||
export async function deleteApiKeyAction(id: string) {
|
||||
return await deleteApiKey(id);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
const isAuthorized = await canUserAccessApiKey(session.user.id, id);
|
||||
|
||||
if (isAuthorized) {
|
||||
return await deleteApiKey(id);
|
||||
} else {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
}
|
||||
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
|
||||
return await createApiKey(environmentId, apiKeyData);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (isAuthorized) {
|
||||
return await createApiKey(environmentId, apiKeyData);
|
||||
} else {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
@@ -34,8 +34,8 @@ export const updateProductAction = async (
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProduct(productId, data);
|
||||
@@ -62,15 +62,15 @@ export const deleteProductAction = async (environmentId: string, userId: string,
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
const membership = team ? await getMembershipByUserIdTeamId(userId, team.id) : null;
|
||||
|
||||
if (membership?.role !== "admin" && membership?.role !== "owner") {
|
||||
throw new AuthenticationError("You are not allowed to delete products.");
|
||||
throw new AuthorizationError("You are not allowed to delete products.");
|
||||
}
|
||||
|
||||
const availableProducts = team ? await getProducts(team.id) : null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
@@ -19,11 +19,15 @@ export async function GET(req: NextRequest) {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user, environmentId);
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/v1/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { responses } from "@/lib/api/response";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/services/response";
|
||||
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
@@ -21,7 +21,7 @@ const canUserAccessResponse = async (authentication: any, response: TResponse):
|
||||
if (!survey) return false;
|
||||
|
||||
if (authentication.type === "session") {
|
||||
return await hasUserEnvironmentAccess(authentication.session.user, survey.environmentId);
|
||||
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
|
||||
} else if (authentication.type === "apiKey") {
|
||||
return survey.environmentId === authentication.environmentId;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { deleteWebhook, getWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/v1/errors";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
|
||||
import { createWebhook, getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
import { ZWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createHash } from "crypto";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -22,7 +23,7 @@ export const hasEnvironmentAccess = async (
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const ownership = await hasUserEnvironmentAccess(user, environmentId);
|
||||
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
|
||||
if (!ownership) {
|
||||
return false;
|
||||
}
|
||||
@@ -30,34 +31,6 @@ export const hasEnvironmentAccess = async (
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasUserEnvironmentAccess = async (user, environmentId) => {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
|
||||
if (environmentUsers.includes(user.id)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getPlan = async (req, res) => {
|
||||
if (req.headers["x-api-key"]) {
|
||||
const apiKey = req.headers["x-api-key"].toString();
|
||||
|
||||
22
packages/lib/apiKey/auth.ts
Normal file
22
packages/lib/apiKey/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { getApiKey } from "./service";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Promise<boolean> => {
|
||||
return await unstable_cache(
|
||||
async () => {
|
||||
if (!userId) return false;
|
||||
|
||||
const apiKeyFromServer = await getApiKey(apiKeyId);
|
||||
if (!apiKeyFromServer) return false;
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, apiKeyFromServer.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
[`users-${userId}-apiKeys-${apiKeyId}`],
|
||||
{ revalidate: 30 * 60, tags: [`apiKeys-${apiKeyId}`] }
|
||||
)(); // 30 minutes
|
||||
};
|
||||
@@ -10,21 +10,21 @@ import { cache } from "react";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
|
||||
export const getApiKey = async (apiKey: string): Promise<TApiKey | null> => {
|
||||
validateInputs([apiKey, z.string()]);
|
||||
if (!apiKey) {
|
||||
export const getApiKey = async (apiKeyId: string): Promise<TApiKey | null> => {
|
||||
validateInputs([apiKeyId, z.string()]);
|
||||
if (!apiKeyId) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: getHash(apiKey),
|
||||
id: apiKeyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
throw new ResourceNotFoundError("API Key", apiKey);
|
||||
throw new ResourceNotFoundError("API Key from ID", apiKeyId);
|
||||
}
|
||||
|
||||
return apiKeyData;
|
||||
34
packages/lib/environment/auth.ts
Normal file
34
packages/lib/environment/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => {
|
||||
return await unstable_cache(
|
||||
async () => {
|
||||
if (!userId) return false;
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
|
||||
return environmentUsers.includes(userId);
|
||||
},
|
||||
[`users-${userId}-environments-${environmentId}`],
|
||||
{ revalidate: 30 * 60, tags: [`environments-${environmentId}`] }
|
||||
)(); // 30 minutes
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
"@formbricks/types": "*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"next-auth": "^4.22.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^13.0.2",
|
||||
"nodemailer": "^6.9.5",
|
||||
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -28,7 +28,7 @@ importers:
|
||||
version: 3.12.7
|
||||
turbo:
|
||||
specifier: latest
|
||||
version: 1.10.13
|
||||
version: 1.10.3
|
||||
|
||||
apps/demo:
|
||||
dependencies:
|
||||
@@ -22049,64 +22049,65 @@ packages:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
/turbo-darwin-64@1.10.13:
|
||||
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
|
||||
/turbo-darwin-64@1.10.3:
|
||||
resolution: {integrity: sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-darwin-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
|
||||
/turbo-darwin-arm64@1.10.3:
|
||||
resolution: {integrity: sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-linux-64@1.10.13:
|
||||
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
|
||||
/turbo-linux-64@1.10.3:
|
||||
resolution: {integrity: sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-linux-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
|
||||
/turbo-linux-arm64@1.10.3:
|
||||
resolution: {integrity: sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-windows-64@1.10.13:
|
||||
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
|
||||
/turbo-windows-64@1.10.3:
|
||||
resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-windows-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
|
||||
/turbo-windows-arm64@1.10.3:
|
||||
resolution: {integrity: sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo@1.10.13:
|
||||
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
|
||||
/turbo@1.10.3:
|
||||
resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
turbo-darwin-64: 1.10.13
|
||||
turbo-darwin-arm64: 1.10.13
|
||||
turbo-linux-64: 1.10.13
|
||||
turbo-linux-arm64: 1.10.13
|
||||
turbo-windows-64: 1.10.13
|
||||
turbo-windows-arm64: 1.10.13
|
||||
turbo-darwin-64: 1.10.3
|
||||
turbo-darwin-arm64: 1.10.3
|
||||
turbo-linux-64: 1.10.3
|
||||
turbo-linux-arm64: 1.10.3
|
||||
turbo-windows-64: 1.10.3
|
||||
turbo-windows-arm64: 1.10.3
|
||||
dev: true
|
||||
|
||||
/tween-functions@1.2.0:
|
||||
|
||||
Reference in New Issue
Block a user