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:
parent
c774c6a362
commit
7e43f9e990
|
|
@ -7,3 +7,4 @@ dist
|
||||||
.worktrees
|
.worktrees
|
||||||
.superpowers
|
.superpowers
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
|
volumes/db/data/
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
"Доставка согласована",
|
"Доставка согласована",
|
||||||
"Назначен водитель",
|
"Назначен водитель",
|
||||||
"Самовывоз",
|
"Самовывоз",
|
||||||
|
"Требуется адрес",
|
||||||
"Проблема доставки",
|
"Проблема доставки",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue