[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s

This commit is contained in:
2026-11-07 21:54:33 +00:00
parent 960189d14b
commit c8c5822166
13 changed files with 455 additions and 89 deletions
+4 -1
View File
@@ -23,10 +23,12 @@ class EtsySync extends Command
return self::SUCCESS;
}
$failed = false;
try {
$receipts = $etsy->receipts($connection, fresh: true);
$this->line('etsy receipts: refreshed ('.($receipts['count'] ?? 0).' open)');
} catch (Throwable $exception) {
$failed = true;
$this->warn('etsy receipts: '.$exception->getMessage());
}
@@ -34,9 +36,10 @@ class EtsySync extends Command
$listings = $etsy->listings($connection, fresh: true);
$this->line('etsy listings: refreshed ('.count($listings['results'] ?? []).' active)');
} catch (Throwable $exception) {
$failed = true;
$this->warn('etsy listings: '.$exception->getMessage());
}
return self::SUCCESS;
return $failed ? self::FAILURE : self::SUCCESS;
}
}
@@ -2,7 +2,6 @@
namespace App\Console\Commands;
use App\Models\EtsyConnection;
use App\Services\Bambuddy;
use App\Services\DryerClient;
use App\Services\Etsy;
@@ -22,11 +21,6 @@ class RefreshIntegrationCache extends Command
{
$cache->forceRefresh = true;
if (($connection = EtsyConnection::first()) && $etsy->configured()) {
$this->refresh('etsy receipts', fn () => $etsy->receipts($connection));
$this->refresh('etsy listings', fn () => $etsy->listings($connection));
}
$this->refresh('parcel2go', function () use ($parcel2Go): void {
$parcel2Go->verifyCredentials();
foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) {
@@ -21,7 +21,7 @@ class ScheduleSettingsController extends Controller
$frequencyKeys = array_keys(DashboardSchedule::frequencyOptions());
$validated = $request->validate([
'schedules' => ['required', 'array'],
'schedules' => ['required', 'array:'.implode(',', $jobKeys)],
'schedules.*' => ['required', 'string', Rule::in($frequencyKeys)],
]);
+112 -31
View File
@@ -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[] = [
+58 -6
View File
@@ -61,7 +61,7 @@ class EtsyMcpServer
'protocolVersion' => is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : '2024-11-05',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'etsy', 'version' => '1.0.0'],
'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts unless etsy_publish_listing is called with confirm=true. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.',
'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts. Never publish, activate, renew, reprice a live listing, change inventory or shop settings, or ship orders without explicit operator authorisation for that specific action in the current conversation. confirm=true is only a technical guard, not authorisation. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.',
],
'notifications/initialized', 'initialized' => [],
'ping' => ['ok' => true],
@@ -78,6 +78,18 @@ class EtsyMcpServer
private function callTool(string $name, array $arguments): array
{
try {
$validationArguments = $arguments;
if ($name === 'etsy_create_draft_listing') {
unset($validationArguments['state']);
}
if ($name === 'etsy_update_listing' && ($arguments['state'] ?? null) === 'active') {
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true.');
}
$tool = collect($this->tools())->firstWhere('name', $name);
if ($tool === null) {
throw new RuntimeException('Unknown tool: '.$name);
}
$this->validateSchema($validationArguments, $tool['inputSchema'], 'arguments');
$data = $this->invoke($name, $arguments);
$payload = $this->redact($data);
@@ -182,7 +194,7 @@ class EtsyMcpServer
'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']],
],
'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'],
'additionalProperties' => true,
'additionalProperties' => false,
]),
$this->tool('etsy_update_listing', 'Update HickLab3D listing fields (title, description, tags, state=inactive, etc). Refuses state=active — use etsy_publish_listing with confirm=true to go live.', [
'type' => 'object',
@@ -195,7 +207,7 @@ class EtsyMcpServer
'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'],
],
'required' => ['listing_id'],
'additionalProperties' => true,
'additionalProperties' => false,
]),
$this->tool('etsy_publish_listing', 'Publish a draft HickLab3D listing (state=active). Requires confirm=true. The listing must already have images (and a shipping profile for physical items).', [
'type' => 'object',
@@ -287,11 +299,51 @@ class EtsyMcpServer
*/
private function requiredInt(array $arguments, string $key): int
{
if (! isset($arguments[$key]) || ! is_numeric($arguments[$key])) {
throw new RuntimeException("{$key} is required.");
if (! isset($arguments[$key]) || ! is_int($arguments[$key]) || $arguments[$key] < 1) {
throw new RuntimeException("{$key} must be a positive integer.");
}
return (int) $arguments[$key];
return $arguments[$key];
}
/** @param array<string, mixed> $schema */
private function validateSchema(mixed $value, array $schema, string $path): void
{
$validType = match ($schema['type'] ?? null) {
'object' => is_array($value) && ($value === [] || ! array_is_list($value)),
'array' => is_array($value) && array_is_list($value),
'integer' => is_int($value),
'number' => is_int($value) || is_float($value),
'boolean' => is_bool($value),
'string' => is_string($value),
default => false,
};
if (! $validType
|| (isset($schema['enum']) && ! in_array($value, $schema['enum'], true))
|| (isset($schema['minimum']) && $value < $schema['minimum'])
|| (isset($schema['maximum']) && $value > $schema['maximum'])) {
throw new RuntimeException('Invalid listing payload: '.$path.' does not match the tool schema.');
}
if (($schema['type'] ?? null) === 'object') {
$properties = (array) ($schema['properties'] ?? []);
foreach ($schema['required'] ?? [] as $key) {
if (! array_key_exists($key, $value)) {
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is required.');
}
}
foreach ($value as $key => $item) {
if (isset($properties[$key])) {
$this->validateSchema($item, $properties[$key], $path.'.'.$key);
} elseif (($schema['additionalProperties'] ?? true) === false) {
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is not supported.');
}
}
}
if (($schema['type'] ?? null) === 'array' && isset($schema['items'])) {
foreach ($value as $key => $item) {
$this->validateSchema($item, $schema['items'], $path.'.'.$key);
}
}
}
/**
+1 -1
View File
@@ -44,7 +44,7 @@ class IntegrationCache
try {
$value = $fetch();
} catch (Throwable $exception) {
if (is_array($entry)) {
if (is_array($entry) && ! $fresh) {
report($exception);
return $entry['value'];
+21 -6
View File
@@ -26,7 +26,7 @@ class DashboardSchedule
'name' => 'Integration cache refresh',
'feature' => 'Integrations',
'command' => 'integrations:refresh',
'description' => 'Refreshes cached Etsy, Parcel2Go, BamBuddy and dryer data so dashboard pages load instantly. Migrated from the previous hard-coded schedule entry — frequency and withoutOverlapping(5) behavior unchanged.',
'description' => 'Refreshes cached Parcel2Go, BamBuddy and dryer data. Etsy runs separately through etsy:sync; the 30-second cadence and withoutOverlapping(5) guard are unchanged.',
'default_frequency' => 'every_30_seconds',
'overlap_minutes' => 5,
'manual' => true,
@@ -46,16 +46,16 @@ class DashboardSchedule
'command' => 'etsy:sync',
'description' => 'Forces a fresh pull of Etsy receipts and active listings into the same IntegrationCache entries the Etsy page reads, independent of the bundled integrations:refresh job so it can be tuned or disabled on its own.',
'default_frequency' => 'every_15_minutes',
'overlap_minutes' => null,
'overlap_minutes' => 5,
'manual' => true,
],
'etsy_token_refresh' => [
'name' => 'Etsy token refresh',
'feature' => 'Etsy',
'command' => 'etsy:refresh-token',
'description' => 'Proactively refreshes the Etsy OAuth access token (tokens last ~1 hour) so the connection stays alive even during quiet periods with no receipts or listings activity to trigger an on-demand refresh.',
'default_frequency' => 'every_45_minutes',
'overlap_minutes' => null,
'description' => 'Checks every five minutes by default and refreshes only when expiry is within ten minutes. Disabling or slowing this job can leave idle tokens expired; on-demand reads also refresh expired tokens.',
'default_frequency' => 'every_5_minutes',
'overlap_minutes' => 5,
'manual' => true,
],
];
@@ -104,7 +104,22 @@ class DashboardSchedule
->all();
File::ensureDirectoryExists(dirname(self::runtimeSettingsPath()));
File::put(self::runtimeSettingsPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$lock = fopen(self::runtimeSettingsPath().'.lock', 'c');
if ($lock === false) {
throw new \RuntimeException('Unable to open schedule settings lock.');
}
try {
if (! flock($lock, LOCK_EX)) {
throw new \RuntimeException('Unable to lock schedule settings.');
}
$stored = File::exists(self::runtimeSettingsPath())
? json_decode(File::get(self::runtimeSettingsPath()), true, 512, JSON_THROW_ON_ERROR)
: [];
File::replace(self::runtimeSettingsPath(), json_encode(array_replace(is_array($stored) ? $stored : [], $payload), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
public static function register(): void