Middleware tRPC вместо guard: где ломается пайплайн Nest
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Страж вы написали сами, а потом нашли его один в один в настоящем Nest. Но в прошлой статье выяснилась неприятная вещь: до процедур tRPC конвейер Nest не доходит — плагин стоит на платформе, раньше всего, что Nest делает с запросом. Значит, @UseGuards рядом с процедурой — это не забытый замок, а замок, повешенный на стену рядом с дверью: он есть, он виден в коде, и он ничего не держит. Разберёмся, почему так вышло — а вышло не из-за плагина, а из-за того, что ExecutionContext собирается из класса контроллера и функции обработчика, которых у процедуры нет вовсе, — и переложим проверки туда, где они сработают: в звенья tRPC. По дороге соберём ограничитель частоты с ключом «процедура + адрес», ровно такой, какой стоит в боевом API учебника, и увидим у него край, на котором предел «десять в минуту» пропускает девятнадцать вызовов за секунду.
Картина из прошлой статьи: приложение Nest, внутри него процедуры tRPC, и глобальный страж, который за одиннадцать запросов отметился один раз — на маршруте контроллера. Объяснение звучало так: плагин регистрируется на экземпляре Fastify, то есть раньше, чем начинается конвейер Nest. Объяснение верное, но неполное — из него следует, будто достаточно подвинуть плагин, и страж заработает.
Не заработает. Посмотрите, что страж получает на вход. В своём каркасе вы отдавали ему имена контроллера и метода, а Nest отдаёт ExecutionContext — и главное в нём не запрос, а пара getClass() и getHandler(): сам класс и сама функция. По ним Reflector читает пометки, и именно так делается @Public() в закрытом контроллере или @Roles('admin') на одном методе.
А теперь спросите, что здесь класс и что функция, если процедура — это поле обычного объекта, собранное вызовом t.procedure.query(…). Класса нет. Метода нет. Даже если бы страж доехал, положить в его контекст было бы нечего: пометки читать не с чего. Имя процедуры, правда, видно — оно стоит прямо в адресе, /trpc/notes.mine, — но это снова строка, по которой ни пометки не прочесть, ни зависимости не спросить. А в пачке вызовов адрес называет и вовсе несколько процедур сразу: /trpc/notes.list,notes.mine?batch=1 — один запрос, одно решение стража и два разных ответа.
У звена tRPC на входе другое. Вот что лежит в объекте, который оно получает, — список снят с @trpc/server 11.18.0, а не из документации.
| Что нужно проверке | Страж Nest | Звено tRPC |
|---|---|---|
| кого позвали | getClass() и getHandler() — класс и функция | path — строка вроде notes.mine, и type: query, mutation или subscription |
| пометки над обработчиком | Reflector читает метаданные с класса и с функции | meta — объект, объявленный при сборке самой процедуры |
| запрос платформы | switchToHttp().getRequest() | только то, что положил в контекст createContext; обычно сам req туда и кладут |
| вход | стражу не виден вовсе, трубе — после разбора | input после схемы и getRawInput() до неё |
| как отказать | вернуть false или бросить HttpException | бросить TRPCError с одним из двадцати одного кода |
| что передать дальше | ничего: страж только пропускает или не пропускает | next({ ctx }) — новый контекст тем, кто ниже |
Разница в первой строке — это и есть вся статья. Страж спрашивает «кого выбрали» у самого объекта, который выбрали; звено получает строку. Строка беднее — по ней не прочесть пометку, не взять тип параметра, не спросить контейнер. Но у звена есть то, чего у стража нет: последняя строка таблицы. Страж умеет только пропустить; звено умеет передать вниз результат своей работы.
Пометки у процедур всё-таки есть — просто не через Reflector. Тип пометки объявляется один раз, при создании initTRPC, а сама пометка вешается на процедуру методом .meta(); звено находит её в своём opts. Прогноз: сколько раз выполнится единственное звено и что оно напечатает про пометку у открытой процедуры.
import { initTRPC, TRPCError } from '@trpc/server'; // Пометка процедуры: то же, чем в Nest служит свой декоратор над методом type Meta = { roles?: Role[] }; export type Role = 'user' | 'admin'; export type Ctx = { role: Role }; const t = initTRPC.context<Ctx>().meta<Meta>().create(); // Звено читает пометку той процедуры, которую позвали, а не класс и не функцию const roleGuard = t.middleware(({ ctx, meta, path, type, next }) => { const need = meta?.roles; console.log(`${path} (${type}): пометка ${JSON.stringify(need ?? null)}, роль ${ctx.role}`); if (need && !need.includes(ctx.role)) throw new TRPCError({ code: 'FORBIDDEN', message: 'WRONG_ROLE' }); return next(); }); const guarded = t.procedure.use(roleGuard); export const appRouter = t.router({ list: guarded.query(() => ['купить хлеб']), publish: guarded.meta({ roles: ['admin'] }).mutation(() => 'опубликовано'), });
list (query): пометка null, роль user notes.list от роли user → ["купить хлеб"] publish (mutation): пометка ["admin"], роль user notes.publish от роли user → отказ FORBIDDEN list (query): пометка null, роль admin notes.list от роли admin → ["купить хлеб"] publish (mutation): пометка ["admin"], роль admin notes.publish от роли admin → "опубликовано"
Одно звено стоит на обеих процедурах и решает по пометке — ровно то, ради чего в Nest заводят Reflector. Отличие в том, где живёт тип: у Nest пометка — это ключ метаданных, и её тип знает только тот, кто её читает, а у tRPC тип пометки объявлен при сборке роутера, и .meta({ roles: ['admni'] }) с опечаткой в роли компилятор не пропустит.
Страж Nest спрашивает «кто пришёл» у класса и у функции. Процедура — поле обычного объекта: спрашивать не у кого, и подвинуть плагин это не лечит.
Договор звена короткий: функция получает один объект — в нём ctx, path, input и next — и обязана либо вернуть next(…), либо бросить. Позвала next() — пустила запрос дальше; не позвала — отказала. Это тот же договор, что у звена вашего конвейера, с одной важной чертой: next() здесь возвращает ответ, как у koa и у перехватчиков Nest, а не «ничего», как в Express. Значит, из звена получается матрёшка: строка после next() выполнится, когда всё, что стоит дальше, уже отработало.
Второе отличие важнее первого. Цепочка принадлежит не приложению, а процедуре: use ничего не правит на месте, а отдаёт новый строитель — с тем же входом и цепочкой длиннее на одно звено. Поэтому от одной заготовки можно отойти в три стороны, и заготовка останется прежней.
Соберём это на своих процедурах: у них уже есть схема входа рядом с обработчиком, добавляется третье поле — цепочка. Обработчик заодно получает второй аргумент: контекст. Прогноз: в каком порядке встанут строки журнала у вызова с токеном и какие строки останутся у вызова без токена.
Журнал первого вызова читается как матрёшка: замер начался первым и кончился последним, личность выполнилась внутри него, а обработчик — внутри личности. Обработчик при этом взял ctx.userId, которого в исходном контексте не было: его положило звено.
Журнал второго вызова на одну строку короче, чем можно было ждать. Обработчика нет — понятно, звено отказало. Но нет и «замер: после»: исключение поднялось сквозь next() и вынесло остаток звена вместе со второй половиной замера. Вы это уже видели на своём конвейере; вывод тот же и лечится тем же — finally. В Nest у этой же грабли своё имя: tap теряет ровно те запросы, ради которых замер и заводили, а finalize срабатывает всегда.
Осталось выяснить, где в этой цепочке стоит разбор входа. У настоящего tRPC место разбора объявляется явно — вызовом .input(), — и звенья, написанные до него и после него, живут по-разному. Прогноз: что напечатает журнал у каждой из двух процедур на негодном входе.
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; export const journal: string[] = []; const t = initTRPC.context<{ ip: string }>().create(); const say = (name: string) => t.middleware(({ next }) => { journal.push(name); return next(); }); const input = z.object({ id: z.number().int().positive() }); export const appRouter = t.router({ // звено объявлено ДО схемы входа early: t.procedure .use(say('звено до .input()')) .input(input) .query(({ input: value }) => { journal.push('обработчик'); return value.id; }), // то же звено объявлено ПОСЛЕ схемы входа late: t.procedure .input(input) .use(say('звено после .input()')) .query(({ input: value }) => { journal.push('обработчик'); return value.id; }), });
early, вход годный: звено до .input() · обработчик → ответ 7 early, вход негодный: звено до .input() → отказ BAD_REQUEST late, вход годный: звено после .input() · обработчик → ответ 7 late, вход негодный: (журнал пуст) → отказ BAD_REQUEST
Звено, объявленное до .input(), выполняется раньше разбора — и на негодном входе тоже. Звено после .input() на негодном входе не выполняется вовсе: журнал пуст, отказ сложила схема. Это ваш порядок «страж, потом труба», записанный не сборкой каркаса, а позицией в цепочке, — и с той же ценой ошибки: разбор, случившийся раньше проверки, рассказывает гостю форму ваших данных.
Практическое правило отсюда одно. Ограничитель частоты и проверка личности идут до .input(): они не смотрят на вход, а разбор негодного тела для них — лишняя работа, оплаченная сервером. Всё, чему нужен разобранный вход — проверка владения по input.id, например, — идёт после.
Порядок звеньев записан в самой процедуре, и поменять его, не трогая процедуру, нельзя. Это скорее хорошо: порядок видно там же, где обработчик, а не в трёх местах сборки приложения.
createContext зовётся на каждый запрос и собирает то, что известно до всяких проверок: адрес, заголовки, сам req. Личность туда тоже кладут — но по-доброму: есть годный токен, будет userId, нет — не будет, и запрос всё равно поедет дальше. Так устроен AppContext боевого API: лента заметок открыта гостю, а вошедший видит в ней ещё и свои черновики. Отклонять запрос — работа звена.
Звено может дописать личность в контекст двумя способами: поправить объект, который пришёл, или вернуть next({ ctx }). Выглядят они одинаково полезно. Прогноз: сколько диагностик даст tsc на файле, где написаны оба.
import { initTRPC, TRPCError } from '@trpc/server'; // Контекст, который собрал createContext: про личность здесь ничего нет type Ctx = { ip: string; token: string | null }; type User = { id: string; role: 'user' | 'admin' }; const users = new Map<string, User>([['token-anya', { id: 'anya', role: 'user' }]]); const notes = [{ id: 1, author: 'anya', title: 'купить хлеб' }]; const t = initTRPC.context<Ctx>().create(); // Звено дописывает личность в тот же объект контекста const patching = t.middleware(({ ctx, next }) => { const user = users.get(ctx.token ?? ''); if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' }); ctx.userId = user.id; return next(); }); // Звено отдаёт новый контекст тем, кто ниже по цепочке const authenticate = t.middleware(({ ctx, next }) => { const user = users.get(ctx.token ?? ''); if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' }); return next({ ctx: { userId: user.id, role: user.role } }); }); export const appRouter = t.router({ patched: t.procedure.use(patching).query(({ ctx }) => notes.filter((note) => note.author === ctx.userId)), strict: t.procedure.use(authenticate).query(({ ctx }) => notes.filter((note) => note.author === ctx.userId)), // процедура с двумя звеньями подряд admin: t.procedure .use(authenticate) .use(({ ctx, next }) => (ctx.role === 'admin' ? next() : Promise.reject(new TRPCError({ code: 'FORBIDDEN' })))) .query(({ ctx }) => `${ctx.ip} ${ctx.userId} ${ctx.role}`), });
router.ts(16,7): error TS2339: Property 'userId' does not exist on type '{ ip: string; token: string | null; }'.
router.ts(28,100): error TS2339: Property 'userId' does not exist on type '{ ip: string; token: string | null; }'.Диагностики две, и обе про одно поле. Первая — на строке, где звено пишет ctx.userId = user.id: у контекста такого поля нет и появиться ему неоткуда, тип контекста объявлен при initTRPC. Вторая — на процедуре, которая этим звеном «защищена»: она читает ctx.userId и получает тот же самый тип, что и процедура без звена. Правка на месте не меняет тип, а значит, не меняет ничего и для тех, кто ниже.
Процедура strict, собранная через next({ ctx }), компилируется молча. И процедура admin тоже — а в ней видно, что контексты складываются, а не подменяются: ip приехал из первого контекста, userId и role положило звено личности, и всё это лежит рядом. Роль после этого — не string | undefined, а объявленный союз, и сравнение с 'admin' компилятор проверяет по буквам.
Отсюда и вся польза приёма. protectedProcedure — это не соглашение команды и не комментарий «сюда только с токеном», а тип: у любой процедуры, собранной от него, ctx.userId объявлен как string, а не как string | undefined, и привычная оговорка «а вдруг личности нет» проверяет то, чего тип уже не допускает. Забыть звено можно — но тогда не соберётся обработчик, а не «тихо откроется ручка».
Если после проверки личности обработчик всё ещё проверяет, есть ли личность, — проверка стоит не там, где он думает.
Первое звено, которое появляется в боевом API, — обычно не про личность, а про частоту. Причина простая: гостевые процедуры — вход, регистрация, «забыл пароль» — токена не требуют, а стоят дорого. Хеш пароля дорог намеренно, письмо уходит по сети, адрес у процедуры публичный. Перебор с одного адреса кладёт не только вход: он съедает процессор, который делят база, веб и прокси.
Устройство ограничителя — три вещи: ключ, по которому ведётся счёт, окно, за которое считают, и пауза после превышения. Прогноз: сколько вызовов подряд ограничитель пропустит на краю окна, если предел — десять в минуту.
Первые строки без сюрпризов: десять вызовов проходят, одиннадцатый отказывает и ставит паузу, двенадцатый попадает уже в неё — счётчик его даже не считает, и это не придирка: иначе пауза продлевала бы сама себя при каждом новом стуке. На сороковой секунде пауза ещё держится, на семидесятой истекли и она, и окно.
Дальше интересное. Соседние ключи друг друга не задевают: auth.register с того же адреса начинает со своего первого вызова, auth.login с другого адреса — тоже. А вот край окна оказался дырявым: окно открывается первым вызовом и живёт ровно минуту, поэтому девять вызовов в конце одного окна и десять в начале следующего проходят подряд — девятнадцать за секунду при пределе «десять в минуту».
Это цена простоты, и её стоит знать заранее, а не выяснять по графику нагрузки. Лечится она скользящим окном или корзиной с пополнением, но для учебника и для стенда обе дороже своей пользы: понятие новое, а разница на этих числах — между «девятнадцать за секунду» и «десять за секунду», и та и другая величина сервер не кладёт. Важно другое: если вы считаете, что ваш ограничитель пропускает десять, — посчитайте край.
Теперь про ключ, потому что он главный. Общий ключ на все процедуры плох дважды. Во-первых, перебор пароля закрывал бы заодно и восстановление пароля — то есть единственную дверь, через которую жертва могла бы вернуться. Во-вторых, у хранилища частоты флаг блокировки лежит на ключе, и снятие паузы у одного адреса задевало бы счётчики всех остальных.
А для процедур с личностью ключ меняется целиком: не «процедура + адрес», а «процедура + пользователь». Адрес за NAT общий — в офисе, в общежитии, у мобильного оператора, — и лимит на адрес делится там на всех сразу. Вдобавок у процедуры с личностью бывает многостраничная выдача, и предел в десять вызовов она упирает в одиннадцатую страницу: у процедур синхронизации потолки поэтому другие — шестьсот вызовов в минуту на выдачу и сто двадцать на приём.
И последнее, без чего весь ограничитель — украшение. Адрес берётся из ctx.req.ip, а это настоящий адрес клиента только если сервер настроен верить заголовку обратного прокси. Без доверия сюда приедет адрес контейнера прокси — один на всех, и ограничитель превратится в общий предел для всего интернета. С доверием появляется обратная беда: заголовок можно подделать, и тогда каждый запрос приходит будто бы с нового адреса. Где именно проходит эта граница — в статье про обратный прокси.
Ограничитель без ключа — это ограничитель на всех сразу. Первый вопрос к любому ограничителю: что именно он считает и чей это счёт.
Соберём это в одно приложение и посмотрим на границу целиком. Внутри — контроллер /health, роутер tRPC на /trpc, глобальный страж Nest, который печатает класс и метод, и хук onRequest, повешенный прямо на Fastify. У процедур три звена: ограничитель на гостевых, личность на своих, роль поверх личности.
Прогноз: на запрос к процедуре строку в журнал напишет хук платформы, страж Nest, оба или никто?
import { initTRPC, TRPCError } from '@trpc/server'; import { trace } from './trace.js'; import { findByToken } from './users.js'; // Контекст запроса: всё, что createContext собрал до всякой проверки export type Ctx = { ip: string; token: string | null }; const t = initTRPC.context<Ctx>().create(); export const router = t.router; /** Вызовов на пару «процедура + адрес» в окне. */ const LIMIT = 10; /** Окно подсчёта и пауза после превышения — как в rate-limit.middleware.ts. */ const WINDOW_MS = 60_000; const BLOCK_MS = 60_000; type Window = { hits: number; expiresAt: number; blockedUntil: number }; const windows = new Map<string, Window>(); const rateLimit = t.middleware(({ ctx, path, next }) => { // ключ считает каждую пару «процедура + адрес» отдельно const key = `${path}:${ctx.ip}`; const now = Date.now(); const known = windows.get(key); const window = known && now < known.expiresAt ? known : { hits: 0, expiresAt: now + WINDOW_MS, blockedUntil: 0 }; windows.set(key, window); if (now < window.blockedUntil) { trace.push(`ограничитель ${key}: пауза`); throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'TOO_MANY_REQUESTS' }); } window.hits += 1; trace.push(`ограничитель ${key}: ${window.hits}`); if (window.hits > LIMIT) { window.blockedUntil = now + BLOCK_MS; throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'TOO_MANY_REQUESTS' }); } return next(); }); const authenticate = t.middleware(({ ctx, next }) => { const user = findByToken(ctx.token); if (!user) { trace.push('личность: токена нет или он чужой'); throw new TRPCError({ code: 'UNAUTHORIZED', message: 'NOT_AUTHENTICATED' }); } trace.push(`личность: ${user.id}, роль ${user.role}`); // контекст не правится на месте: next отдаёт новый тем, кто ниже по цепочке return next({ ctx: { userId: user.id, role: user.role } }); }); /** Открытая процедура: ни личности, ни ограничителя. */ export const publicProcedure = t.procedure; /** Гостевая: входа не требует, поэтому стоит под ограничителем частоты. */ export const guestProcedure = t.procedure.use(rateLimit); /** С личностью: ниже по цепочке userId и role уже в контексте. */ export const protectedProcedure = t.procedure.use(authenticate); /** Только администратор. Звено видит ctx.role, потому что стоит после authenticate. */ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { trace.push(`роль: нужна admin, в контексте ${ctx.role}`); if (ctx.role !== 'admin') throw new TRPCError({ code: 'FORBIDDEN', message: 'WRONG_ROLE' }); return next(); });
GET /health → 200 {"ok":true}
журнал: хук Fastify: GET /health · страж Nest: HealthController.status
GET notes.list (без токена) → 200 ["купить хлеб"]
журнал: хук Fastify: GET /trpc/notes.list
POST auth.login ×10 с адреса 10.0.0.7 → 200
POST auth.login №11 с того же адреса → 429 TOO_MANY_REQUESTS: TOO_MANY_REQUESTS
журнал: хук Fastify: POST /trpc/auth.login · ограничитель auth.login:10.0.0.7: 11
POST auth.login №12 с того же адреса → 429 TOO_MANY_REQUESTS: TOO_MANY_REQUESTS
журнал: хук Fastify: POST /trpc/auth.login · ограничитель auth.login:10.0.0.7: пауза
POST auth.register с того же адреса → 200 {"token":"token-anya"}
журнал: хук Fastify: POST /trpc/auth.register · ограничитель auth.register:10.0.0.7: 1
POST auth.login с адреса 10.0.0.8 → 200 {"token":"token-anya"}
журнал: хук Fastify: POST /trpc/auth.login · ограничитель auth.login:10.0.0.8: 1
GET notes.mine без токена → 401 UNAUTHORIZED: NOT_AUTHENTICATED
журнал: хук Fastify: GET /trpc/notes.mine · личность: токена нет или он чужой
GET notes.mine с токеном anya → 200 ["купить хлеб","черновик про кефир"]
журнал: хук Fastify: GET /trpc/notes.mine · личность: anya, роль user
POST notes.publish с токеном anya → 403 FORBIDDEN: WRONG_ROLE
журнал: хук Fastify: POST /trpc/notes.publish · личность: anya, роль user · роль: нужна admin, в контексте user
POST notes.publish с токеном boris → 200 "черновик про кефир"
журнал: хук Fastify: POST /trpc/notes.publish · личность: boris, роль admin · роль: нужна admin, в контексте adminХук Fastify отметился в каждом напечатанном журнале — и у контроллера, и у всех процедур. Страж Nest — ровно в одном, и назвал там HealthController.status: класс и метод, то есть в точности то, чего у процедуры нет. Вот и граница: плагин tRPC — часть Fastify, поэтому всё, что навешено на Fastify, процедуры видят, а всё, что навешено на Nest, — нет. Nest ведь и не веб-сервер: сервером работает Fastify, и плагин встал рядом с Nest, а не под ним. Практический вывод: журнал запросов, CORS, предел размера тела — на платформе, они достанутся обоим транспортам; проверки — звеньями, их придётся написать отдельно.
Коды ответов сложил сам tRPC: TOO_MANY_REQUESTS стал 429, UNAUTHORIZED — 401, FORBIDDEN — 403. Фильтров Nest здесь нет ни одного, и это тот самый случай, когда отказ идёт мимо доменных кодов: «слишком часто» — не беда предметной области, а отказ транспорта.
Ключи ограничителя видно построчно: auth.login:10.0.0.7 досчитал до одиннадцати и встал, auth.register:10.0.0.7 начал со своего первого, auth.login:10.0.0.8 — тоже со своего. Одиннадцатый вызов напечатал номер и отказал, двенадцатый номера уже не получил: он попал в паузу.
И последнее сравнение, ради которого стоило собирать оба транспорта рядом. У конвейера Nest на успешном запросе четырнадцать шагов, и у усилителя три уровня — общий, класса и метода, — а у трубы ещё и четвёртый, сам параметр. У процедуры — ровно столько звеньев, сколько ей написали, и один уровень. Механизма меньше, и мест, где проверка «стоит, но не работает», тоже меньше: забыть глобальный уровень нельзя, потому что его нет. Взамен нельзя и закрыть всё приложение одной строкой в main.ts — каждая процедура собирается от заготовки, и заготовку надо выбрать руками.
Один транспорт — один набор усилителей. Два транспорта в одном приложении — два набора, и общего у них ровно столько, сколько лежит на платформе и в контейнере.
У API учебника двенадцать процедур и два контроллера: здоровье и загрузка файла. Ограничитель там написан звеном, и в шапке файла сказано, почему именно так. Ниже выдержка из apps/api/src/configs/trpc/middlewares/rate-limit.middleware.ts: убраны только импорты, остальное дословно.
Комментарий к решению стоит перечитать: «guard'ы Nest висят на контроллерах, а процедуры объявлены декораторами nestjs-trpc и через Nest-пайплайн запроса не проходят — ExecutionContext до них не доезжает». От штатного @nestjs/throttler взято одно хранилище, а решение принимается в звене. Приём общий: если у чужого механизма полезна только его середина, берут середину, а не механизм.
Числа те же, что в нашей модели: десять вызовов, окно минута, пауза минута. Ключ — path:ip, и рядом лежит объяснение, почему не общий. А TRPCError здесь брошен мимо domainError — с подписью, что «слишком часто» отказ транспортный, а не доменный.
Проверка личности устроена в два слоя, и это стоит разглядеть. Контекст заполняет личность по-доброму и запрос не отклоняет; авторитетная проверка живёт в отдельной функции, общей для всех звеньев.
Функция делает три вещи, а не одну: проверяет подпись, находит пользователя в базе и сверяет роль из токена с ролью в базе. Токен живёт тридцать дней — за это время учётку могли удалить, а роль понизить, и токен об этом не узнает. Почему роль перепроверяют в своей таблице, а не верят написанному в токене, разбирается в главе про доступ; здесь важна форма — звено тонкое, работа в общей функции, и та же функция стоит под звеном ролей.
Обратите внимание на первый комментарий листинга: Identity объявлена type, а не interface, потому что у алиаса есть неявная индексная сигнатура, а без неё next({ ctx }) не принимает объект как запись. Это мелочь на один день отладки — и ровно тот случай, когда комментарий в чужом коде экономит чужой день.
Теперь про контроллеры. У загрузки файла страж есть, и он работает — потому что загрузка и правда контроллер: multipart tRPC не умеет. Но проверяет этот страж только подпись токена; существование пользователя и его роль перепроверяет всё та же функция под звеном tRPC. В комментарии самого стража это сказано прямо: общего слоя у контроллеров Nest и процедур нет. Два транспорта — две двери, и на каждой свой замок.
| Проверка | В контроллере Nest | В процедуре tRPC |
|---|---|---|
| личность | страж: ExecutionContext и Reflector | звено до .input(), отдающее next({ ctx }) |
| роль | страж плюс пометка над методом | звено после личности — или пометка .meta() |
| частота | ThrottlerGuard | звено: хранилище чужое, решение своё |
| форма входа | труба у параметра | .input(схема) |
| владение вещью | обработчик или сервис: страж видит параметры маршрута, но не саму заметку | то же: звено знает путь, но не знает, чья заметка |
| замер и журнал | перехватчик с finalize | первое звено цепочки — или хук платформы, если замер нужен на оба транспорта |
| формат отказа | фильтр | errorFormatter в настройках роутера |
Правило выбора в этой таблице одно и то же для обеих колонок, и оно старше tRPC: привратник, которого можно обойти, охраной не считается. Проверка должна стоять там, где её нельзя миновать, придя другой дверью. Для процедур такое место — их собственная цепочка; для контроллеров — их собственный конвейер; и ни одно из них не закрывает второе.
Осталась третья часть того же договора — схема входа. Пока она у нас написана прямо в процедуре, а в боевом API лежит отдельным пакетом, общим с клиентом: контракт — следующая статья главы.
Двенадцать процедур и два контроллера: у контроллеров стражи, у процедур звенья. Это не два стиля письма, а два транспорта, и у каждого своё место, где проверка срабатывает.
- Страж Nest до процедур tRPC не доезжает по двум причинам сразу: плагин зарегистрирован на платформе, раньше конвейера Nest, а
ExecutionContextсобирается из класса контроллера и функции обработчика — у процедуры нет ни того, ни другого. Подвинуть плагин это не лечит. - Звено tRPC получает
path,type, контекст,metaи вход, а отказывает черезTRPCError. Пометки у процедур есть —.meta()вместо метаданных, читаемыхReflector, — и их тип объявлен при сборке роутера. - Цепочка звеньев принадлежит процедуре, а не приложению:
useотдаёт новый строитель, аnext()возвращает ответ, поэтому у звена есть «до» и «после». Исключение выносит вторую половину звена — замер пишут черезfinally. - Позиция звена относительно
.input()решает, увидит ли оно негодный вход: до схемы — выполняется всегда, после — только на годном. Ограничитель и личность ставят до схемы, проверку владения — после. next({ ctx })не правит контекст, а отдаёт новый — слитый со старым и с новым типом. Правку на месте компилятор отвергает, и поэтому «защищённая заготовка» — это тип, а не соглашение команды.- Ограничитель частоты держат на трёх вещах: ключ, окно, пауза. Для гостевых процедур ключ — «процедура + адрес», для процедур с личностью — «процедура + пользователь», иначе за одним NAT лимит общий. У фиксированного окна есть край, на котором предел «десять в минуту» пропускает девятнадцать вызовов за секунду.
1/4Почему глобальный страж Nest не срабатывает на процедурах tRPC?
2/4Звено объявлено после .input(). Что с ним случится, когда придёт негодный вход?
3/4Ограничитель: предел десять вызовов в минуту на ключ, окно открывается первым вызовом. Сколько вызовов может пройти подряд на краю окна?
4/4Звено написало ctx.userId = user.id вместо next({ ctx: { userId } }). Что увидит обработчик?
Ограничитель считает не того
Ограничитель ниже написан почти верно: окно, предел, счётчик — всё на месте. Но считает он не то, что нужно: десять вызовов Ани закрывают вход Боре, который пришёл с другого адреса и ничего не нарушал. Почините ограничитель так, чтобы первый вызов Бори прошёл, а одиннадцатый вызов Ани по-прежнему отказал. Строка с // ваш код — единственная, которую надо менять.
Считать надо каждую пару «процедура + адрес» отдельно — ключ собирается из обоих:
После правки первый вызов Бори проходит: у пары auth.login:10.0.0.8 свой счётчик, и он пуст. Одиннадцатый вызов Ани по-прежнему отказывает — её пара досчитала до одиннадцати. Вызов auth.register с адреса Бори проходил и до правки, но по другой причине: у него другой путь, а значит, и до правки был другой ключ. Именно поэтому проверять такое лечение одной строкой вывода нельзя — нужны обе: и другой адрес, и другая процедура.
Противоположная крайность тоже встречается: ключ, собранный из адреса и содержимого запроса. Тогда перебор пароля обойдёт ограничитель бесплатно — каждая новая попытка даёт новый ключ.
Роль без личности
Процедура публикации собрана из двух звеньев: одно проверяет личность, другое — роль. Токен администратора в примере есть, и по смыслу публикация должна пройти, но отказ приходит обоим вызовам. Почините цепочку, не трогая сами звенья: Боря с ролью admin должен опубликовать заметку, Аня с ролью user — получить WRONG_ROLE.
Звенья выполняются в том порядке, в каком их добавили, и сейчас первым стоит звено роли. Роль оно берёт из контекста, а в контекст её кладёт звено личности — которое ещё не выполнялось. Отсюда в контексте undefined в журнале и WRONG_ROLE обоим. Правка — одна строка:
Теперь журнал Бори читается целиком: личность, роль, обработчик. У Ани личность проходит, а роль отказывает — и обработчик не выполняется.
Заметьте, чего в исходном коде не было: ошибки. Он собирался, запускался и отвечал отказом — то есть выглядел работающим и даже строгим. Так выглядит и обратная перестановка, когда звено роли случайно оказывается в цепочке раньше ограничителя частоты: всё отвечает, и только счётчик считает не то, что нужно. В настоящем tRPC от половины таких перестановок спасает тип: звено, которое читает ctx.role, собранное от заготовки без личности, просто не скомпилируется.
Сколько строк останется в журнале
Процедура публикации собрана из четырёх звеньев: замер, ограничитель частоты, личность, роль — в этом порядке. Сами звенья и сборка цепочки написаны целиком, а journal, use и invoke — те же, что в разделе про звенья, а base и people — те же, что в задаче выше. Вызов приходит без токена. Ответьте по листингу, не запуская: сколько строк окажется в журнале, какие именно и чем кончится вызов.
Строк две: «замер notes.publish: до» и «ограничитель notes.publish:10.0.0.7». Вызов кончается исключением NOT_AUTHENTICATED.
Разбирается это по цепочке. Замер пишет первую строку и зовёт next(). Ограничитель пишет вторую и зовёт next(). Звено личности токена не находит и бросает — до своей строки журнала оно не доходит, потому что push стоит после проверки. Звено роли и обработчик не выполняются вовсе.
А главное — не выполняется вторая половина замера: исключение поднимается сквозь next() и выносит остаток звена. Замер, написанный так, считает только удачные вызовы, то есть теряет ровно те запросы, ради которых замер и заводят. Лечится это finally: