[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
This commit is contained in:
+112
-31
@@ -6,6 +6,8 @@ use App\Models\EtsyAppCredential;
|
||||
use App\Models\EtsyConnection;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use RuntimeException;
|
||||
|
||||
class Etsy
|
||||
@@ -217,18 +219,55 @@ class Etsy
|
||||
return $this->summarizeListing($listing, detailed: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
private function requireScope(EtsyConnection $connection, string $scope): void
|
||||
{
|
||||
if (! in_array($scope, $this->scopeList($connection->scopes) ?? [], true)) {
|
||||
throw new RuntimeException('An admin must reconnect Etsy in the dashboard to grant '.$scope.'.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validatedListingPayload(array $payload, bool $creating): array
|
||||
{
|
||||
unset($payload['listing_id']);
|
||||
if ($creating) {
|
||||
unset($payload['shop_id'], $payload['state']);
|
||||
}
|
||||
$allowedFields = $creating
|
||||
? 'title,description,price,quantity,who_made,when_made,taxonomy_id,shipping_profile_id,tags,materials,type'
|
||||
: 'title,description,taxonomy_id,tags,state';
|
||||
$required = $creating ? 'required' : 'sometimes';
|
||||
$rules = [
|
||||
'listing' => ['required', 'array:'.$allowedFields],
|
||||
'listing.title' => [$required, 'string', 'max:140'],
|
||||
'listing.description' => [$required, 'string'],
|
||||
'listing.price' => [$required, 'numeric', 'gt:0'],
|
||||
'listing.quantity' => [$required, 'integer', 'min:1'],
|
||||
'listing.who_made' => [$required, Rule::in(['i_did', 'someone_else', 'collective'])],
|
||||
'listing.when_made' => [$required, 'string', 'max:32'],
|
||||
'listing.taxonomy_id' => [$required, 'integer', 'min:1'],
|
||||
'listing.shipping_profile_id' => ['sometimes', 'integer', 'min:1'],
|
||||
'listing.tags' => ['sometimes', 'array', 'max:13'],
|
||||
'listing.tags.*' => ['string', 'max:20'],
|
||||
'listing.materials' => ['sometimes', 'array'],
|
||||
'listing.materials.*' => ['string'],
|
||||
'listing.type' => ['sometimes', Rule::in(['physical', 'download', 'both'])],
|
||||
'listing.state' => ['sometimes', Rule::in(['draft', 'inactive'])],
|
||||
];
|
||||
$validator = Validator::make(['listing' => $payload], $rules);
|
||||
if ($validator->fails()) {
|
||||
throw new RuntimeException('Invalid listing payload: '.$validator->errors()->first());
|
||||
}
|
||||
|
||||
return $validator->validated()['listing'];
|
||||
}
|
||||
|
||||
public function createDraftListing(EtsyConnection $connection, array $payload): array
|
||||
{
|
||||
foreach (['quantity', 'title', 'description', 'price', 'who_made', 'when_made', 'taxonomy_id'] as $field) {
|
||||
if (! array_key_exists($field, $payload) || $payload[$field] === '' || $payload[$field] === null) {
|
||||
throw new RuntimeException("Draft listing requires {$field}.");
|
||||
}
|
||||
}
|
||||
unset($payload['state'], $payload['shop_id'], $payload['listing_id']);
|
||||
$this->requireScope($connection, 'listings_w');
|
||||
$payload = $this->validatedListingPayload($payload, true);
|
||||
$listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
|
||||
@@ -244,7 +283,9 @@ class Etsy
|
||||
if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') {
|
||||
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
||||
}
|
||||
unset($payload['shop_id'], $payload['listing_id']);
|
||||
$this->requireScope($connection, 'listings_w');
|
||||
$payload = $this->validatedListingPayload($payload, false);
|
||||
$this->getListing($connection, $listingId);
|
||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload);
|
||||
$this->assertListingBelongsToShop($connection, $listing);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
@@ -257,9 +298,11 @@ class Etsy
|
||||
*/
|
||||
public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array
|
||||
{
|
||||
$this->requireScope($connection, 'listings_w');
|
||||
if (! $confirm) {
|
||||
throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
||||
}
|
||||
$this->getListing($connection, $listingId);
|
||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']);
|
||||
$this->assertListingBelongsToShop($connection, $listing);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
@@ -272,6 +315,8 @@ class Etsy
|
||||
*/
|
||||
public function getListingInventory(EtsyConnection $connection, int $listingId): array
|
||||
{
|
||||
$this->getListing($connection, $listingId);
|
||||
|
||||
return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection));
|
||||
}
|
||||
|
||||
@@ -281,13 +326,38 @@ class Etsy
|
||||
*/
|
||||
public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array
|
||||
{
|
||||
$this->requireScope($connection, 'listings_w');
|
||||
$validator = Validator::make($payload, [
|
||||
'quantity' => ['required_without:products', 'integer', 'min:0'],
|
||||
'products' => ['required_without:quantity', 'array', 'min:1'],
|
||||
'products.*' => ['array:sku,property_values,offerings'],
|
||||
'products.*.sku' => ['sometimes', 'string'],
|
||||
'products.*.property_values' => ['sometimes', 'array'],
|
||||
'products.*.offerings' => ['required', 'array', 'min:1'],
|
||||
'products.*.offerings.*' => ['array:price,quantity,is_enabled,readiness_state_id'],
|
||||
'products.*.offerings.*.price' => ['required', 'numeric', 'gt:0'],
|
||||
'products.*.offerings.*.quantity' => ['required', 'integer', 'min:0'],
|
||||
'products.*.offerings.*.is_enabled' => ['required', 'boolean'],
|
||||
'products.*.offerings.*.readiness_state_id' => ['sometimes', 'integer', 'min:1'],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
throw new RuntimeException('Invalid inventory payload: '.$validator->errors()->first());
|
||||
}
|
||||
$this->getListing($connection, $listingId);
|
||||
$currentInventory = $this->getListingInventory($connection, $listingId);
|
||||
if (! isset($payload['products']) && array_key_exists('quantity', $payload)) {
|
||||
$payload = ['products' => $this->productsWithQuantity($this->getListingInventory($connection, $listingId), (int) $payload['quantity'])];
|
||||
$payload = ['products' => $this->productsWithQuantity($currentInventory, (int) $payload['quantity'])];
|
||||
}
|
||||
if (! isset($payload['products']) || ! is_array($payload['products'])) {
|
||||
throw new RuntimeException('Inventory update requires products or quantity.');
|
||||
}
|
||||
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: ['products' => $payload['products']], jsonBody: true);
|
||||
$body = ['products' => $payload['products']];
|
||||
foreach (['price_on_property', 'quantity_on_property', 'sku_on_property', 'readiness_state_on_property'] as $field) {
|
||||
if (array_key_exists($field, $currentInventory)) {
|
||||
$body[$field] = $currentInventory[$field];
|
||||
}
|
||||
}
|
||||
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, jsonBody: true);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
|
||||
return $inventory;
|
||||
@@ -332,6 +402,7 @@ class Etsy
|
||||
*/
|
||||
public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array
|
||||
{
|
||||
$this->requireScope($connection, 'transactions_w');
|
||||
if ($trackingCode === '' || $carrierName === '') {
|
||||
throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.');
|
||||
}
|
||||
@@ -400,29 +471,38 @@ class Etsy
|
||||
*/
|
||||
public function refreshAccessToken(EtsyConnection $connection): string
|
||||
{
|
||||
return $this->accessToken($connection);
|
||||
return $this->accessToken($connection, 600);
|
||||
}
|
||||
|
||||
private function accessToken(EtsyConnection $connection): string
|
||||
private function accessToken(EtsyConnection $connection, int $marginSeconds = 0): string
|
||||
{
|
||||
if ($connection->expires_at->isFuture()) {
|
||||
return $connection->access_token;
|
||||
}
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post(self::API_URL.'/public/oauth/token', [
|
||||
'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'],
|
||||
'refresh_token' => $connection->refresh_token,
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
||||
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
||||
}
|
||||
$connection->update([
|
||||
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
||||
...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [],
|
||||
]);
|
||||
return $connection->getConnection()->transaction(function () use ($connection, $marginSeconds): string {
|
||||
// Lock the shared database row, not a per-process cache: HTTP, MCP
|
||||
// and scheduler workers must re-read the latest rotating token.
|
||||
$current = EtsyConnection::query()->whereKey($connection->getKey())->lockForUpdate()->firstOrFail();
|
||||
if ($current->expires_at->greaterThan(now()->addSeconds($marginSeconds))) {
|
||||
$connection->setRawAttributes($current->getAttributes(), true);
|
||||
|
||||
return $tokens['access_token'];
|
||||
return $current->access_token;
|
||||
}
|
||||
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post(self::API_URL.'/public/oauth/token', [
|
||||
'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'],
|
||||
'refresh_token' => $current->refresh_token,
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
||||
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
||||
}
|
||||
$current->update([
|
||||
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
||||
...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [],
|
||||
]);
|
||||
$connection->setRawAttributes($current->getAttributes(), true);
|
||||
|
||||
return $current->access_token;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,7 +551,7 @@ class Etsy
|
||||
*/
|
||||
private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void
|
||||
{
|
||||
if (isset($listing['shop_id']) && (int) $listing['shop_id'] !== (int) $connection->shop_id) {
|
||||
if (! isset($listing['shop_id']) || (int) $listing['shop_id'] !== (int) $connection->shop_id) {
|
||||
throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.');
|
||||
}
|
||||
}
|
||||
@@ -563,6 +643,7 @@ class Etsy
|
||||
'price' => $price,
|
||||
'quantity' => $quantity,
|
||||
'is_enabled' => $offering['is_enabled'] ?? true,
|
||||
...array_intersect_key($offering, array_flip(['readiness_state_id'])),
|
||||
];
|
||||
}
|
||||
$products[] = [
|
||||
|
||||
Reference in New Issue
Block a user