From 7e43f9e9902c19eaff0de5d3d17e3c76c3008a83 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Jun 2026 14:52:17 +0000 Subject: [PATCH] feat: requires_address status + address input for pickup-to-delivery switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New status: requires_address (Требуется адрес) in deliveryWorkflow - ClientDeliveryPage: address input shown when delivery chosen without address on file - OrderDetailPanel: requires_address status badge + address input for manager - confirm_delivery_choice_by_token RPC: p_delivery_address param, sets requires_address status - Edge function: delivery_address passed to RPC - deliveryInvitationApi: deliveryAddress param - CHECK constraint: requires_address, address_required added - PickupSlotsPicker: styled storage conditions info block --- .gitignore | 1 + src/components/client/PickupSlotsPicker.jsx | 39 +- src/components/orders/OrderDetailPanel.jsx | 14 +- src/constants/deliveryWorkflow.js | 16 +- src/pages/ClientDeliveryPage.jsx | 25 ++ src/services/deliveryInvitationApi.js | 4 +- .../confirm-delivery-choice/index.ts | 11 +- volumes/api/kong-entrypoint.sh | 49 +++ volumes/api/kong.yml | 411 ++++++++++++++++++ 9 files changed, 556 insertions(+), 14 deletions(-) create mode 100755 volumes/api/kong-entrypoint.sh create mode 100644 volumes/api/kong.yml diff --git a/.gitignore b/.gitignore index c2ad370..1d55b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist .worktrees .superpowers .ruff_cache +volumes/db/data/ diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx index efcb16a..93653f1 100644 --- a/src/components/client/PickupSlotsPicker.jsx +++ b/src/components/client/PickupSlotsPicker.jsx @@ -105,14 +105,37 @@ const getPickupSlots = (referenceDate = new Date()) => { }; const FREE_STORAGE_NOTICE = ( -
-

ℹ️ Условия хранения

-

- Бесплатное хранение — 2 рабочих дня с даты готовности. -

-

- Начиная с 3-го рабочего дня — 300 ₽/день платного хранения. -

+
+
+ + 📦 + +
+

+ Условия хранения +

+
    +
  • + + + Бесплатное хранение — 2 рабочих дня с даты готовности + +
  • +
  • + + + С 3-го рабочего дня — 300 ₽/день платного хранения + +
  • +
+
+
); diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index bbb3cd0..ceee214 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -557,6 +557,7 @@ export const OrderDetailPanel = ({ const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery"); const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || ""); const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]); + const [deliveryAddress, setDeliveryAddress] = React.useState(order?.deliveryAddress || order?.customerAddress || ""); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const [currentMonth, setCurrentMonth] = React.useState(() => { const existingDeliveryDate = fromDateKey(order?.deliveryDate); @@ -637,6 +638,7 @@ export const OrderDetailPanel = ({ deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime, deliveryType, ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), + ...(deliveryType === "delivery" && deliveryAddress.trim() ? { deliveryAddress: deliveryAddress.trim() } : {}), }); if (result?.success) { @@ -709,7 +711,16 @@ export const OrderDetailPanel = ({

Тип доставки

-

{order.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}

+

{order.deliveryType === "pickup" ? "Самовывоз" : order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address" ? "Доставка (требуется адрес)" : "Доставка"}

+ {(order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && ( +
+ 📍 +
+

Адрес доставки не указан

+

Клиент выбрал доставку, но адрес отсутствует. Уточните адрес у клиента и заполните поле ниже.

+
+
+ )}

@@ -1125,6 +1136,7 @@ export const OrderDetailPanel = ({ { value: "on_route", label: "В пути", manual: true }, { value: "delivered", label: "Доставлено", manual: true }, { value: "pickup", label: "Самовывоз", manual: true }, + { value: "requires_address", label: "Требуется адрес", manual: true }, { value: "problem", label: "Проблема", manual: true }, { value: "cancelled", label: "Отменено", manual: true }, ].map((statusOption) => { diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js index 8fcf233..15f1491 100644 --- a/src/constants/deliveryWorkflow.js +++ b/src/constants/deliveryWorkflow.js @@ -109,6 +109,15 @@ export const ORDER_STATUS_META = { criticalAfterHours: 48, tone: "accent", }, + "Требуется адрес": { + comment: "Клиент выбрал доставку, но адрес доставки отсутствует. Менеджеру нужно уточнить адрес.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 4, + criticalAfterHours: 12, + tone: "warning", + }, "Передан логисту": { comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.", ownerRole: "logistician", @@ -228,8 +237,8 @@ export const ORDER_STATUS_TRANSITIONS = { "В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"], "Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"], "Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"], - "Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Проблема доставки", "Отменён"], - "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз"], + "Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"], + "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"], "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], "Назначен водитель": ["Загружен", "Проблема доставки"], Загружен: ["Доставлен", "Проблема доставки"], @@ -238,6 +247,7 @@ export const ORDER_STATUS_TRANSITIONS = { "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], "Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"], "Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"], + "Требуется адрес": ["Доставка согласована", "Самовывоз", "Отменён", "Проблема доставки"], Закрыт: [], Отменён: [], }; @@ -254,6 +264,7 @@ export const ROLE_TRANSITION_TARGETS = { "Передан логисту", "Назначен водитель", "Самовывоз", + "Требуется адрес", "Проблема доставки", "Платное хранение", "Закрыт", @@ -276,6 +287,7 @@ export const LOGISTICS_STATUSES = [ "Доставка согласована", "Назначен водитель", "Самовывоз", + "Требуется адрес", "Проблема доставки", ]; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 3780e2b..c6385b6 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -193,6 +193,7 @@ export const ClientDeliveryPage = () => { const [selectedSlot, setSelectedSlot] = React.useState(null); const [choiceSaved, setChoiceSaved] = React.useState(false); const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY); + const [deliveryAddress, setDeliveryAddress] = React.useState(""); const referenceDate = React.useMemo(() => new Date(), [token]); React.useEffect(() => { @@ -278,6 +279,9 @@ export const ClientDeliveryPage = () => { pickupDate: effectiveSelectedSlot.date, pickupTimeSlot: effectiveSelectedSlot.time, } : {}), + ...(activeTab === TAB_DELIVERY && deliveryAddress.trim() ? { + deliveryAddress: deliveryAddress.trim(), + } : {}), }); const loadedInvitation = await fetchDeliveryInvitation(token); setInvitation(loadedInvitation); @@ -403,6 +407,27 @@ export const ClientDeliveryPage = () => {

+ {activeTab === TAB_DELIVERY && !invitation?.deliveryAddress && !invitation?.customerAddress && ( + +
+ 📍 +
+

Укажите адрес доставки

+

+ Адрес доставки отсутствует в заказе. Пожалуйста, введите полный адрес, куда нужно привезти заказ. +

+
+
+ setDeliveryAddress(e.target.value)} + placeholder="Город, улица, дом, квартира" + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none" + /> +
+ )} + {activeTab === TAB_DELIVERY && slots.length ? ( { } }; -export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot }) => { +export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot, deliveryAddress }) => { if (isLocalClientInvitationToken(token)) { const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token); const invitation = cacheInvitation({ ...baseInvitation, deliveryType: deliveryType || "delivery", ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), + ...(deliveryType === "delivery" && deliveryAddress ? { deliveryAddress, customerAddress: deliveryAddress } : {}), deliveryDate, deliveryTime, state: "confirmed", @@ -247,6 +248,7 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, p_delivery_type: deliveryType || "delivery", p_pickup_date: pickupDate || null, p_pickup_time_slot: pickupTimeSlot || null, + p_delivery_address: deliveryAddress || null, }); }; diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 982cd83..5ffd95d 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -137,7 +137,14 @@ Deno.serve(async (request) => { } const deliveryType = body.deliveryType || "delivery"; - const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed"; + + // When user switches from pickup to delivery but has no address → requires_address + const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim(); + const effectiveDeliveryStatus = deliveryType === "pickup" + ? "pickup" + : hasAddress + ? "agreed" + : "requires_address"; if (invitation.order_group_id) { const { data: currentGroup, error: groupError } = await supabase @@ -194,7 +201,7 @@ Deno.serve(async (request) => { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, delivery_type: deliveryType, - notification_status: "confirmed", + notification_status: effectiveDeliveryStatus === "requires_address" ? "address_required" : "confirmed", updated_at: new Date().toISOString(), }; diff --git a/volumes/api/kong-entrypoint.sh b/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..d5eee93 --- /dev/null +++ b/volumes/api/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Custom entrypoint for Kong that builds Lua expressions for request-transformer +# and performs environment variable substitution in the declarative config. + +# Build Lua expressions for translating opaque API keys to asymmetric JWTs. +# When opaque keys are not configured (empty env vars), expressions fall through +# to legacy-only behavior - just passing apikey as-is. +# +# Full expression logic (when opaque keys are configured): +# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT) +# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key" +# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key" +# 4. Fallback: pass apikey as-is (legacy HS256 JWT) + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # Opaque keys configured -> full translation expressions + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" + + # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey + # via query string), outputs to x-api-key header which Realtime checks first. + export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" +else + # Legacy API keys, not sb_ API keys -> pass apikey through unchanged + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" + export LUA_RT_WS_EXPR="\$(query_params.apikey)" +fi + +# Substitute environment variables in the Kong declarative config. +# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double +# quotes, breaking "Header: value" patterns that YAML parses as mappings). +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +# Remove empty key-auth credentials (unconfigured opaque keys) +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/volumes/api/kong.yml b/volumes/api/kong.yml new file mode 100644 index 0000000..c1dbd60 --- /dev/null +++ b/volumes/api/kong.yml @@ -0,0 +1,411 @@ +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - key: $SUPABASE_PUBLISHABLE_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + - key: $SUPABASE_SECRET_KEY + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: '$DASHBOARD_USERNAME' + password: '$DASHBOARD_PASSWORD' + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + _comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*' + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + _comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*' + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + _comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*' + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + - name: auth-v1-open-jwks + _comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json' + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'Auth: /auth/v1/* -> http://auth:9999/*' + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure PostgREST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Content-Profile: graphql_public" + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "x-api-key:$LUA_RT_WS_EXPR" + replace: + querystring: + - "apikey:$LUA_RT_WS_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Storage API endpoint (with Authorization header transformation). + ## No key-auth — S3 protocol requests don't carry an apikey header. + ## + ## The request-transformer translates opaque API keys to asymmetric JWTs + ## and passes through existing Authorization headers (user JWTs, AWS SigV4). + ## When no Authorization or apikey header is present (S3 presigned URLs), + ## the Lua expression evaluates to nil which Kong renders as empty string. + ## The post-function strips this empty header so Storage's S3 signature + ## verification falls through to query-parameter parsing. + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://functions:9000/ + read_timeout: 150000 + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + config: + origins: + - "https://dost.supersamsev.ru" + - "https://supa.supersamsev.ru" + - "https://supasevdev.mkn8n.ru" + - "http://localhost:5173" + - "http://localhost:3000" + - "http://localhost:5174" + methods: + - GET + - DELETE + - POST + - PATCH + - OPTIONS + - PUT + headers: + - Content-Type + - Authorization + - apikey + - x-application-name + - x-client-info + credentials: true + max_age: 86400 + + ## OAuth 2.0 Authorization Server Metadata (RFC 8414) + - name: well-known-oauth + _comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server' + url: http://auth:9999/.well-known/oauth-authorization-server + routes: + - name: well-known-oauth + strip_path: true + paths: + - /.well-known/oauth-authorization-server + plugins: + - name: cors + + ## Analytics routes + ## Not used - Studio and Vector talk directly to analytics via Docker networking. + ## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth. + # - name: analytics-v1-api + # _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*' + # url: http://analytics:4000/api/endpoints + # routes: + # - name: analytics-v1-api + # strip_path: true + # paths: + # - /analytics/v1/api/endpoints/ + # - name: analytics-v1 + # _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + # url: http://analytics:4000/ + # routes: + # - name: dashboard-v1-all + # strip_path: true + # paths: + # - /analytics/v1 + # plugins: + # - name: cors + # - name: basic-auth + # config: + # hide_credentials: true + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Block access to /api/mcp + - name: mcp-blocker + _comment: 'Block direct access to /api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp-blocker-route + strip_path: true + paths: + - /api/mcp + plugins: + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + + ## MCP endpoint - local access + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + # Block access to /mcp by default + - name: request-termination + config: + status_code: 403 + message: "Access is forbidden." + # Enable local access (danger zone!) + # 1. Comment out the 'request-termination' section above + # 2. Uncomment the entire section below, including 'deny' + # 3. Add your local IPs to the 'allow' list + #- name: cors + #- name: ip-restriction + # config: + # allow: + # - 127.0.0.1 + # - ::1 + # deny: [] + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true