diff --git a/app-modules/bot-discord/CONTEXT.md b/app-modules/bot-discord/CONTEXT.md index de05e8608..9718cec06 100644 --- a/app-modules/bot-discord/CONTEXT.md +++ b/app-modules/bot-discord/CONTEXT.md @@ -4,13 +4,16 @@ Discord bot runtime powered by Laracord. Handles websocket events, slash command ## Glossary -| Term | Definition | Not to be confused with | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | -| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | -| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | -| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | -| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| Term | Definition | Not to be confused with | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | +| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | +| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | +| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | +| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| **Sala Empresarial** | A `/sala`-created voice room that a partner-company member privatized via `/sala-empresarial`: `@everyone` is denied view-channel/connect/speak/use-voice-activity/send-messages/read-history while one Partner Role is allowed. Fully hidden from non-members — they cannot see the room nor who is inside. | A normal `/sala` room (public, permissions inherited from the category) | +| **Empresa Parceira** | An external company in a partnership with He4rt whose members hold a dedicated Discord role. Registered in `bot-discord.roles.partners` (`slug => role_id`); the system supports many, not just one. | A Squad (internal community group, owned by the `squads` module) | +| **Partner Role** | The Discord role that identifies the members of one Empresa Parceira (e.g. `brd`). It is the `allow` target stamped on a room when it becomes a Sala Empresarial; a caller must hold the role of the company they select. | Moderation admin/mod roles (`he4rt.discord.moderation.*`) | ## Module Boundaries diff --git a/app-modules/bot-discord/config/bot-discord.php b/app-modules/bot-discord/config/bot-discord.php index df1fd3429..6f0931081 100644 --- a/app-modules/bot-discord/config/bot-discord.php +++ b/app-modules/bot-discord/config/bot-discord.php @@ -14,5 +14,14 @@ 'presentation' => env('HE4RT_PRESENTATION_ROLE_ID', '546150872397119491'), 'comite_delas' => env('HE4RT_COMITE_DELAS_ROLE_ID', '1014311739397001288'), 'he4rt_delas' => env('HE4RT_DELAS_ROLE_ID', '1018009963903328308'), + + /* + * Empresa Parceira registry: partner-company slug => Discord Partner Role id. + * Each entry enables `/sala-empresarial empresa:` for that company. + * Onboarding a new partner is one env + one line here — no code change. + */ + 'partners' => [ + 'brd' => env('HE4RT_PARTNER_BRD_ROLE_ID', '1522599722370465904'), + ], ], ]; diff --git a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md new file mode 100644 index 000000000..44395d51d --- /dev/null +++ b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md @@ -0,0 +1,51 @@ +--- +type: adr +title: 'Sala Empresarial — role-gated private voice rooms' +module: bot-discord +status: accepted +date: 2026-07-23 +--- + +# Sala Empresarial — role-gated private voice rooms + +Partner companies (**Empresa Parceira**) need private voice rooms on the guild. We add a +separate `/sala-empresarial empresa:` slash command that **converts an existing +`/sala`-tracked room** into a private one by stamping Discord permission overwrites: `deny` +`@everyone` and `allow` the selected company's **Partner Role** for `VIEW_CHANNEL`, +`CONNECT`, `SPEAK`, `USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock +the room's built-in text chat). `MENTION_EVERYONE` is left unchanged by the command — it +keeps whatever the category inherits (every `/sala` room already blocks it). Because +`VIEW_CHANNEL` is denied to `@everyone`, the room is **fully hidden**: non-members cannot see +it in the channel list nor who is connected. + +## Considered Options + +- **Convert existing room vs. a flag on `/sala`.** We convert. `/sala` is shared by the + whole guild; branching its logic and gating a `empresarial:true` flag by role would + couple a general command to partner concerns. A dedicated command mirrors `/sala-limite` + and keeps `/sala` untouched. +- **Explicit `empresa` parameter vs. inferring the caller's role.** Explicit parameter. A + caller can belong to more than one Empresa Parceira, so intersecting their roles with the + registry is ambiguous. The parameter picks exactly one company; we then validate the + caller actually holds that company's Partner Role and reject (ephemeral) otherwise. +- **Config registry vs. hardcoding `brd`.** A flat `bot-discord.roles.partners` + (`slug => role_id`) registry. Adding a second partner company is one env + one config + line, no code change. The slash-command `choices` are built from this map. Each partner's + Partner Role id is seeded as an env-var default in `config/bot-discord.php` (the source of + truth), so no role ids live in this ADR. +- **Stateless vs. persisting the empresarial state.** Stateless. We only convert + `/sala`-tracked rooms, which `DynamicVoiceTask` auto-deletes when empty, so the overwrites + die with the channel. `VoiceChannelDTO` is left unchanged and Discord's overwrites are the + single source of truth; re-running the command is a harmless re-stamp. There is no revert + command — a room un-privatizes by emptying and being deleted. + +## Consequences + +- This is the first permission-overwrite code in `bot-discord`; prior room commands only + set the channel's parent/user-limit and relied on category inheritance. +- Gating is by Partner Role membership, **not** room ownership (unlike `/sala-limite`) — any + member of the selected company may privatize the room. +- Because `VIEW_CHANNEL` is denied to `@everyone`, empresarial rooms disappear from the + channel list for non-members and their occupant list is hidden. Discord force-disconnects + any non-member connected at conversion time (losing view access ejects them from a voice + channel) — accepted, and consistent with a fully private room. diff --git a/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php new file mode 100644 index 000000000..b851f9263 --- /dev/null +++ b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php @@ -0,0 +1,72 @@ + $callerRoleIds Discord role ids held by the caller. + * @param list $activeChannels The `/sala`-tracked rooms cache. + */ + public function execute( + string $companySlug, + array $callerRoleIds, + ?string $currentChannelId, + array $activeChannels, + ): EmpresarialRoomDecision { + if (!$this->isInsideTrackedRoom($currentChannelId, $activeChannels)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::NotInTrackedRoom); + } + + $partnerRoleId = $this->resolvePartnerRoleId($companySlug); + + if ($partnerRoleId === null) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::UnknownCompany); + } + + if (!in_array($partnerRoleId, $callerRoleIds, strict: true)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::MissingPartnerRole); + } + + return EmpresarialRoomDecision::approve( + EmpresarialOverwritePlan::for($companySlug, $partnerRoleId), + ); + } + + /** + * @param list $activeChannels + */ + private function isInsideTrackedRoom(?string $currentChannelId, array $activeChannels): bool + { + if ($currentChannelId === null) { + return false; + } + + return array_any($activeChannels, fn (VoiceChannelDTO $channel): bool => $channel->channelId === $currentChannelId); + } + + private function resolvePartnerRoleId(string $companySlug): ?string + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + if (!array_key_exists($companySlug, $partners)) { + return null; + } + + return $partners[$companySlug]; + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php new file mode 100644 index 000000000..12b3c1750 --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php @@ -0,0 +1,54 @@ + + */ + public const array PRIVATE_ROOM_PERMISSIONS = [ + 'view_channel', + 'connect', + 'speak', + 'use_vad', + 'send_messages', + 'read_message_history', + ]; + + /** + * @param list $denyEveryone Permissions denied to `@everyone`. + * @param list $allowPartnerRole Permissions allowed for the Partner Role. + */ + private function __construct( + public string $companySlug, + public string $partnerRoleId, + public array $denyEveryone, + public array $allowPartnerRole, + ) {} + + public static function for(string $companySlug, string $partnerRoleId): self + { + return new self( + companySlug: $companySlug, + partnerRoleId: $partnerRoleId, + denyEveryone: self::PRIVATE_ROOM_PERMISSIONS, + allowPartnerRole: self::PRIVATE_ROOM_PERMISSIONS, + ); + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php new file mode 100644 index 000000000..1894b304f --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php @@ -0,0 +1,34 @@ +plan instanceof EmpresarialOverwritePlan; + } +} diff --git a/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php new file mode 100644 index 000000000..2b1a987ed --- /dev/null +++ b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php @@ -0,0 +1,27 @@ + '❌ Você precisa estar dentro de uma sala criada com /sala para usar este comando!', + self::MissingPartnerRole => '❌ Você não faz parte da empresa selecionada e não pode torná-la privada.', + self::UnknownCompany => '❌ Empresa parceira não encontrada.', + }; + } +} diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php new file mode 100644 index 000000000..3f8e8c06d --- /dev/null +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -0,0 +1,140 @@ +member->id; + $channelId = cache()->tags(['voice_tracking'])->get('user_last_channel_'.$userId); + + /** @var list $activeChannels */ + $activeChannels = cache()->tags(['voice_channels'])->get('active_voice_channels_keys', []); + + $decision = resolve(ConfigureEmpresarialRoomAction::class)->execute( + companySlug: (string) $this->value('empresa'), + callerRoleIds: $this->callerRoleIds($interaction), + currentChannelId: $channelId ? (string) $channelId : null, + activeChannels: $activeChannels, + ); + + if (!$decision->isApproved()) { + $this->message() + ->content($decision->rejection->message()) + ->reply($interaction, ephemeral: true); + + return; + } + + $this->applyOverwrites($interaction, (string) $channelId, $decision->plan); + } + + /** + * @return array + */ + public function options(): array + { + return [ + [ + 'name' => 'empresa', + 'description' => 'Empresa parceira para a qual a sala ficará privada.', + 'type' => Option::STRING, + 'required' => true, + 'choices' => $this->companyChoices(), + ], + ]; + } + + /** + * @return list + */ + private function callerRoleIds(Interaction $interaction): array + { + $roleIds = []; + + foreach ($interaction->member->roles as $role) { + /** @var Role $role */ + $roleIds[] = $role->id; + } + + return $roleIds; + } + + private function applyOverwrites(Interaction $interaction, string $channelId, EmpresarialOverwritePlan $plan): void + { + /** @var Channel|null $voiceChannel */ + $voiceChannel = $interaction->guild->channels->get('id', $channelId); + + /** @var Role|null $partnerRole */ + $partnerRole = $interaction->guild->roles->get('id', $plan->partnerRoleId); + + // The `@everyone` role shares the guild id in Discord. + /** @var Role|null $everyoneRole */ + $everyoneRole = $interaction->guild->roles->get('id', $interaction->guild->id); + + if (!$voiceChannel || !$partnerRole || !$everyoneRole) { + $this->message() + ->content('❌ Não foi possível configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + + return; + } + + try { + // Allow the partner role first: if the @everyone deny below fails, the room + // stays public rather than fully locked (deny-first would strand everyone out). + /** @var PromiseInterface $allowPartner */ + $allowPartner = $voiceChannel->setPermissions($partnerRole, $plan->allowPartnerRole, []); + await($allowPartner); + + /** @var PromiseInterface $denyEveryone */ + $denyEveryone = $voiceChannel->setPermissions($everyoneRole, [], $plan->denyEveryone); + await($denyEveryone); + + $this->message() + ->content(sprintf('✅ Sala configurada como privada para a empresa **%s**!', mb_strtoupper($plan->companySlug))) + ->reply($interaction, ephemeral: true); + } catch (Exception) { + $this->message() + ->content('❌ Erro ao configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + } + } + + /** + * @return list + */ + private function companyChoices(): array + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + return array_map( + fn (string $slug): array => ['name' => mb_strtoupper($slug), 'value' => $slug], + array_keys($partners), + ); + } +} diff --git a/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php new file mode 100644 index 000000000..fd48fa4ed --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php @@ -0,0 +1,134 @@ +set('bot-discord.roles.partners', [ + 'brd' => 'role-brd', + 'acme' => 'role-acme', + ]); +}); + +/** + * @param list $users + */ +function trackedRoom(string $channelId, string $ownerId = 'owner-1', array $users = []): VoiceChannelDTO +{ + return VoiceChannelDTO::make([ + 'guildId' => 'guild-1', + 'channelId' => $channelId, + 'ownerId' => $ownerId, + 'usersCount' => count($users), + 'users' => $users, + ]); +} + +it('approves a member privatizing their own /sala room', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->rejection)->toBeNull() + ->and($decision->plan)->toBeInstanceOf(EmpresarialOverwritePlan::class) + ->and($decision->plan->companySlug)->toBe('brd') + ->and($decision->plan->partnerRoleId)->toBe('role-brd') + ->and($decision->plan->denyEveryone)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']) + ->and($decision->plan->allowPartnerRole)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']); +}); + +it('approves privatizing a room a teammate created (gate is role, not ownership)', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1', ownerId: 'someone-else')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->partnerRoleId)->toBe('role-brd'); +}); + +it('rejects when the caller does not hold the selected company role', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeFalse() + ->and($decision->plan)->toBeNull() + ->and($decision->rejection)->toBe(EmpresarialRejectionReason::MissingPartnerRole); +}); + +it('rejects when the caller is in a channel that is not /sala-tracked', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'permanent-channel', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the caller is not connected to any voice channel', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: null, + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the selected company is not registered', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'ghost-corp', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::UnknownCompany); +}); + +it('allows only the selected company role for a member of several partners', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd', 'role-acme'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->companySlug)->toBe('acme') + ->and($decision->plan->partnerRoleId)->toBe('role-acme'); +}); + +it('is a harmless re-stamp when run again on an already-configured room', function (): void { + $action = new ConfigureEmpresarialRoomAction(); + $args = [ + 'companySlug' => 'brd', + 'callerRoleIds' => ['role-brd'], + 'currentChannelId' => 'room-1', + 'activeChannels' => [trackedRoom('room-1')], + ]; + + $first = $action->execute(...$args); + $second = $action->execute(...$args); + + expect($first->plan->partnerRoleId)->toBe($second->plan->partnerRoleId) + ->and($first->plan->denyEveryone)->toBe($second->plan->denyEveryone) + ->and($first->plan->allowPartnerRole)->toBe($second->plan->allowPartnerRole); +}); diff --git a/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php new file mode 100644 index 000000000..0daef9de9 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php @@ -0,0 +1,22 @@ + $reason->message(), + EmpresarialRejectionReason::cases(), + ); + + expect($messages) + ->each->toStartWith('❌') + ->and(array_unique($messages))->toHaveSameSize(EmpresarialRejectionReason::cases()); +}); + +it('maps each case to its backed slug', function (): void { + expect(EmpresarialRejectionReason::NotInTrackedRoom->value)->toBe('not-in-tracked-room') + ->and(EmpresarialRejectionReason::MissingPartnerRole->value)->toBe('missing-partner-role') + ->and(EmpresarialRejectionReason::UnknownCompany->value)->toBe('unknown-company'); +}); diff --git a/package-lock.json b/package-lock.json index a46e98c73..29ed7abc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", - "concurrently": "10.0.3", + "concurrently": "10.0.4", "husky": "9.1.7", "laravel-vite-plugin": "3.1.3", "lint-staged": "17.2.0", @@ -816,15 +816,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", + "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -1360,9 +1360,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1463,9 +1463,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d28601b85..0ceaf5f33 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", - "concurrently": "10.0.3", + "concurrently": "10.0.4", "husky": "9.1.7", "laravel-vite-plugin": "3.1.3", "lint-staged": "17.2.0",