feat: requires_address status + address input for pickup-to-delivery switch

- 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
This commit is contained in:
root 2026-06-10 14:52:17 +00:00
parent c774c6a362
commit 7e43f9e990
9 changed files with 556 additions and 14 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ dist
.worktrees .worktrees
.superpowers .superpowers
.ruff_cache .ruff_cache
volumes/db/data/

View File

@ -105,14 +105,37 @@ const getPickupSlots = (referenceDate = new Date()) => {
}; };
const FREE_STORAGE_NOTICE = ( const FREE_STORAGE_NOTICE = (
<div className="mt-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6 text-[var(--color-text-muted)]"> <div
<p className="font-semibold text-[var(--color-text)]"> Условия хранения</p> className="mt-4 overflow-hidden rounded-[28px] border border-[var(--color-border)]"
<p className="mt-1"> style={{ background: "linear-gradient(135deg, var(--color-accent-soft) 0%, var(--color-surface) 60%)" }}
Бесплатное хранение <strong>2 рабочих дня</strong> с даты готовности. >
</p> <div className="flex items-start gap-3 p-5 sm:p-6">
<p> <span
Начиная с 3-го рабочего дня <strong>300 /день</strong> платного хранения. className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl text-lg"
style={{ background: "var(--color-accent)", color: "var(--color-accent-contrast)" }}
>
📦
</span>
<div className="min-w-0">
<p className="text-sm font-bold tracking-wide uppercase text-[var(--color-accent)]">
Условия хранения
</p> </p>
<ul className="mt-2 space-y-1.5 text-sm leading-6 text-[var(--color-text)]">
<li className="flex items-start gap-2">
<span className="mt-1.5 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-accent)]" />
<span>
Бесплатное хранение <strong>2 рабочих дня</strong> с даты готовности
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-1.5 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-warning)]" />
<span>
С 3-го рабочего дня <strong>300 /день</strong> платного хранения
</span>
</li>
</ul>
</div>
</div>
</div> </div>
); );

View File

