POS BFF — Phase 3
Что сделано
NEW lib/approvalToken.ts
HS256 JWT через node:crypto (без зависимости jsonwebtoken).
issueApprovalToken(scope, sub) → { token, payload }
verifyApprovalToken(token) → ApprovalPayload | null // null если подпись/exp невалидныPayload: { approval_for, max_amount?, max_pct?, sub, exp, jti }. Подпись через config.jwtSecret.
NEW routes/manager-auth.ts
POST /api/v1/pos/manager-auth/verify-pin
Body: { pin: string, scope: { type: "refund_amount" | "discount_pct", max_amount?/max_pct? } }Flow:
requireCashier— токен валиден,pos.accessесть- POST
/internal/users/validate-pin(user-service) с{ pin, store_id } - Проверяем что в возвращённых
permissions[]естьpos.settings.edit— иначе 403 NOT_A_MANAGER issueApprovalToken(scope, manager.id)→ возвращаем{ approval_token, expires_in: 300, scope }
NEW routes/customers.ts
GET /api/v1/pos/customers/search?phone=…→/internal/customers/search-by-phone?franchise_id=…&phone=…POST /api/v1/pos/customers/quick-create→/internal/customers/quick-create?franchise_id=…&employee_id=…
middleware/auth.ts — NEW requireApprovalToken
Универсальная: requireCashier → достаёт X-Approval-Token, валидирует через verifyApprovalToken. Кладёт payload в request.approvalPayload. Сама проверка scope (max_amount/max_pct) — в route handler’е (там видно конкретный amount/pct).
routes/orders.ts — расширения
SubmitOrderBody: новые поляcustomer_id?,discount_pct?- В
/submit: еслиdiscount_pct > config.approval.discountMaxPct(default 20) — проверяем X-Approval-Token (scope=discount_pctAND max_pct ≥ запрошенному). Без него → 403 APPROVAL_REQUIRED. Логируем factой что approved. OpenOrderBody.customer_id,OpenDineInBody.customer_id— прокидываются в order-service- NEW
PATCH /:id/customer— proxy на/internal/orders/:id/customer - NEW
DELETE /:id/customer— proxy
routes/refunds.ts — Manager PIN flow
- amount >
config.approval.refundMaxAmountRub(default 1000 ₽) — проверка X-Approval-Token (scope=refund_amountAND max_amount ≥ запрошенному). Без него — 403 APPROVAL_REQUIRED.
config.ts — новые поля
customerServiceUrl: string,
jwtSecret: string,
approval: {
refundMaxAmountRub: 1000,
discountMaxPct: 20,
tokenTtlSec: 300,
}server.ts
app.register(customerRoutes, { prefix: "/api/v1/pos/customers" });
app.register(managerAuthRoutes, { prefix: "/api/v1/pos/manager-auth" });Файлы
- NEW
bff/src/lib/approvalToken.ts - NEW
bff/src/routes/customers.ts - NEW
bff/src/routes/manager-auth.ts bff/src/middleware/auth.ts(requireApprovalToken)bff/src/routes/orders.ts(extend)bff/src/routes/refunds.ts(extend)bff/src/config.ts(extend)bff/src/server.ts(register)
Smoke
# Manager PIN issue (требует валидный JWT кассира — нет в этом тесте)
curl -X POST http://localhost:3022/api/v1/pos/manager-auth/verify-pin \
-H 'Authorization: Bearer <cashier-jwt>' -H 'Content-Type: application/json' \
-d '{"pin":"<manager-pin>","scope":{"type":"refund_amount","max_amount":1500}}'
# → { "data": { "approval_token":"<jwt>", "expires_in":300, "scope":{...} }}
# Refund > 1000 без token
curl -X POST http://localhost:3022/api/v1/pos/refunds/ \
-H 'Authorization: Bearer <cashier-jwt>' -H 'Content-Type: application/json' \
-d '{...amount=150000,...}' # 1500 ₽ в копейках
# → 403 APPROVAL_REQUIRED
# Refund > 1000 с token
curl ... -H "X-Approval-Token: <jwt>" ...
# → 200/201 + refund record