Middleware tRPC вместо guard: где ломается пайплайн Nest

транспорт≈ 18 минут

Здесь будет встроен видео-разбор темы (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. Прогноз: сколько раз выполнится единственное звено и что оно напечатает про пометку у открытой процедуры.

прогон node 26.5.0 · 23 сентября 2026
Пометка .meta() у процедуры и звено, которое её читает
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(() => 'опубликовано'),
});
node main.ts

Одно звено стоит на обеих процедурах и решает по пометке — ровно то, ради чего в Nest заводят Reflector. Отличие в том, где живёт тип: у Nest пометка — это ключ метаданных, и её тип знает только тот, кто её читает, а у tRPC тип пометки объявлен при сборке роутера, и .meta({ roles: ['admni'] }) с опечаткой в роли компилятор не пропустит.

Страж Nest спрашивает «кто пришёл» у класса и у функции. Процедура — поле обычного объекта: спрашивать не у кого, и подвинуть плагин это не лечит.

Договор звена короткий: функция получает один объект — в нём ctx, path, input и next — и обязана либо вернуть next(…), либо бросить. Позвала next() — пустила запрос дальше; не позвала — отказала. Это тот же договор, что у звена вашего конвейера, с одной важной чертой: next() здесь возвращает ответ, как у koa и у перехватчиков Nest, а не «ничего», как в Express. Значит, из звена получается матрёшка: строка после next() выполнится, когда всё, что стоит дальше, уже отработало.

Второе отличие важнее первого. Цепочка принадлежит не приложению, а процедуре: use ничего не правит на месте, а отдаёт новый строитель — с тем же входом и цепочкой длиннее на одно звено. Поэтому от одной заготовки можно отойти в три стороны, и заготовка останется прежней.

Соберём это на своих процедурах: у них уже есть схема входа рядом с обработчиком, добавляется третье поле — цепочка. Обработчик заодно получает второй аргумент: контекст. Прогноз: в каком порядке встанут строки журнала у вызова с токеном и какие строки останутся у вызова без токена.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Журнал первого вызова читается как матрёшка: замер начался первым и кончился последним, личность выполнилась внутри него, а обработчик — внутри личности. Обработчик при этом взял ctx.userId, которого в исходном контексте не было: его положило звено.

Журнал второго вызова на одну строку короче, чем можно было ждать. Обработчика нет — понятно, звено отказало. Но нет и «замер: после»: исключение поднялось сквозь next() и вынесло остаток звена вместе со второй половиной замера. Вы это уже видели на своём конвейере; вывод тот же и лечится тем же — finally. В Nest у этой же грабли своё имя: tap теряет ровно те запросы, ради которых замер и заводили, а finalize срабатывает всегда.

Осталось выяснить, где в этой цепочке стоит разбор входа. У настоящего tRPC место разбора объявляется явно — вызовом .input(), — и звенья, написанные до него и после него, живут по-разному. Прогноз: что напечатает журнал у каждой из двух процедур на негодном входе.

прогон node 26.5.0 · 23 сентября 2026
Одно и то же звено, объявленное до схемы входа и после неё, на годном и негодном входе
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;
    }),
});
node main.ts

Звено, объявленное до .input(), выполняется раньше разбора — и на негодном входе тоже. Звено после .input() на негодном входе не выполняется вовсе: журнал пуст, отказ сложила схема. Это ваш порядок «страж, потом труба», записанный не сборкой каркаса, а позицией в цепочке, — и с той же ценой ошибки: разбор, случившийся раньше проверки, рассказывает гостю форму ваших данных.

Практическое правило отсюда одно. Ограничитель частоты и проверка личности идут до .input(): они не смотрят на вход, а разбор негодного тела для них — лишняя работа, оплаченная сервером. Всё, чему нужен разобранный вход — проверка владения по input.id, например, — идёт после.

Порядок звеньев записан в самой процедуре, и поменять его, не трогая процедуру, нельзя. Это скорее хорошо: порядок видно там же, где обработчик, а не в трёх местах сборки приложения.

createContext зовётся на каждый запрос и собирает то, что известно до всяких проверок: адрес, заголовки, сам req. Личность туда тоже кладут — но по-доброму: есть годный токен, будет userId, нет — не будет, и запрос всё равно поедет дальше. Так устроен AppContext боевого API: лента заметок открыта гостю, а вошедший видит в ней ещё и свои черновики. Отклонять запрос — работа звена.

Звено может дописать личность в контекст двумя способами: поправить объект, который пришёл, или вернуть next({ ctx }). Выглядят они одинаково полезно. Прогноз: сколько диагностик даст tsc на файле, где написаны оба.