@ -557,6 +557,7 @@ export const OrderDetailPanel = ({
const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery"); const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery");
const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || ""); const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || "");
const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [deliveryAddress, setDeliveryAddress] = React.useState(order?.deliveryAddress || order?.customerAddress || "");
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
const [currentMonth, setCurrentMonth] = React.useState(() => { const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate); const existingDeliveryDate = fromDateKey(order?.deliveryDate);
@ -637,6 +638,7 @@ export const OrderDetailPanel = ({
deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime, deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime,
deliveryType, deliveryType,
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
...(deliveryType === "delivery" && deliveryAddress.trim() ? { deliveryAddress: deliveryAddress.trim() } : {}),
}); });
if (result?.success) { if (result?.success) {
@ -709,7 +711,16 @@ export const OrderDetailPanel = ({
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Тип доставки Тип доставки
</p> </p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{order.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</p> <p className="mt-1 text-base font-medium !text-[var(--color-text)]">{order.deliveryType === "pickup" ? "Самовывоз" : order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address" ? "Доставка (требуется адрес)" : "Доставка"}</p>
{(order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && (
<div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3">
<span className="text-lg">📍</span>
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">Адрес доставки не указан</p>
<p className="text-xs text-[var(--color-text-muted)]">Клиент выбрал доставку, но адрес отсутствует. Уточните адрес у клиента и заполните поле ниже.</p>
</div>
</div>
)}
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
@ -1125,6 +1136,7 @@ export const OrderDetailPanel = ({
{ value: "on_route", label: "В пути", manual: true }, { value: "on_route", label: "В пути", manual: true },
{ value: "delivered", label: "Доставлено", manual: true }, { value: "delivered", label: "Доставлено", manual: true },
{ value: "pickup", label: "Самовывоз", manual: true }, { value: "pickup", label: "Самовывоз", manual: true },
{ value: "requires_address", label: "Требуется адрес", manual: true },
{ value: "problem", label: "Проблема", manual: true }, { value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true }, { value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => { ].map((statusOption) => {

View File

@ -109,6 +109,15 @@ export const ORDER_STATUS_META = {
criticalAfterHours: 48, criticalAfterHours: 48,
tone: "accent", tone: "accent",
}, },
"Требуется адрес": {
comment: "Клиент выбрал доставку, но адрес доставки отсутствует. Менеджеру нужно уточнить адрес.",
ownerRole: "logistician",
stageKey: "logistics",
stageLabel: getStageLabel("logistics"),
warningAfterHours: 4,
criticalAfterHours: 12,
tone: "warning",
},
"Передан логисту": { "Передан логисту": {
comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.", comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.",
ownerRole: "logistician", 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 = [
"Доставка согласована", "Доставка согласована",
"Назначен водитель", "Назначен водитель",
"Самовывоз", "Самовывоз",
"Требуется адрес",
"Проблема доставки", "Проблема доставки",
]; ];

View File

@ -193,6 +193,7 @@ export const ClientDeliveryPage = () => {
const [selectedSlot, setSelectedSlot] = React.useState(null); const [selectedSlot, setSelectedSlot] = React.useState(null);
const [choiceSaved, setChoiceSaved] = React.useState(false); const [choiceSaved, setChoiceSaved] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY); const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
const [deliveryAddress, setDeliveryAddress] = React.useState("");
const referenceDate = React.useMemo(() => new Date(), [token]); const referenceDate = React.useMemo(() => new Date(), [token]);
React.useEffect(() => { React.useEffect(() => {
@ -278,6 +279,9 @@ export const ClientDeliveryPage = () => {
pickupDate: effectiveSelectedSlot.date, pickupDate: effectiveSelectedSlot.date,
pickupTimeSlot: effectiveSelectedSlot.time, pickupTimeSlot: effectiveSelectedSlot.time,
} : {}), } : {}),
...(activeTab === TAB_DELIVERY && deliveryAddress.trim() ? {
deliveryAddress: deliveryAddress.trim(),
} : {}),
}); });
const loadedInvitation = await fetchDeliveryInvitation(token); const loadedInvitation = await fetchDeliveryInvitation(token);
setInvitation(loadedInvitation); setInvitation(loadedInvitation);
@ -403,6 +407,27 @@ export const ClientDeliveryPage = () => {
</button> </button>
</div> </div>
{activeTab === TAB_DELIVERY && !invitation?.deliveryAddress && !invitation?.customerAddress && (
<Panel className="space-y-3 border-[rgba(239,68,68,0.3)] bg-[var(--color-surface)] p-5 sm:p-6">
<div className="flex items-start gap-3">
<span className="text-xl">📍</span>
<div className="flex-1 space-y-2">
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">Укажите адрес доставки</p>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Адрес доставки отсутствует в заказе. Пожалуйста, введите полный адрес, куда нужно привезти заказ.
</p>
</div>
</div>
<input
type="text"
value={deliveryAddress}
onChange={(e) => 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"
/>
</Panel>
)}
{activeTab === TAB_DELIVERY && slots.length ? ( {activeTab === TAB_DELIVERY && slots.length ? (
<DeliverySlotsPicker <DeliverySlotsPicker
slots={slots} slots={slots}

View File

@ -223,13 +223,14 @@ export const fetchDeliveryInvitation = async (token) => {
} }
}; };
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot }) => { export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot, deliveryAddress }) => {
if (isLocalClientInvitationToken(token)) { if (isLocalClientInvitationToken(token)) {
const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token); const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token);
const invitation = cacheInvitation({ const invitation = cacheInvitation({
...baseInvitation, ...baseInvitation,
deliveryType: deliveryType || "delivery", deliveryType: deliveryType || "delivery",
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
...(deliveryType === "delivery" && deliveryAddress ? { deliveryAddress, customerAddress: deliveryAddress } : {}),
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
state: "confirmed", state: "confirmed",
@ -247,6 +248,7 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime,
p_delivery_type: deliveryType || "delivery", p_delivery_type: deliveryType || "delivery",
p_pickup_date: pickupDate || null, p_pickup_date: pickupDate || null,
p_pickup_time_slot: pickupTimeSlot || null, p_pickup_time_slot: pickupTimeSlot || null,
p_delivery_address: deliveryAddress || null,
}); });
}; };

View File

@ -137,7 +137,14 @@ Deno.serve(async (request) => {
} }
const deliveryType = body.deliveryType || "delivery"; 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) { if (invitation.order_group_id) {
const { data: currentGroup, error: groupError } = await supabase const { data: currentGroup, error: groupError } = await supabase
@ -194,7 +201,7 @@ Deno.serve(async (request) => {
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType, delivery_type: deliveryType,
notification_status: "confirmed", notification_status: effectiveDeliveryStatus === "requires_address" ? "address_required" : "confirmed",
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };

49
volumes/api/kong-entrypoint.sh Executable file
View File

@ -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

411
volumes/api/kong.yml Normal file
View File

@ -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