Harden auth and delivery endpoints

This commit is contained in:
Codex 2026-04-29 13:27:04 +03:00
parent 5dcfa80940
commit e29a51e7ea
18 changed files with 1635 additions and 337 deletions

View File

@ -1,5 +1,11 @@
VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key VITE_SUPABASE_ANON_KEY=your-anon-key
APP_ALLOWED_ORIGINS=http://localhost:5173
INTEGRATION_ALLOWED_ORIGINS=http://localhost:5173
WEBHOOK_ALLOWED_ORIGINS=http://localhost:5173
INTEGRATION_API_KEY=replace-me
INTEGRATION_WEBHOOK_SECRET=replace-me
CHATBOT_WEBHOOK_SECRET=replace-me
# Self-hosted Supabase auth # Self-hosted Supabase auth
SUPABASE_PUBLIC_URL=https://supa.example.com SUPABASE_PUBLIC_URL=https://supa.example.com

View File

@ -0,0 +1,403 @@
# План устранения уязвимостей и защиты персональных данных
Дата аудита: 2026-04-29
## 1. Контекст и поверхность атаки
Приложение работает как веб-кабинет доставки с персональными данными клиентов: ФИО, телефон, адрес, состав заказа, история доставки, выбранные слоты, сообщения и статусы. Основные поверхности атаки:
- `/login` — вход сотрудников по email OTP.
- `/dashboard` — авторизованные кабинеты сотрудников.
- `/delivery/:token` — публичная клиентская ссылка без логина.
- Supabase Edge Functions — публичные и интеграционные endpoints.
- Supabase Postgres — таблицы `orders`, `users`, `delivery_invitations`, `chat_messages`, `order_history`, `delivery_slots`, `integration_events`.
- PWA/localStorage — локальное хранение состояния и клиентских приглашений.
## 2. Что уже сделано хорошо
- Включены RLS-политики на основных таблицах: `roles`, `users`, `orders`, `order_history`, `delivery_slots`, `chat_messages`, `delivery_invitations`, `integration_events`.
- Для `orders` доступ ограничен ролью и назначением: менеджер, логист, водитель, администратор.
- Invitation token в БД хранится как `token_hash`, а не как открытый token.
- Клиентская ссылка работает без авторизованного пользователя, но доступ идёт по token.
- Email OTP настроен с `shouldCreateUser: false`, то есть саморегистрация через фронтенд не должна создавать новых пользователей.
## 3. Критические риски
### P0. Нет rate limits на OTP, публичные ссылки и Edge Functions
Найдено:
- `src/context/AuthContext.jsx:117` вызывает `supabase.auth.signInWithOtp(...)` напрямую из браузера.
- `supabase/functions/get-delivery-invitation/index.ts:57` и `supabase/functions/confirm-delivery-choice/index.ts:14` принимают публичные запросы без лимитов.
- `supabase/functions/create-delivery-invitation/index.ts:17`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` также не имеют throttle/rate limit.
Риск:
- Заспамить OTP-письма на один email или набор email.
- Брутфорсить invitation token.
- Массово дергать клиентские ссылки и собирать признаки существующих заказов.
- Перегрузить Edge Functions и базу.
- Создать много `delivery_slots`, `order_history`, `integration_events` через повторные запросы.
План устранения:
1. Добавить таблицу `public.rate_limits`:
```sql
create table public.rate_limits (
id uuid primary key default gen_random_uuid(),
scope text not null,
key text not null,
window_start timestamptz not null,
count integer not null default 1,
blocked_until timestamptz,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now()),
unique (scope, key, window_start)
);
```
2. Добавить SQL/RPC `public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds)` с атомарным `insert ... on conflict ... update`.
3. Для Edge Functions сделать shared helper `supabase/functions/_shared/rate-limit.ts`.
4. Лимиты по умолчанию:
| Endpoint | Ключ | Лимит |
|---|---|---|
| OTP request | `ip + email_hash` | 3 запроса / 10 минут, блок 30 минут |
| OTP verify | `ip + email_hash` | 5 попыток / 10 минут, блок 30 минут |
| get invitation | `ip + token_hash_prefix` | 30 запросов / 10 минут |
| confirm delivery | `ip + token_hash` | 5 POST / 10 минут, блок 1 час |
| create invitation | `integration_key + order_id` | 10 запросов / 10 минут |
| chatbot webhook | `provider + external_message_id/ip` | 60 запросов / минуту |
| report delivery result | `integration_key + order_id` | 10 запросов / 10 минут |
5. Для `/login` лучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Function `request-otp`, которая валидирует email, применяет rate limit и только потом инициирует OTP.
6. Добавить тесты на rate-limit helper: allowed, exhausted, blocked, reset after window.
### P0. Service role используется в публичных functions без отдельной авторизации
Найдено:
- `supabase/functions/_shared/chatbot.ts:18` создаёт Supabase client через `SUPABASE_SERVICE_ROLE_KEY`.
- `create-delivery-invitation`, `confirm-delivery-choice`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` используют service client и обходят RLS.
- `create-delivery-invitation` принимает `orderId` из тела запроса и обновляет заказ через service role.
- `report-delivery-result` и `transfer-to-logistics` также меняют заказ по `orderId` без проверки пользователя/подписи.
Риск:
- Если endpoint доступен извне, злоумышленник может менять статусы заказов, создавать приглашения, переводить в платное хранение или доставлено, если узнает/подберёт `orderId`.
- RLS не помогает, потому что service role её обходит.
План устранения:
1. Разделить Edge Functions на публичные и интеграционные:
- публичные: `get-delivery-invitation`, `confirm-delivery-choice`;
- внутренние/integration-only: `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`, `send-chatbot-message`.
2. Для internal functions требовать `Authorization: Bearer <INTEGRATION_API_KEY>` или HMAC-подпись:
- `X-Signature: hex(hmac_sha256(raw_body, secret))`
- `X-Timestamp`
- `X-Request-Id`
3. Отклонять запросы старше 5 минут.
4. Хранить обработанные `X-Request-Id` в `integration_events` или отдельной таблице `idempotency_keys`.
5. Добавить `verifyIntegrationRequest(request, rawBody)` в `_shared/auth.ts`.
6. Для functions, вызываемых из авторизованного UI, проверять JWT пользователя через anon client или `supabase.auth.getUser(jwt)`, а service role использовать только после проверки роли.
### P0. CORS открыт на все origin
Найдено:
- `Access-Control-Allow-Origin: "*"` в `get-delivery-invitation`, `confirm-delivery-choice`, `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`.
Риск:
- Любой сайт может дергать публичные endpoints из браузера.
- Для публичной клиентской ссылки это допустимо только частично, но для integration-only endpoints опасно.
План устранения:
1. Добавить shared helper `cors.ts`.
2. Настроить allowlist через env:
- `PUBLIC_APP_URL`
- `APP_ALLOWED_ORIGINS`
- `INTEGRATION_ALLOWED_ORIGINS`
3. Для public invitation endpoints разрешить только домены приложения.
4. Для integration-only endpoints либо не отвечать CORS вообще, либо разрешить только внутренний origin/n8n.
5. На неизвестный origin возвращать `403`.
### P1. Token публичной ссылки не имеет срока жизни и политики ротации
Найдено:
- `delivery_invitations` не содержит `expires_at`.
- `get-delivery-invitation` возвращает данные, если token найден и состояние вычислено по заказу.
- `confirm-delivery-choice` проверяет active state, но не проверяет срок жизни token.
Риск:
- Старая ссылка остаётся рабочей слишком долго.
- Утечка ссылки даёт доступ к данным заказа и действию подтверждения.
План устранения:
1. Добавить поля:
```sql
alter table public.delivery_invitations
add column if not exists expires_at timestamptz,
add column if not exists revoked_at timestamptz,
add column if not exists last_accessed_at timestamptz,
add column if not exists access_count integer not null default 0;
```
2. При создании invitation ставить `expires_at = now() + interval '7 days'` или другой срок по ТЗ.
3. В `get-delivery-invitation` и `confirm-delivery-choice` возвращать `410 Gone`, если `expires_at < now()` или `revoked_at is not null`.
4. После успешного `confirm-delivery-choice` запрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора.
5. Добавить ротацию token при повторной отправке ссылки.
### P1. Публичная ссылка раскрывает лишние персональные данные
Найдено:
- `get-delivery-invitation` возвращает `customerName`, `customerPhone`, `orderNumber`, `orderItems`, `orderStatus`, `deliveryAgreementStatus`.
Риск:
- При утечке token третье лицо видит телефон, имя, состав заказа и статус.
- Эти данные могут быть использованы для социальной инженерии.
План устранения:
1. Минимизировать ответ публичной страницы:
- показывать имя частично: `Мария В.`;
- телефон маскировать: `+7 *** ***-12-31`;
- номер заказа показывать частично или только последние 4 символа;
- состав заказа показывать укрупнённо, если это не нужно клиенту для выбора слота.
2. Не возвращать `orderStatus` и `deliveryAgreementStatus` в сыром виде, отдавать только public state: `awaiting_choice`, `agreed`, `delivered`, `expired`.
3. В `delivery_invitations` хранить public-safe snapshot, чтобы не тянуть весь `orders.customer`.
4. Добавить тест, что public API не отдаёт полный телефон и внутренний статус.
### P1. Webhook не проверяет подпись провайдера и не ограничивает provider
Найдено:
- `chatbot-webhook` берёт `provider` из query string.
- Тело события нормализуется без проверки подписи.
- `external_message_id` есть, но повторная вставка при конфликте может давать ошибку и 500, а не идемпотентный `ok`.
Риск:
- Любой может отправить fake webhook и изменить заказ.
- Возможен replay одного webhook.
- Возможна подмена provider.
План устранения:
1. Для каждого provider добавить проверку подписи:
- Telegram: secret token/header или webhook secret path.
- VK/Max: HMAC/secret по контракту провайдера.
2. Убрать произвольный `provider` из query или валидировать строго по allowlist.
3. Для `external_message_id` реализовать `upsert`/идемпотентный возврат `ok: true` при повторе.
4. В payload логировать только безопасные поля, без полного сырого тела, если оно содержит ПДн.
### P1. Нет ограничений на размер JSON и длину полей
Найдено:
- Edge Functions делают `await request.json()` без проверки `Content-Length`.
- `chatbot-webhook` записывает `text` и `payload` как пришли.
- `confirm-delivery-choice` принимает `deliveryDate` и `deliveryTime` без строгой проверки формата и allowed values.
Риск:
- DoS через большой body.
- Загрязнение истории/логов огромным payload.
- Некорректные даты и значения слотов.
План устранения:
1. Добавить helper `readJsonBody(request, { maxBytes })`.
2. Лимиты:
- public endpoints: 8 KB;
- webhook: 64 KB;
- internal dispatch: 16 KB.
3. Валидировать:
- UUID для `orderId`;
- `deliveryDate` как `YYYY-MM-DD`;
- `deliveryTime` только из `available_slots`;
- `note`, `reason`, `text` по максимальной длине.
4. Возвращать `413 Payload Too Large` и `422 Unprocessable Entity`.
### P1. Политики RLS слишком широкие для некоторых ролей
Найдено:
- `users self or admin` фактически разрешает всем авторизованным пользователям читать всех пользователей.
- `orders update by workflow role` позволяет `production_lead` обновлять все заказы.
- `orders insert managers admin` всё ещё разрешает менеджеру создавать заказы, хотя по текущему ТЗ заказы приходят из 1С.
Риск:
- Лишний доступ к email/пользователям.
- Шире нужного доступ к заказам и ПДн.
- Внутренний пользователь может создать заказ вручную.
План устранения:
1. Разделить policy users:
- self может читать только себя;
- admin читает всех;
- при необходимости логист/менеджер получают только `id, name, role`, без email/last_login.
2. Запретить insert заказов из UI для manager, если 1С является единственным источником.
3. Ограничить `production_lead` только source/production полями или убрать роль из текущего демонстрационного контура.
4. Добавить database policy tests через Supabase local или SQL assertions.
### P2. Локальный fallback содержит демонстрационные персональные данные
Найдено:
- `src/services/deliveryInvitationApi.js` содержит `showcase`, `client-flow-*`, имя, телефон и состав заказа.
- `AuthContext` в fallback принимает OTP `000000`.
Риск:
- При production build без env приложение может работать в локальном/demo режиме.
- Демонстрационные данные могут быть восприняты как реальные.
План устранения:
1. Добавить build guard: production build падает без `VITE_SUPABASE_URL` и `VITE_SUPABASE_ANON_KEY`.
2. Обернуть fallback только в `import.meta.env.DEV`.
3. В production не поддерживать `showcase` и `client-flow-*`.
4. Добавить тест, что в production mode fallback недоступен.
### P2. Ошибки возвращают внутренние сообщения наружу
Найдено:
- Edge Functions возвращают `error instanceof Error ? error.message : "Unexpected error"` клиенту.
Риск:
- Утечка деталей Supabase, структуры таблиц, provider errors.
План устранения:
1. Ввести `publicError(status, code, message)` и `logInternalError(error, context)`.
2. Клиенту отдавать стабильные коды: `not_found`, `expired`, `rate_limited`, `invalid_payload`, `temporary_error`.
3. Внутреннюю ошибку писать в `error_logs`/observability без ПДн.
## 4. План работ по этапам
### Этап 1. Срочная защита от спама и неавторизованных изменений
Срок: 1-2 дня.
- Добавить `rate_limits` и shared rate-limit helper.
- Закрыть `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `send-chatbot-message`, `chatbot-webhook` через integration secret/HMAC.
- Ограничить CORS для Edge Functions.
- Добавить проверку размера JSON body.
- Добавить smoke-тесты на `429`, `401`, `403`.
Критерий готовности:
- Нельзя массово дергать OTP/public endpoints без `429`.
- Нельзя изменить заказ через integration endpoint без валидного секрета.
- Unknown origin не получает CORS-доступ.
### Этап 2. Защита публичной клиентской ссылки
Срок: 1-2 дня.
- Добавить `expires_at`, `revoked_at`, `access_count`, `last_accessed_at`.
- Проверять срок действия token.
- Маскировать phone/name/orderNumber.
- Убрать внутренние `orderStatus` и `deliveryAgreementStatus` из public response.
- Проверять `deliveryDate/deliveryTime` против доступных слотов.
- Сделать подтверждение выбора идемпотентным.
Критерий готовности:
- Просроченная ссылка возвращает `410`.
- Public API не отдаёт полный телефон и внутренние статусы.
- Повторное подтверждение не создаёт дубли `delivery_slots`.
### Этап 3. Ужесточение RLS и ролей
Срок: 1 день.
- Сузить `users` policy.
- Запретить создание заказов менеджером, если источник только 1С.
- Проверить права `production_lead` и убрать из текущего UI/политик, если роль не используется.
- Добавить SQL tests на доступ manager/logistician/driver/admin.
Критерий готовности:
- Сотрудник не может читать список email всех пользователей без нужной роли.
- Менеджер не создаёт заказ напрямую через Supabase client.
- Водитель видит только свои доставки.
### Этап 4. Production hardening
Срок: 1 день.
- Отключить demo/local fallback в production.
- Добавить security headers:
- `Content-Security-Policy`;
- `X-Content-Type-Options: nosniff`;
- `Referrer-Policy: no-referrer`;
- `Permissions-Policy`;
- `Strict-Transport-Security` на уровне хостинга.
- Убедиться, что service worker не кеширует ответы с ПДн.
- Добавить redaction для логов: телефоны, email, адреса, token.
Критерий готовности:
- Production build невозможен без Supabase env.
- В кэше нет API-ответов с ПДн.
- Логи не содержат полные телефоны/email/token.
## 5. Рекомендуемые значения лимитов
| Сценарий | Лимит | Блокировка |
|---|---:|---:|
| Запрос OTP на email | 3 / 10 минут | 30 минут |
| Проверка OTP | 5 / 10 минут | 30 минут |
| Открытие public invitation | 30 / 10 минут | 10 минут |
| Подтверждение выбора доставки | 5 / 10 минут | 1 час |
| Создание invitation | 10 / 10 минут на orderId | 30 минут |
| Webhook сообщения | 60 / минуту на provider | 5 минут |
| Ошибки 4xx/5xx с одного IP | 100 / 10 минут | 30 минут |
## 6. Проверки после внедрения
- Unit tests для rate-limit helper.
- Edge Function tests:
- no secret -> `401`;
- wrong origin -> `403`;
- too many requests -> `429`;
- too large body -> `413`;
- invalid payload -> `422`;
- expired invitation -> `410`.
- RLS tests:
- manager видит только свои заказы;
- logistician видит только назначенные наборы;
- driver видит только назначенные доставки;
- обычный пользователь не читает всех `users`.
- Manual security checklist:
- OTP нельзя заспамить;
- публичная ссылка не раскрывает полный телефон;
- повторный webhook не дублирует событие;
- интеграционный endpoint без подписи не меняет заказ.
## 7. Приоритетный backlog
1. `P0` Rate limits для OTP и public/integration Edge Functions.
2. `P0` Integration auth/HMAC для service-role endpoints.
3. `P0` CORS allowlist вместо `*`.
4. `P1` Expiration/revocation invitation token.
5. `P1` Минимизация ПДн в public invitation response.
6. `P1` Валидация payload и body size limits.
7. `P1` Ужесточение RLS для `users` и `orders insert`.
8. `P2` Production build guard против demo fallback.
9. `P2` Security headers и service-worker cache policy.
10. `P2` Redaction логов и мониторинг подозрительной активности.

View File

@ -120,10 +120,12 @@ export const AuthProvider = ({ children }) => {
setAuthError(""); setAuthError("");
try { try {
if (hasSupabaseConfig && supabase) { if (hasSupabaseConfig && supabase) {
const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email)); const { data, error } = await supabase.functions.invoke("request-otp", {
body: buildOtpRequestPayload(email),
});
if (error) { if (error || data?.ok === false) {
throw normalizeOtpError(error); throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
} }
} else { } else {
localStorage.setItem("construction-auth-role-hint", roleHint || "manager"); localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
@ -144,19 +146,30 @@ export const AuthProvider = ({ children }) => {
setIsLoading(true); setIsLoading(true);
try { try {
if (hasSupabaseConfig && supabase) { if (hasSupabaseConfig && supabase) {
const { data, error } = await supabase.auth.verifyOtp({ const { data, error } = await supabase.functions.invoke("verify-otp", {
email, body: { email, otp },
token: otp,
type: "email",
}); });
if (error) { if (error || data?.ok === false) {
throw normalizeOtpError(error); throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
} }
setUser(mapSessionUserToAuthUser(data.session?.user)); if (data?.session?.access_token && data?.session?.refresh_token) {
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
access_token: data.session.access_token,
refresh_token: data.session.refresh_token,
});
if (sessionError) {
throw normalizeOtpError(sessionError);
}
setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user));
} else {
setUser(mapSessionUserToAuthUser(data?.user || null));
}
setAuthError(""); setAuthError("");
return { success: Boolean(data.session) }; return { success: Boolean(data?.session || data?.user) };
} }
if (otp !== "000000") { if (otp !== "000000") {

View File

@ -5,6 +5,9 @@
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
`chat_messages` и при необходимости обновляет статус заказа и `order_history`. `chat_messages` и при необходимости обновляет статус заказа и `order_history`.
Требует подпись `X-Signature` или `Authorization: Bearer <INTEGRATION_API_KEY>`, а также
ограничивает частоту входящих событий.
Пример вызова: Пример вызова:
```bash ```bash
@ -32,10 +35,21 @@ curl -X POST \
- `SUPABASE_URL` - `SUPABASE_URL`
- `SUPABASE_SERVICE_ROLE_KEY` - `SUPABASE_SERVICE_ROLE_KEY`
- `INTEGRATION_API_KEY`
- `INTEGRATION_WEBHOOK_SECRET`
- `TELEGRAM_BOT_TOKEN` - `TELEGRAM_BOT_TOKEN`
- `VK_BOT_TOKEN` - `VK_BOT_TOKEN`
- `MESSENGER_MAX_TOKEN` - `MESSENGER_MAX_TOKEN`
## `request-otp`
Отправляет код входа по email после проверки лимитов по IP и адресу. Используется страницей
логина вместо прямого вызова `supabase.auth.signInWithOtp` из браузера.
## `verify-otp`
Проверяет код входа, тоже с rate limit, и возвращает session для установки в клиенте.
## `create-delivery-invitation` ## `create-delivery-invitation`
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_AVAILABLE_SLOTS, DEFAULT_AVAILABLE_SLOTS,
buildPublicInvitationView,
getClientInvitationStateFromOrderStatus, getClientInvitationStateFromOrderStatus,
getOrderUpdateForDeliveryInvitationAction, getOrderUpdateForDeliveryInvitationAction,
isInvitationExpired,
normalizeAvailableSlots, normalizeAvailableSlots,
} from "./delivery-invitations"; } from "./delivery-invitations";
@ -32,4 +34,51 @@ describe("delivery invitation helpers", () => {
expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]); expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]);
expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS); expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS);
}); });
it("marks expired and revoked invitations as inactive", () => {
expect(
isInvitationExpired({
order_id: "order-1",
token_hash: "token",
state: "awaiting_choice",
expires_at: "2026-04-01T00:00:00.000Z",
}, new Date("2026-04-02T00:00:00.000Z")),
).toBe(true);
expect(
isInvitationExpired({
order_id: "order-1",
token_hash: "token",
state: "awaiting_choice",
revoked_at: "2026-04-01T00:00:00.000Z",
}),
).toBe(true);
});
it("masks customer contact details in the public invitation view", () => {
const invitation = buildPublicInvitationView(
{
order_id: "order-1",
token_hash: "token",
state: "awaiting_choice",
customer_name: "Мария Волкова",
customer_phone: "+7 978 123-45-67",
order_number: "CD-240031",
available_slots: ["2026-04-15, До обеда"],
},
{
order_number: "CD-240031",
customer: {
name: "Мария Волкова",
phone: "+7 978 123-45-67",
items: [{ name: "Кухонный гарнитур", quantity: "1 комплект" }],
},
},
);
expect(invitation.customerName).toBe("Мария В.");
expect(invitation.customerPhone).toContain("***");
expect(invitation.orderStatus).toBeNull();
expect(invitation.deliveryAgreementStatus).toBeNull();
});
}); });

View File

@ -1,3 +1,8 @@
import {
maskCustomerName,
maskPhoneNumber,
} from "./security.ts";
export type DeliveryInvitationAction = export type DeliveryInvitationAction =
| "create_delivery_invitation" | "create_delivery_invitation"
| "send_delivery_offer" | "send_delivery_offer"
@ -108,3 +113,68 @@ export const resolvePublicAppUrl = (
export const buildInvitationUrl = (baseUrl: string, token: string) => export const buildInvitationUrl = (baseUrl: string, token: string) =>
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`; `${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
export type DeliveryInvitationRecord = {
id?: string;
order_id: string;
token_hash: string;
state: string;
order_number?: string | null;
customer_name?: string | null;
customer_phone?: string | null;
customer_messenger?: string | null;
available_slots?: string[] | null;
expires_at?: string | null;
revoked_at?: string | null;
delivery_date?: string | null;
delivery_time?: string | null;
sent_at?: string | null;
opened_at?: string | null;
confirmed_at?: string | null;
logistics_transferred_at?: string | null;
paid_storage_at?: string | null;
delivered_at?: string | null;
updated_at?: string | null;
};
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
if (invitation.revoked_at) {
return true;
}
if (!invitation.expires_at) {
return false;
}
return new Date(invitation.expires_at).getTime() <= now.getTime();
};
export const buildPublicInvitationView = (
invitation: DeliveryInvitationRecord,
order: {
order_number?: string | null;
customer?: { name?: string | null; phone?: string | null; items?: unknown };
status?: string | null;
delivery_agreement_status?: string | null;
},
) => {
const availableSlots = invitation.available_slots || [];
const orderItems = Array.isArray(order.customer?.items)
? order.customer?.items
: [];
return {
orderId: invitation.order_id,
state: invitation.state,
token: "",
orderNumber: order.order_number || invitation.order_number || null,
customerName: maskCustomerName(order.customer?.name || invitation.customer_name || null),
customerPhone: maskPhoneNumber(order.customer?.phone || invitation.customer_phone || null),
orderItems,
availableSlots,
deliveryDate: invitation.delivery_date || null,
deliveryTime: invitation.delivery_time || null,
orderStatus: null,
deliveryAgreementStatus: null,
};
};

View File

@ -11,7 +11,7 @@ type IntegrationEventPayload = {
export const insertIntegrationEvent = async ( export const insertIntegrationEvent = async (
supabase: { supabase: {
from: (table: string) => { from: (table: string) => {
insert: (payload: IntegrationEventPayload) => Promise<{ error: Error | null }>; insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>;
}; };
}, },
payload: IntegrationEventPayload, payload: IntegrationEventPayload,

View File

@ -0,0 +1,370 @@
type CorsMode = "public" | "integration" | "webhook";
type JsonBodyOptions = {
maxBytes: number;
errorMessage?: string;
};
type RateLimitOptions = {
scope: string;
key: string;
maxCount: number;
windowSeconds: number;
blockSeconds?: number;
};
type RateLimitResult = {
allowed: boolean;
currentCount: number;
limitCount: number;
blockedUntil: string | null;
windowStart: string;
};
type IntegrationAuthOptions = {
rawBody: string;
secretEnvNames?: string[];
tokenEnvNames?: string[];
signatureHeader?: string;
timestampHeader?: string;
requestIdHeader?: string;
allowedClockSkewSeconds?: number;
};
const DEFAULT_LOCAL_ORIGINS = [
"http://localhost:5173",
"http://localhost:4173",
"http://127.0.0.1:5173",
"http://127.0.0.1:4173",
];
const normalizeOrigin = (value: string) => value.replace(/\/$/, "");
const splitList = (value: string | null | undefined) =>
(value || "")
.split(",")
.map((item) => normalizeOrigin(item.trim()))
.filter(Boolean);
const getRequestOrigin = (request: Request) => {
const origin = request.headers.get("origin");
if (origin) {
return normalizeOrigin(origin);
}
const referer = request.headers.get("referer");
if (!referer) {
return "";
}
try {
return normalizeOrigin(new URL(referer).origin);
} catch {
return "";
}
};
const readEnv = (name: string) => {
try {
if (typeof Deno === "undefined") {
return "";
}
return Deno.env.get(name) || "";
} catch {
return "";
}
};
const isLocalhostOrigin = (origin: string) =>
/:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
const resolveAllowedOrigins = (mode: CorsMode) => {
const publicOrigins = [
...splitList(readEnv("APP_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
...splitList(readEnv("APP_PUBLIC_URL")),
];
const integrationOrigins = [
...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
];
const webhookOrigins = [
...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")),
...splitList(readEnv("PUBLIC_APP_URL")),
];
const configured =
mode === "public"
? publicOrigins
: mode === "integration"
? integrationOrigins
: webhookOrigins;
if (configured.length > 0) {
return Array.from(new Set(configured));
}
const currentMode = readEnv("NODE_ENV") || "development";
if (currentMode === "production") {
return [];
}
return [...DEFAULT_LOCAL_ORIGINS];
};
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
export const jsonResponse = (
body: unknown,
status = 200,
headers: HeadersInit = {},
) =>
new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
export const getCorsHeaders = (request: Request, mode: CorsMode) => {
const origin = getRequestOrigin(request);
const allowedOrigins = resolveAllowedOrigins(mode);
if (!origin) {
if (allowedOrigins.length === 0) {
return null;
}
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
} satisfies Record<string, string>;
}
const isAllowed =
allowedOrigins.length === 0
? false
: allowedOrigins.some((allowedOrigin) => {
if (allowedOrigin === "*") {
return true;
}
return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`);
}) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin));
if (!isAllowed) {
return null;
}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
} satisfies Record<string, string>;
};
export const preflightResponse = (request: Request, mode: CorsMode) => {
const corsHeaders = getCorsHeaders(request, mode);
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
return new Response("ok", {
status: 204,
headers: corsHeaders,
});
};
export const assertAllowedOrigin = (request: Request, mode: CorsMode) => {
const corsHeaders = getCorsHeaders(request, mode);
if (!corsHeaders) {
throw new HttpError(403, "Origin not allowed");
}
return corsHeaders;
};
export const readJsonBody = async <T extends Record<string, unknown>>(
request: Request,
options: JsonBodyOptions,
): Promise<{ body: T; rawBody: string }> => {
const rawBody = await request.clone().text();
const byteLength = new TextEncoder().encode(rawBody).length;
if (byteLength > options.maxBytes) {
throw new HttpError(413, options.errorMessage || "Payload too large");
}
if (!rawBody.trim()) {
throw new HttpError(400, "Request body is required");
}
try {
return {
body: JSON.parse(rawBody) as T,
rawBody,
};
} catch {
throw new HttpError(400, "Invalid JSON payload");
}
};
export const getClientIp = (request: Request) => {
const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
return forwardedFor.split(",")[0]?.trim() || "unknown";
};
export const sha256Hex = async (value: string) => {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
export const hashText = sha256Hex;
const hmacHex = async (secret: string, value: string) => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
export const verifyInternalRequest = async (
request: Request,
rawBody: string,
options: IntegrationAuthOptions = { rawBody },
) => {
const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"];
const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"];
const bearerToken = request.headers.get("authorization") || "";
const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : "";
const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || "";
const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || "";
const signature = request.headers.get(options.signatureHeader || "x-signature") || "";
const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean);
const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean);
if (token && sharedTokens.some((candidate) => candidate === token)) {
return { requestId, authenticatedBy: "bearer" as const };
}
if (sharedSecrets.length === 0) {
throw new HttpError(401, "Integration auth is not configured");
}
if (!timestamp || !signature) {
throw new HttpError(401, "Missing integration signature");
}
const timestampNumber = Number(timestamp);
if (!Number.isFinite(timestampNumber)) {
throw new HttpError(401, "Invalid integration timestamp");
}
const now = Date.now();
const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000;
if (Math.abs(now - timestampNumber) > allowedSkew) {
throw new HttpError(401, "Stale integration request");
}
const payload = `${timestamp}.${rawBody}`;
const expectedSignatures = await Promise.all(
sharedSecrets.map(async (secret) => hmacHex(secret, payload)),
);
if (!expectedSignatures.some((candidate) => candidate === signature)) {
throw new HttpError(401, "Invalid integration signature");
}
return { requestId, authenticatedBy: "hmac" as const };
};
export const maskPhoneNumber = (phone: string | null | undefined) => {
const value = String(phone || "").trim();
if (!value) {
return null;
}
const digits = value.replace(/\D/g, "");
if (digits.length < 4) {
return value;
}
const tail = digits.slice(-4);
const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+";
return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`;
};
export const maskCustomerName = (name: string | null | undefined) => {
const value = String(name || "").trim();
if (!value) {
return null;
}
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length === 1) {
return `${parts[0].slice(0, 1)}.`;
}
return `${parts[0]} ${parts[1].slice(0, 1)}.`;
};
export const maskOrderNumber = (orderNumber: string | null | undefined) => {
const value = String(orderNumber || "").trim();
if (!value) {
return null;
}
if (value.length <= 4) {
return value;
}
return `${value.slice(-4)}`;
};
export const requireRateLimit = async (
supabase: {
rpc: (
name: string,
params: Record<string, unknown>,
) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>;
},
options: RateLimitOptions,
) => {
const { data, error } = await supabase.rpc("check_rate_limit", {
p_scope: options.scope,
p_key: options.key,
p_max_count: options.maxCount,
p_window_seconds: options.windowSeconds,
p_block_seconds: options.blockSeconds || 0,
});
if (error) {
throw error;
}
if (!data?.allowed) {
throw new HttpError(429, "Too many requests");
}
return data;
};

View File

@ -6,31 +6,69 @@ import {
orderUpdateByAction, orderUpdateByAction,
type ProviderName, type ProviderName,
} from "../_shared/chatbot.ts"; } from "../_shared/chatbot.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
readJsonBody,
requireRateLimit,
verifyInternalRequest,
} from "../_shared/security.ts";
const corsHeaders = { const MAX_BODY_BYTES = 64 * 1024;
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", const allowedProviders = new Set<ProviderName>(["telegram", "vk", "messenger_max"]);
};
Deno.serve(async (request) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders }); const corsHeaders = getCorsHeaders(request, "webhook");
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403);
} }
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "webhook") || {};
try { try {
const url = new URL(request.url); const url = new URL(request.url);
const provider = url.searchParams.get("provider") as ProviderName | null; const provider = url.searchParams.get("provider") as ProviderName | null;
if (!provider) { if (!provider || !allowedProviders.has(provider)) {
return json({ error: "provider is required" }, 400); return json({ error: "provider is required" }, 400);
} }
const body = (await request.json()) as Record<string, unknown>; const { body, rawBody } = await readJsonBody<Record<string, unknown>>(request, {
maxBytes: MAX_BODY_BYTES,
});
await verifyInternalRequest(request, rawBody, {
rawBody,
secretEnvNames: [
`CHATBOT_WEBHOOK_SECRET_${provider.toUpperCase()}`,
"CHATBOT_WEBHOOK_SECRET",
],
tokenEnvNames: [
`CHATBOT_WEBHOOK_TOKEN_${provider.toUpperCase()}`,
"CHATBOT_WEBHOOK_TOKEN",
],
});
const event = normalizeIncomingEvent(provider, body); const event = normalizeIncomingEvent(provider, body);
if (!event.orderId) { if (!event.orderId) {
return json({ error: "order_id is required" }, 400); return json({ error: "order_id is required" }, 400);
} }
const supabase = createServiceClient(); const supabase = createServiceClient();
const rateKey = event.externalMessageId || (await hashText(`${provider}:${getClientIp(request)}:${event.text}`));
await requireRateLimit(supabase, {
scope: `webhook-${provider}`,
key: rateKey,
maxCount: 60,
windowSeconds: 60,
blockSeconds: 300,
});
const orderUpdate = orderUpdateByAction(event.action); const orderUpdate = orderUpdateByAction(event.action);
const messagePayload = { const messagePayload = {
@ -44,7 +82,7 @@ Deno.serve(async (request) => {
}; };
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload); const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
if (messageError) { if (messageError && messageError.code !== "23505") {
throw messageError; throw messageError;
} }
@ -89,24 +127,15 @@ Deno.serve(async (request) => {
} }
return new Response(JSON.stringify({ ok: true }), { return new Response(JSON.stringify({ ok: true }), {
headers: { headers: corsHeaders,
...corsHeaders,
"Content-Type": "application/json",
},
}); });
} catch (error) { } catch (error) {
return new Response( return json(
JSON.stringify({ {
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
); );
} }
}); });

View File

@ -2,49 +2,89 @@ import {
getOrderUpdateForDeliveryInvitationAction, getOrderUpdateForDeliveryInvitationAction,
hashInvitationToken, hashInvitationToken,
isActiveInvitationState, isActiveInvitationState,
isInvitationExpired,
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
const corsHeaders = { const MAX_BODY_BYTES = 8 * 1024;
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", type ConfirmBody = {
token?: string;
deliveryDate?: string;
deliveryTime?: string;
};
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const resolveRequestedSlot = (
invitation: {
delivery_date?: string | null;
delivery_time?: string | null;
available_slots?: string[] | null;
},
body: ConfirmBody,
) => {
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
if (!deliveryDate || !deliveryTime || !isValidDate(deliveryDate)) {
return null;
}
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
const availableSlots = invitation.available_slots || [];
if (availableSlots.length > 0 && !availableSlots.includes(slotLabel)) {
return null;
}
return { deliveryDate, deliveryTime };
}; };
Deno.serve(async (request) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders }); return preflightResponse(request, "public");
} }
if (request.method !== "POST") { if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), { return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
status: 405, }
headers: {
...corsHeaders, const corsHeaders = getCorsHeaders(request, "public");
"Content-Type": "application/json", if (!corsHeaders) {
}, return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
});
} }
try { try {
const body = (await request.json()) as { const { body } = await readJsonBody<ConfirmBody>(request, {
token?: string; maxBytes: MAX_BODY_BYTES,
deliveryDate?: string; });
deliveryTime?: string;
};
if (!body.token) { if (!body.token) {
return new Response(JSON.stringify({ error: "token is required" }), { return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
status: 400,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
} }
const tokenHash = await hashInvitationToken(body.token); const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient(); const supabase = createServiceClient();
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
scope: "invitation-confirm",
key: `${ipHash}:${tokenHash.slice(0, 16)}`,
maxCount: 5,
windowSeconds: 600,
blockSeconds: 3600,
});
const { data: invitation, error: invitationError } = await supabase const { data: invitation, error: invitationError } = await supabase
.from("delivery_invitations") .from("delivery_invitations")
@ -54,21 +94,16 @@ Deno.serve(async (request) => {
if (invitationError) { if (invitationError) {
if (invitationError.code === "PGRST116") { if (invitationError.code === "PGRST116") {
return new Response( return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
JSON.stringify({ ok: false, error: "Invitation not found" }),
{
status: 404,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
},
);
} }
throw invitationError; throw invitationError;
} }
if (isInvitationExpired(invitation)) {
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
}
const { data: currentOrder, error: orderError } = await supabase const { data: currentOrder, error: orderError } = await supabase
.from("orders") .from("orders")
.select("id, status, delivery_agreement_status") .select("id, status, delivery_agreement_status")
@ -80,32 +115,39 @@ Deno.serve(async (request) => {
} }
if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) { if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) {
return new Response( return jsonResponse(
JSON.stringify({ {
ok: false, ok: false,
error: "Invitation is no longer active", error: "Invitation is no longer active",
}), },
409,
corsHeaders,
);
}
const requestedSlot = resolveRequestedSlot(invitation, body);
if (!requestedSlot) {
return jsonResponse(
{ {
status: 409, ok: false,
headers: { error: "Selected slot is not available",
...corsHeaders,
"Content-Type": "application/json",
},
}, },
422,
corsHeaders,
); );
} }
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice"); const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice");
const deliveryDate = body.deliveryDate || new Date().toISOString().slice(0, 10);
const deliveryTime = body.deliveryTime || "Первая половина дня";
const { error: invitationUpdateError } = await supabase const { error: invitationUpdateError } = await supabase
.from("delivery_invitations") .from("delivery_invitations")
.update({ .update({
state: "agreed", state: "agreed",
delivery_date: deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: deliveryTime, delivery_time: requestedSlot.deliveryTime,
confirmed_at: new Date().toISOString(), confirmed_at: new Date().toISOString(),
access_count: (invitation.access_count || 0) + 1,
last_accessed_at: new Date().toISOString(),
}) })
.eq("id", invitation.id); .eq("id", invitation.id);
@ -127,8 +169,8 @@ Deno.serve(async (request) => {
const { error: slotError } = await supabase.from("delivery_slots").insert({ const { error: slotError } = await supabase.from("delivery_slots").insert({
order_id: invitation.order_id, order_id: invitation.order_id,
delivery_date: deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: deliveryTime, delivery_time: requestedSlot.deliveryTime,
logistician_id: null, logistician_id: null,
status: "confirmed_by_client", status: "confirmed_by_client",
}); });
@ -145,8 +187,8 @@ Deno.serve(async (request) => {
metadata: { metadata: {
old_delivery_agreement_status: currentOrder.delivery_agreement_status, old_delivery_agreement_status: currentOrder.delivery_agreement_status,
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
delivery_date: deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: deliveryTime, delivery_time: requestedSlot.deliveryTime,
}, },
}); });
@ -160,38 +202,34 @@ Deno.serve(async (request) => {
direction: "inbound", direction: "inbound",
status: "success", status: "success",
payload: { payload: {
delivery_date: deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: deliveryTime, delivery_time: requestedSlot.deliveryTime,
}, },
}); });
return new Response( return jsonResponse(
JSON.stringify({ {
ok: true, ok: true,
orderId: invitation.order_id, orderId: invitation.order_id,
status: orderUpdate?.status, status: orderUpdate?.status,
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
200,
corsHeaders,
); );
} catch (error) { } catch (error) {
return new Response( if (error instanceof Error && "status" in error) {
JSON.stringify({ const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
corsHeaders,
); );
} }
}); });

View File

@ -8,49 +8,68 @@ import {
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts"; import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import {
getClientIp,
getCorsHeaders,
jsonResponse,
readJsonBody,
requireRateLimit,
verifyInternalRequest,
} from "../_shared/security.ts";
const corsHeaders = { const MAX_BODY_BYTES = 16 * 1024;
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (request) => { type CreateInvitationBody = {
if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
}
try {
const body = (await request.json()) as {
orderId?: string; orderId?: string;
orderNumber?: string; orderNumber?: string;
customerName?: string; customerName?: string;
customerPhone?: string; customerPhone?: string;
customerMessenger?: string; customerMessenger?: string;
availableSlots?: string[]; availableSlots?: string[];
}; source?: string;
};
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const corsHeaders = getCorsHeaders(request, "integration");
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "integration") || {};
try {
const { body, rawBody } = await readJsonBody<CreateInvitationBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
const auth = await verifyInternalRequest(request, rawBody, {
rawBody,
allowedClockSkewSeconds: 300,
});
if (!body.orderId) { if (!body.orderId) {
return json({ error: "orderId is required" }, 400); return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
} }
const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "delivery-invitation-create",
key: body.orderId,
maxCount: 10,
windowSeconds: 600,
blockSeconds: 1800,
});
const token = generateInvitationToken(); const token = generateInvitationToken();
const tokenHash = await hashInvitationToken(token); const tokenHash = await hashInvitationToken(token);
const supabase = createServiceClient();
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation"); const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
const { data: currentOrder, error: orderError } = await supabase const { data: currentOrder, error: orderError } = await supabase
.from("orders") .from("orders")
.select("id, status, delivery_agreement_status, delivery_flow_started_at") .select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
.eq("id", body.orderId) .eq("id", body.orderId)
.single(); .single();
@ -60,7 +79,9 @@ Deno.serve(async (request) => {
const { data: existingInvitation, error: existingInvitationError } = await supabase const { data: existingInvitation, error: existingInvitationError } = await supabase
.from("delivery_invitations") .from("delivery_invitations")
.select("id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at") .select(
"id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at",
)
.eq("order_id", body.orderId) .eq("order_id", body.orderId)
.maybeSingle(); .maybeSingle();
@ -69,8 +90,8 @@ Deno.serve(async (request) => {
} }
if (currentOrder.delivery_flow_started_at || existingInvitation) { if (currentOrder.delivery_flow_started_at || existingInvitation) {
return new Response( return jsonResponse(
JSON.stringify({ {
ok: true, ok: true,
alreadyStarted: true, alreadyStarted: true,
invitation: existingInvitation invitation: existingInvitation
@ -87,13 +108,9 @@ Deno.serve(async (request) => {
orderId: body.orderId, orderId: body.orderId,
state: "awaiting_choice", state: "awaiting_choice",
}, },
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
200,
corsHeaders,
); );
} }
@ -106,6 +123,7 @@ Deno.serve(async (request) => {
customer_phone: body.customerPhone || null, customer_phone: body.customerPhone || null,
customer_messenger: body.customerMessenger || null, customer_messenger: body.customerMessenger || null,
available_slots: normalizeAvailableSlots(body.availableSlots), available_slots: normalizeAvailableSlots(body.availableSlots),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
sent_at: new Date().toISOString(), sent_at: new Date().toISOString(),
}; };
@ -139,6 +157,7 @@ Deno.serve(async (request) => {
old_delivery_agreement_status: currentOrder.delivery_agreement_status, old_delivery_agreement_status: currentOrder.delivery_agreement_status,
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
channel: channelFromProvider("telegram"), channel: channelFromProvider("telegram"),
auth: auth.authenticatedBy,
}, },
}); });
@ -159,8 +178,8 @@ Deno.serve(async (request) => {
const publicBaseUrl = resolvePublicAppUrl(request); const publicBaseUrl = resolvePublicAppUrl(request);
return new Response( return jsonResponse(
JSON.stringify({ {
ok: true, ok: true,
invitation: { invitation: {
orderId: body.orderId, orderId: body.orderId,
@ -169,27 +188,23 @@ Deno.serve(async (request) => {
state: "awaiting_choice", state: "awaiting_choice",
availableSlots: invitationPayload.available_slots, availableSlots: invitationPayload.available_slots,
}, },
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
200,
corsHeaders,
); );
} catch (error) { } catch (error) {
return new Response( if (error instanceof Error && "status" in error) {
JSON.stringify({ const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
corsHeaders,
); );
} }
}); });

View File

@ -1,91 +1,68 @@
import { import {
buildPublicInvitationView,
getClientInvitationStateFromOrderStatus, getClientInvitationStateFromOrderStatus,
hashInvitationToken, hashInvitationToken,
isActiveInvitationState, isActiveInvitationState,
isInvitationExpired,
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
const normalizeOrderItems = ( const MAX_BODY_BYTES = 8 * 1024;
items: unknown,
): Array<{ name: string; quantity?: string }> => {
if (!Array.isArray(items)) {
return [];
}
return items type InvitationBody = {
.map((item) => { token?: string;
if (typeof item === "string") {
const [namePart, quantityPart] = item.split("|").map((part) => part.trim());
const name = namePart || item.trim();
if (!name) {
return null;
}
return quantityPart ? { name, quantity: quantityPart } : { name };
}
if (item && typeof item === "object") {
const typedItem = item as { name?: unknown; quantity?: unknown; label?: unknown };
const name = typeof typedItem.name === "string"
? typedItem.name.trim()
: typeof typedItem.label === "string"
? typedItem.label.trim()
: "";
const quantity = typeof typedItem.quantity === "string"
? typedItem.quantity.trim()
: typeof typedItem.quantity === "number"
? String(typedItem.quantity)
: "";
if (!name && !quantity) {
return null;
}
return quantity ? { name: name || "Позиция", quantity } : { name: name || "Позиция" };
}
return null;
})
.filter((item): item is { name: string; quantity?: string } => Boolean(item));
}; };
const corsHeaders = { const getTokenFromRequest = async (request: Request) => {
"Access-Control-Allow-Origin": "*", if (request.method === "GET") {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", return new URL(request.url).searchParams.get("token") || "";
}
const { body } = await readJsonBody<InvitationBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
return String(body.token || "").trim();
}; };
Deno.serve(async (request) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders }); return preflightResponse(request, "public");
} }
if (!["GET", "POST"].includes(request.method)) { if (!["GET", "POST"].includes(request.method)) {
return new Response(JSON.stringify({ error: "Method not allowed" }), { return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
status: 405, }
headers: {
...corsHeaders, const corsHeaders = getCorsHeaders(request, "public");
"Content-Type": "application/json", if (!corsHeaders) {
}, return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
});
} }
try { try {
const url = new URL(request.url); const token = await getTokenFromRequest(request);
const token = request.method === "POST"
? ((await request.json()) as { token?: string })?.token || ""
: url.searchParams.get("token") || "";
if (!token) { if (!token) {
return new Response(JSON.stringify({ error: "token is required" }), { return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
status: 400,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
} }
const tokenHash = await hashInvitationToken(token); const tokenHash = await hashInvitationToken(token);
const supabase = createServiceClient(); const supabase = createServiceClient();
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
scope: "invitation-get",
key: `${ipHash}:${tokenHash.slice(0, 16)}`,
maxCount: 30,
windowSeconds: 600,
});
const { data: invitation, error: invitationError } = await supabase const { data: invitation, error: invitationError } = await supabase
.from("delivery_invitations") .from("delivery_invitations")
@ -95,21 +72,16 @@ Deno.serve(async (request) => {
if (invitationError) { if (invitationError) {
if (invitationError.code === "PGRST116") { if (invitationError.code === "PGRST116") {
return new Response( return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
JSON.stringify({ ok: false, error: "Invitation not found" }),
{
status: 404,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
},
);
} }
throw invitationError; throw invitationError;
} }
if (isInvitationExpired(invitation)) {
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
}
const { data: order, error: orderError } = await supabase const { data: order, error: orderError } = await supabase
.from("orders") .from("orders")
.select("id, order_number, status, delivery_agreement_status, customer") .select("id, order_number, status, delivery_agreement_status, customer")
@ -127,48 +99,47 @@ Deno.serve(async (request) => {
.from("delivery_invitations") .from("delivery_invitations")
.update({ .update({
opened_at: new Date().toISOString(), opened_at: new Date().toISOString(),
access_count: (invitation.access_count || 0) + 1,
last_accessed_at: new Date().toISOString(),
})
.eq("id", invitation.id);
} else {
await supabase
.from("delivery_invitations")
.update({
access_count: (invitation.access_count || 0) + 1,
last_accessed_at: new Date().toISOString(),
}) })
.eq("id", invitation.id); .eq("id", invitation.id);
} }
return new Response( const invitationView = buildPublicInvitationView(invitation, order);
JSON.stringify({
return jsonResponse(
{
ok: true, ok: true,
invitation: { invitation: {
orderId: invitation.order_id, ...invitationView,
token,
state: publicState, state: publicState,
token: token,
orderNumber: order.order_number,
customerName: order.customer?.name || invitation.customer_name || null,
customerPhone: order.customer?.phone || invitation.customer_phone || null,
orderItems: normalizeOrderItems(order.customer?.items),
availableSlots: invitation.available_slots || [],
deliveryDate: invitation.delivery_date || null,
deliveryTime: invitation.delivery_time || null,
orderStatus: order.status,
deliveryAgreementStatus: order.delivery_agreement_status,
},
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
}, },
}, },
200,
corsHeaders,
); );
} catch (error) { } catch (error) {
return new Response( if (error instanceof Error && "status" in error) {
JSON.stringify({ const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
corsHeaders,
); );
} }
}); });

View File

@ -3,46 +3,54 @@ import {
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import {
getCorsHeaders,
jsonResponse,
readJsonBody,
requireRateLimit,
verifyInternalRequest,
} from "../_shared/security.ts";
const corsHeaders = { const MAX_BODY_BYTES = 16 * 1024;
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (request) => { type ReportBody = {
if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
}
try {
const body = (await request.json()) as {
orderId?: string; orderId?: string;
result?: "delivered" | "problem"; result?: "delivered" | "problem";
note?: string; note?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const corsHeaders = getCorsHeaders(request, "integration");
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "integration") || {};
try {
const { body, rawBody } = await readJsonBody<ReportBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
await verifyInternalRequest(request, rawBody, { rawBody });
if (!body.orderId) { if (!body.orderId) {
return new Response(JSON.stringify({ error: "orderId is required" }), { return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
status: 400,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
} }
const supabase = createServiceClient(); const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "delivery-report",
key: body.orderId,
maxCount: 10,
windowSeconds: 600,
blockSeconds: 1800,
});
const { data: currentOrder, error: orderError } = await supabase const { data: currentOrder, error: orderError } = await supabase
.from("orders") .from("orders")
.select("id, status, delivery_agreement_status") .select("id, status, delivery_agreement_status")
@ -114,8 +122,8 @@ Deno.serve(async (request) => {
}, },
}); });
return new Response( return jsonResponse(
JSON.stringify({ {
ok: true, ok: true,
orderId: body.orderId, orderId: body.orderId,
status: nextStatus, status: nextStatus,
@ -123,27 +131,23 @@ Deno.serve(async (request) => {
? "Подтверждено клиентом" ? "Подтверждено клиентом"
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
workflowStatus: nextStatus, workflowStatus: nextStatus,
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
200,
corsHeaders,
); );
} catch (error) { } catch (error) {
return new Response( if (error instanceof Error && "status" in error) {
JSON.stringify({ const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
corsHeaders,
); );
} }
}); });

View File

@ -0,0 +1,80 @@
import { createServiceClient } from "../_shared/chatbot.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<{ email?: string }>(request, {
maxBytes: MAX_BODY_BYTES,
});
const email = String(body.email || "").trim().toLowerCase();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
}
const supabase = createServiceClient();
const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
scope: "otp-request",
key: `${ipHash}:${emailHash}`,
maxCount: 3,
windowSeconds: 600,
blockSeconds: 1800,
});
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
shouldCreateUser: false,
},
});
if (error) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
}
return jsonResponse({ ok: true }, 200, corsHeaders);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

View File

@ -5,6 +5,12 @@ import {
type ProviderName, type ProviderName,
} from "../_shared/chatbot.ts"; } from "../_shared/chatbot.ts";
import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts"; import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts";
import {
getCorsHeaders,
readJsonBody,
requireRateLimit,
verifyInternalRequest,
} from "../_shared/security.ts";
const providerTokens: Record<ProviderName, string | undefined> = { const providerTokens: Record<ProviderName, string | undefined> = {
telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"), telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"),
@ -12,6 +18,8 @@ const providerTokens: Record<ProviderName, string | undefined> = {
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"), messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
}; };
const MAX_BODY_BYTES = 16 * 1024;
const sendToProvider = async ({ const sendToProvider = async ({
provider, provider,
recipientId, recipientId,
@ -38,22 +46,41 @@ const sendToProvider = async ({
}; };
Deno.serve(async (request) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const corsHeaders = getCorsHeaders(request, "integration");
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") { if (request.method !== "POST") {
return json({ error: "Method not allowed" }, 405); return json({ error: "Method not allowed" }, 405);
} }
const corsHeaders = getCorsHeaders(request, "integration") || {};
try { try {
const body = (await request.json()) as { const { body, rawBody } = await readJsonBody<{
provider: ProviderName; provider: ProviderName;
orderId: string; orderId: string;
recipientId: string; recipientId: string;
text: string; text: string;
buttons?: Array<{ title: string; action: string }>; buttons?: Array<{ title: string; action: string }>;
workflowAction?: OutboundWorkflowAction; workflowAction?: OutboundWorkflowAction;
}; }>(request, {
maxBytes: MAX_BODY_BYTES,
});
await verifyInternalRequest(request, rawBody, { rawBody });
const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "chatbot-dispatch",
key: body.orderId,
maxCount: 10,
windowSeconds: 600,
blockSeconds: 1800,
});
const dispatchResult = await sendToProvider(body); const dispatchResult = await sendToProvider(body);
const supabase = createServiceClient();
const { error } = await supabase.from("chat_messages").insert({ const { error } = await supabase.from("chat_messages").insert({
order_id: body.orderId, order_id: body.orderId,

View File

@ -3,46 +3,54 @@ import {
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import {
getCorsHeaders,
jsonResponse,
readJsonBody,
requireRateLimit,
verifyInternalRequest,
} from "../_shared/security.ts";
const corsHeaders = { const MAX_BODY_BYTES = 16 * 1024;
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (request) => { type TransferBody = {
if (request.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
}
try {
const body = (await request.json()) as {
orderId?: string; orderId?: string;
reason?: string; reason?: string;
note?: string; note?: string;
targetStatus?: "Передан логисту" | "Платное хранение"; targetStatus?: "Передан логисту" | "Платное хранение";
}; };
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const corsHeaders = getCorsHeaders(request, "integration");
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "integration") || {};
try {
const { body, rawBody } = await readJsonBody<TransferBody>(request, {
maxBytes: MAX_BODY_BYTES,
});
await verifyInternalRequest(request, rawBody, { rawBody });
if (!body.orderId) { if (!body.orderId) {
return new Response(JSON.stringify({ error: "orderId is required" }), { return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
status: 400,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
} }
const supabase = createServiceClient(); const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "delivery-transfer",
key: body.orderId,
maxCount: 10,
windowSeconds: 600,
blockSeconds: 1800,
});
const { data: currentOrder, error: orderError } = await supabase const { data: currentOrder, error: orderError } = await supabase
.from("orders") .from("orders")
.select("id, status, delivery_agreement_status") .select("id, status, delivery_agreement_status")
@ -113,33 +121,29 @@ Deno.serve(async (request) => {
}, },
}); });
return new Response( return jsonResponse(
JSON.stringify({ {
ok: true, ok: true,
orderId: body.orderId, orderId: body.orderId,
status: orderUpdate?.status, status: orderUpdate?.status,
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus, deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
}),
{
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
200,
corsHeaders,
); );
} catch (error) { } catch (error) {
return new Response( if (error instanceof Error && "status" in error) {
JSON.stringify({ const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false, ok: false,
error: error instanceof Error ? error.message : "Unexpected error", error: error instanceof Error ? error.message : "Unexpected error",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}, },
500,
corsHeaders,
); );
} }
}); });

View File

@ -0,0 +1,92 @@
import { createServiceClient } from "../_shared/chatbot.ts";
import {
getClientIp,
getCorsHeaders,
hashText,
jsonResponse,
preflightResponse,
readJsonBody,
requireRateLimit,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "public");
}
if (request.method !== "POST") {
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
const corsHeaders = getCorsHeaders(request, "public");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
maxBytes: MAX_BODY_BYTES,
});
const email = String(body.email || "").trim().toLowerCase();
const otp = String(body.otp || "").trim();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
}
if (!otp || otp.length < 4 || otp.length > 12) {
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
}
const supabase = createServiceClient();
const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
scope: "otp-verify",
key: `${ipHash}:${emailHash}`,
maxCount: 5,
windowSeconds: 600,
blockSeconds: 1800,
});
const { data, error } = await supabase.auth.verifyOtp({
email,
token: otp,
type: "email",
});
if (error) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders);
}
return jsonResponse(
{
ok: true,
session: data.session || null,
user: data.session?.user || null,
},
200,
corsHeaders,
);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{
ok: false,
error: error instanceof Error ? error.message : "Unexpected error",
},
500,
corsHeaders,
);
}
});

View File

@ -95,6 +95,10 @@ create table if not exists public.delivery_invitations (
customer_phone text, customer_phone text,
customer_messenger text, customer_messenger text,
available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'], available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'],
expires_at timestamptz,
revoked_at timestamptz,
access_count integer not null default 0,
last_accessed_at timestamptz,
delivery_date date, delivery_date date,
delivery_time text, delivery_time text,
sent_at timestamptz, sent_at timestamptz,
@ -119,6 +123,18 @@ create table if not exists public.integration_events (
created_at timestamptz not null default timezone('utc', now()) created_at timestamptz not null default timezone('utc', now())
); );
create table if not exists public.rate_limits (
id uuid primary key default gen_random_uuid(),
scope text not null,
rate_key text not null,
window_start timestamptz not null,
count integer not null default 1,
blocked_until timestamptz,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now()),
unique (scope, rate_key, window_start)
);
alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато'; alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато';
alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id); alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id);
alter table public.orders add column if not exists ready_for_delivery_at timestamptz; alter table public.orders add column if not exists ready_for_delivery_at timestamptz;
@ -130,6 +146,10 @@ alter table public.chat_messages
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')); check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice'; alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
alter table public.delivery_invitations add column if not exists expires_at timestamptz;
alter table public.delivery_invitations add column if not exists revoked_at timestamptz;
alter table public.delivery_invitations add column if not exists access_count integer not null default 0;
alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz;
alter table public.delivery_invitations add column if not exists delivery_date date; alter table public.delivery_invitations add column if not exists delivery_date date;
alter table public.delivery_invitations add column if not exists delivery_time text; alter table public.delivery_invitations add column if not exists delivery_time text;
alter table public.delivery_invitations add column if not exists sent_at timestamptz; alter table public.delivery_invitations add column if not exists sent_at timestamptz;
@ -172,6 +192,13 @@ alter table public.integration_events add column if not exists source text not n
alter table public.integration_events add column if not exists status text not null default 'success'; alter table public.integration_events add column if not exists status text not null default 'success';
alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb; alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb;
alter table public.integration_events add column if not exists error_message text; alter table public.integration_events add column if not exists error_message text;
alter table public.rate_limits add column if not exists scope text not null;
alter table public.rate_limits add column if not exists rate_key text not null;
alter table public.rate_limits add column if not exists window_start timestamptz not null;
alter table public.rate_limits add column if not exists count integer not null default 1;
alter table public.rate_limits add column if not exists blocked_until timestamptz;
alter table public.rate_limits add column if not exists created_at timestamptz not null default timezone('utc', now());
alter table public.rate_limits add column if not exists updated_at timestamptz not null default timezone('utc', now());
create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key); create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key);
create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status); create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status);
@ -348,8 +375,87 @@ create index if not exists idx_chat_messages_search on public.chat_messages usin
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id); create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash); create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash);
create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state); create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state);
create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at);
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc); create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
create index if not exists idx_integration_events_event_type on public.integration_events (event_type); create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
create index if not exists idx_rate_limits_blocked_until on public.rate_limits (blocked_until);
create or replace function public.check_rate_limit(
p_scope text,
p_key text,
p_max_count integer,
p_window_seconds integer,
p_block_seconds integer default 0
)
returns table (
allowed boolean,
current_count integer,
limit_count integer,
blocked_until timestamptz,
window_start timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_now timestamptz := timezone('utc', now());
v_window_start timestamptz;
v_count integer;
v_blocked_until timestamptz;
begin
if p_max_count <= 0 then
raise exception 'max_count must be positive';
end if;
if p_window_seconds <= 0 then
raise exception 'window_seconds must be positive';
end if;
v_window_start := to_timestamp(floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds);
select rl.blocked_until
into v_blocked_until
from public.rate_limits rl
where rl.scope = p_scope
and rl.rate_key = p_key
and rl.blocked_until is not null
and rl.blocked_until > v_now
order by rl.blocked_until desc
limit 1;
if v_blocked_until is not null then
return query
select false, 0, p_max_count, v_blocked_until, v_window_start;
return;
end if;
insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until)
values (p_scope, p_key, v_window_start, 1, null)
on conflict (scope, rate_key, window_start)
do update set
count = public.rate_limits.count + 1,
blocked_until = case
when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest(
coalesce(public.rate_limits.blocked_until, v_now),
v_now + make_interval(secs => p_block_seconds)
)
else public.rate_limits.blocked_until
end,
updated_at = v_now
returning count, blocked_until
into v_count, v_blocked_until;
return query
select
v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now),
v_count,
p_max_count,
v_blocked_until,
v_window_start;
end;
$$;
alter table public.roles enable row level security; alter table public.roles enable row level security;
alter table public.users enable row level security; alter table public.users enable row level security;
@ -386,7 +492,7 @@ using (public.current_role_name() = 'admin');
drop policy if exists "users self or admin" on public.users; drop policy if exists "users self or admin" on public.users;
create policy "users self or admin" on public.users create policy "users self or admin" on public.users
for select for select
using (public.current_role_name() is not null); using (public.current_role_name() = 'admin' or id = auth.uid());
drop policy if exists "users admin update" on public.users; drop policy if exists "users admin update" on public.users;
create policy "users admin update" on public.users create policy "users admin update" on public.users
@ -571,6 +677,13 @@ for all
using (public.current_role_name() = 'admin') using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin'); with check (public.current_role_name() = 'admin');
alter table public.rate_limits enable row level security;
drop policy if exists "rate limits admin only" on public.rate_limits;
create policy "rate limits admin only" on public.rate_limits
for all
using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin');
drop policy if exists "integration events admin only" on public.integration_events; drop policy if exists "integration events admin only" on public.integration_events;
create policy "integration events admin only" on public.integration_events create policy "integration events admin only" on public.integration_events
for all for all