прогон node 26.5.0 · 23 сентября 2026
Два способа положить личность в контекст под проверкой tsc --noEmit
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}`),
});
tsc --noEmit

Диагностики две, и обе про одно поле. Первая — на строке, где звено пишет ctx.userId = user.id: у контекста такого поля нет и появиться ему неоткуда, тип контекста объявлен при initTRPC. Вторая — на процедуре, которая этим звеном «защищена»: она читает ctx.userId и получает тот же самый тип, что и процедура без звена. Правка на месте не меняет тип, а значит, не меняет ничего и для тех, кто ниже.

Процедура strict, собранная через next({ ctx }), компилируется молча. И процедура admin тоже — а в ней видно, что контексты складываются, а не подменяются: ip приехал из первого контекста, userId и role положило звено личности, и всё это лежит рядом. Роль после этого — не string | undefined, а объявленный союз, и сравнение с 'admin' компилятор проверяет по буквам.

Отсюда и вся польза приёма. protectedProcedure — это не соглашение команды и не комментарий «сюда только с токеном», а тип: у любой процедуры, собранной от него, ctx.userId объявлен как string, а не как string | undefined, и привычная оговорка «а вдруг личности нет» проверяет то, чего тип уже не допускает. Забыть звено можно — но тогда не соберётся обработчик, а не «тихо откроется ручка».

Если после проверки личности обработчик всё ещё проверяет, есть ли личность, — проверка стоит не там, где он думает.

Первое звено, которое появляется в боевом API, — обычно не про личность, а про частоту. Причина простая: гостевые процедуры — вход, регистрация, «забыл пароль» — токена не требуют, а стоят дорого. Хеш пароля дорог намеренно, письмо уходит по сети, адрес у процедуры публичный. Перебор с одного адреса кладёт не только вход: он съедает процессор, который делят база, веб и прокси.

Устройство ограничителя — три вещи: ключ, по которому ведётся счёт, окно, за которое считают, и пауза после превышения. Прогноз: сколько вызовов подряд ограничитель пропустит на краю окна, если предел — десять в минуту.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первые строки без сюрпризов: десять вызовов проходят, одиннадцатый отказывает и ставит паузу, двенадцатый попадает уже в неё — счётчик его даже не считает, и это не придирка: иначе пауза продлевала бы сама себя при каждом новом стуке. На сороковой секунде пауза ещё держится, на семидесятой истекли и она, и окно.

Дальше интересное. Соседние ключи друг друга не задевают: auth.register с того же адреса начинает со своего первого вызова, auth.login с другого адреса — тоже. А вот край окна оказался дырявым: окно открывается первым вызовом и живёт ровно минуту, поэтому девять вызовов в конце одного окна и десять в начале следующего проходят подряд — девятнадцать за секунду при пределе «десять в минуту».

Это цена простоты, и её стоит знать заранее, а не выяснять по графику нагрузки. Лечится она скользящим окном или корзиной с пополнением, но для учебника и для стенда обе дороже своей пользы: понятие новое, а разница на этих числах — между «девятнадцать за секунду» и «десять за секунду», и та и другая величина сервер не кладёт. Важно другое: если вы считаете, что ваш ограничитель пропускает десять, — посчитайте край.

Теперь про ключ, потому что он главный. Общий ключ на все процедуры плох дважды. Во-первых, перебор пароля закрывал бы заодно и восстановление пароля — то есть единственную дверь, через которую жертва могла бы вернуться. Во-вторых, у хранилища частоты флаг блокировки лежит на ключе, и снятие паузы у одного адреса задевало бы счётчики всех остальных.

А для процедур с личностью ключ меняется целиком: не «процедура + адрес», а «процедура + пользователь». Адрес за NAT общий — в офисе, в общежитии, у мобильного оператора, — и лимит на адрес делится там на всех сразу. Вдобавок у процедуры с личностью бывает многостраничная выдача, и предел в десять вызовов она упирает в одиннадцатую страницу: у процедур синхронизации потолки поэтому другие — шестьсот вызовов в минуту на выдачу и сто двадцать на приём.

И последнее, без чего весь ограничитель — украшение. Адрес берётся из ctx.req.ip, а это настоящий адрес клиента только если сервер настроен верить заголовку обратного прокси. Без доверия сюда приедет адрес контейнера прокси — один на всех, и ограничитель превратится в общий предел для всего интернета. С доверием появляется обратная беда: заголовок можно подделать, и тогда каждый запрос приходит будто бы с нового адреса. Где именно проходит эта граница — в статье про обратный прокси.

Ограничитель без ключа — это ограничитель на всех сразу. Первый вопрос к любому ограничителю: что именно он считает и чей это счёт.

Соберём это в одно приложение и посмотрим на границу целиком. Внутри — контроллер /health, роутер tRPC на /trpc, глобальный страж Nest, который печатает класс и метод, и хук onRequest, повешенный прямо на Fastify. У процедур три звена: ограничитель на гостевых, личность на своих, роль поверх личности.

Прогноз: на запрос к процедуре строку в журнал напишет хук платформы, страж Nest, оба или никто?

прогон node 26.5.0 · 23 сентября 2026
Одно приложение, два транспорта: журнал и код ответа на каждый запрос
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();
});
nest start

Хук 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: убраны только импорты, остальное дословно.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Комментарий к решению стоит перечитать: «guard'ы Nest висят на контроллерах, а процедуры объявлены декораторами nestjs-trpc и через Nest-пайплайн запроса не проходят — ExecutionContext до них не доезжает». От штатного @nestjs/throttler взято одно хранилище, а решение принимается в звене. Приём общий: если у чужого механизма полезна только его середина, берут середину, а не механизм.

Числа те же, что в нашей модели: десять вызовов, окно минута, пауза минута. Ключ — path:ip, и рядом лежит объяснение, почему не общий. А TRPCError здесь брошен мимо domainError — с подписью, что «слишком часто» отказ транспортный, а не доменный.

Проверка личности устроена в два слоя, и это стоит разглядеть. Контекст заполняет личность по-доброму и запрос не отклоняет; авторитетная проверка живёт в отдельной функции, общей для всех звеньев.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Функция делает три вещи, а не одну: проверяет подпись, находит пользователя в базе и сверяет роль из токена с ролью в базе. Токен живёт тридцать дней — за это время учётку могли удалить, а роль понизить, и токен об этом не узнает. Почему роль перепроверяют в своей таблице, а не верят написанному в токене, разбирается в главе про доступ; здесь важна форма — звено тонкое, работа в общей функции, и та же функция стоит под звеном ролей.

Обратите внимание на первый комментарий листинга: 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 } }). Что увидит обработчик?

Ограничитель считает не того

Ограничитель ниже написан почти верно: окно, предел, счётчик — всё на месте. Но считает он не то, что нужно: десять вызовов Ани закрывают вход Боре, который пришёл с другого адреса и ничего не нарушал. Почините ограничитель так, чтобы первый вызов Бори прошёл, а одиннадцатый вызов Ани по-прежнему отказал. Строка с // ваш код — единственная, которую надо менять.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Считать надо каждую пару «процедура + адрес» отдельно — ключ собирается из обоих:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки первый вызов Бори проходит: у пары auth.login:10.0.0.8 свой счётчик, и он пуст. Одиннадцатый вызов Ани по-прежнему отказывает — её пара досчитала до одиннадцати. Вызов auth.register с адреса Бори проходил и до правки, но по другой причине: у него другой путь, а значит, и до правки был другой ключ. Именно поэтому проверять такое лечение одной строкой вывода нельзя — нужны обе: и другой адрес, и другая процедура.

Противоположная крайность тоже встречается: ключ, собранный из адреса и содержимого запроса. Тогда перебор пароля обойдёт ограничитель бесплатно — каждая новая попытка даёт новый ключ.

Роль без личности

Процедура публикации собрана из двух звеньев: одно проверяет личность, другое — роль. Токен администратора в примере есть, и по смыслу публикация должна пройти, но отказ приходит обоим вызовам. Почините цепочку, не трогая сами звенья: Боря с ролью admin должен опубликовать заметку, Аня с ролью user — получить WRONG_ROLE.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Звенья выполняются в том порядке, в каком их добавили, и сейчас первым стоит звено роли. Роль оно берёт из контекста, а в контекст её кладёт звено личности — которое ещё не выполнялось. Отсюда в контексте undefined в журнале и WRONG_ROLE обоим. Правка — одна строка:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь журнал Бори читается целиком: личность, роль, обработчик. У Ани личность проходит, а роль отказывает — и обработчик не выполняется.

Заметьте, чего в исходном коде не было: ошибки. Он собирался, запускался и отвечал отказом — то есть выглядел работающим и даже строгим. Так выглядит и обратная перестановка, когда звено роли случайно оказывается в цепочке раньше ограничителя частоты: всё отвечает, и только счётчик считает не то, что нужно. В настоящем tRPC от половины таких перестановок спасает тип: звено, которое читает ctx.role, собранное от заготовки без личности, просто не скомпилируется.

Сколько строк останется в журнале

Процедура публикации собрана из четырёх звеньев: замер, ограничитель частоты, личность, роль — в этом порядке. Сами звенья и сборка цепочки написаны целиком, а journal, use и invoke — те же, что в разделе про звенья, а base и people — те же, что в задаче выше. Вызов приходит без токена. Ответьте по листингу, не запуская: сколько строк окажется в журнале, какие именно и чем кончится вызов.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строк две: «замер notes.publish: до» и «ограничитель notes.publish:10.0.0.7». Вызов кончается исключением NOT_AUTHENTICATED.

Разбирается это по цепочке. Замер пишет первую строку и зовёт next(). Ограничитель пишет вторую и зовёт next(). Звено личности токена не находит и бросает — до своей строки журнала оно не доходит, потому что push стоит после проверки. Звено роли и обработчик не выполняются вовсе.

А главное — не выполняется вторая половина замера: исключение поднимается сквозь next() и выносит остаток звена. Замер, написанный так, считает только удачные вызовы, то есть теряет ровно те запросы, ради которых замер и заводят. Лечится это finally:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.