chore: Новый черновик DRAFT, удалить старые docs-v

This commit is contained in:
2026-07-30 09:19:59 +03:00
parent 6f6e4896af
commit 590fb63ca7
122 changed files with 29145 additions and 3253 deletions

15
DRAFT/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Черновики SLM
> Материалы в `DRAFT` являются рабочими черновиками и не задают нормативную спецификацию SLM.
## Материалы
- [Первый уровень](./level-1/README.md) - базовые слои, модули и зависимости.
- [Домены](./domains/README.md) - исследование доменов и строгих границ выполнения.
- [Правила](./rules/README.md) - канонические наборы, формат и правила формулировки.
## Соглашение
Черновики могут содержать определения, правила, рекомендации, примеры и открытые вопросы.
Нормативные определения задаются терминологией соответствующего уровня. Только блокирующие правила получают код SLM; тематические черновики ссылаются на канонические правила и не повторяют их формулировки.

84
DRAFT/domains/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Domains: рабочие заметки
> Статус: исследовательский черновик. Материалы в этой папке не являются спецификацией и пока не задают обязательных правил SLM.
Эта папка фиксирует текущую гипотезу о новой сущности `Domain`, business-модуле внутри неё, framework-neutral factory, ports, adapters, presets и framework bindings.
Идентификаторы вида `DOM-N001` и `FAC-N001` являются стабильными якорями заметок. Они нужны для обсуждения и последующего переноса решений в спецификацию, но не являются идентификаторами нормативных правил.
## Основная формула
```text
Business определяет ЧТО делать.
Ports описывают ЧТО business нужно.
Factory создаёт business API из ports.
Adapters реализуют ports в конкретной среде.
Preset выбирает adapters, scope и lifecycle.
Framework binding подключает готовый API к React, Vue, Next.js и другим фреймворкам.
```
Краткая схема:
```text
┌─ browser preset
├─ SSR request preset
Business factory + ports ├─ server action preset
├─ per-test assembly
└─ другой application preset
готовый business API instance
├─ framework bindings
├─ compositions
└─ другие business factories через ports
```
## Зафиксированные гипотезы
### DOM-N001: Domain является отдельной архитектурной сущностью
Domain является границей владения одной предметной областью. Он содержит modules и logical groups с разной технической ролью, но общей доменной принадлежностью.
### DOM-N002: Business внутри Domain является модулем
`business` имеет собственную ответственность и public API, поэтому это module, а не segment. `types/`, `services/`, `errors/` и `lib/` внутри business остаются segments.
### FAC-N001: Один business-контракт имеет одну factory
Разные среды выполнения не требуют разных factories, если они предоставляют один и тот же API. Различия среды выражаются ports, adapters и presets.
### PRE-N001: Одна factory допускает несколько presets
Browser, SSR, server action, tests и другие контексты могут собирать одну factory с разными реализациями ports.
### FAC-N002: Business и factory нейтральны к framework и environment
Изоморфный business import graph не достигает React, Vue, Next.js, browser-only, server-only, SDK, storage implementations и других concrete runtimes.
### PRE-N002: Среда является свойством preset
Client/server/request различия определяются preset и выбранными adapters, а не `mode` внутри factory. Tests создают отдельную per-test assembly напрямую через factory и не требуют общего test preset.
## Карта заметок
- [Domain](./domain.md) - роль новой сущности, структура и публичные границы.
- [Business](./business.md) - ответственность business-модуля, types, pure functions и errors.
- [Factory, ports и adapters](./factory-ports-adapters.md) - контракт factory и требования изоморфности.
- [Presets и SSR](./presets.md) - варианты сборки, lifecycle и защита server-only кода.
- [Framework bindings](./framework-bindings.md) - React/Vue/Next-код внутри Domain.
- [Тестирование](./testing.md) - границы тестов, factory-level contract, harness, adapters, presets, framework и UI.
- [Auth как проверочный пример](./auth-example.md) - применение гипотез к реальному модулю.
- [Открытые вопросы](./open-questions.md) - решения, которые ещё нельзя превращать в правила.
## Предварительная структура приложения
```text
src/
├── app/
├── compositions/
├── domains/
├── infra/
├── ui/
└── shared/
```
`domains/` пока рассматривается как новая верхнеуровневая область, заменяющая разнесение одной доменной ответственности между `business/{domain}` и `compositions/business/{domain}`.

View File

@@ -0,0 +1,173 @@
# Auth как проверочный пример
> Рабочая заметка на основе реального модуля `/home/gromov/projects/biocad/newbiocadru/apps/web/src/business/auth`. Код проекта не изменялся.
Цель примера: проверить гипотезы Domain на существующем SLM business-модуле, а не предложить немедленную миграцию.
## Текущее устройство
```text
business/auth/
├── auth.factory.ts
├── errors/
├── hooks/
├── mappers/
├── services/
├── tests/
├── types/
└── index.ts
```
Runtime-сборка находится отдельно:
```text
compositions/business/knv/auth/
├── adapters/
├── create-knv-auth-business.ts
└── index.ts
```
Новая сущность Domain может колоцировать обе ответственности без смешивания ролей:
```text
domains/auth/
├── business/
├── presets/
│ └── {preset-name}/
│ └── adapters/
└── {framework-binding}/
```
## Factory и client boundary
### AUTH-N001: Текущий AuthApi содержит client-oriented hook
`auth.factory.ts` импортирует `createAuthHook`, а `hooks/use-auth.hook.ts` содержит `'use client'`. Кроме того, `AuthDeps.session` описывает `useToken`.
Текущий transitive graph:
```text
authFactory
→ createAuthHook
→ 'use client'
```
Это практический пример того, почему neutral factory должна проверяться по всему transitive import graph, а framework hooks должны находиться в отдельном framework module Domain. Точный путь этого module пока не выбран.
Возможное направление:
```text
business AuthApi
→ framework-neutral state observation
React binding
→ useAuth над готовым AuthApi
```
Финальный state contract пока не выбран.
## Pure phone logic
### AUTH-N002: Нормализация телефона уже дублируется
Business содержит private `normalizePhoneOtpPhone`, а auth-widget содержит отдельный `getPhoneDigits` и собственный `PHONE_DIGITS_LENGTH`.
Это кандидат на public pure business function:
```ts
import {
normalizeAuthPhone,
validateAuthPhone,
} from '@/domains/auth/business'
```
Business service и UI могут использовать одну семантику. Business service всё равно повторно валидирует вход независимо от UI-проверки.
Существующий `business/user` показывает другой workaround: pure validators возвращаются через собранный `userFactory` API. Прямой pure export позволит не требовать assembly для детерминированной функции.
## Error contract
### AUTH-N003: Error contract фактически публичен, но описан не полностью
Business создаёт `AuthBusinessError` с `code` и `retryAfterSeconds`, но public `index.ts` экспортирует только type `AuthErrorCode`.
Consumer auth-widget поэтому:
- повторяет строковые error codes в message map;
- создаёт локальный `AuthErrorData`;
- вручную проверяет `code` и `retryAfterSeconds` в `unknown`;
- самостоятельно нормализует форму caught error.
Предварительное исправление границы:
```ts
// Public business API.
export { AUTH_ERROR_CODES, isAuthError }
export type { AuthError, AuthErrorCode }
// Business-private implementation.
class AuthBusinessError extends Error {}
const createAuthBusinessError = (...) => {}
```
Consumer получает безопасный observation contract, но не получает constructor и source mapping.
## Presets
### AUTH-N004: Текущий createKnvAuthBusiness является preset
`createKnvAuthBusiness()` выбирает `knvAuthPhoneAdapter` и `appAuthSessionAdapter`, затем вызывает `authFactory`.
В новой терминологии это application preset, внутри которого могут оставаться KNV-specific adapters:
```text
domains/auth/presets/application/create-application-auth.ts
```
Он не является единственно допустимым assembly site. Tests, SSR request composition и другой product preset могут напрямую вызвать ту же `authFactory`.
## SSR-вариант
Одна factory позволяет получить request-scoped API без второй реализации business:
```ts
import 'server-only'
export const createAuthForRequest = (input: AuthRequestInput) => {
return authFactory({
authPhone: createKnvServerAuthPhoneAdapter(input),
session: createRequestAuthSessionAdapter(input),
})
}
```
Browser preset использует другую реализацию тех же ports. Factory, business types, pure functions и error contract остаются общими.
## Предварительная целевая структура
```text
domains/auth/
├── business/
│ ├── auth.factory.ts
│ ├── errors/
│ ├── lib/
│ ├── mappers/
│ ├── services/
│ ├── tests/
│ ├── types/
│ └── index.ts
├── presets/
│ └── application/
│ ├── adapters/
│ ├── create-application-auth.ts
│ ├── create-application-auth.test.ts
│ └── index.ts
└── {framework-binding}/
└── index.ts
```
Это только проверочная структура. Она не фиксирует обязательность всех папок и не должна использоваться как scaffold checklist.
Server-only/request preset может быть добавлен отдельным module при реальной потребности. Он не образует обязательную `server`-ветку Domain.
Tests не используют общий testing preset. Business tests выполняют per-test assembly напрямую через `authFactory`, а production presets тестируются рядом с собственной реализацией только на wiring, scope и lifecycle.

174
DRAFT/domains/business.md Normal file
View File

@@ -0,0 +1,174 @@
# Business module внутри Domain
> Рабочая заметка. Не является нормативным разделом спецификации.
## Роль
### BUS-N001: Business является семантическим ядром Domain
Business-модуль владеет:
- публичными бизнес-сценариями;
- business-owned types и contracts;
- business API;
- factory и ports;
- детерминированными доменными правилами;
- доменным error contract;
- преобразованием внешних результатов в доменные результаты.
Business не владеет concrete runtime, environment wiring и framework integration.
## Public API business-модуля
### BUS-N002: Business может экспортировать четыре категории сущностей
| Категория | Примеры |
|---|---|
| Factory | `authFactory` |
| Types и contracts | `AuthApi`, `AuthDeps`, `AuthState`, `AuthErrorCode` |
| Pure domain functions | `normalizeAuthPhone`, `validateAuthPhone` |
| Error observation contract | `AUTH_ERROR_CODES`, `AuthError`, `isAuthError` |
Это заменяет старую гипотезу, что business `index.ts` может экспортировать в runtime только factory.
Предварительный public API:
```ts
export { authFactory } from './auth.factory'
export {
AUTH_ERROR_CODES,
isAuthError,
} from './errors/auth-error'
export {
normalizeAuthPhone,
validateAuthPhone,
} from './lib/auth-phone'
export type {
AuthApi,
AuthDeps,
AuthError,
AuthErrorCode,
AuthFactory,
AuthState,
}
```
## Types
### BUS-N003: Business contracts остаются внутри business
Отдельный `model` submodule пока не требуется. Типы размещаются по ownership:
| Тип | Место |
|---|---|
| `AuthApi`, `AuthDeps`, `AuthState` | `domains/auth/business/types` |
| `AuthError`, `AuthErrorCode` | `domains/auth/business/types` или `errors` |
| SDK DTO | Adapter или infra runtime |
| React provider props | Выбранный React binding module Domain |
| View model конкретного screen | Consumer composition |
`types/` является segment business-модуля, а не самостоятельным общим хранилищем Domain.
## Pure domain functions
### BUS-N004: Детерминированная доменная функция может экспортироваться напрямую
Pure domain function:
- получает все данные через аргументы;
- возвращает результат только на основе аргументов;
- не использует `Deps`;
- не выполняет I/O;
- не читает mutable runtime state;
- не зависит от clock, random, env или platform API;
- не импортирует React, Vue, Next.js или state manager;
- использует business language и реализует доменное правило.
Примеры:
```ts
normalizeAuthPhone(value)
validateAuthPhone(value)
calculateOrderTotal(order)
hasRequiredUserAgreements(user)
```
Consumer может использовать такую функцию для раннего UX feedback. Business scenario всё равно обязан повторно проверить вход на своей границе.
### BUS-N005: Не каждая pure function становится public
Функция остаётся private, если она нужна только одному service или является технической деталью реализации. Public export оправдан доменной семантикой и реальным внешним либо межмодульным consumer.
Папки `domain/shared` и `domain/public` не создаются только ради видимости. Public contract определяется entrypoint business-модуля.
## Domain errors
### BUS-N006: Создание и наблюдение ошибки являются разными контрактами
Business создаёт domain error. Consumer только распознаёт ошибку и читает поля, от которых зависит его поведение.
Public observation contract:
```ts
export const AUTH_ERROR_CODES = {
PHONE_OTP_PHONE_INVALID: 'AUTH_PHONE_OTP_PHONE_INVALID',
PHONE_OTP_VERIFY_CODE_INVALID: 'AUTH_PHONE_OTP_VERIFY_CODE_INVALID',
PHONE_OTP_RESEND_TOO_SOON: 'AUTH_PHONE_OTP_RESEND_TOO_SOON',
} as const
export type AuthErrorCode =
(typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES]
export type AuthError = Readonly<{
code: AuthErrorCode
retryAfterSeconds: number | null
}>
export const isAuthError = (value: unknown): value is AuthError => {
// Structural runtime validation.
}
```
Private creation contract:
```ts
class AuthBusinessError extends Error implements AuthError {
// Constructor, cause и source diagnostics.
}
const createAuthBusinessError = (...) => {
// Source error mapping.
}
```
### BUS-N007: Error constructor не является consumer API
Consumer не должен создавать `AuthBusinessError`, выбирать source mapping или подделывать business failure. Поэтому наружу предполагается экспортировать:
- stable error code values;
- error code type;
- read-only observable error shape;
- runtime guard или parser.
Наружу не предполагается экспортировать:
- error constructor;
- error factory;
- source error mapper;
- transport-specific error data;
- internal fallback selection.
### BUS-N008: Одних типов недостаточно при throw-based API
TypeScript не описывает checked exceptions. Для сигнатуры
```ts
(data: VerifyPhoneOtpData) => Promise<void>
```
значение в `catch` всё равно имеет тип `unknown`. Если consumer различает ошибки по `code`, business должен предоставить runtime discriminator либо перейти на typed `Result`.
Выбор между throw + guard и typed `Result` пока не закрыт окончательно. Текущий минимальный путь совместимости: throw + public observation contract.

206
DRAFT/domains/domain.md Normal file
View File

@@ -0,0 +1,206 @@
# Domain
> Рабочая заметка. Не является нормативным разделом спецификации.
## Определение
### DOM-N003: Domain является границей владения предметной областью
Domain группирует business-контракт, concrete integrations, готовые presets и framework-specific bindings одной предметной области.
Примеры Domain:
- `auth`;
- `user`;
- `catalog`;
- `orders`;
- `checkout`.
Domain не является одним большим module. Он является границей, внутри которой могут находиться modules и logical groups с заданным направлением зависимостей.
```text
Domain
├── business module
├── presets group
│ └── preset modules
├── framework binding module или group
└── optional reusable adapters group
└── adapter modules
```
## Предварительная структура
```text
domains/auth/
├── business/
│ ├── auth.factory.ts
│ ├── errors/
│ ├── lib/
│ ├── services/
│ ├── tests/
│ ├── types/
│ └── index.ts
├── presets/
│ └── {preset-name}/
│ ├── adapters/
│ ├── create-auth.ts
│ ├── create-auth.test.ts
│ └── index.ts
└── {framework-binding}/
├── hooks/
├── providers/
├── tests/
├── ui/
└── index.ts
```
`{preset-name}` и `{framework-binding}` являются placeholders, а не обязательными именами папок. Preset называется по своему scope или назначению. Framework binding может быть оформлен как `react`, `bindings/react`, `framework/react` или по другому локальному соглашению.
Environment-specific preset, включая server-only вариант, может быть добавлен отдельным preset module. SLM не требует заранее делить `presets` или `adapters` на `browser`, `server` и другие технические категории.
## Возможные ветки Domain
### DOM-N007: Domain не имеет фиксированного набора верхних папок
| Роль | Типичная форма | Статус |
|---|---|---|
| Business | Один business module | Основная гипотеза Domain |
| Presets | Logical group с preset modules | По наличию повторяемых assemblies |
| Framework bindings | Module или logical group | По наличию framework integration |
| Reusable adapters | Logical group с adapter modules | Только после promotion из владельца |
| Tests | Segment конкретного module | Не создаётся в корне Domain |
`model`, `types`, `errors`, `lib`, `ui`, `client` и `server` не становятся верхними Domain-разделами автоматически. Они размещаются внутри module-владельца либо появляются как локальное соглашение с отдельным обоснованием.
## Иерархия сущностей
### DOM-N004: Роль и структурный вид являются независимыми характеристиками
Архитектурная роль отвечает на вопрос «какую ответственность выполняет код»:
- business;
- preset;
- framework binding;
- adapter.
Структурный вид отвечает на вопрос «как оформлена граница кода»:
- Domain;
- module;
- group;
- segment;
- file.
```text
Domain
├── Module
│ ├── Segment
│ │ └── File
│ └── File
└── Group
├── Module
└── Group
└── Module
```
Правила структурных видов:
- Module владеет самостоятельной ответственностью и public API.
- Group является logical directory для навигации, не имеет `index.ts`, runtime и собственных файлов реализации.
- Segment существует внутри module, группирует его файлы по назначению и не имеет отдельного внешнего API.
- Имя папки само по себе не доказывает её структурный вид.
Пример классификации:
| Путь | Роль | Структурный вид |
|---|---|---|
| `domains/auth` | Предметная область Auth | Domain |
| `domains/auth/business` | Business | Module |
| `domains/auth/business/services` | Business scenarios | Segment |
| `domains/auth/business/tests` | Business tests | Segment |
| `domains/auth/presets` | Навигация presets | Group |
| `domains/auth/presets/{preset-name}` | Preset | Module |
| `domains/auth/presets/{preset-name}/adapters` | Private adapters preset | Segment |
| `domains/auth/{framework-binding}` | Framework binding | Module или Group по фактической границе |
| `domains/auth/adapters` | Навигация promoted adapters | Optional group |
| `domains/auth/adapters/{adapter-name}` | Reusable adapter | Module |
## Публичные границы
### DOM-N005: Domain предоставляет отдельные public submodules
Предварительно Domain не имеет обязательного общего facade. Каждый public module предоставляет собственный entrypoint:
```ts
import { authFactory, validateAuthPhone } from '@/domains/auth/business'
import { createApplicationAuth } from '@/domains/auth/presets/application'
import { AuthProvider, useAuth } from '@/domains/auth/react'
```
`application` и `react` здесь являются только примерами пользовательских имён. Отдельные entrypoints не смешивают business, concrete assembly и framework code в одном import graph.
Возможные public entrypoints:
```text
@/domains/auth/business
@/domains/auth/presets/{preset-name}
@/domains/auth/{framework-binding}
@/domains/auth/adapters/{adapter-name} # только для promoted adapter module
```
Private adapters внутри preset не получают собственного внешнего entrypoint.
### DOM-N006: Omnibus barrel для всего Domain опасен
Такой entrypoint может связать изоморфный, client-only и server-only graphs:
```ts
// Не использовать как default-подход.
export * from './business'
export * from './presets/application'
export * from './react'
```
Tree shaking не считается security boundary. Server-only submodule не должен быть достижим из изоморфного или client entrypoint даже через re-export.
## Предварительное направление зависимостей
```text
business
preset + private adapters
готовый business API instance
framework bindings / compositions
```
Более точная схема импортов:
```text
business -/→ adapters | presets | framework | infra concrete runtime
preset-private adapters → business contracts + concrete runtime
promoted adapter module → business contracts + concrete runtime
presets → business factory + private or promoted adapters
framework → business contracts + ready API or preset
compositions → ready business API + framework bindings
```
Framework module может одновременно быть assembly site, если он явно владеет lifecycle API instance. Наличие папки `presets/` не даёт ей монополию на вызов factory.
## Domain и compositions
Domain владеет повторяемой доменной ответственностью. Composition по-прежнему владеет страницей, route tree, экраном и конкретным пользовательским outcome.
Предварительная граница:
| Ответственность | Владелец |
|---|---|
| Auth scenarios и contracts | `domains/auth/business` |
| Private auth adapters одной assembly | Segment внутри соответствующего preset module |
| Reusable auth adapter | Optional adapter module после promotion |
| Повторяемая сборка AuthApi | Конкретный preset module или другой assembly site |
| Auth React provider/access hook | Выбранный framework binding module |
| Текст ошибки, redirect, экран и route outcome | Consumer composition |
Граница domain-specific UI пока остаётся открытым вопросом.

View File

@@ -0,0 +1,200 @@
# Factory, ports и adapters
> Рабочая заметка. Не является нормативным разделом спецификации.
## Терминология
### FAC-N003: Собирается API instance, а не factory
```text
Factory + Deps implementations → business API instance
```
- Factory является функцией создания.
- Ports являются business-owned контрактами capabilities.
- `Deps` группирует ports, нужные factory.
- Adapters реализуют ports в concrete runtime.
- Assembly site вызывает factory и получает API instance.
- Preset является готовой конфигурацией assembly.
Формулировка «собранная фабрика» неточна. Factory конфигурируется зависимостями и создаёт собранный API.
## Business factory
### FAC-N004: Factory является framework-neutral и environment-neutral
Factory не знает, где будет использована:
- в browser;
- во время SSR;
- в server action;
- в background process;
- в unit test;
- в React, Vue или другом framework.
```ts
export type AuthFactory = (deps: AuthDeps) => AuthApi
```
### FAC-N005: Factory имеет стабильную форму результата
Все presets одной factory создают один и тот же business API contract. Среда не выбирается через аргумент `mode`, а форма API не зависит от наличия optional dependency.
Не рекомендуется:
```ts
authFactory({
mode: 'server',
serverAdminClient: optionalClient,
})
```
Не рекомендуется возвращать методы, которые существуют в общем API, но намеренно падают в одной из сред.
### FAC-N006: Factory construction не выполняет side effects
Вызов factory не должен:
- выполнять network request;
- читать cookies, storage или env;
- запускать subscription или timer;
- обращаться к browser либо Node API;
- создавать скрытый application singleton;
- выбирать concrete adapter;
- выполнять framework lifecycle.
Factory может синхронно создать детерминированные services и связать их с переданными ports.
## Гигиена import graph
### FAC-N007: Весь достижимый из business import graph должен быть изоморфным
Недостаточно проверить только файл `{domain}.factory.ts`. Ни один production import, достижимый из business public entrypoint, не должен приводить к:
- React, Vue, Next.js и другим frameworks;
- `'use client'`, `client-only` или `server-only` boundary;
- browser API;
- Node-only API;
- concrete SDK/client;
- concrete storage;
- state/query runtime;
- adapters и presets;
- environment configuration.
Tree shaking не используется как доказательство изоляции.
## Ports
### PORT-N001: Port принадлежит business
Port описывает capability на языке business, а не форму concrete implementation.
```ts
export type AuthPhonePort = {
requestCode: (phone: string) => Promise<unknown>
verifyCode: (data: VerifyPhoneOtpData) => Promise<unknown>
}
```
Port не должен раскрывать SDK client, generated operation, `Request`, `Window`, React hook, Zustand `StoreApi` и другие environment/framework types.
### PORT-N002: Ports абстрагируют implementation, но не доступность capability
Одна factory возможна, пока каждый preset способен реализовать одинаковые ports.
Server capability может остаться общим port, если browser adapter реализует её через безопасный HTTP/RPC boundary. Если capability принципиально невозможно реализовать в одной из поддерживаемых сред, её нельзя маскировать optional dependency общего API.
### PORT-N003: Reactive port должен быть framework-neutral
Client hook в `Deps` делает контракт client-oriented. Вместо `useToken` базовый port может описывать framework-neutral observation protocol:
```ts
export type AuthSessionPort = {
getSnapshot: () => AuthState
subscribe: (listener: () => void) => () => void
setToken: (token: string | null) => void
}
```
React binding может построить `useAuth` поверх `getSnapshot` и `subscribe`. Vue binding использует тот же port через собственный lifecycle.
Точная форма reactive ports требует отдельной проверки на реальном state manager.
## Adapters
### ADP-N001: Adapter реализует business port
Adapter знает одновременно business contract и concrete runtime:
```text
business port ← adapter → SDK / storage / browser / request
```
Adapter может:
- преобразовать domain arguments в transport arguments;
- вызвать concrete source;
- привести concrete runtime к минимальной форме port;
- управлять техническими деталями конкретной integration.
Adapter не должен:
- определять business error code;
- выбирать domain fallback;
- менять business invariant;
- расширять public business API методами concrete client.
### ADP-N002: Adapter размещается у минимального владельца
SLM не задаёт обязательную структуру `adapters/browser`, `adapters/server` или другую техническую классификацию.
Adapter может быть:
- private файлом или segment конкретного preset module;
- самостоятельным Domain module после появления нескольких assembly consumers;
- частью пользовательской logical group, если она действительно упрощает навигацию.
Default colocation для adapter, принадлежащего одной assembly:
```text
domains/auth/presets/{preset-name}/
├── adapters/
├── create-auth.ts
└── index.ts
```
Возможный promotion переиспользуемого adapter:
```text
domains/auth/adapters/ # optional logical group
└── {adapter-name}/ # adapter module
└── index.ts
```
Environment-specific code не должен быть достижим из entrypoint, объявленного framework-neutral или environment-neutral. Способ физической изоляции выбирает проект. Группировка по `browser/server` допустима как локальное соглашение, но не является требованием SLM.
## Assembly sites
### ASM-N001: Вызов factory определяет роль assembler
Factory может быть вызвана в preset, provider, route/request composition, test setup или другом месте. Путь сам по себе не запрещает сборку.
Assembly site обязан:
- предоставить полный `Deps`;
- выбрать concrete adapters;
- определить предполагаемый scope API instance;
- вернуть необходимые lifecycle/dispose handles;
- не скрывать создание graph от фактического владельца.
После возврата результата lifecycle принадлежит caller/graph owner, который удерживает API instance. Например, request владеет request-scoped instance, а Provider владеет instance до unmount. Preset описывает создание и передачу ownership, но не становится долгоживущим владельцем только из-за своего расположения.
### ASM-N002: Consumer использует готовый API
Screen, component или service, который только выполняет business-сценарий, получает готовый business API, например `AuthApi`. Если такой consumer вызывает factory, он становится assembler и должен удовлетворять всем требованиям assembly role.
### ASM-N003: Cross-domain dependency получает собранный API
Business одного Domain не создаёт factory другого Domain внутри себя. Он описывает необходимую capability через свой `Deps`, а graph owner передаёт уже собранный API.
Tests вправе напрямую вызывать factory с mocks и fakes. Это один из основных сценариев существования factory.

View File

@@ -0,0 +1,109 @@
# Framework bindings внутри Domain
> Рабочая заметка. Не является нормативным разделом спецификации.
## Определение
### FW-N001: Framework code определяется зависимостью от framework
К framework code относится код, существующий из-за React, Vue, Next.js или другого framework/runtime contract:
- components;
- providers и contexts;
- framework hooks;
- framework lifecycle;
- directives и framework entrypoints;
- framework-specific types;
- server/client component boundaries.
Такой код может принадлежать Domain по смыслу, но не размещается внутри framework-neutral business.
## Роль binding
### FW-N002: Framework binding адаптирует готовый business API
Framework binding может:
- предоставить готовый business API через context/provider;
- построить React/Vue hook доступа;
- связать framework lifecycle с domain subscription;
- предоставить domain-specific framework component;
- получить API instance через props, context или preset.
Framework binding не изменяет business rules и не реализует source adapter вместо Domain preset/adapters.
## Возможная структура
```text
domains/auth/{framework-binding}/
├── providers/
├── hooks/
├── components/
├── types/
└── index.ts
```
`{framework-binding}` является placeholder. SLM пока не выбирает между `react`, `bindings/react`, `framework/react` и другим локальным соглашением. Чёткая граница определяется самостоятельным module и отдельным public entrypoint, а не обязательным именем родительской папки.
Если одна папка предоставляет cohesive framework API, она является module. Если папка только классифицирует несколько самостоятельных binding modules, она является logical group и не имеет собственного `index.ts`.
## Reactive state
### FW-N003: Framework hook строится снаружи business
Если business предоставляет framework-neutral `getSnapshot` и `subscribe`, React binding может использовать `useSyncExternalStore`:
```ts
'use client'
export const createUseAuth = (authApi: AuthApi) => {
return () => {
return useSyncExternalStore(
authApi.subscribeAuthState,
authApi.getAuthState,
authApi.getAuthState,
)
}
}
```
Это только иллюстрация направления. Финальная форма state port должна учитывать реальный state/query runtime.
Business при таком подходе не импортирует React и не возвращает React hook как единственный способ чтения состояния.
## Framework module как assembly site
### FW-N004: Provider может владеть API instance
Provider вправе вызвать preset или factory, если provider является явным владельцем scope и lifecycle:
```text
AuthProvider
→ createBrowserAuth preset
→ AuthApi instance
→ context
→ access hooks
```
Provider construction не должен запускать I/O или subscription до framework commit/effect. Cleanup выполняется владельцем lifecycle.
Framework module не обязан собирать API. Он также может получить готовый instance от route/page/application graph owner.
## Framework-neutral и environment-neutral
Эти свойства различаются:
| Свойство | Запрещённая зависимость |
|---|---|
| Framework-neutral | React, Vue, Next lifecycle и types |
| Environment-neutral | Browser-only, Node-only, server-only, env/runtime globals |
Business factory должна удовлетворять обоим свойствам. Framework binding по определению framework-specific, а preset по определению может быть environment-specific.
## Domain UI
### FW-N005: Framework принадлежность не доказывает Domain ownership
React component размещается внутри Domain только если его ответственность принадлежит Domain. Page, screen, route outcome, локальный текст ошибки и продуктовая композиция могут остаться в `compositions`.
Граница между domain-specific components и consumer compositions пока требует отдельных примеров.

View File

@@ -0,0 +1,113 @@
# Открытые вопросы Domains
> Эти вопросы намеренно не сформулированы как правила.
## Ошибки
### OPEN-N001: Throw или typed Result
Нужно решить, остаются ли ожидаемые domain failures исключениями с public runtime guard или business API возвращает discriminated `Result<T, DomainError>`.
Текущий совместимый вариант: throw + `isDomainError`. Typed Result потребует изменения формы всех scenario methods.
### OPEN-N002: Универсальный или domain-specific error guard
Нужно определить, достаточно ли общего `isDomainError`, либо каждый business-модуль экспортирует `isAuthError`, `isUserError` и собственную проверку code set.
## Domain structure
### OPEN-N003: Имена framework modules
Варианты:
```text
domains/auth/framework/react
domains/auth/bindings/react
domains/auth/react
```
`framework/react` явно классифицирует роль, `react` сокращает import path, а `bindings/react` подчёркивает adapter-like назначение границы. Выбор пока не сделан.
### OPEN-N004: Нужен ли root Domain entrypoint
Статус: предварительно закрыт в пользу нескольких entrypoints.
Каждый public module Domain предоставляет собственную точку входа: business, конкретный preset, framework binding и promoted adapter. Обязательный root runtime barrel не создаётся, потому что он может смешать isomorphic, client-only и server-only graphs.
### OPEN-N005: Public adapters
Текущая гипотеза: adapter начинается как private segment минимального владельца, обычно preset module. При появлении самостоятельной ответственности или нескольких assembly consumers он может быть поднят в отдельный adapter module с собственным entrypoint.
Открытым остаётся точный promotion criterion; фиксированная числовая граница пока не выбрана.
## Factory и ports
### OPEN-N006: Гранулярность одной factory
Одна factory может возвращать большой API, хотя конкретному SSR scope нужны два метода. Нужно проверить, достаточно ли narrowed preset view, или крупные contracts требуют нескольких business modules/factories.
Предварительный принцип: одна factory на один связный business API contract; разные environments сами по себе не создают новую factory.
### OPEN-N007: Reactive state contract
Нужно проверить на реальном Zustand/React/SSR кейсе форму framework-neutral state port:
- `getSnapshot` + `subscribe`;
- commands/selectors;
- initial server snapshot;
- hydration;
- cleanup;
- concurrent rendering.
## Framework boundary
### OPEN-N008: Domain-specific UI
Нужно решить, какие auth components принадлежат выбранному Auth framework binding module, а какие остаются composition widgets/screens.
Framework dependency сама по себе не доказывает Domain ownership.
## Cross-domain dependencies
### OPEN-N009: Прямой импорт pure functions другого Domain
Нужно определить, может ли business одного Domain напрямую импортировать pure function другого Domain или cross-domain связь всегда должна проходить через `Deps`.
Возможный компромисс:
- type-only contracts разрешены;
- runtime API передаётся через ports;
- pure function import разрешён только как явно зафиксированная ацикличная Domain dependency.
## Уровни архитектуры
### OPEN-N010: На каком уровне появляется Domain
Нужно встроить Domain в монотонную шкалу архитектурных уровней. Более высокий уровень должен добавлять требования и не отменять правила предыдущего.
Предварительный вариант:
```text
Level 1: modules
Level 2: layers
Level 3: domains
Level 4: runtime-safe factories, ports, presets и verification
```
Точная классификация будет выполнена после извлечения атомарных правил из legacy-документации и этих заметок.
## Проверяемость
### OPEN-N011: Architecture lint
Будущие проверки могут контролировать:
- запрещённые imports из `business/**`;
- отсутствие server-only graph в isomorphic entrypoint;
- отсутствие client framework в factory graph;
- разрешённые категории exports business public API;
- запрет `export *` на environment boundaries;
- cycles между Domain modules;
- preset lifecycle declarations.
Семантическую чистоту функции нельзя надёжно доказать только по имени export. Для этого потребуется сочетание folder conventions, import restrictions, AST checks и public API tests.

141
DRAFT/domains/presets.md Normal file
View File

@@ -0,0 +1,141 @@
# Presets и SSR
> Рабочая заметка. Не является нормативным разделом спецификации.
## Определение
### PRE-N003: Preset является готовым вариантом assembly
Preset выбирает implementations ports и создаёт API одной business factory для конкретного execution context.
```ts
export const createKnvAuthBusiness = (): AuthApi => {
return authFactory({
authPhone: knvAuthPhoneAdapter,
session: appAuthSessionAdapter,
})
}
```
`createKnvAuthBusiness` является preset builder, а не второй factory и не единственно допустимое место сборки.
## Несколько presets одной factory
```text
authFactory
├── createBrowserAuth
├── createAuthForRequest
├── createAuthForServerAction
└── другие production presets
tests и custom graph owners могут вызывать authFactory напрямую
```
### PRE-N004: Presets могут отличаться adapters и lifecycle
Browser preset может использовать browser storage и query runtime. Request preset может использовать cookies, headers и request-scoped client. Tests вместо общего preset создают локальную per-test assembly с memory ports, mocks или fakes.
Business rules и форма создаваемого `AuthApi` при этом не меняются.
### PRE-N005: Preset может предоставлять суженный API view
Preset может не раскрывать consumer все методы созданного API:
```ts
export type AuthSsrApi = Pick<AuthApi, 'resolveSession'>
export const createAuthForRequest = (
input: AuthRequestInput,
): AuthSsrApi => {
const authApi = authFactory(createRequestAuthDeps(input))
return {
resolveSession: authApi.resolveSession,
}
}
```
Это ограничивает contract конкретного scope, но не создаёт новую business factory.
## SSR
### PRE-N006: Request владеет instance, созданным request preset
Если API зависит от cookies, headers, tenant, locale, request ID или abort signal, preset создаёт новый instance для каждого request и передаёт ownership вызывающему request scope.
Application singleton для request data недопустим, потому что может смешать состояния независимых запросов. Если preset создаёт disposable resource, результат должен позволить request owner выполнить cleanup.
Предварительная форма:
```ts
import 'server-only'
export const createAuthForRequest = (
input: AuthRequestInput,
): AuthApi => {
return authFactory({
authPhone: createServerAuthPhoneAdapter(input),
session: createRequestSessionAdapter(input),
})
}
```
### PRE-N007: SSR использует тот же business contract
Преимущества одной factory:
- одинаковые business rules в browser и на server;
- одинаковые domain types и errors;
- request adapters не протекают в business;
- factory тестируется без Next.js;
- backend, cookies и headers заменяются независимо;
- server rendering не требует второй реализации business.
## Server-only boundary
### PRE-N008: Environment-specific preset может иметь отдельный public entrypoint
Если preset должен быть недостижим из client graph, проект может выделить для него отдельный entrypoint и использовать framework/build marker. Имя и физическая группировка preset не задаются SLM.
```ts
// Один из возможных server-only preset entrypoints.
import 'server-only'
export { createAuthForRequest } from './create-auth-for-request'
```
Этот entrypoint не реэкспортируется через:
- `domains/auth/business`;
- browser preset;
- React client binding;
- общий Domain barrel.
Server adapters также могут иметь собственный `server-only` marker для защиты от ошибочного прямого импорта.
### PRE-N009: Isomorphic factory не импортирует server-only marker
`server-only` относится к preset/framework boundary, а не к business factory. Это позволяет вызывать factory в unit tests, другом server framework или browser preset.
## Browser boundary
### PRE-N010: Client-compatible preset не достигает server-only graph
Client-compatible preset импортирует только isomorphic business и совместимые с ним adapters. Secrets, privileged SDK и Node-only modules не должны входить в его transitive import graph.
Framework marker `'use client'` размещается в framework binding или client entrypoint, а не в business.
## Preset не является обязательным посредником
### PRE-N011: Custom assembly остаётся допустимой
Graph owner может напрямую вызвать factory:
```ts
const authApi = authFactory({
authPhone: customAuthPhoneAdapter,
session: memorySessionAdapter,
})
```
Preset нужен для повторяемой готовой конфигурации. Он не ограничивает DI-возможности factory.

402
DRAFT/domains/testing.md Normal file
View File

@@ -0,0 +1,402 @@
# Тестирование Domain
> Рабочая заметка. Не является нормативным разделом спецификации.
## Главный принцип
### TST-N001: Тест размещается у владельца проверяемой ответственности
Domain не получает одну общую папку `tests/` для всего кода. Business behavior, adapter wiring, preset lifecycle, framework bindings и UI имеют разных владельцев и тестируются рядом с ними.
```text
business behavior → business tests
pure domain rule → colocated business test
adapter behavior → adapter test
preset assembly → preset test
framework lifecycle → framework binding test
UI interaction → UI owner test
cross-domain graph → graph owner test
```
## Матрица покрытия
| Граница | Предварительная обязательность | Что проверяется |
|---|---|---|
| Business factory | Главная, обязательная | Scenarios, state, errors, ports, порядок effects |
| Public pure business functions | Обязательная | Validation, normalization, invariants и edge cases |
| Internal runtime-safe logic | По сложности | Mappers, guards, parsers, races и branching |
| Domain error implementation | Обязательная при runtime errors | Codes, guard, observable fields и source isolation |
| Adapters | Обязательная при наличии | Port contract, payload, raw result/error и cleanup |
| Production presets | Обязательная при наличии | Wiring, scope, ownership transfer и construction safety |
| Framework bindings | При наличии поведения | Provider, hooks, reactivity, lifecycle и hydration |
| Domain-owned UI | При наличии значимого поведения | States, interactions и accessibility contract |
| Cross-domain graph | При наличии graph | Assembly order, API handoff, scope и cleanup |
| E2E | По продуктовой потребности | Полный пользовательский поток |
## Предварительная структура
```text
domains/auth/
├── business/
│ ├── auth.factory.ts
│ ├── index.ts
│ ├── index.test.ts
│ ├── errors/
│ │ ├── auth-error.ts
│ │ └── auth-error.test.ts
│ ├── lib/
│ │ ├── auth-phone.ts
│ │ └── auth-phone.test.ts
│ ├── services/
│ ├── types/
│ └── tests/
│ └── factory/
│ ├── public-api.test.ts
│ ├── request-phone-otp.test.ts
│ ├── resend-phone-otp.test.ts
│ ├── verify-phone-otp.test.ts
│ └── testing/
│ └── create-auth-test-harness.ts
├── presets/
│ └── {preset-name}/
│ ├── adapters/
│ │ ├── auth-source.adapter.ts
│ │ └── auth-source.adapter.test.ts
│ ├── create-auth.ts
│ ├── create-auth.test.ts
│ └── index.ts
└── {framework-binding}/
├── auth.provider.tsx
├── auth.provider.test.tsx
├── use-auth.ts
└── use-auth.test.tsx
```
Это карта возможных тестов, а не обязательный scaffold. Файл создаётся только вместе с реальным поведением, которое требуется проверить.
## Business tests
### TST-N002: Factory-level tests являются главными тестами Domain behavior
Business factory тестируется как black box через public API business-модуля:
```ts
import {
authFactory,
AUTH_ERROR_CODES,
isAuthError,
} from '@/domains/auth/business'
```
Factory-level tests не зависят от React, Next.js, production SDK, real storage или production presets. Все runtime capabilities заменяются test ports, mocks, stubs или in-memory fakes.
Обязательная матрица для public scenarios:
- форма возвращаемого API;
- отсутствие side effects при вызове factory;
- happy path;
- input validation;
- нормализация результатов ports;
- nullable, empty и malformed results;
- rejected promise dependency;
- synchronous throw dependency;
- stable domain error code;
- отсутствие raw source error как consumer contract;
- порядок side effects;
- остановка следующих effects после failure;
- state transitions;
- repeated и concurrent calls, если они влияют на контракт;
- lifecycle operations и cleanup, если они входят в public business API.
Если business behavior невозможно проверить без React, Vue, Next.js или concrete SDK, это сигнал о проникновении framework/runtime ответственности внутрь business.
### TST-N003: Factory-level test использует per-test assembly
Каждый test case создаёт factory с нужной именно ему конфигурацией ports:
```ts
it('maps source failure to domain error', async () => {
const cause = new Error('Network failed')
const requestCode = vi.fn().mockRejectedValue(cause)
const { api } = createAuthTestHarness({ requestCode })
await expect(api.requestPhoneOtp(phone)).rejects.toMatchObject({
code: AUTH_ERROR_CODES.PHONE_OTP_REQUEST_FAILED,
})
})
```
Другой test case создаёт независимую assembly:
```ts
it('does not call source for invalid phone', async () => {
const requestCode = vi.fn()
const { api } = createAuthTestHarness({ requestCode })
await expect(api.requestPhoneOtp('123')).rejects.toMatchObject({
code: AUTH_ERROR_CODES.PHONE_OTP_PHONE_INVALID,
})
expect(requestCode).not.toHaveBeenCalled()
})
```
### TST-N004: Test harness не является preset
Test harness является private test utility, которая уменьшает boilerplate и предоставляет observability:
```ts
const { api, ports, state } = createAuthTestHarness(overrides)
```
Test harness:
- private для конкретной test suite;
- не экспортируется production entrypoint;
- допускает произвольные scenario-specific overrides;
- создаёт новый API instance для каждого test case;
- не представляет устойчивую application environment;
- не имеет собственного production lifecycle;
- не размещается в `presets/`.
Общий `test preset` по умолчанию не создаётся. Если Storybook, demo application или e2e environment получают устойчивую именованную конфигурацию, это отдельный application preset, а не универсальная конфигурация unit tests.
Предварительное имя helper:
```text
business/tests/factory/testing/create-auth-test-harness.ts
```
## Public API tests
### TST-N005: Business public API проверяется отдельно
Runtime public exports фиксируются тестом entrypoint:
```ts
import * as authBusiness from '.'
expect(Object.keys(authBusiness).sort()).toEqual([
'AUTH_ERROR_CODES',
'authFactory',
'isAuthError',
'normalizeAuthPhone',
'validateAuthPhone',
])
```
Этот тест обнаруживает случайный runtime export, но не видит type-only exports. Полная проверка type surface должна выполняться будущим architecture lint или TypeScript API check.
Форма API instance также фиксируется factory-level test:
```ts
expect(Object.keys(authFactory(ports)).sort()).toEqual([
'requestPhoneOtp',
'resendPhoneOtp',
'signOut',
'verifyPhoneOtp',
])
```
## Pure domain functions
### TST-N006: Pure functions тестируются рядом с реализацией
```text
business/lib/auth-phone.ts
business/lib/auth-phone.test.ts
```
Проверяются:
- canonical values;
- boundary values;
- malformed input;
- normalization;
- invariants;
- отсутствие mutation входа;
- детерминированность результата.
```ts
describe('normalizeAuthPhone', () => {
it.each([
['8 (999) 111-22-33', '+79991112233'],
['+7 999 111 22 33', '+79991112233'],
['123', null],
])('normalizes %s', (input, expected) => {
expect(normalizeAuthPhone(input)).toBe(expected)
})
})
```
Business scenario повторно применяет то же правило на своей границе. UI validation не заменяет business validation.
## Internal tests
### TST-N007: Colocated tests дополняют public contract tests
Colocated tests оправданы для:
- mappers и normalizers;
- runtime guards и parsers;
- private error implementation;
- сложного branching;
- race/concurrency algorithms;
- reusable internal pure functions.
Отдельный test каждого service не требуется автоматически. Factory-level tests остаются главным доказательством, что внутренняя реализация подключена к public scenario правильно.
Service test добавляется, если он существенно упрощает проверку сложного внутреннего алгоритма и не дублирует целиком factory-level matrix.
## Domain errors
### TST-N008: Consumer contract ошибки тестируется без public constructor
Factory-level test проверяет observable contract:
```ts
try {
await api.verifyPhoneOtp(data)
} catch (error) {
expect(isAuthError(error)).toBe(true)
if (isAuthError(error)) {
expect(error.code).toBe(
AUTH_ERROR_CODES.PHONE_OTP_VERIFY_CODE_INVALID,
)
}
}
```
Consumer-level test не использует private `AuthBusinessError` constructor и не зависит от `instanceof` internal class.
Colocated test error implementation может отдельно проверить:
- private constructor;
- `cause`;
- source code mapping;
- source metadata normalization;
- защиту от malformed error values.
## Adapter tests
### TST-N009: Adapter test проверяет port boundary, а не business behavior
Adapter test размещается рядом с adapter и проверяет:
- правильную concrete operation;
- transport payload;
- преобразование domain arguments в concrete arguments;
- raw/unknown result согласно port contract;
- проброс source error без создания domain error;
- subscription cleanup;
- отсутствие лишних SDK operations в минимальном client;
- environment boundary, если она проверяема build/lint средствами.
Adapter test не повторяет domain error mapping, business fallback и scenario orchestration.
Если несколько adapters реализуют один нетривиальный behavioral port contract, позднее можно выделить reusable contract test suite. Она остаётся test-only utility и не становится preset.
## Preset tests
### TST-N010: Production preset test проверяет assembly risk
Preset test размещается рядом с production preset и проверяет:
- выбор правильных adapters;
- передачу полного `Deps` в factory;
- exact narrowed API view, если preset его задаёт;
- отсутствие I/O при construction;
- отсутствие import-time subscriptions и storage reads;
- scope API instance;
- передачу lifecycle/dispose handles caller;
- изоляцию двух request-scoped instances;
- server/client import boundary.
Preset test не повторяет happy path и error matrix business scenarios. Эти гарантии принадлежат factory-level tests.
## Framework binding tests
### TST-N011: Framework binding тестируется через fake business API
Framework unit test по умолчанию получает fake API, а не собирает реальную factory:
```tsx
const authApi = createAuthApiFake()
render(
<AuthProvider api={authApi}>
<Consumer />
</AuthProvider>,
)
```
Проверяются:
- Provider предоставляет переданный instance;
- access hook возвращает правильный API;
- использование без Provider даёт предсказуемую ошибку;
- изменение framework-neutral state вызывает framework update;
- subscriptions запускаются в правильной lifecycle phase;
- cleanup выполняется после unmount;
- Strict Mode не запускает construction side effects;
- server snapshot и hydration согласованы, если binding участвует в SSR.
Отдельный smoke test с real factory и memory ports добавляется только при самостоятельном integration risk. Такой тест принадлежит framework module либо graph owner, который действительно собирает эту связку.
## UI tests
### TST-N012: Domain UI тестируется при наличии значимого поведения
Компонент не требует test только потому, что он существует. Test оправдан, если Domain-owned UI:
- содержит interaction;
- отображает несколько domain states;
- реагирует на domain error code;
- управляет focus или keyboard navigation;
- имеет значимый accessibility contract;
- использует framework lifecycle;
- содержит регрессионно опасную presentation logic.
Проверяются observable behavior и accessibility semantics, а не внутренняя структура JSX/Vue template.
Snapshot-only tests не являются обязательным доказательством. Визуальные различия при необходимости проверяются отдельным visual regression инструментом.
Universal UI module тестируется в слое `ui`, а page/screen/composition UI тестируется у соответствующего composition owner. Наличие React/Vue само по себе не переносит ownership теста в Domain.
## Graph и E2E tests
### TST-N013: Cross-domain graph тестируется у graph owner
Проверяются:
- topological assembly order;
- передача собранных API в dependent factories;
- exact graph type;
- отсутствие повторной assembly без нужного scope;
- ownership instance;
- lifecycle start и cleanup;
- request/application/page isolation.
Business modules не содержат tests полного application graph.
### TST-N014: E2E дополняет, но не заменяет Domain tests
E2E проверяет пользовательский поток через реальный application entry. Он не заменяет factory-level tests, потому что не способен дешёво и детерминированно перебрать malformed responses, synchronous throws, races и все domain error mappings.
## Чего избегать
### TST-N015: Test suite не повторяет одну ответственность на всех уровнях
Не рекомендуется:
- повторять одну scenario matrix в service, factory, preset и framework tests;
- тестировать business через production SDK;
- использовать общий mutable API instance между tests;
- экспортировать test harness из production public API;
- создавать `presets/testing` как default-механизм unit tests;
- проверять private implementation из factory-level tests;
- считать type-only файл требующим runtime unit test;
- использовать real network или process env в business tests.
Минимальная правильная граница предпочтительнее большого количества дублирующих tests.

33
DRAFT/index.md Normal file
View File

@@ -0,0 +1,33 @@
---
layout: home
title: SLM Level 1
hero:
name: SLM Level 1
text: Базовая архитектура фронтенд-приложений
tagline: Слои, модули, зависимости, публичные границы и жизненный цикл без отдельной доменной архитектуры.
image:
src: /logo.svg
alt: SLM Design
actions:
- theme: brand
text: Читать Level 1
link: /level-1/
- theme: alt
text: Открыть правила
link: /rules/level-1
features:
- title: Пять слоёв
details: Линейный порядок app, compositions, infra, ui и shared задаёт роли кода и допустимое направление зависимостей.
- title: Модульные границы
details: Ответственность имеет одного владельца, а внешний код использует модуль только через его публичный API.
- title: 14 правил
details: Пять правил проверяются автоматически, девять требуют архитектурного ревью.
---
## Что опубликовано
Сайт содержит только рабочий черновик SLM Level 1 и его канонический реестр правил. Доменные уровни, монорепозитории и дополнительные режимы архитектуры пока не входят в опубликованную документацию.
Определения Level 1 нормативны внутри черновика. Точные формулировки блокирующих требований находятся только в [реестре правил](/rules/level-1).

53
DRAFT/level-1/README.md Normal file
View File

@@ -0,0 +1,53 @@
# SLM Level 1
> Статус: рабочий черновик. Документы в этой папке не являются спецификацией.
Level 1 задаёт основу SLM для лёгких проектов, которым нужна понятная организация без отдельной доменной архитектуры.
## Место в уровнях SLM
| Уровень | Назначение |
|---|---|
| Level 1 | Слои, зависимости, структурные сущности и жизненный цикл ресурсов |
| Level 2 | Слой `domains` для модулей с доменной логикой |
| Level 3 | Строгие правила доменов для крупных и критичных проектов |
Повышение уровня может требовать рефакторинга, но базовые понятия Level 1 сохраняются.
## Область Level 1
Level 1 описывает слои, модули, группы, сегменты, компоненты, публичный API, граф зависимостей и владение жизненным циклом ресурсов.
Level 1 не описывает домены, фабрики, порты, адаптеры, обязательный поток данных, монорепозитории, соглашения об именовании и файловый стайлгайд.
Появление самостоятельной доменной логики является сигналом рассмотреть Level 2.
## Виды утверждений
- **Определение** нормативно задаёт смысл архитектурного термина, но не получает код правила.
- **Правило** задаёт блокирующий архитектурный инвариант и объявляется в каноническом реестре.
- **Рекомендация** помогает принять решение, но не делает архитектуру невалидной.
- **Пример** иллюстрирует модель и не задаёт обязательную структуру.
Нормативные определения находятся в [терминологии](./terminology.md). Канонический набор правил: [правила SLM Level 1](../rules/level-1.md). Формат и требования к ним: [Правила SLM](../rules/).
Остальные документы Level 1 объясняют и иллюстрируют модель, но не владеют точными формулировками определений и правил.
## Основная идея
Модуль является основной архитектурной единицей SLM. Слой задаёт роль и направление зависимостей. Группа помогает навигации, сегмент организует внутреннее содержимое, а компонент всегда принадлежит модулю.
Level 1 требует отдельную папку и единый публичный API модуля. Имена этих элементов, внутренняя файловая форма модулей, сегментов и компонентов определяются стайлгайдом проекта.
## Карта черновика
- [Терминология](./terminology.md)
- [Слои](./layers.md)
- [Зависимости](./dependencies.md)
- [Модули](./modules.md)
- [Группы](./groups.md)
- [Сегменты](./segments.md)
- [Компоненты](./components.md)
- [Вложенные модули](./nested-modules.md)
- [Жизненный цикл](./lifecycle.md)
- [Проверка](./validation.md)

View File

@@ -0,0 +1,65 @@
# Компоненты Level 1
> Пояснение нормативной модели компонентов Level 1.
Компонент является строительным элементом интерфейса, а не самостоятельной архитектурной единицей.
## Связанные правила
- [`SLM-L1-COMPONENT-R009`](../rules/level-1.md#slm-l1-component-r009)
- [`SLM-L1-LAYER-A002`](../rules/level-1.md#slm-l1-layer-a002)
- [`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
- [`SLM-L1-DEPENDENCY-A005`](../rules/level-1.md#slm-l1-dependency-a005)
- [`SLM-L1-MODULE-R011`](../rules/level-1.md#slm-l1-module-r011)
- [`SLM-L1-LIFECYCLE-R013`](../rules/level-1.md#slm-l1-lifecycle-r013)
## Файловая форма
Файловую форму компонента определяет стайлгайд. Компонент может быть одним файлом фреймворка или каталогом со вспомогательными файлами.
```text
landing/
└── ui/
└── hero.tsx
```
```text
landing/
└── ui/
└── hero/
├── hero.tsx
├── styles/
│ └── hero.module.css
└── types/
└── hero-props.type.ts
```
Наличие каталога, типов, стилей или локального `index.ts` не превращает компонент в модуль.
## Реализация
Компонент может отображать входные данные, вызывать переданные обработчики, условно строить интерфейс и хранить локальное состояние представления.
Level 1 не вводит отдельного запрета на импорты, выполняемые кодом компонента. Каждый такой импорт считается зависимостью родительского модуля и должен соблюдать направление слоёв, публичные API и запрет циклов.
Доступ к данным, состояние, контекст или код жизненного цикла внутри компонента сами по себе не создают новую архитектурную границу. Их источники, зависимости и область жизни определяет родительский модуль.
Провайдер может технически реализовывать контекст и жизненный цикл фреймворка, но владельцем состояния и ресурсов остаётся родительский модуль.
Файл в `app` может технически быть компонентом React или Vue. Архитектурно он является точкой входа фреймворка, а не компонентом SLM.
## Компонент и модуль
| Признак | Компонент | Модуль |
|---|---|---|
| Самостоятельная ответственность | Нет | Да |
| Собственный публичный API | Нет | Да |
| Собственная граница зависимостей | Нет | Да |
| Вспомогательные файлы | Может иметь | Может иметь |
| Сегменты и вложенные модули | Нет | Может иметь |
Модуль может состоять всего из одного корневого компонента. Различие определяется владением, а не количеством файлов.
## Когда нужен вложенный модуль
Если часть интерфейса получает самостоятельную ответственность, публичный API, собственную границу зависимостей, область жизни или внутреннюю модульную декомпозицию, она является модулем. При локальном использовании такой модуль может размещаться как вложенный.

View File

@@ -0,0 +1,49 @@
# Зависимости Level 1
> Пояснение нормативной модели зависимостей Level 1.
Слои задают допустимое направление связей, а модули образуют граф зависимостей.
## Что считается зависимостью
- Обычный импорт, импорт типа и реэкспорт одинаково создают архитектурную зависимость.
- Импорт между файлами одного модуля не пересекает модульную границу и не создаёт отдельный узел графа.
- Импорт любого внутреннего файла, сегмента или компонента считается зависимостью ближайшего модуля-владельца.
- Вложенный модуль является обычным самостоятельным узлом графа.
- Группы, сегменты и компоненты не являются самостоятельными узлами графа.
Точка входа фреймворка не является модулем. Её импорты участвуют в проверке направления слоёв, но не образуют исходящий узел графа модулей.
Ресурс `shared` также не является модулем. Его прямой импорт участвует в проверке направления слоёв, но не нарушает требование о публичном API модуля.
## Направление
- Модуль может импортировать модули своего или любого нижнего слоя.
- Модули одного слоя могут импортировать друг друга.
- Промежуточный слой не является обязательным посредником.
Направление слоёв определено в [Слоях](./layers.md).
## Связанные правила
- [`SLM-L1-LAYER-A002`](../rules/level-1.md#slm-l1-layer-a002)
- [`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
- [`SLM-L1-DEPENDENCY-A005`](../rules/level-1.md#slm-l1-dependency-a005)
- [`SLM-L1-NESTED_MODULE-A010`](../rules/level-1.md#slm-l1-nested_module-a010)
## Публичный API
```ts
// Допустимо
import { Button } from '@/ui/button'
// Недопустимо
import { Button } from '@/ui/button/button'
```
## Циклы
```text
ui/modal → ui/button → ui/icon
ui/icon -/→ ui/modal
```

25
DRAFT/level-1/groups.md Normal file
View File

@@ -0,0 +1,25 @@
# Группы Level 1
> Пояснение нормативной модели групп Level 1.
Группа помогает ориентироваться в большом количестве модулей. Она классифицирует структуру, но ничего не реализует и не образует узел графа зависимостей.
## Связанное правило
- [`SLM-L1-GROUP-R007`](../rules/level-1.md#slm-l1-group-r007)
- [`SLM-L1-MODULE-R011`](../rules/level-1.md#slm-l1-module-r011)
## Пример
```text
compositions/
├── pages/ # Группа
│ ├── landing/ # Модуль
│ └── contacts/ # Модуль
└── layouts/ # Группа
└── main/ # Модуль
```
`pages` и `layouts` являются возможной группировкой проекта, а не обязательной структурой Level 1.
Рекомендуется создавать группу только при реальной навигационной потребности. Если папка начинает владеть файлами реализации, состоянием, жизненным циклом или публичным API, она является модулем и должна получить модульную границу.

85
DRAFT/level-1/layers.md Normal file
View File

@@ -0,0 +1,85 @@
# Слои Level 1
> Пояснение нормативной модели слоёв Level 1.
## Базовая структура
```text
src/
├── app/
├── compositions/
├── infra/
├── ui/
└── shared/
```
`src/` здесь является примером SLM root. Фактическую границу определяет устройство приложения.
Отсутствующая в проекте роль не требует пустой папки. Слой является доступной архитектурной ролью, а не обязательным элементом каркаса.
## Роли слоёв
### App
`app` связывает приложение с фреймворком: запускает его, объявляет маршруты, преобразует входные данные и подключает публичные API нижних модулей или ресурсы `shared`. Файлы `app` являются точками входа фреймворка, а не модулями SLM.
Точка входа может напрямую использовать `compositions`, `infra`, `ui` или `shared`, если зависимость разрешена общим порядком слоёв. Такое использование не переносит ответственность нижнего модуля в `app`.
### Compositions
`compositions` содержит продуктовый интерфейс: страницы, макеты, экраны, виджеты, точки входа и другие композиционные модули. Внутреннюю группировку слоя определяет проект.
### Infra
`infra` содержит технические сервисы приложения: аналитику, локализацию, тему, телеметрию и другие возможности среды выполнения без самостоятельной доменной модели.
### UI
`ui` содержит универсальные модули интерфейса, которые не зависят от конкретной страницы или продуктовой композиции.
### Shared
`shared` содержит независимый детерминированный фундамент без знания о продукте, изменяемого состояния и ввода-вывода.
В `shared` допускаются обычные модули и специальные немодульные ресурсы: небольшие чистые утилиты, общие типы, стили, конфигурация и статические файлы. Ресурс не имеет самостоятельной ответственности, публичного API или жизненного цикла и может импортироваться напрямую по пути, установленному стайлгайдом.
Если ресурсу нужны самостоятельная ответственность, собственные архитектурные зависимости, несколько файлов реализации, изменяемое состояние, ввод-вывод или область жизни, он оформляется как модуль. Каталог ресурсов не реэкспортирует модули и не используется для обхода их публичных API.
## Порядок слоёв
```text
app
compositions
infra
ui
shared
```
Код слоя может импортировать модули своего или любого нижнего слоя. Промежуточные слои можно пропускать.
| Слой | Может импортировать нижние слои |
|---|---|
| `app` | `compositions`, `infra`, `ui`, `shared` |
| `compositions` | `infra`, `ui`, `shared` |
| `infra` | `ui`, `shared` |
| `ui` | `shared` |
| `shared` | Нет |
Импорты внутри слоя, публичный API и циклы описаны отдельно в [Зависимостях](./dependencies.md).
## Связанные правила
- [`SLM-L1-LAYER-R001`](../rules/level-1.md#slm-l1-layer-r001)
- [`SLM-L1-LAYER-A002`](../rules/level-1.md#slm-l1-layer-a002)
- [`SLM-L1-LAYER-R003`](../rules/level-1.md#slm-l1-layer-r003)
- [`SLM-L1-MODULE-R011`](../rules/level-1.md#slm-l1-module-r011)
## Граница Level 1
Разрешённый импорт не переносит владение. Например, `infra` может использовать `ui`, но продуктовый интерфейс по-прежнему принадлежит `compositions`.
Самостоятельная доменная модель или сценарий являются сигналом рассмотреть Level 2, а не расширять ответственность `shared` или `infra`.

View File

@@ -0,0 +1,31 @@
# Жизненный цикл Level 1
> Пояснение нормативной модели владения ресурсами Level 1.
Жизненный цикл является частью ответственности модуля. Файл, компонент, провайдер или точка входа фреймворка могут технически создавать и останавливать ресурс, но архитектурным владельцем остаётся модуль.
## Связанные правила
- [`SLM-L1-MODULE-R011`](../rules/level-1.md#slm-l1-module-r011)
- [`SLM-L1-LIFECYCLE-R013`](../rules/level-1.md#slm-l1-lifecycle-r013)
## Граница ресурса
Для ресурса определяются:
- модуль-владелец;
- место создания;
- момент начала работы;
- область жизни;
- допустимое число экземпляров;
- способ остановки и очистки.
Ресурс начинает работу не раньше начала своей области жизни и не остаётся активным после её завершения. Подписки, слушатели, таймеры, наблюдатели, запросы и соединения рассматриваются одинаково, если требуют явного завершения или отмены.
## Реализация
Очистку может выполнять сам модуль, компонент, провайдер или фреймворк. Способ реализации не меняет владельца и не переносит ответственность в технический файл.
Одиночный экземпляр на всё приложение допустим только тогда, когда модуль действительно владеет областью жизни приложения или процесса. Размещение экземпляра на уровне файла само по себе этого не доказывает.
Точка входа `app` может запускать или подключать ресурс через публичный API нижнего модуля, но не становится его владельцем.

43
DRAFT/level-1/modules.md Normal file
View File

@@ -0,0 +1,43 @@
# Модули Level 1
> Пояснение нормативной модели модулей Level 1.
Модуль является основной архитектурной единицей SLM. Он размещается в отдельной папке, но может состоять только из публичной точки входа и одного файла реализации.
## Связанные правила
- [`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
- [`SLM-L1-MODULE-A014`](../rules/level-1.md#slm-l1-module-a014)
- [`SLM-L1-MODULE-R006`](../rules/level-1.md#slm-l1-module-r006)
- [`SLM-L1-MODULE-R011`](../rules/level-1.md#slm-l1-module-r011)
- [`SLM-L1-MODULE-R012`](../rules/level-1.md#slm-l1-module-r012)
## Владение
Каждая самостоятельная ответственность имеет одного модуля-владельца. Модуль определяет её публичный API, зависимости, состояние, область жизни и внутреннее устройство независимо от того, в каком файле выполняется конкретный код.
Точки входа `app` и нормативные ресурсы `shared` являются единственными немодульными исключениями. Остальной код внутри SLM root либо принадлежит существующему модулю, либо образует новый модуль.
## Публичный API
Модуль предоставляет один логический публичный API. Конкретное имя точки входа и механизм экспорта определяет стайлгайд проекта.
Внешний код использует модуль только через публичный API. Сам API открывает только контракт, необходимый реальным внешним потребителям; внутренние механизмы, изменяемое состояние и детали жизненного цикла остаются закрытыми.
## Внутреннее устройство
Модуль может содержать корневые файлы, сегменты, компоненты и [вложенные модули](./nested-modules.md). Внутри своей границы он может использовать относительные импорты и не обязан обращаться к собственному публичному API; точную форму внутренних импортов определяет стайлгайд.
SLM не требует полного каркаса или обязательного каталога сегментов.
## Визуальный модуль
Визуальный модуль обычно имеет корневой компонент, который экспортируется через публичный API.
```text
button/
├── button.tsx
└── index.ts
```
Корневой компонент остаётся компонентом, а владельцем ответственности является модуль `button`.

View File

@@ -0,0 +1,30 @@
# Вложенные модули Level 1
> Пояснение нормативной модели вложенных модулей Level 1.
Вложенный модуль является обычным модулем, размещённым внутри границы родительского модуля. Он имеет собственные ответственность, публичный API и узел графа зависимостей и подчиняется всем общим правилам модулей.
## Связанные правила
- [`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
- [`SLM-L1-MODULE-R006`](../rules/level-1.md#slm-l1-module-r006)
- [`SLM-L1-DEPENDENCY-A005`](../rules/level-1.md#slm-l1-dependency-a005)
- [`SLM-L1-NESTED_MODULE-A010`](../rules/level-1.md#slm-l1-nested_module-a010)
## Пример
```text
landing/
├── landing.page.tsx
├── parts/
│ └── hero/
│ ├── hero.tsx
│ └── index.ts
└── index.ts
```
`parts/` здесь является примером сегмента, а не обязательным именем.
Код родительского модуля использует вложенный модуль через его собственный публичный API. Код за пределами родительского модуля получает доступ только через публичный API родителя.
Если вложенный модуль становится нужен за пределами родителя, рекомендуется перенести его в минимальную общую область без изменения внутренней формы. Доступ через API родителя при этом остаётся допустимым и сам по себе не требует переноса.

29
DRAFT/level-1/segments.md Normal file
View File

@@ -0,0 +1,29 @@
# Сегменты Level 1
> Пояснение нормативной модели сегментов Level 1.
Сегмент организует внутреннее содержимое модуля. Level 1 определяет роль сегмента, но не задаёт обязательный список имён.
## Связанное правило
- [`SLM-L1-SEGMENT-R008`](../rules/level-1.md#slm-l1-segment-r008)
- [`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
## Файловая форма
Названия, набор и содержимое сегментов определяет стайлгайд проекта. Сегмент может группировать файлы, компоненты или вложенные модули.
Файлы и компоненты сегмента принадлежат родительскому модулю. Вложенный модуль внутри сегмента сохраняет собственные ответственность, публичный API и узел графа зависимостей.
## Пример
```text
landing/ # Модуль
└── ui/ # Сегмент модуля
└── hero/ # Каталог компонента
├── hero.tsx
├── styles/ # Вспомогательный каталог компонента
└── types/ # Вспомогательный каталог компонента
```
`styles/` и `types/` внутри каталога компонента не обязаны считаться сегментами SLM. Их форму определяет стайлгайд компонентов.

View File

@@ -0,0 +1,117 @@
# Терминология Level 1
> Нормативные определения рабочего черновика. Этот раздел не объявляет правила.
Определения Level 1 задают обязательный смысл архитектурных терминов и используются при толковании всех правил. Код получают только блокирующие требования, а не сами определения.
## Базовые понятия
### SLM root
Граница структурной архитектуры одного приложения. Внутри неё определяются слои, модули и граф зависимостей Level 1. Монорепозиторий, пакеты и отношения между несколькими SLM root находятся за пределами Level 1.
### Ответственность
Связная часть приложения с одной причиной изменяться. Ответственность является самостоятельной, когда ей нужны собственные публичный API, зависимости, состояние или область жизни.
### Владелец
Модуль, который определяет публичный API ответственности, её зависимости, состояние, область жизни и внутреннее устройство. Место выполнения кода не переносит владение.
### Публичный API
Единая логическая точка внешнего доступа к модулю. Публичный API скрывает внутреннее устройство; конкретное имя файла и механизм экспорта определяет стайлгайд проекта.
### Зависимость
Статическая связь внутри одного SLM root, которую импорт или реэкспорт создаёт между архитектурными границами. Обычный импорт, импорт типа и реэкспорт одинаково создают архитектурную зависимость.
Зависимость любого внутреннего файла, сегмента или компонента относится к ближайшему модулю-владельцу. Вложенный модуль начинает собственную границу и становится отдельным узлом графа зависимостей.
### Область жизни
Период, в течение которого принадлежащие модулю состояние или долгоживущий ресурс должны оставаться активными.
### Ресурс жизненного цикла
Ресурс, работа которого продолжается после первоначального вызова и требует остановки, отмены, отписки или освобождения. Например, подписка, слушатель событий, таймер, наблюдатель, запрос или соединение.
### Очистка
Гарантированное прекращение работы ресурса не позже завершения его области жизни. Автоматическая очистка фреймворка считается очисткой владельца, если модуль устанавливает и контролирует соответствующую границу.
## Структурные сущности
### Слой
Одна из пяти верхнеуровневых ролей внутри SLM root:
| Слой | Роль |
|---|---|
| `app` | Связь приложения с фреймворком: запуск, маршруты и преобразование входных данных |
| `compositions` | Сборка продуктового интерфейса: страницы, макеты, экраны, виджеты и другие композиции |
| `infra` | Технические сервисы и возможности приложения |
| `ui` | Универсальные модули интерфейса без зависимости от конкретной продуктовой композиции |
| `shared` | Независимый детерминированный фундамент без знания о продукте, изменяемого состояния и ввода-вывода |
Слои образуют линейный порядок `app → compositions → infra → ui → shared`. Нижним считается любой слой справа от исходного; промежуточный слой не является обязательным посредником.
### Модуль
Минимальная самостоятельная архитектурная единица Level 1. Модуль владеет одной связной ответственностью, размещается в отдельной папке и предоставляет публичный API.
### Группа
Навигационная папка для модулей и других групп. Группа не является владельцем ответственности, состояния, области жизни, публичного API или узла графа зависимостей.
### Сегмент
Внутренняя часть одного модуля, которая группирует его содержимое по назначению. Сегмент не является самостоятельным владельцем, публичным API или узлом графа зависимостей.
### Компонент
Сущность фреймворка, которая реализует часть интерфейса родительского модуля. Компонент не образует собственного владельца, публичного API или узла графа зависимостей.
Импорты, состояние, доступ к данным и код жизненного цикла компонента принадлежат родительскому модулю. Их наличие само по себе не создаёт новый модуль; решающим признаком является самостоятельная ответственность.
### Вложенный модуль
Обычный модуль, размещённый внутри границы родительского модуля. Он имеет собственные ответственность, публичный API и узел графа зависимостей и подчиняется всем общим правилам модулей.
Публичный API вложенного модуля доступен коду родительской границы. Для кода за пределами родительского модуля вложенный модуль остаётся внутренней реализацией родителя.
### Точка входа фреймворка
Специальная немодульная единица слоя `app`, которая непосредственно связывает приложение с фреймворком. Её импорты участвуют в проверке направления слоёв, но сама точка входа не является узлом графа модулей.
### Ресурс shared
Специальная немодульная единица слоя `shared`: небольшая детерминированная утилита, общий тип, стиль, конфигурация или статический ресурс без продуктового знания, изменяемого состояния, ввода-вывода, области жизни и собственного публичного API.
Ресурс `shared` может импортироваться напрямую по пути, установленному стайлгайдом, и не является узлом графа модулей. Доступный по этому пути файл является всей единицей и не скрывает отдельное внутреннее устройство.
Если ресурсу нужны самостоятельная ответственность, собственные архитектурные зависимости, несколько файлов реализации, изменяемое состояние, ввод-вывод или область жизни, он оформляется как модуль.
## Структурная модель
```text
SLM root
├── app
│ └── точка входа фреймворка
├── compositions | infra | ui
│ ├── группа
│ │ └── модуль
│ └── модуль
│ ├── корневые файлы
│ ├── сегмент
│ │ ├── файлы
│ │ ├── компоненты
│ │ └── вложенные модули
│ └── вложенный модуль
└── shared
├── группа
├── модуль
└── ресурс shared
```
Путь и имя папки сами по себе не определяют сущность. Её определяют ответственность, владелец и публичная граница. Физическое сопоставление путей с сущностями задаётся стайлгайдом или конфигурацией проверки проекта.

View File

@@ -0,0 +1,23 @@
# Проверка Level 1
> Граница автоматической проверки и архитектурного ревью Level 1.
## Автоматическая проверка
Проект, заявляющий соответствие Level 1, сопоставляет физические пути с SLM root, слоями, модулями, вложенными модулями, публичными точками входа, точками входа `app` и ресурсами `shared`. Такое сопоставление задаётся стайлгайдом или конфигурацией проверки и не изменяет нормативный смысл сущностей.
Каждое правило класса `A` должно быть реализовано проверкой проекта и блокировать её при нарушении. Level 1 не навязывает конкретный инструмент.
Скрипт `draft-rules.js` проверяет только целостность документов: формат и уникальность кодов, ссылки и наличие тематических упоминаний. Он не проверяет архитектуру приложения.
Актуальный список правил скрипт получает из [канонического реестра](../rules/level-1.md).
## Архитектурное ревью
Правила класса `R` проверяются вручную. Статический анализ может обнаружить подозрительный код, но не способен окончательно определить:
- ответственность и её владельца;
- соответствие кода роли слоя;
- необходимость экспортов публичного API;
- область жизни ресурса и достаточность очистки;
- наличие самостоятельной границы у компонента, группы или сегмента.

117
DRAFT/rules/README.md Normal file
View File

@@ -0,0 +1,117 @@
# Правила SLM
> Статус: системный черновик. Не является нормативной спецификацией.
Эта директория является единственным местом объявления правил SLM. Остальные черновики объясняют архитектуру и ссылаются на канонические коды, но не повторяют формулировки правил.
## Что считается правилом
Правило задаёт один блокирующий архитектурный инвариант.
Определения, рекомендации, разрешения, примеры и открытые вопросы не получают код правила.
Нормативные определения объявляются в терминологии соответствующего уровня. Они обязательны для толкования правил, но нормативность определения сама по себе не превращает его в правило.
## Код правила
```text
SLM-L{level}-{group}-{class}{number}
```
| Часть | Значение |
|---|---|
| `SLM` | Принадлежность архитектуре SLM |
| `L{level}` | Уровень архитектуры |
| `group` | Раздел правил |
| `class` | Способ проверки: `A` или `R` |
| `number` | Трёхзначный номер внутри уровня |
## Способы проверки
### `A`: автоматическая проверка
Всё правило можно однозначно проверить программно без понимания предметного смысла кода. Нарушение такого правила должно блокировать автоматическую проверку.
### `R`: проверка на ревью
Для окончательного решения требуется понимание ответственности, владения или смысла зависимости. Линтер может проверять отдельные признаки, но не заменяет решение на ревью.
Одно правило не разделяется на автоматическую и ручную копии только из-за разных способов проверки. Если существенная часть инварианта требует смыслового решения, всё правило получает класс `R`.
## Разделы правил
| Код | Раздел |
|---|---|
| `LAYER` | Слои |
| `DEPENDENCY` | Зависимости |
| `MODULE` | Модули |
| `GROUP` | Группы |
| `SEGMENT` | Сегменты |
| `COMPONENT` | Компоненты |
| `NESTED_MODULE` | Вложенные модули |
| `LIFECYCLE` | Жизненный цикл |
Код раздела записывается полным английским именем в `UPPER_SNAKE_CASE`. Новый код добавляется в таблицу до первого использования.
## Формат записи
```md
### SLM-L1-MODULE-A004
> **Публичный API модуля**
>
> Каждый модуль предоставляет единый публичный API; код за пределами модуля импортирует его содержимое только через этот API.
```
Код является заголовком третьего уровня и автоматически получает адрес для ссылки `#slm-l1-module-a004`.
Название и описание входят в одну цитату. Название выделяется жирным и служит кратким именем правила. Описание полностью формулирует требование и занимает одну физическую строку.
Ссылка из тематического черновика:
```md
[`SLM-L1-MODULE-A004`](../rules/level-1.md#slm-l1-module-a004)
```
## Как формулировать правила
1. Правило понятно без чтения тематической главы и опирается только на нормативные термины своего уровня.
2. Правило защищает один архитектурный инвариант.
3. Один инвариант получает один код независимо от числа участников и способов проверки.
4. Название является кратким и устойчивым именем правила.
5. Название обозначает предмет правила, а описание полностью формулирует требование.
6. Описание объясняет допустимую границу и то, что считается нарушением.
7. Описание раскрывает названный инвариант и не вводит второе независимое требование.
8. Описание использует нормативные определения и не пересказывает их без необходимости.
9. Название и описание используют человеческий язык и только необходимые архитектурные термины.
10. Обоснование, подробности, примеры и исключения размещаются в тематическом черновике, а не в описании.
11. Правило не создаётся отдельно с позиции владельца и потребителя, если обе формулировки защищают одну границу.
12. Перед добавлением правила реестр проверяется на дубли и противоречия.
13. Код присваивается после проверки правила на примерах и контрпримерах.
## Нумерация
1. Номер уникален внутри уровня независимо от раздела и способа проверки.
2. Номер не обозначает важность или порядок выполнения.
3. Удалённый номер не переиспользуется для другого правила.
4. При изменении способа проверки номер сохраняется, но меняется полный код.
## Проверка качества
Перед принятием правила нужно ответить «да»:
- Понятно, о чём правило?
- Название кратко и однозначно называет правило?
- Понятно, что оно требует?
- Понятно, что является нарушением?
- Нельзя ли объединить его с существующим правилом?
- Не содержит ли оно рекомендацию или разрешение?
- Соответствует ли класс способу окончательной проверки?
## Проверка документов
Корневой скрипт `draft-rules.js` читает объявления только из этой директории, проверяет формат и уникальность кодов, валидирует ссылки из остальных черновиков и выводит правила разделами «Автоматические» и «Для ревью». Скрипт проверяет документы, а не архитектуру приложения.
## Наборы правил
- [Первый уровень](./level-1.md)

102
DRAFT/rules/level-1.md Normal file
View File

@@ -0,0 +1,102 @@
# Правила SLM первого уровня
Здесь собраны правила первого уровня. Это единственное место, где они формулируются; тематические черновики объясняют их и ссылаются на коды.
## Размещение кода по слоям
### SLM-L1-LAYER-R001
> **Назначение слоёв**
>
> Код внутри SLM root размещается в слое, нормативная роль которого соответствует ответственности этого кода.
### SLM-L1-LAYER-A002
> **Направление зависимостей**
>
> Внутри одного SLM root код каждого слоя может зависеть только от кода этого же или любого нижнего слоя в порядке `app → compositions → infra → ui → shared`.
### SLM-L1-LAYER-R003
> **Граница слоя `app`**
>
> В `app` размещаются только точки входа фреймворка для запуска, маршрутов, преобразования входных данных и подключения публичных API нижних модулей или ресурсов `shared`; ответственности нижних слоёв остаются за пределами `app`.
## Границы модулей
### SLM-L1-MODULE-A004
> **Публичный API модуля**
>
> Каждый модуль предоставляет единый публичный API; код за пределами модуля импортирует его содержимое только через этот API.
### SLM-L1-MODULE-A014
> **Папка модуля**
>
> Каждый модуль размещается в отдельной папке; его публичный API и внутренняя реализация находятся внутри этой границы, а вложенные модули образуют собственные папки.
### SLM-L1-MODULE-R006
> **Ответственность модуля**
>
> Одна модульная граница содержит код одной связной ответственности; части, которые изменяются по несвязанным причинам, размещаются в разных модулях.
### SLM-L1-MODULE-R011
> **Владелец ответственности**
>
> Каждая самостоятельная ответственность и относящийся к ней код принадлежат ровно одному модулю; вне модульной границы допускаются только точки входа `app` и нормативные ресурсы `shared`.
### SLM-L1-MODULE-R012
> **Состав публичного API**
>
> Публичный API модуля открывает только контракт, необходимый реальным внешним потребителям; детали реализации и изменяемые внутренние механизмы остаются закрытыми.
## Зависимости между модулями
### SLM-L1-DEPENDENCY-A005
> **Циклические зависимости**
>
> Граф зависимостей модулей внутри одного SLM root, включая вложенные модули, не содержит циклов.
## Назначение групп
### SLM-L1-GROUP-R007
> **Назначение группы**
>
> Группа содержит только модули и другие группы, не владеет файлами реализации, состоянием, жизненным циклом или публичным API и не импортируется внешним кодом.
## Назначение сегментов
### SLM-L1-SEGMENT-R008
> **Граница сегмента**
>
> Сегмент организует код только внутри одного модуля и не имеет собственной ответственности, публичного API или узла графа зависимостей.
## Ответственность компонентов
### SLM-L1-COMPONENT-R009
> **Ответственность компонента**
>
> Компонент реализует часть ответственности одного родительского модуля; все его зависимости, состояние и жизненный цикл принадлежат этому модулю и не образуют самостоятельную архитектурную границу.
## Границы вложенных модулей
### SLM-L1-NESTED_MODULE-A010
> **Доступ к вложенному модулю**
>
> Код за пределами родительского модуля не импортирует вложенный модуль напрямую и получает его экспорты только через публичный API родителя.
## Жизненный цикл
### SLM-L1-LIFECYCLE-R013
> **Жизненный цикл ресурсов**
>
> Для каждого ресурса жизненного цикла модуль-владелец определяет создание, область жизни, число экземпляров и очистку; ресурс активен только внутри своей области жизни.

View File

@@ -4,11 +4,12 @@
## Структура
- `docs/` — новый нормативный корпус и материалы сайта.
- `old-docs/` — действующая legacy-документация для текущего skill.
- `site/` — VitePress-конфигурация, тема и статические ресурсы.
- `src-skills/` — исходники agent skills.
- `skills/` — собранные skills для установки через `npx skills`.
- `DRAFT/` - рабочая документация Level 1 и источник содержимого сайта.
- `site/` - VitePress-конфигурация, тема и статические ресурсы.
- `docs/` и `docs-v3/` - архивные версии документации, не используемые сайтом.
- `old-docs/` - действующая legacy-документация для текущего skill.
- `src-skills/` - исходники agent skills.
- `skills/` - собранные skills для установки через `npx skills`.
## Сборка
@@ -19,7 +20,7 @@ npm run build
npm run check
```
`npm run build` пересобирает текущий `skills/slm-design/` из `old-docs/` и `src-skills/slm-design/`. Не редактируй собранные файлы вручную.
`npm run build` пересобирает текущий `skills/slm-design/` из `old-docs/` и `src-skills/slm-design/`. `npm run check` дополнительно проверяет правила и собирает сайт из `DRAFT/`. Не редактируй собранные файлы вручную.
## Установка

View File

@@ -1,24 +0,0 @@
---
layout: home
title: SLM Design in English
titleTemplate: false
sidebar: false
hero:
name: English edition
text: Translation is planned
tagline: The Russian specification is currently the only normative source. The English edition will preserve its structure and rule IDs.
actions:
- theme: brand
text: Open Russian specification
link: /ru/specification/
- theme: alt
text: Language selection
link: /
features:
- title: No partial translation
details: An incomplete English rule set is not published as normative documentation.
- title: Stable identifiers
details: Future translated requirements will use the same SLM rule IDs as the Russian source.
- title: Equal URL structure
details: English documentation is reserved under /en/ alongside the Russian /ru/ section.
---

View File

@@ -1,24 +0,0 @@
---
layout: home
title: SLM Design
titleTemplate: false
sidebar: false
hero:
name: SLM Design
text: Explicit architecture boundaries
tagline: A draft specification for ownership, dependencies, runtime, and lifecycle in product applications.
actions:
- theme: brand
text: Русская спецификация
link: /ru/
- theme: alt
text: English
link: /en/
features:
- title: Base SLM
details: A complete minimal architecture built around explicit ownership and five application layers.
- title: Independent overlays
details: Advanced and Pro add separate rule sets directly to base SLM without inheriting each other.
- title: Stable rules
details: Every normative requirement has a permanent rule ID suitable for reviews and automated checks.
---

View File

@@ -1,28 +0,0 @@
---
layout: home
title: SLM Design
titleTemplate: false
sidebar: false
hero:
name: Документация SLM Design
text: Один портал для правил и практики
tagline: Нормативная спецификация, реестр правил и будущий архитектурный гайд в единой структуре.
actions:
- theme: brand
text: Открыть спецификацию
link: /ru/specification/
- theme: alt
text: Найти правило
link: /ru/specification/rules
features:
- title: SLM Design Specification
details: Нормативный источник архитектурных правил, ownership boundaries и требований соответствия.
link: /ru/specification/
linkText: Читать спецификацию
- title: Реестр правил
details: Все Base, Advanced и Pro rules с фильтрами, точными anchors и копируемыми permalink-ссылками.
link: /ru/specification/rules
linkText: Открыть реестр
- title: Architecture Guide
details: Будущий учебный материал для последовательного изучения и практического применения Specification.
---

View File

@@ -1,72 +0,0 @@
---
title: Архитектурная модель
status: draft
normative: true
---
# Архитектурная Модель
## Структура приложения
```text
src/
├── app/
├── compositions/
├── infra/
├── ui/
└── shared/
```
**SLM-BASE-ARCH-001 - ОБЯЗАН.** Base SLM-приложение должно разделять код по ответственности между слоями `app`, `compositions`, `infra`, `ui` и `shared`.
Не каждый слой обязан содержать код в минимальном приложении. Пустые папки и speculative scaffolding не требуются.
## Группы ответственности
| Группа | Слои | Ответственность |
|---|---|---|
| Framework composition | `app`, `compositions` | Подключение к framework и сборка application flows |
| Product | Product owner; в base SLM - `compositions` | Product semantics, UI и flows владеющего module |
| Technical | `infra`, `ui` | Technical capabilities и универсальный UI |
| Foundation | `shared` | Детерминированный общий фундамент |
## Верхнеуровневое направление
```text
app -> compositions | shared
compositions -> compositions | infra | ui | shared
infra -> infra | shared
ui -> ui | shared
shared -/-> остальные SLM-слои
```
Framework APIs и external packages регулируются ответственностью импортирующего слоя и не показаны как SLM-слои.
**SLM-BASE-ARCH-002 - ОБЯЗАН.** Верхнеуровневое направление зависимостей между base SLM-слоями должно соблюдаться для runtime imports и type imports, кроме явно описанных исключений.
**SLM-BASE-ARCH-003 - ЗАПРЕЩЕНО.** Нижний слой не может импортировать `app` или `compositions`.
**SLM-BASE-ARCH-004 - ЗАПРЕЩЕНО.** `infra`, `ui` и `shared` не могут владеть product wiring или выступать service locator для application modules.
## Путь данных
```text
app
-> product owner public API
-> infra public API
-> external source
```
**SLM-BASE-ARCH-005 - ОБЯЗАН.** Каждый переход product data должен сохранять ownership: framework связывает, product owner определяет semantics, а technical capability не присваивает себе product model.
## Путь UI
```text
app route
-> page/layout composition
-> product UI
-> universal UI
-> shared styles/resources
```
Product UI принадлежит product owner; в base SLM таким owner является composition. Универсальный product-agnostic UI принадлежит `ui`.

View File

@@ -1,81 +0,0 @@
---
title: Архитектурные modes
status: draft
normative: true
---
# Архитектурные Modes
SLM является самостоятельной базовой архитектурой. Architecture mode - опциональный независимый overlay, который добавляет или явно заменяет отдельные правила base SLM.
```text
SLM Advanced = SLM + Advanced rules
SLM Pro = SLM + Pro rules
```
`SLM Advanced` и `SLM Pro` не наследуют друг друга. Совпадающее требование декларируется отдельно внутри каждого overlay и не создаёт общей mode-ветки.
## Выбор архитектуры
Приложение использует один из трёх вариантов:
```text
SLM
SLM + Advanced
SLM + Pro
```
**SLM-BASE-MODE-001 - ОБЯЗАН.** Приложение должно зафиксировать использование base SLM и, при наличии, ровно одного overlay: `Advanced` или `Pro`.
**SLM-BASE-MODE-002 - ЗАПРЕЩЕНО.** Одно приложение не может одновременно заявлять соответствие `SLM Advanced` и `SLM Pro`.
**SLM-BASE-MODE-003 - ОБЯЗАН.** Выбранный overlay должен применяться ко всему приложению в пределах одной SLM application boundary.
Выбор выполняет команда на стадии планирования. Сигналами могут быть количество product responsibilities, связанность modules, runtime state, client/server execution, lifecycle risks и количество команд разработки. Фиксированные числовые пороги не устанавливаются.
| Вариант | Когда рассматривать |
|---|---|
| `SLM` | Product responsibilities удобно удерживать внутри compositions без дополнительного слоя |
| `SLM Advanced` | Нужны самостоятельные domains, но команда хочет свободно выбирать их внутреннюю структуру и связи |
| `SLM Pro` | Нужны изолированные domains, явные runtime contracts, adapters, lifecycle и усиленные checks |
## Применимость правил
Base-правило имеет идентификатор вида:
```text
SLM-BASE-AREA-NNN
```
Mode-specific правила имеют идентификаторы:
```text
SLM-ADV-AREA-NNN
SLM-PRO-AREA-NNN
```
**SLM-BASE-MODE-004 - ОБЯЗАН.** Base-правила SLM применяются при любом выбранном варианте архитектуры. Если overlay явно заменяет base rule только в определённом scope, исходное base-правило продолжает действовать за пределами этого scope.
**SLM-BASE-MODE-005 - ОБЯЗАН.** Для `SLM Advanced` применяются только base-правила и правила из `modes/advanced`.
**SLM-BASE-MODE-006 - ОБЯЗАН.** Для `SLM Pro` применяются только base-правила и правила из `modes/pro`.
**SLM-BASE-MODE-007 - ЗАПРЕЩЕНО.** Правило другого overlay не может использоваться как обязательное требование, разрешение или исключение.
**SLM-BASE-MODE-008 - ОБЯЗАН.** Mode-specific правило, заменяющее base-поведение, должно явно назвать заменяемый base rule ID или нормативный раздел и точный scope замены.
## Независимые overlays
### SLM Advanced
[SLM Advanced](./modes/advanced/index.md) описывает полный Advanced-delta относительно base SLM.
### SLM Pro
[SLM Pro](./modes/pro/index.md) описывает полный Pro-delta относительно base SLM.
## Изменение overlay
**SLM-BASE-MODE-009 - МОЖЕТ.** Команда может подключить, заменить или удалить overlay при изменении требований к архитектуре.
**SLM-BASE-MODE-010 - ОБЯЗАН.** После изменения конфигурации приложение может заявлять соответствие только после выполнения применимых base-правил с учётом scoped replacements и, при наличии, полного rule set выбранного overlay.

View File

@@ -1,39 +0,0 @@
---
title: Основные инварианты
status: draft
normative: true
---
# Основные Инварианты
SLM Design организует frontend-приложение по владельцам ответственности. Архитектурная единица определяется не типом файла, а тем, кто владеет моделью, поведением, данными, runtime и lifecycle.
## Ответственность до размещения
**SLM-BASE-FND-001 - ОБЯЗАН.** Перед размещением кода необходимо определить его владельца, public boundary, runtime dependencies и lifecycle scope.
**SLM-BASE-FND-002 - СЛЕДУЕТ.** Код следует размещать в минимальном scope, который полностью владеет его ответственностью.
**SLM-BASE-FND-003 - ЗАПРЕЩЕНО.** Нельзя переносить код в общий слой или общий package только на основании предполагаемого будущего переиспользования.
## Путь продуктовых данных
Product data проходят через public boundary текущего владельца согласно [SLM-BASE-DATA-001](./state-and-data.md#product-gateway). Внешний сервис может оставаться физическим источником данных, но transport contract не становится product model автоматически.
## Явные зависимости
**SLM-BASE-FND-007 - ОБЯЗАН.** Runtime capabilities должны поступать владельцу поведения через разрешённые imports, явные arguments или contracts, а не через скрытый service locator или global mutable state.
**SLM-BASE-FND-008 - ЗАПРЕЩЕНО.** Type cast, barrel, alias, dynamic import или helper в `shared` не могут использоваться для обхода применимой архитектурной границы.
## Public API
Межмодульное взаимодействие и deep imports регулируются [SLM-BASE-API-001 - SLM-BASE-API-005](./public-api-and-imports.md#общие-правила).
## Scope и lifecycle
Создание, scope, activation и cleanup применимых runtimes и resources определены в [Runtime и lifecycle](./runtime-and-lifecycle.md).
## Overlays
Base SLM не вводит дополнительные архитектурные слои и специализированные runtime contracts. Каждый overlay самостоятельно определяет свои добавления и замены base-правил.

View File

@@ -1,93 +0,0 @@
---
title: SLM Design Specification
version: 0.1.0-draft
status: draft
normative: true
---
# SLM Design Specification
Эта директория содержит единый нормативный корпус SLM Design 2.0. Base SLM является законченной минимальной архитектурой; дополнительные ограничения подключаются независимыми overlays `SLM Advanced` или `SLM Pro`.
Пока статус равен `draft`, документы описывают проектируемую архитектуру и не заменяют действующую документацию в `old-docs/`.
## Нормативный язык
| Термин | Значение |
|---|---|
| `ОБЯЗАН` | Требование необходимо выполнить для соответствия спецификации |
| `ЗАПРЕЩЕНО` | Действие является нарушением спецификации |
| `СЛЕДУЕТ` | Рекомендуемое решение; отступление требует явного обоснования |
| `МОЖЕТ` | Допустимый, но необязательный вариант |
Правила имеют стабильные идентификаторы. Base использует формат `SLM-BASE-AREA-NNN`, Advanced - `SLM-ADV-AREA-NNN`, Pro - `SLM-PRO-AREA-NNN`. Точное нормативное требование принадлежит только той главе, где объявлен его rule ID.
Все объявления доступны в [реестре правил](./rules.md). Для прямого перехода можно открыть поиск `Ctrl/⌘ K` и ввести полный rule ID.
## Architecture modes
Base SLM не требует overlay. Если команда выбирает дополнительную архитектурную политику, она подключает ровно один независимый mode согласно [Архитектурным modes](./architecture-modes.md):
```text
SLM Advanced = SLM + Advanced rules
SLM Pro = SLM + Pro rules
```
## Приоритет
**SLM-BASE-DOC-001 - ОБЯЗАН.** При конфликте между главами спецификации и любым ненормативным материалом приоритет имеет спецификация.
**SLM-BASE-DOC-002 - ЗАПРЕЩЕНО.** Ненормативный документ не может вводить новое обязательное правило, исключение или архитектурную границу.
**SLM-BASE-DOC-003 - ОБЯЗАН.** Изменение принятого архитектурного правила должно вноситься в главу, которая владеет соответствующим rule ID.
## Base SLM
### Основы
- [Основные инварианты](./foundations.md)
- [Терминология](./terminology.md)
- [Архитектурная модель](./architecture-model.md)
### Слои
- [Обзор слоёв](./layers/index.md)
- [App](./layers/app.md)
- [Compositions](./layers/compositions.md)
- [Infra](./layers/infra.md)
- [UI](./layers/ui.md)
- [Shared](./layers/shared.md)
### Общие правила
- [Модули и группы](./modules-and-groups.md)
- [Сегменты](./segments.md)
- [Public API и импорты](./public-api-and-imports.md)
- [State и data](./state-and-data.md)
- [Runtime и lifecycle](./runtime-and-lifecycle.md)
- [Тестирование и соответствие](./testing-and-conformance.md)
- [Монорепозитории](./monorepo.md)
## Overlays
### SLM Advanced
- [Отличия Advanced от base SLM](./modes/advanced/index.md)
- [Domains в SLM Advanced](./modes/advanced/domains.md)
### SLM Pro
- [Отличия Pro от base SLM](./modes/pro/index.md)
- [Domains в SLM Pro](./modes/pro/domains/index.md)
- [Business](./modes/pro/domains/business.md)
- [Framework surface](./modes/pro/domains/framework.md)
- [Ports и adapters](./modes/pro/domains/ports-and-adapters.md)
- [Client и server assembly](./modes/pro/domains/client-and-server.md)
- [Cross-domain boundary](./modes/pro/domains/cross-domain-boundary.md)
- [Тестирование Pro domains](./modes/pro/domains/testing.md)
## Область текущего draft
Base SLM фиксирует ownership, пять основных слоёв, public boundaries, state и lifecycle. Текущие версии Advanced и Pro в первую очередь определяют собственные независимые модели слоя `domains`; будущие mode-specific правила могут относиться к любому разделу архитектуры.
Точная форма React Providers, окончательная политика package extraction и единая модель query cache не фиксируются сверх явно объявленных инвариантов base или выбранного overlay.

View File

@@ -1,70 +0,0 @@
---
title: Слой App
status: draft
normative: true
---
# Слой App
`app` является boundary между framework routing/runtime и SLM-модулями приложения.
## Ответственность
`app` может содержать:
- route files;
- framework layout/error/loading/not-found entries;
- framework metadata и route parameters;
- bootstrap imports;
- подключение global styles/assets;
- framework-required middleware и handlers.
## Правила
**SLM-BASE-APP-001 - ОБЯЗАН.** Route entry должен оставаться тонким adapter, нормализующим framework input и делегирующим готовому composition module.
```text
framework route
→ composition entry
```
**SLM-BASE-APP-002 - ЗАПРЕЩЕНО.** `app` не может владеть product page, screen, widget, product scenario, store или application wiring.
**SLM-BASE-APP-003 - ЗАПРЕЩЕНО.** Route entry не должен напрямую собирать product integrations, вызывать SDK или формировать product model.
**SLM-BASE-APP-004 - ОБЯЗАН.** Framework-specific input должен быть считан в `app` и передан вниз в минимальной нормализованной форме.
Механическая нормализация включает извлечение route params, headers и framework wrappers. Product validation, создание value objects и выбор product outcome остаются у владельца product semantics.
Запрет другим SLM-слоям импортировать `app` определяется base-правилом `SLM-BASE-ARCH-003`.
**SLM-BASE-APP-006 - СЛЕДУЕТ.** Framework behavior, которому нужны product dependencies или product UI, следует реализовать готовым composition entry и только подключить из `app`.
**SLM-BASE-APP-007 - МОЖЕТ.** `app` может напрямую импортировать framework APIs и static/global resources из `shared`, если framework требует подключить их в root entry.
## Допустимая структура
Структуру `app` определяет framework. SLM не требует превращать framework directories в SLM modules и не требует `index.ts` для route folders.
```text
app/
├── layout.tsx
├── error.tsx
├── not-found.tsx
├── api/
└── products/
└── [product]/
└── page.tsx
```
## Примеры нарушений
Следующие сущности являются примерами нарушений `SLM-BASE-APP-002` и `SLM-BASE-APP-003`:
- `ProductPage`;
- product Provider;
- application service creator;
- page-local store;
- product mapper;
- reusable product component;
- concrete product integration.

View File

@@ -1,85 +0,0 @@
---
title: Слой Compositions
status: draft
normative: true
---
# Слой Compositions
`compositions` собирает application flows из module public APIs, technical capabilities и UI modules и может владеть product logic в пределах своей ответственности.
## Ответственность
Composition может быть:
- page;
- route composition entry;
- layout;
- screen;
- widget;
- provider composition;
- multi-module hook;
- non-visual application wiring owner.
Структура слоя свободна и отражает продуктовую навигацию приложения.
```text
compositions/
├── pages/
├── layouts/
├── screens/
├── widgets/
└── providers/
```
Эти папки являются groups, а не отдельными слоями.
## Product ownership
**SLM-BASE-CMP-001 - ОБЯЗАН.** Product flow и его локальная product logic должны принадлежать минимальной composition, охватывающей всех consumers этой ответственности.
Composition может использовать public API `infra` для external operations, сохраняя product mapping, outcomes и fallback semantics у себя.
## Public boundaries
**SLM-BASE-CMP-005 - ЗАПРЕЩЕНО.** Composition не может импортировать private services, integrations, stores, Context или другие internal paths используемого module.
## Product UI
**SLM-BASE-CMP-006 - ОБЯЗАН.** UI, объединяющий несколько самостоятельных modules, route/page scope либо application flow, принадлежит `compositions`.
Примеры:
- application header;
- order flow, объединяющий несколько product responsibilities;
- page screen;
- route guard с navigation outcome;
- widget, использующий public APIs двух самостоятельных modules.
**SLM-BASE-CMP-007 - МОЖЕТ.** Composition может использовать product UI, опубликованный другими modules, и universal UI, передавая props, callbacks и slots.
## State
**SLM-BASE-CMP-008 - ОБЯЗАН.** Page-local presentation state принадлежит минимальной composition, охватывающей всех его consumers.
Примеры page-local state:
- открытие sidebar;
- активная вкладка;
- route-local wizard step;
- presentation filters;
- состояние раскрытия section.
**SLM-BASE-CMP-009 - ЗАПРЕЩЕНО.** Page store не может становиться параллельным владельцем product model или canonical product cache другого owner.
## Imports
**SLM-BASE-CMP-010 - МОЖЕТ.** Composition module может импортировать public API других composition modules, infra, ui и shared.
Runtime-циклы между composition modules запрещены base-правилом `SLM-BASE-API-016`.
**SLM-BASE-CMP-015 - ОБЯЗАН.** Client и server composition entries должны иметь раздельные public entrypoints и environment markers, если composition участвует в обоих runtime graphs.
## Scope
Composition может владеть application, route, page, request или test scope. Выбор scope должен следовать правилам [runtime и lifecycle](../runtime-and-lifecycle.md).

View File

@@ -1,44 +0,0 @@
---
title: Слои
status: draft
normative: true
---
# Слои
Слой определяет вид ответственности, допустимые зависимости и типы modules внутри верхнеуровневой папки `src`.
## Матрица ответственности
| Слой | Владеет | Не владеет |
|---|---|---|
| [`app`](./app.md) | Framework routes, bootstrap, глобальные framework boundaries | Product UI, product logic, page state, application wiring |
| [`compositions`](./compositions.md) | Pages, layouts, screens, widgets, product flows, application wiring и scope | Universal UI primitives, technical transports |
| [`infra`](./infra.md) | Technical services, transports, platform integrations | Product semantics и application wiring |
| [`ui`](./ui.md) | Product-agnostic UI modules | Product scenarios и data sources |
| [`shared`](./shared.md) | Детерминированные общие resources | Runtime state, I/O и product knowledge |
## Общие правила
**SLM-BASE-LAY-001 - ОБЯЗАН.** Module должен располагаться в слое, который владеет его основной ответственностью.
**SLM-BASE-LAY-002 - ЗАПРЕЩЕНО.** Нельзя выбирать слой по техническому типу файла без определения владельца поведения и данных.
**SLM-BASE-LAY-003 - ОБЯЗАН.** Межслойный import должен одновременно соответствовать общей dependency direction и public API импортируемого module.
**SLM-BASE-LAY-004 - ЗАПРЕЩЕНО.** Нельзя создавать proxy module в разрешённом слое только для обхода запрещённого направления import.
**SLM-BASE-LAY-005 - СЛЕДУЕТ.** При смешанной ответственности module следует разделить по реальным владельцам. Application flow и UI нескольких самостоятельных modules следует собирать в `compositions`.
## Выбор слоя
| Вопрос | Слой |
|---|---|
| Код существует только из-за framework route/bootstrap? | `app` |
| Код собирает page, route или несколько самостоятельных modules? | `compositions` |
| Код выражает product flow или product responsibility без owner, введённого overlay? | `compositions` |
| Код предоставляет technical capability приложения? | `infra` |
| Компонент не содержит product semantics и scenario? | `ui` |
| Код детерминирован, не знает продукт и не имеет runtime state? | `shared` |
Overlay может добавлять собственный слой и изменять ownership только в явно объявленном delta.

View File

@@ -1,53 +0,0 @@
---
title: Слой Infra
status: draft
normative: true
---
# Слой Infra
`infra` содержит technical capabilities приложения, не определяющие product model и scenarios.
## Примеры modules
```text
infra/
├── http/
├── backend-api/
├── realtime/
├── analytics/
├── logger/
├── app-config/
├── storage/
├── i18n/
└── theme/
```
## Правила
**SLM-BASE-INF-001 - ОБЯЗАН.** Infra module должен описывать technical capability, а не product semantics или scenario.
**SLM-BASE-INF-002 - МОЖЕТ.** Infra module может импортировать public API другого infra module и `shared`.
Запрет infra импортировать `compositions` или `app` определяется base-правилом `SLM-BASE-ARCH-003`.
**SLM-BASE-INF-004 - ЗАПРЕЩЕНО.** Infra не может владеть product wiring, собирать application graph или предоставлять generic product service locator.
**SLM-BASE-INF-005 - ЗАПРЕЩЕНО.** Infra не создаёт product errors, product fallback и product model из transport DTO.
**SLM-BASE-INF-006 - МОЖЕТ.** Infra может экспортировать technical client, transport, event source, storage primitive или platform wrapper через собственный public API.
**SLM-BASE-INF-007 - ОБЯЗАН.** Generated SDK и transport details должны оставаться внутри technical или private integration boundary владельца и не становиться частью public product contract.
## Product integration
Infra знает technical mechanism:
```text
HTTP client
WebSocket transport
local storage primitive
analytics SDK
```
Product owner определяет semantics использования capability; infra предоставляет механизм через public API. Один infra module может использоваться несколькими product owners без знания их semantics.

View File

@@ -1,42 +0,0 @@
---
title: Слой Shared
status: draft
normative: true
---
# Слой Shared
`shared` является детерминированным фундаментом приложения и не знает о SLM-модулях верхних слоёв.
## Допустимое содержимое
- pure utilities;
- value predicates;
- product-agnostic types;
- styling foundation и tokens;
- static resources;
- compile-time constants без product ownership;
- deterministic formatting primitives.
## Правила
**SLM-BASE-SHR-001 - ОБЯЗАН.** Результат shared utility должен определяться явными аргументами и не зависеть от скрытого runtime environment.
**SLM-BASE-SHR-002 - ЗАПРЕЩЕНО.** `shared` не может импортировать `app`, `compositions`, `infra` или `ui`.
**SLM-BASE-SHR-003 - ЗАПРЕЩЕНО.** `shared` не может владеть product types, product rules, runtime state, I/O, storage access или event subscriptions.
**SLM-BASE-SHR-004 - ЗАПРЕЩЕНО.** Нельзя переносить product helper, DTO, integration contract или product config в `shared` для обхода import boundary.
**SLM-BASE-SHR-005 - СЛЕДУЕТ.** Код следует поднимать в `shared` только при подтверждённой product-agnostic semantics, а не из-за повторения нескольких строк.
## Отличие от других слоёв
| Код | Владелец |
|---|---|
| Email validator с product rules | Владеющий product module |
| Generic string trim utility | `shared` |
| Browser storage wrapper | `infra` |
| Product storage integration | Product owner; storage primitive - `infra` |
| UI spacing tokens | `shared` |
| Button consuming spacing tokens | `ui` |

View File

@@ -1,48 +0,0 @@
---
title: Слой UI
status: draft
normative: true
---
# Слой UI
`ui` содержит reusable presentation modules без product scenario и product ownership.
## Примеры
```text
ui/
├── button/
├── input/
├── icon/
├── modal/
├── carousel/
├── tabs/
└── tooltip/
```
## Правила
**SLM-BASE-UI-001 - ОБЯЗАН.** UI module должен быть применим без product-specific knowledge.
**SLM-BASE-UI-002 - ЗАПРЕЩЕНО.** UI module не может импортировать `compositions`, `app` или product-specific infra.
**SLM-BASE-UI-003 - МОЖЕТ.** UI module может импортировать public API других UI modules и `shared`.
**SLM-BASE-UI-004 - ЗАПРЕЩЕНО.** UI module не выбирает product data source, не вызывает product scenario и не владеет multi-module behavior.
**SLM-BASE-UI-005 - МОЖЕТ.** UI module может владеть локальным interaction state, необходимым только для собственной presentation mechanics.
**SLM-BASE-UI-006 - ОБЯЗАН.** Компонент с product semantics должен принадлежать владеющему product module, а не `ui`.
## Классификация
| Сущность | Владелец |
|---|---|
| `Button`, `Input`, `Modal` | `ui` |
| `LoginForm` одной auth responsibility | Владеющий product module |
| Application header | `compositions` |
| Generic date picker | `ui` |
| Medication schedule | Владеющий product module согласно ownership |
Универсальность определяется отсутствием product knowledge, а не количеством текущих consumers.

View File

@@ -1,122 +0,0 @@
---
title: Domains в SLM Advanced
status: draft
normative: true
overlay: advanced
base: slm
---
# Domains в SLM Advanced
> Overlay: `SLM Advanced`. Base: [SLM](../../index.md).
Domain является законченным вертикальным product module с одной предметной ответственностью и явным public boundary. Кроме base-правил modules и segments, Advanced не предписывает обязательную внутреннюю архитектуру domain.
## Domain и group
**SLM-ADV-DOM-001 - ОБЯЗАН.** Конечный domain должен располагаться непосредственно в `domains` или внутри одной или нескольких навигационных groups.
```text
domains/{domain}
domains/{group}/{domain}
domains/{group}/{nested-group}/{domain}
```
**SLM-ADV-DOM-002 - ОБЯЗАН.** Узел domain tree с собственным public API, state, integration или runtime должен классифицироваться как конечный domain, а не domain group.
```text
domains/
├── navigation/ # domain
└── knv/ # group
├── auth/ # domain
├── user/ # domain
└── orders/ # domain
```
**SLM-ADV-DOM-003 - ОБЯЗАН.** Первой архитектурной единицей в group tree является конечная папка, владеющая самостоятельной product responsibility.
## Ownership
**SLM-ADV-DOM-004 - ОБЯЗАН.** Domain должен владеть одной сформулированной product responsibility и предоставлять её внешним consumers через собственный public API.
Domain может владеть:
- product model и value objects;
- scenarios и operations;
- domain state и transitions;
- normalization и product errors;
- product source integration;
- framework hooks и UI одного domain;
- runtime-specific setup.
**SLM-ADV-DOM-005 - ЗАПРЕЩЕНО.** Domain не может владеть framework route entry, page/layout composition, UI нескольких самостоятельных product responsibilities, universal technical capability или product-agnostic UI primitive.
## Структура
```text
domains/knv/auth/
├── hooks/
├── providers/
├── services/
├── stores/
├── mappers/
├── types/
├── ui/
├── parts/
└── index.ts
```
Это пример, а не обязательный scaffold. Небольшой domain может состоять из одного файла и public entrypoint.
Domain может хранить файлы в корне и использовать любые необходимые segments согласно base-правилам [SLM-BASE-SEG-001 - SLM-BASE-SEG-003](../../segments.md#правила).
**SLM-ADV-DOM-006 - ЗАПРЕЩЕНО.** Нельзя создавать пустые segments или копировать полную структуру другого domain без текущей ответственности.
**SLM-ADV-DOM-007 - МОЖЕТ.** Domain может владеть hooks, Providers, Context, services, stores, mappers, types, product UI и другими implementation units своей ответственности.
## Public API
Public boundary Advanced domain следует base-правилам `SLM-BASE-API-001` и `SLM-BASE-API-002`.
**SLM-ADV-DOM-009 - МОЖЕТ.** Public API domain может экспортировать выбранные командой hooks, Providers, Context, components, service APIs, store access APIs и types как стабильный contract.
**SLM-ADV-DOM-010 - ЗАПРЕЩЕНО.** Если product responsibility получила domain owner, app, composition или infra не могут создавать параллельную модель этой ответственности либо обходить её public boundary.
## Dependencies
```text
composition -> domain
domain -> domain | infra | ui | shared
```
**SLM-ADV-DOM-011 - МОЖЕТ.** Domain может runtime-импортировать public API другого Advanced domain.
**SLM-ADV-DOM-012 - МОЖЕТ.** Domain может напрямую использовать public API `infra`, `ui` и `shared` без обязательной промежуточной abstraction.
Runtime cycles запрещены base-правилом `SLM-BASE-API-016`.
**SLM-ADV-DOM-013 - ЗАПРЕЩЕНО.** Type-only dependency cycle между domains запрещён, даже если runtime graph остаётся ацикличным.
## Data flow
```text
composition
-> domain public API
-> domain hook/service
-> infra
-> external source
```
**SLM-ADV-DOM-014 - ОБЯЗАН.** Product consumers за пределами domain должны получать его данные и поведение через public API domain, а не повторять тот же integration flow напрямую через `infra`.
## Product UI
**SLM-ADV-DOM-015 - МОЖЕТ.** Product UI одной domain responsibility может принадлежать этому domain.
UI нескольких самостоятельных responsibilities остаётся в `compositions` согласно base-правилу `SLM-BASE-CMP-006`.
## Monorepo boundary
**SLM-ADV-DOM-016 - ОБЯЗАН.** Advanced domain должен оставаться внутри `apps/{app}/src/domains` до принятия отдельной package-модели.
**SLM-ADV-DOM-017 - ЗАПРЕЩЕНО.** Workspace package не может называться Advanced Domain для целей Specification, если он не соответствует application path и ownership этой главы.

View File

@@ -1,60 +0,0 @@
---
title: SLM Advanced
status: draft
normative: true
overlay: advanced
base: slm
---
# SLM Advanced
`SLM Advanced` является независимым overlay непосредственно над [base SLM](../../index.md).
```text
SLM Advanced = SLM + Advanced rules
```
## Отличия от SLM
| Область | Base SLM | SLM Advanced |
|---|---|---|
| Product ownership | Product logic принадлежит compositions | Устойчивая product responsibility может быть извлечена в domain |
| Слои | `app`, `compositions`, `infra`, `ui`, `shared` | Добавляется `domains` |
| Структура domain | Отсутствует | Свободная, внутренние роли выбирает команда |
| Domain dependencies | Отсутствуют | Ацикличные imports через public API разрешены |
| External integration | Composition использует infra | Domain может использовать infra напрямую |
## Расширение архитектурной модели
**SLM-ADV-ARCH-001 - ОБЯЗАН.** SLM Advanced должен расширять набор base-слоёв слоем `domains` для самостоятельных product responsibilities.
```text
src/
├── app/
├── compositions/
├── domains/
├── infra/
├── ui/
└── shared/
```
**SLM-ADV-ARCH-002 - ОБЯЗАН.** Дополнительные dependency edges Advanced должны соответствовать следующему направлению:
```text
compositions -> domains
domains -> domains | infra | ui | shared
```
Base dependency direction для остальных слоёв сохраняется.
## Изменение product ownership
**SLM-ADV-CMP-001 - ОБЯЗАН.** Если product responsibility получила domain owner, Advanced заменяет для этой ответственности base-правило `SLM-BASE-CMP-001`: domain владеет собственной product logic, а composition владеет application flow и связывает public APIs.
Product logic без domain owner продолжает следовать base SLM и принадлежит минимальной composition.
**SLM-ADV-CMP-010 - МОЖЕТ.** Composition module может импортировать public API Advanced domains в дополнение к imports, разрешённым base-правилом `SLM-BASE-CMP-010`.
## Advanced Domain Specification
Полная Advanced-модель слоя описана в [Domains](./domains.md). Других mode-specific отличий текущий draft Advanced не вводит.

View File

@@ -1,130 +0,0 @@
---
title: Business в SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# Business
> Overlay: `SLM Pro`.
`business` является framework-neutral зоной domain и единственным владельцем его продуктовой semantics.
## Структура
```text
domains/{group...}/{domain}/business/
├── {domain}.factory.ts
├── index.ts
├── types/
├── ports/
├── services/
├── errors/
├── mappers/
├── selectors/
├── validators/
└── lib/
```
Конкретный набор внутренних segments определяется размером domain. Обязательны роль factory и public boundary, но не каждая папка из примера.
## Factory boundary
**SLM-PRO-BUS-001 - ОБЯЗАН.** Business должен создавать public runtime API через factory `{domain}Factory`.
**SLM-PRO-BUS-002 - ОБЯЗАН.** Factory должна принимать все runtime capabilities через business-owned dependency contracts.
**SLM-PRO-BUS-003 - ОБЯЗАН.** Factory должна возвращать framework-neutral DomainRuntime. Stateless logic API считается DomainRuntime и соблюдает тот же public boundary.
**SLM-PRO-BUS-004 - ЗАПРЕЩЕНО.** Factory не может возвращать React hooks, components, Providers, layouts, route guards или framework boundaries.
**SLM-PRO-BUS-005 - ЗАПРЕЩЕНО.** Factory constructor не может выполнять I/O, открывать socket, регистрировать subscription, запускать timer или читать hidden environment.
## Public API
**SLM-PRO-BUS-006 - ОБЯЗАН.** `business/index.ts` должен экспортировать единственное runtime value: factory.
**SLM-PRO-BUS-007 - МОЖЕТ.** `business/index.ts` может экспортировать business-owned types через `export type`.
```ts
export { authFactory } from './auth.factory'
export type {
AuthDeps,
AuthFactory,
AuthRuntime,
AuthState,
} from './types'
```
**SLM-PRO-BUS-008 - ЗАПРЕЩЕНО.** Error classes, error guards, error code constants, selectors, validators, formatters, services, mappers и port implementations не экспортируются как отдельные runtime values.
Если внешнему consumer нужна такая capability, она должна быть осмысленной частью factory runtime API, а не обходным direct export.
## Runtime API
DomainRuntime может предоставлять:
- commands;
- imperative queries;
- snapshots;
- subscriptions;
- selectors через стабильные methods;
- validation operations;
- typed outcomes;
- explicit lifecycle operations.
**SLM-PRO-BUS-009 - ОБЯЗАН.** Runtime API должен говорить на языке domain и не повторять endpoint names, SDK tree или storage schema.
**SLM-PRO-BUS-010 - ЗАПРЕЩЕНО.** Public contract не может раскрывать generated DTO, SDK client, query-library result, concrete store API, raw Context или adapter.
## Dependencies и ports
**SLM-PRO-BUS-011 - ОБЯЗАН.** Business-owned dependency описывает минимальную внешнюю возможность на языке domain.
```ts
export type AuthPhonePort = {
requestCode: (phone: string) => Promise<unknown>
verifyCode: (input: VerifyPhoneCodeInput) => Promise<unknown>
}
```
**SLM-PRO-BUS-012 - ОБЯЗАН.** Ненадёжный внешний результат должен приниматься как `unknown`, если business обязан проверить его runtime-форму.
**SLM-PRO-BUS-013 - ЗАПРЕЩЕНО.** Business dependency не может быть generated DTO, полный SDK client, `StoreApi`, QueryClient или framework hook.
**SLM-PRO-BUS-014 - ОБЯЗАН.** Subscription port должен предоставлять cleanup contract.
## Imports
Business может runtime-импортировать:
- собственные файлы;
- детерминированный `shared`;
- pure libraries без I/O, hidden state и public type leakage.
Business может type-only импортировать стабильный public contract другого domain, если dependency невозможно корректно описать локальным port. Локальный consumer-owned port является предпочтительным вариантом.
**SLM-PRO-BUS-015 - ЗАПРЕЩЕНО.** Business не импортирует React, query runtime, state manager, SDK, generated operation, HTTP client, storage implementation, browser API, infra, composition или assembly.
Cross-domain imports дополнительно регулируются правилами `SLM-PRO-XDOM-*` в [Cross-domain boundary](./cross-domain-boundary.md).
## Normalization и errors
**SLM-PRO-BUS-017 - ОБЯЗАН.** External result должен быть нормализован в business-owned model до выхода из DomainRuntime.
**SLM-PRO-BUS-018 - ОБЯЗАН.** Malformed successful response должен считаться нарушением runtime contract, а не валидным отсутствием данных.
**SLM-PRO-BUS-019 - ОБЯЗАН.** Expected domain outcome и technical failure должны быть различимы в public contract.
**SLM-PRO-BUS-020 - ЗАПРЕЩЕНО.** Source error, HTTP status, SDK error class, raw response и transport message не могут быть consumer contract.
Business может выражать ожидаемые outcomes через typed result или domain error. Эта draft-версия не предписывает единственную форму обработки ожидаемых ошибок, но требует business-owned semantics и стабильных discriminants.
## State
**SLM-PRO-BUS-021 - ОБЯЗАН.** Business владеет domain state model, допустимыми transitions и semantics commands/selectors.
Framework-neutral state runtime может быть создан самой factory или предоставлен через business-owned port. Concrete store implementation остаётся запрещённой dependency по [SLM-PRO-BUS-015](#imports) и не раскрывается в public API.

View File

@@ -1,105 +0,0 @@
---
title: Client и server assembly в SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# Client и Server Assembly
> Overlay: `SLM Pro`.
`client` и `server` создают готовые runtime-specific instances одного domain поверх его business factory и adapters.
## Client assembly
```text
domains/{group...}/{domain}/client/
├── create-{domain}-client-runtime.ts
└── index.ts
```
```ts
export const createAuthClientRuntime = (): AuthRuntime => {
return authFactory({
phoneAuth: browserPhoneAuthAdapter,
session: browserSessionAdapter,
})
}
```
Client technical inputs ограничены client environment/config и platform capabilities, необходимыми для создания adapters собственного domain. Runtime values другого domain technical input не являются.
**SLM-PRO-ASM-001 - ОБЯЗАН.** Runtime imports client assembly должны ограничиваться собственной business factory, собственными client adapters, собственной React surface и необходимыми client technical inputs. Type-only foreign contracts допускаются по [SLM-PRO-XDOM-005](./cross-domain-boundary.md#type-only-contracts).
**SLM-PRO-ASM-002 - ОБЯЗАН.** Client assembly должна возвращать готовый runtime собственного domain.
Запрет на foreign runtime values определяется правилом [SLM-PRO-XDOM-001](./cross-domain-boundary.md#runtime-imports).
**SLM-PRO-ASM-004 - МОЖЕТ.** Client или server assembly может принимать готовую внешнюю capability через собственный input contract.
```ts
createUserClientRuntime({ auth: auth.session })
```
Такой input не даёт user domain права создавать AuthRuntime или импортировать его client entrypoint.
## Server assembly
```text
domains/{group...}/{domain}/server/
├── create-{domain}-server-runtime.ts
└── index.ts
```
Server technical inputs ограничены request/framework data, server environment/config и platform capabilities, необходимыми для создания server adapters собственного domain. Runtime values другого domain technical input не являются.
**SLM-PRO-ASM-005 - ОБЯЗАН.** Server assembly должна создавать новый runtime в scope, соответствующем request или другой явно выбранной server lifetime.
**SLM-PRO-ASM-006 - ЗАПРЕЩЕНО.** Server assembly не может повторно использовать adapter или runtime instance, захвативший request credentials, cookies, headers или user-specific state другого scope.
**SLM-PRO-ASM-007 - ОБЯЗАН.** Framework/request input используется только для создания server adapters и не протекает как raw framework object в business API.
**SLM-PRO-ASM-008 - ОБЯЗАН.** Server entrypoint должен иметь явный server-only marker, если framework предоставляет такой механизм.
**SLM-PRO-ASM-016 - ОБЯЗАН.** Runtime imports server assembly должны ограничиваться собственной business factory, собственными server adapters и необходимыми server technical inputs; runtime import React/client surface запрещён. Type-only foreign contracts допускаются по [SLM-PRO-XDOM-005](./cross-domain-boundary.md#type-only-contracts).
## Constructor и activation
Assembly определяет способ создания, но не владеет полным cross-domain graph.
Отсутствие product request, socket connection и background resource при вызове runtime creator определяется base-правилом `SLM-BASE-LIFE-002`.
```text
module import
→ определяет creator
creator call
→ создаёт runtime instance
explicit start
→ запускает resources
```
**SLM-PRO-ASM-010 - ОБЯЗАН.** Resources запускает composition scope owner в выбранном scope согласно [lifecycle rules](../../../runtime-and-lifecycle.md).
## Public entrypoints
**SLM-PRO-CMP-004 - ЗАПРЕЩЕНО.** Composition не может повторять adapter wiring, если domain public assembly уже создаёт готовый runtime.
**SLM-PRO-CMP-013 - ОБЯЗАН.** Composition должна использовать public client/server creator domain, если domain предоставляет runtime-specific assembly.
**SLM-PRO-CMP-014 - МОЖЕТ.** Composition может вызвать public business factory напрямую только для universal domain, у которого нет external ports, concrete adapters и runtime-specific input.
**SLM-PRO-ASM-011 - ОБЯЗАН.** Client и server assembly должны иметь разные public entrypoints.
**SLM-PRO-ASM-012 - ЗАПРЕЩЕНО.** Общий domain barrel не может runtime-реэкспортировать одновременно client и server surfaces.
## Server/client bridge
Client и server runtimes являются разными instances над общей business semantics.
Запрет на передачу DomainRuntime, functions, Context, store или query client через serializable server/client boundary определяется правилом [SLM-BASE-DATA-012](../../../state-and-data.md#serializable-boundaries).
**SLM-PRO-ASM-014 - МОЖЕТ.** Server может передать client assembly только serializable business-owned bootstrap data без secrets и mutable runtime objects.

View File

@@ -1,125 +0,0 @@
---
title: Cross-domain boundary
status: draft
normative: true
overlay: pro
base: slm
---
# Cross-domain Boundary
> Overlay: `SLM Pro`.
Domains не образуют скрытый runtime graph внутри слоя `domains`. Граф связывается только graph owner в `compositions`.
## Composition graph
**SLM-PRO-CMP-001 - ОБЯЗАН.** Runtime graph нескольких domains должен собираться в composition, которая владеет его scope.
```ts
const auth = createAuthRuntime()
const user = createUserRuntime({ auth: auth.session })
const orders = createOrdersRuntime({ user: user.agreements })
```
**SLM-PRO-CMP-002 - ОБЯЗАН.** Composition должна создавать domain runtimes в явном ацикличном порядке.
**SLM-PRO-CMP-003 - ОБЯЗАН.** Cross-domain dependency должна передаваться как готовая минимальная capability, а не разрешаться service locator или domain import.
**SLM-PRO-CMP-012 - ОБЯЗАН.** App-specific graph type должен отражать только реально предоставленные runtimes; `Partial<Graph>` с последующим приведением к полному graph запрещён.
## Runtime imports
**SLM-PRO-XDOM-001 - ЗАПРЕЩЕНО.** Ни одна zone domain A не может импортировать, реэкспортировать, dynamic-import или разрешать через service locator runtime value domain B.
Запрет включает foreign business API, hooks, Provider, Context, components, adapters, runtime creators и event emitters.
Foreign runtime capability может поступить только argument-ом от composition согласно разделу [Runtime capability injection](#runtime-capability-injection).
## Type-only contracts
**SLM-PRO-API-008 - СЛЕДУЕТ.** Cross-domain capability следует описывать consumer-owned structural port вместо зависимости от полного foreign API type.
**SLM-PRO-XDOM-014 - ЗАПРЕЩЕНО.** Public contract зависимого domain не может реэкспортировать полный foreign DomainRuntime type как собственную cross-domain dependency.
**SLM-PRO-XDOM-005 - МОЖЕТ.** Business и client/server input contracts domain могут type-only импортировать минимальный стабильный business contract другого domain.
Предпочтение consumer-owned port определяется правилом `SLM-PRO-API-008`.
```ts
export type UserAuthPort = {
getSessionSnapshot: () => SessionSnapshot
subscribeToSession: (listener: () => void) => () => void
}
```
Type-only import не разрешает runtime import и не переносит ownership.
**SLM-PRO-XDOM-012 - ЗАПРЕЩЕНО.** Type dependency cycle между domains запрещён, даже если не создаёт runtime cycle.
## Runtime capability injection
**SLM-PRO-XDOM-007 - МОЖЕТ.** Domain runtime creator может принять готовую structurally compatible capability, созданную другим domain и переданную composition.
```ts
const auth = createAuthClientRuntime()
const user = createUserClientRuntime({ auth: auth.session })
```
User domain знает только свой input contract. Он не знает creator, Provider, adapters и scope AuthRuntime.
**SLM-PRO-XDOM-008 - ОБЯЗАН.** Передаваемая capability должна быть минимальной и не раскрывать raw store, Context, SDK client или mutable internals foreign domain.
**SLM-PRO-XDOM-013 - МОЖЕТ.** Structurally compatible foreign capability может реализовать consumer-owned port напрямую. Wrapper adapter создаётся только при необходимости преобразовать contracts или lifecycle.
## React composition
Если React-сущность использует runtime API двух domains, она принадлежит `compositions`.
```tsx
const ProtectedOrderForm = () => {
const auth = useAuth()
const order = useOrder()
return auth.isAuthenticated
? <OrderForm order={order} />
: <AuthPrompt />
}
```
**SLM-PRO-XDOM-009 - ОБЯЗАН.** Props, callbacks и slots, передаваемые из composition в domain UI, должны оставаться domain-local или presentation-neutral. Foreign domain semantics остаётся во владеющей composition.
```tsx
<AuthRequired>
<OrderForm />
</AuthRequired>
```
Такое связывание выполняется в composition, а не внутри auth или orders.
## Events
Прямая подписка на event emitter другого domain через runtime import запрещена правилом `SLM-PRO-XDOM-001`.
Composition может передать event capability через consumer-owned port:
```ts
const orders = createOrdersClientRuntime({
userEvents: {
subscribeToIdentity: user.identity.subscribe,
},
})
```
## Cycles
**SLM-PRO-XDOM-011 - ЗАПРЕЩЕНО.** Runtime dependency cycle между domains является нарушением границы и не может скрываться event bus, lazy resolution или two-way service locator.
**SLM-PRO-LIFE-008 - ОБЯЗАН.** Cross-domain graph запускается в dependency order и освобождается в обратном порядке.
Ненормативное пояснение: при обнаружении цикла следует пересмотреть один из вариантов:
- пересмотреть границы domains;
- перенести orchestration в composition;
- выделить отдельную product responsibility;
- инвертировать зависимость через consumer-owned port.

View File

@@ -1,81 +0,0 @@
---
title: Framework surface в SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# Framework Surface
> Overlay: `SLM Pro`.
Framework surface адаптирует готовый DomainRuntime к execution model конкретного framework. В текущей структуре React surface располагается в `react/`.
## Структура React surface
```text
domains/{group...}/{domain}/react/
├── context/
├── providers/
├── hooks/
├── ui/
└── index.ts
```
Ни один segment не обязателен без реальной потребности.
## Runtime access
**SLM-PRO-FRM-001 - ОБЯЗАН.** Framework surface должна работать с конкретным DomainRuntime через domain-owned runtime access boundary.
**SLM-PRO-FRM-002 - ЗАПРЕЩЕНО.** Framework hook или component не может самостоятельно вызывать business factory, создавать adapters или разрешать runtime из global service locator.
**SLM-PRO-FRM-003 - ОБЯЗАН.** Runtime access boundary должна получать готовый DomainRuntime извне и не создавать параллельное domain state.
Для React типичным механизмом является private Context, связывающий статически экспортированные hooks/components с переданным runtime instance. Это пояснение не предписывает точную форму или количество Providers в текущем draft.
**Domain runtime Provider** - часть framework surface, получающая готовый DomainRuntime и предоставляющая его framework consumers одного domain. Provider не создаёт cross-domain graph автоматически.
## Imports
**SLM-PRO-FRM-004 - ОБЯЗАН.** React surface должна импортировать business runtime contracts только через `import type`.
**SLM-PRO-FRM-005 - ЗАПРЕЩЕНО.** React surface не может runtime-импортировать business factory, private business services, selectors, validators, errors или constants.
**SLM-PRO-FRM-006 - ЗАПРЕЩЕНО.** React surface не может импортировать domain adapters, SDK, product infra client или assembly.
**SLM-PRO-FRM-007 - МОЖЕТ.** React surface может импортировать public API `ui`, `shared` и framework libraries, разрешённые её runtime profile.
Cross-domain runtime imports framework surface запрещены правилом [SLM-PRO-XDOM-001](./cross-domain-boundary.md#runtime-imports).
## Hooks
**SLM-PRO-FRM-009 - ОБЯЗАН.** Domain hook должен получать product data и behavior только через текущий DomainRuntime.
**SLM-PRO-FRM-010 - МОЖЕТ.** Hook может использовать framework query/cache runtime как private implementation поверх imperative DomainRuntime query.
**SLM-PRO-FRM-011 - ЗАПРЕЩЕНО.** Query hook не может использовать adapter или SDK call как fetcher в обход DomainRuntime.
**SLM-PRO-FRM-012 - ЗАПРЕЩЕНО.** Query-library types, cache keys и raw mutate API не могут становиться public business contract.
## Domain UI
Domain React UI может:
- вызывать hooks своего domain;
- использовать universal UI;
- отображать domain-owned states и outcomes;
- принимать callbacks, props и slots от composition.
**SLM-PRO-FRM-013 - ЗАПРЕЩЕНО.** Domain UI не может импортировать runtime другого domain или оркестрировать route/page flow.
Владение React UI, использующим несколько domains, определено base-правилом [SLM-BASE-CMP-006](../../../layers/compositions.md#product-ui).
## Client boundary
**SLM-PRO-FRM-015 - ОБЯЗАН.** Entry point React hooks, Context и interactive UI должен быть явно отмечен как client runtime согласно правилам используемого framework.
**SLM-PRO-FRM-016 - ЗАПРЕЩЕНО.** Server-compatible React export не может попадать в client entrypoint только из-за нахождения рядом с client hooks или Provider.
React не является синонимом client runtime; environment profile определяется фактическими dependencies export.

View File

@@ -1,158 +0,0 @@
---
title: Domains в SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# Domains в SLM Pro
> Overlay: `SLM Pro`. Base: [SLM](../../../index.md).
Domain является изолированным вертикальным product module с одной предметной ответственностью, явным public boundary и строгими внутренними dependency zones.
## Domain и group
**SLM-PRO-DOM-001 - ОБЯЗАН.** Конечный Pro domain должен располагаться непосредственно в `domains` или внутри одной или нескольких навигационных groups.
```text
domains/{domain}
domains/{group}/{domain}
domains/{group}/{nested-group}/{domain}
```
**SLM-PRO-DOM-002 - ОБЯЗАН.** Узел domain tree с собственным public API, state, integration, assembly или runtime должен классифицироваться как конечный domain, а не domain group.
```text
domains/
├── navigation/ # domain
└── knv/ # group
├── auth/ # domain
├── user/ # domain
└── orders/ # domain
```
**SLM-PRO-DOM-003 - ОБЯЗАН.** Первой архитектурной единицей в group tree является конечная папка, владеющая самостоятельной product responsibility.
## Ownership
**SLM-PRO-DOM-004 - ОБЯЗАН.** Pro domain должен владеть одной сформулированной product responsibility и предоставлять её внешним consumers через собственные public entrypoints.
Pro domain может владеть:
- product model и value objects;
- scenarios и operations;
- domain state и transitions;
- normalization и product errors;
- business-owned ports;
- concrete integrations собственных ports;
- framework hooks и UI одного domain;
- client/server runtime assembly.
**SLM-PRO-DOM-005 - ЗАПРЕЩЕНО.** Domain не может владеть framework route entry, page/layout composition, UI нескольких самостоятельных product responsibilities, universal technical capability или product-agnostic UI primitive.
Public entrypoints Pro domain следуют base-правилам `SLM-BASE-API-001` и `SLM-BASE-API-002`; Pro-главы вводят дополнительные ограничения exports.
**SLM-PRO-DOM-007 - ЗАПРЕЩЕНО.** Если product responsibility получила Pro domain owner, app, composition или infra не могут создавать параллельную модель этой ответственности либо обходить её public boundary.
**SLM-PRO-DOM-008 - ОБЯЗАН.** Для domain-owned responsibility это правило заменяет base-правило `SLM-BASE-CMP-001`: business владеет product logic, а composition владеет application flow и runtime graph.
Product responsibility считается устойчивой, если имеет самостоятельную product model или transitions, используется несколькими application flows либо владеет external integration/lifecycle contract.
**SLM-PRO-DOM-017 - ОБЯЗАН.** Каждая устойчивая product responsibility должна иметь Pro domain owner; route/page-local presentation flow остаётся ответственностью composition.
## Внутренние zones
```text
domains/{group...}/{domain}/
├── business/
├── react/
├── adapters/
├── client/
└── server/
```
| Zone | Статус | Ответственность |
|---|---|---|
| [`business`](./business.md) | Обязательная | Product model, factory, ports, scenarios, errors |
| [`react`](./framework.md) | Опциональная | React runtime access, hooks, Providers, domain UI |
| [`adapters`](./ports-and-adapters.md) | Опциональная | Concrete реализации business-owned ports |
| [`client`](./client-and-server.md) | Опциональная | Browser/client assembly одного domain |
| [`server`](./client-and-server.md) | Опциональная | Server/request assembly одного domain |
**SLM-PRO-DOM-009 - ОБЯЗАН.** Каждый Pro domain должен содержать `business` как единственного владельца product model и business semantics.
**SLM-PRO-DOM-010 - СЛЕДУЕТ.** Опциональную zone следует добавлять только при наличии реального runtime consumer и самостоятельной ответственности.
**SLM-PRO-DOM-011 - ЗАПРЕЩЕНО.** Нельзя создавать пустые симметричные `react`, `adapters`, `client` или `server` на будущее.
**SLM-PRO-DOM-012 - ОБЯЗАН.** Domain zones должны соблюдать внутреннюю dependency direction, даже если физически находятся под одним владельцем.
**SLM-PRO-MOD-001 - ОБЯЗАН.** `business`, `react`, `adapters`, `client` и `server` являются внутренними zones одного domain, а не самостоятельными верхнеуровневыми modules.
**SLM-PRO-SEG-001 - ЗАПРЕЩЕНО.** Domain zones нельзя трактовать как взаимозаменяемые generic segments.
Внутри каждой zone могут использоваться обычные base SLM segments по фактической необходимости.
## Внутреннее направление
```text
business -> shared | pure libraries
react -> ui | shared | framework libraries
adapters -> infra | SDK | platform runtime
client -> own business factory | own client adapters | own framework surface | client technical inputs
server -> own business factory | own server adapters | server technical inputs
```
Матрица описывает runtime imports. React surface может type-only импортировать собственные business contracts, adapters - собственные business ports/types, а client/server inputs - разрешённые cross-domain contracts.
## Путь данных
```text
composition
-> domain client/server assembly при наличии runtime-specific setup
или напрямую business factory для universal domain
-> DomainRuntime
-> business scenario
-> business-owned port
-> domain adapter
-> infra / SDK / storage / external source
```
**SLM-PRO-DOM-013 - ОБЯЗАН.** DomainRuntime, созданный business factory, должен быть единственным product gateway своего Pro domain для runtime consumers.
Stateless logic API также является DomainRuntime, если он создан factory и соблюдает тот же public boundary.
## Product UI
**SLM-PRO-DOM-014 - МОЖЕТ.** Product UI одной Pro domain responsibility может принадлежать framework surface этого domain.
UI нескольких самостоятельных responsibilities остаётся в `compositions` согласно base-правилу `SLM-BASE-CMP-006`.
## Cross-domain graph
```text
composition
-> создаёт несколько domain runtimes
-> передаёт готовые capabilities
```
Pro domain не создаёт runtime другого domain и не импортирует его runtime surface. Точные правила определены в [Cross-domain boundary](./cross-domain-boundary.md).
**Graph owner** - composition, являющаяся scope owner нескольких DomainRuntime, связанных направленными dependencies в одном ацикличном graph, и определяющая порядок их создания, activation и cleanup.
## Monorepo boundary
**SLM-PRO-DOM-015 - ОБЯЗАН.** Pro domain должен оставаться внутри `apps/{app}/src/domains` до принятия отдельной package-модели.
**SLM-PRO-DOM-016 - ЗАПРЕЩЕНО.** Workspace package не может называться Pro Domain для целей Specification, если он не соответствует application path и ownership этой главы.
## Главы Pro Domain Specification
- [Business](./business.md)
- [Framework surface](./framework.md)
- [Ports и adapters](./ports-and-adapters.md)
- [Client и server assembly](./client-and-server.md)
- [Cross-domain boundary](./cross-domain-boundary.md)
- [Тестирование Pro domains](./testing.md)

View File

@@ -1,100 +0,0 @@
---
title: Ports и adapters в SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# Ports и Adapters
> Overlay: `SLM Pro`.
Port определяет потребность business. Adapter связывает эту потребность с concrete runtime.
## Ownership
```text
domain/
├── business/
│ └── ports/
└── adapters/
```
**SLM-PRO-ADP-001 - ОБЯЗАН.** Port должен принадлежать `business` того domain, который потребляет capability.
**SLM-PRO-ADP-002 - ОБЯЗАН.** Concrete adapter должен принадлежать тому же domain, но находиться вне `business`.
**SLM-PRO-ADP-003 - ЗАПРЕЩЕНО.** Infra или external SDK не могут объявлять business port от имени domain.
**SLM-PRO-ADP-014 - ОБЯЗАН.** External technical capability из infra, SDK, storage или platform runtime должна реализовывать business port через adapter собственного domain, а этот adapter должен подключаться assembly того же domain. Готовая capability другого DomainRuntime может удовлетворять consumer-owned port напрямую только по правилу [SLM-PRO-XDOM-013](./cross-domain-boundary.md#runtime-capability-injection).
## Adapter contract
**SLM-PRO-ADP-004 - ОБЯЗАН.** Responsibilities adapter должны ограничиваться применимыми integration operations:
- импортировать type-only business port и domain input types;
- импортировать public infra API, SDK или platform runtime;
- переводить domain arguments в transport arguments;
- возвращать raw/unknown source result для business normalization;
- подписываться на concrete event source через явный lifecycle contract.
**SLM-PRO-ADP-005 - ЗАПРЕЩЕНО.** Adapter не может выполнять следующие domain/framework responsibilities:
- создавать domain error;
- выбирать domain fallback;
- реализовывать business rule;
- объявлять domain model;
- экспортировать concrete client consumer-коду;
- вызывать framework hook;
- runtime-импортировать или самостоятельно разрешать другой domain runtime.
Adapter может работать с минимальной foreign capability, явно переданной composition, только для преобразования contract или lifecycle согласно `SLM-PRO-XDOM-013`.
**SLM-PRO-ADP-006 - ОБЯЗАН.** Adapter должен реализовывать ровно тот port contract, который необходим business.
**SLM-PRO-ADP-007 - ЗАПРЕЩЕНО.** Нельзя передавать полный client, если port требует ограниченный набор capabilities.
**SLM-PRO-ADP-008 - ЗАПРЕЩЕНО.** Adapter integration logic не должна писаться inline в composition или runtime assembly.
## Client и server adapters
Adapters могут быть разделены по runtime:
```text
adapters/
├── client/
│ ├── browser-session.adapter.ts
│ └── websocket-orders.adapter.ts
└── server/
├── request-session.adapter.ts
└── server-orders-api.adapter.ts
```
**SLM-PRO-ADP-009 - ОБЯЗАН.** Client adapter не должен попадать в server graph, а server adapter - в client graph.
**SLM-PRO-ADP-010 - ОБЯЗАН.** Runtime-specific adapter должен иметь явный environment marker, если framework предоставляет такой механизм.
## Event sources
Socket, subscription и event listener реализуют event port:
```ts
export type OrdersEventsPort = {
subscribe: (listener: (event: unknown) => void) => () => void
}
```
**SLM-PRO-ADP-011 - ОБЯЗАН.** Event adapter должен возвращать cleanup и не открывать connection при module import.
**SLM-PRO-ADP-012 - ОБЯЗАН.** Wire event проходит business normalization до изменения domain state или передачи consumer-коду.
**SLM-PRO-LIFE-013 - МОЖЕТ.** Один physical transport может обслуживать adapters нескольких domains, если transport остаётся domain-agnostic, а adapters получают суженные channels.
## Public boundary
**SLM-PRO-API-014 - ЗАПРЕЩЕНО.** Public business, framework, client или server entrypoint не может реэкспортировать concrete adapter внешним consumers.
**SLM-PRO-ADP-013 - ЗАПРЕЩЕНО.** `adapters` не имеет внешнего public API для app, compositions или других domains.
Adapters доступны только assembly собственного domain и собственным contract tests.

View File

@@ -1,63 +0,0 @@
---
title: Тестирование Pro domains
status: draft
normative: true
overlay: pro
base: slm
---
# Тестирование Pro Domains
> Overlay: `SLM Pro`.
Общие правила [тестирования и соответствия](../../../testing-and-conformance.md) дополняются проверками строгих business, adapter, assembly и framework boundaries.
## Business factory tests
**SLM-PRO-TEST-001 - ОБЯЗАН.** Каждый public method runtime API, возвращаемого factory, должен иметь factory-level tests.
Factory-level tests должны проверять применимые случаи:
- happy path;
- malformed external result;
- rejected dependency;
- синхронное исключение dependency;
- domain outcome/error semantics;
- side-effect order;
- state transition;
- отсутствие constructor-time I/O;
- public API shape.
**SLM-PRO-TEST-002 - ОБЯЗАН.** Factory-level test должен создавать runtime через public `business` entrypoint, а не deep-import factory internals.
## Adapter tests
**SLM-PRO-TEST-003 - ОБЯЗАН.** Adapter с mapping, transport payload, error channel или lifecycle должен иметь contract tests на применимые responsibilities.
**SLM-PRO-TEST-004 - ЗАПРЕЩЕНО.** Adapter test не должен дублировать business scenario tests или утверждать domain fallback/error semantics.
## Assembly tests
**SLM-PRO-TEST-005 - ОБЯЗАН.** Client/server assembly tests должны проверять корректную передачу ports, runtime profile isolation и отсутствие I/O при creation.
**SLM-PRO-TEST-006 - ОБЯЗАН.** Server assembly с request data должен иметь isolation test для параллельных scopes.
## Framework tests
**SLM-PRO-TEST-007 - ОБЯЗАН.** Framework surface tests должны проверять runtime access boundary, предсказуемую ошибку при отсутствии runtime boundary, mapping public outcomes и lifecycle integration.
## Composition tests
**SLM-PRO-TEST-009 - ОБЯЗАН.** Tests cross-domain composition должны проверять topology, точный graph contract, переданные capabilities и lifecycle cleanup.
**SLM-PRO-TEST-010 - ОБЯЗАН.** Scope с неполным набором domains не должен типизироваться как полный application graph.
## Architecture checks
**SLM-PRO-TEST-019 - ОБЯЗАН.** Pro repository checks должны проверять применимые строгие domain boundaries:
- client/server markers;
- forbidden runtime imports между domains;
- private adapters;
- business entrypoint shape;
- zone dependency direction.

View File

@@ -1,55 +0,0 @@
---
title: SLM Pro
status: draft
normative: true
overlay: pro
base: slm
---
# SLM Pro
`SLM Pro` является независимым overlay непосредственно над [base SLM](../../index.md).
```text
SLM Pro = SLM + Pro rules
```
## Отличия от SLM
| Область | Base SLM | SLM Pro |
|---|---|---|
| Product ownership | Product logic принадлежит compositions | Устойчивая product responsibility принадлежит изолированному domain |
| Слои | `app`, `compositions`, `infra`, `ui`, `shared` | Добавляется `domains` |
| Структура domain | Отсутствует | `business`, framework surface, adapters, client/server assembly |
| Domain dependencies | Отсутствуют | Cross-domain runtime imports запрещены, capabilities передаются composition |
| External integration | Composition использует infra | Private domain adapter реализует business-owned port |
| Testing | Risk-based base tests | Обязательные tests для используемых factory, adapter, assembly и graph boundaries |
## Расширение архитектурной модели
**SLM-PRO-ARCH-001 - ОБЯЗАН.** SLM Pro должен расширять набор base-слоёв слоем `domains` для изолированных product responsibilities.
```text
src/
├── app/
├── compositions/
├── domains/
├── infra/
├── ui/
└── shared/
```
**SLM-PRO-ARCH-002 - ОБЯЗАН.** Дополнительные dependency edges Pro должны соответствовать следующему направлению:
```text
compositions -> domains
domains -> согласно внутренним Pro zones
```
Base dependency direction для остальных слоёв сохраняется.
**SLM-PRO-CMP-010 - МОЖЕТ.** Composition module может импортировать public entrypoints Pro domains в дополнение к imports, разрешённым base-правилом `SLM-BASE-CMP-010`.
## Pro Domain Specification
Полная Pro-модель слоя описана в [Domains](./domains/index.md). Других mode-specific отличий текущий draft Pro не вводит.

View File

@@ -1,72 +0,0 @@
---
title: Модули и группы
status: draft
normative: true
---
# Модули и Группы
## Module
Module является минимальным самостоятельным владельцем ответственности и предоставляет public boundary внешнему коду.
**SLM-BASE-MOD-001 - ОБЯЗАН.** Module должен иметь одну сформулированную ответственность и одного архитектурного owner.
**SLM-BASE-MOD-002 - ОБЯЗАН.** Внешний consumer взаимодействует с module только через его public API.
**SLM-BASE-MOD-003 - СЛЕДУЕТ.** Module следует ограничивать только теми внутренними parts и segments, которые необходимы текущей ответственности.
Типичные modules:
- page, layout, screen или widget в `compositions`;
- technical service в `infra`;
- reusable UI module в `ui`.
`app` содержит framework entries и не обязан организовываться как SLM modules. `shared` может содержать небольшие public units, но не runtime modules.
## Group
Group классифицирует modules и другие groups, но не владеет поведением.
**SLM-BASE-MOD-004 - ЗАПРЕЩЕНО.** Group не может иметь `index.ts`, public API, state, runtime, dependencies или assembly.
**SLM-BASE-MOD-005 - ЗАПРЕЩЕНО.** Внешний код не может импортировать group path.
**SLM-BASE-MOD-006 - МОЖЕТ.** Group может содержать другие groups и конечные modules.
```text
compositions/
└── pages/ # group
├── home/ # composition module
└── profile/ # composition module
```
## Component
Component является presentation unit внутри module и не считается самостоятельным архитектурным owner.
**SLM-BASE-MOD-008 - ЗАПРЕЩЕНО.** Component не может самостоятельно выбирать application-level product source, выполнять module wiring или оркестрировать несколько самостоятельных modules.
**SLM-BASE-MOD-009 - МОЖЕТ.** Component может владеть локальной presentation mechanics и рендерить другие components, разрешённые слоем владельца.
**SLM-BASE-MOD-010 - ОБЯЗАН.** Presentation unit с самостоятельной ответственностью, внешними архитектурными dependencies или внутренней modular structure должна оформляться как module или nested module. Сам факт локального hook/state не делает component модулем.
## Nested module
Самостоятельная часть родительского module может быть оформлена nested module, если имеет собственную ответственность и public boundary только внутри родителя.
```text
compositions/pages/home/
└── parts/
└── hero-section/
├── hero-section.tsx
└── index.ts
```
**SLM-BASE-MOD-011 - ЗАПРЕЩЕНО.** Nested module не может использоваться для сокрытия ответственности, которой фактически владеет другой module или layer.
## Scope evolution
**SLM-BASE-MOD-012 - СЛЕДУЕТ.** Код следует поднимать из локального owner в более широкий module только после появления реального совместного consumer или общей ответственности.
**SLM-BASE-MOD-013 - ЗАПРЕЩЕНО.** Физическое повторение само по себе не доказывает общий ownership.

View File

@@ -1,54 +0,0 @@
---
title: Монорепозитории
status: draft
normative: true
---
# Монорепозитории
SLM применяется внутри границы каждого frontend-приложения. Workspace packages имеют собственные public boundaries и ownership.
## Application boundary
```text
apps/
└── web/
└── src/
├── app/
├── compositions/
├── infra/
├── ui/
└── shared/
```
**SLM-BASE-MONO-001 - ОБЯЗАН.** Каждое приложение должно самостоятельно определять свои application compositions, product ownership и runtime wiring.
**SLM-BASE-MONO-002 - ЗАПРЕЩЕНО.** Workspace package не может импортировать код из `apps/*`.
**SLM-BASE-MONO-003 - ЗАПРЕЩЕНО.** Одно приложение не может deep-import исходники другого приложения вместо общего package contract.
## Package boundary
**SLM-BASE-MONO-004 - ОБЯЗАН.** Package должен иметь самостоятельного owner, public exports и подтверждённую reuse/ownership semantics.
**SLM-BASE-MONO-005 - ЗАПРЕЩЕНО.** Нельзя создавать package только для обхода layer direction, public API или иной объявленной dependency boundary.
**SLM-BASE-MONO-006 - ОБЯЗАН.** Consumers импортируют package через объявленный package export, а не через filesystem path к internal source.
## Типичные packages
Допустимыми кандидатами являются:
- product-agnostic UI kit;
- technical infra client;
- deterministic shared foundation;
- schema/codegen/tooling package;
- configuration package без application-specific wiring.
Base SLM не присваивает package дополнительный архитектурный статус автоматически.
## Dependency direction
**SLM-BASE-MONO-009 - ОБЯЗАН.** Package dependency graph должен оставаться ацикличным и соответствовать заявленной ответственности packages.
**SLM-BASE-MONO-010 - ЗАПРЕЩЕНО.** Shared package не может импортировать application composition или app-specific infra package.

View File

@@ -1,55 +0,0 @@
---
title: Public API и импорты
status: draft
normative: true
---
# Public API и Импорты
Public API ограничивает знание consumers о внутренней структуре module. Точная форма entrypoint определяется владельцем и не требует обязательного `index.ts`.
## Общие правила
**SLM-BASE-API-001 - ОБЯЗАН.** Межмодульный import должен использовать объявленный public entrypoint импортируемого module.
**SLM-BASE-API-002 - ЗАПРЕЩЕНО.** Deep imports во внутренние segments, files и иные private paths другого module запрещены.
**SLM-BASE-API-003 - ОБЯЗАН.** Каждый runtime export должен иметь реального consumer за пределами владеющего entrypoint и стабильную ответственность.
**SLM-BASE-API-004 - ЗАПРЕЩЕНО.** Public API не может случайно раскрывать implementation unit, который владелец считает private или lifecycle которого не является частью public contract.
**SLM-BASE-API-005 - ОБЯЗАН.** Alias или package subpath должен физически разрешаться TypeScript, tests и production build.
**SLM-BASE-API-009 - МОЖЕТ.** Public entrypoint может быть root `index.ts`, отдельным named entry, package export или другим явно объявленным path.
**SLM-BASE-API-010 - ОБЯЗАН.** Public и private paths module должны быть различимы consumers и repository tooling.
## Layer matrix
| Importer | Runtime imports |
|---|---|
| `app` | Public composition entries, shared static/global resources |
| `compositions` | Compositions, infra, ui, shared |
| `infra` | Infra, shared |
| `ui` | UI, shared |
| `shared` | External pure libraries only |
## Type-only imports
**SLM-BASE-API-006 - МОЖЕТ.** `import type` может использоваться для разрешённого contract dependency без создания runtime edge.
**SLM-BASE-API-007 - ЗАПРЕЩЕНО.** Type-only import не разрешает перенос ownership, импорт private concrete runtime type или обход layer boundary.
## Groups
Отсутствие public entrypoint у group определяется base-правилом `SLM-BASE-MOD-004`.
**SLM-BASE-API-015 - ОБЯЗАН.** Composition public API экспортирует только entry components, access APIs, types и contracts, необходимые внешним composition consumers.
## Cycles
**SLM-BASE-API-016 - ЗАПРЕЩЕНО.** Runtime import cycle между modules запрещён независимо от того, способен ли bundler его выполнить.
**SLM-BASE-API-017 - ЗАПРЕЩЕНО.** Barrel не должен создавать скрытый cycle между ready composition и access API её children.
Дополнительные entrypoints и import restrictions принадлежат overlay, который их вводит.

View File

@@ -1,13 +0,0 @@
---
title: Реестр правил
status: draft
normative: false
search: false
aside: false
---
# Реестр Правил
Реестр формируется автоматически из нормативных объявлений Specification. Для быстрого перехода к известному ID также можно открыть поиск `Ctrl/⌘ K`, ввести полный идентификатор и нажать `Enter`.
<RuleCatalog />

View File

@@ -1,83 +0,0 @@
---
title: Runtime и lifecycle
status: draft
normative: true
---
# Runtime и Lifecycle
Lifecycle является архитектурной частью любого mutable runtime, subscription и external resource. Эти правила не требуют создавать отдельный runtime или factory, если у module нет соответствующего состояния или resources.
## Definition, creation и activation
Для module с создаваемым runtime применима модель:
```text
definition
-> module объявляет creator
creation
-> creator создаёт instance без external effects
activation
-> scope owner запускает resources и получает cleanup
```
**SLM-BASE-LIFE-001 - ЗАПРЕЩЕНО.** Module import не должен выполнять product I/O, открывать connection или регистрировать global listener.
**SLM-BASE-LIFE-002 - ОБЯЗАН.** Если module предоставляет factory или runtime creator, creation должна быть side-effect free относительно external resources.
**SLM-BASE-LIFE-003 - ОБЯЗАН.** Subscription, socket, timer и listener запускаются явной operation владельца scope.
**SLM-BASE-LIFE-004 - ОБЯЗАН.** Каждый запущенный resource должен иметь cleanup или dispose contract.
## Scope
| Scope | Примеры владельца |
|---|---|
| Application | Root composition/provider |
| Route branch | Route layout composition |
| Page | Page composition/provider |
| Component flow | Nested composition module |
| Request | Server composition/request builder |
| Test | Test setup/wrapper |
**SLM-BASE-LIFE-005 - ОБЯЗАН.** Scope owner должен определить количество instances и duration каждого mutable runtime или resource.
**SLM-BASE-LIFE-006 - ЗАПРЕЩЕНО.** Module-level singleton не может использоваться как случайная замена application scope.
**SLM-BASE-LIFE-007 - МОЖЕТ.** Application singleton допустим только при явном application ownership и отсутствии request-, identity- и user-specific data.
## Activation и cleanup
**SLM-BASE-LIFE-009 - ОБЯЗАН.** Повторный mount/unmount, включая development Strict Mode, не должен оставлять duplicate subscription или abandoned resource.
**SLM-BASE-LIFE-010 - СЛЕДУЕТ.** `start` и cleanup следует проектировать idempotent либо явно защищать от повторного вызова.
**SLM-BASE-LIFE-018 - ОБЯЗАН.** Если activation составного resource set завершилась ошибкой, scope owner должен освободить уже успешно запущенную часть в обратном порядке.
**SLM-BASE-LIFE-019 - ОБЯЗАН.** Ошибка cleanup должна быть наблюдаемой и не должна препятствовать попытке освободить остальные resources scope.
## Events и sockets
Product event обрабатывается владельцем product semantics; socket остаётся technical transport.
**SLM-BASE-LIFE-011 - ЗАПРЕЩЕНО.** Framework component не может открывать product socket напрямую при render или module import.
**SLM-BASE-LIFE-012 - ОБЯЗАН.** Invalid event и connection failure должны преобразовываться в product state/outcome либо technical telemetry согласно их semantics; callback error нельзя терять через unobserved throw.
## Revalidation events
Event может содержать product update или только сообщать об устаревании данных.
**SLM-BASE-LIFE-014 - ОБЯЗАН.** Invalidation intent должен выражаться product language и не требовать import конкретной query library в public product contract.
## Server runtime
**SLM-BASE-LIFE-015 - ОБЯЗАН.** User-specific server runtime создаётся в request scope.
**SLM-BASE-LIFE-016 - ЗАПРЕЩЕНО.** Process singleton не может захватывать request headers, cookies, credentials, AbortSignal или user-specific cache.
**SLM-BASE-LIFE-017 - ОБЯЗАН.** Request cancellation должна передаваться external operations, если runtime и используемая integration поддерживают cancellation.
Overlay может вводить дополнительные lifecycle boundaries только внутри собственного delta.

View File

@@ -1,59 +0,0 @@
---
title: Сегменты
status: draft
normative: true
---
# Сегменты
Segment группирует внутренние файлы module по устойчивой роли. Segment не является самостоятельным layer или module.
## Базовые segments
| Segment | Роль |
|---|---|
| `ui/` | Presentation components текущего module |
| `parts/` | Nested modules текущего module |
| `hooks/` | Framework hooks текущей ответственности |
| `providers/` | Provider implementations текущего module |
| `stores/` | Concrete state runtime текущего owner |
| `services/` | Scenario operations и service objects |
| `mappers/` | Transformation на границе ответственности |
| `types/` | Types текущего module |
| `styles/` | Styles текущего module |
| `lib/` | Небольшие internal utilities |
| `config/` | Constants и configuration текущего module |
| `tests/` | Tests публичной границы или составного runtime |
## Правила
**SLM-BASE-SEG-001 - МОЖЕТ.** Module может использовать любые необходимые segments и не обязан создавать остальные.
**SLM-BASE-SEG-002 - ЗАПРЕЩЕНО.** Нельзя создавать полный симметричный набор segments как scaffold без реального содержимого.
**SLM-BASE-SEG-003 - ОБЯЗАН.** Если файл помещён в segment, роль segment должна соответствовать фактической роли файла, а не только его расширению или имени. Файлы могут оставаться в корне небольшого module.
**SLM-BASE-SEG-004 - ЗАПРЕЩЕНО.** Segment не имеет внешнего public API независимо от module owner.
Запрет deep import в segment другого module определяется base-правилом `SLM-BASE-API-002`.
## UI и Parts
`ui/` содержит presentation components без самостоятельного architectural ownership.
`parts/` содержит nested modules с собственной внутренней структурой и локальным public boundary.
**SLM-BASE-SEG-006 - ОБЯЗАН.** Сущность с самостоятельной ответственностью, внешними архитектурными dependencies или nested modules должна размещаться в `parts`, а не маскироваться как плоский component. Локальные presentation hooks/state сами по себе не требуют `parts`.
## Hooks
**SLM-BASE-SEG-007 - ОБЯЗАН.** Hook принадлежит тому module, чью ответственность и runtime он выражает.
Примеры:
- product hook - владеющий product module;
- page-local hook - владеющая page composition;
- reusable technical hook - соответствующий infra module;
- product-agnostic UI hook - владеющий UI module.
Segments являются только внутренними организационными ролями и не вводят дополнительных архитектурных zones.

View File

@@ -1,70 +0,0 @@
---
title: State и data
status: draft
normative: true
---
# State и Data
SLM рассматривает данные и состояние через одного владельца semantics, даже если runtime использует несколько caches и projections.
## Ownership matrix
| Вид | Владелец |
|---|---|
| Product model и transitions | Product owner |
| Product source integration | Product owner; technical mechanism остаётся в infra |
| Framework projection product data | Public surface владельца product data |
| Page-local presentation state | Composition |
| Component-local interaction | Владеющий component/module |
| Technical connection/cache state | Infra или runtime-specific owner |
| Request context | Server/framework scope |
| Universal UI state | Владеющий UI module |
## Product gateway
**SLM-BASE-DATA-001 - ОБЯЗАН.** Consumer должен получать product data через public boundary владеющего module.
**SLM-BASE-DATA-002 - ЗАПРЕЩЕНО.** Composition, UI или app не могут маппить transport DTO в параллельную product model, если модель уже имеет другого owner.
**SLM-BASE-DATA-003 - ОБЯЗАН.** Product owner владеет normalization, validation и semantics отсутствия данных.
## Product state
**SLM-BASE-DATA-004 - ОБЯЗАН.** Product state model и допустимые transitions должны определяться product owner независимо от concrete state manager.
**SLM-BASE-DATA-005 - ЗАПРЕЩЕНО.** Concrete mutable store implementation не может становиться public product contract без явно объявленного владельцем стабильного store access API.
**SLM-BASE-DATA-006 - ОБЯЗАН.** Mutable product instance должен быть привязан к явному lifecycle scope.
## Query cache
Framework или technical query cache может хранить projection результата product query.
**SLM-BASE-DATA-007 - ОБЯЗАН.** Query/cache consumer за пределами product owner должен использовать public boundary владельца и не может обходить его прямым вызовом private integration или SDK.
**SLM-BASE-DATA-008 - ЗАПРЕЩЕНО.** Query cache не может объявлять собственную product model, error taxonomy или fallback policy.
**SLM-BASE-DATA-009 - ОБЯЗАН.** User/session-scoped cache keys и invalidation должны изолировать данные разных identities и scopes без использования secret как публичного key contract.
Эта draft-версия не предписывает единственное физическое место QueryClient/SWR cache. Конкретная модель оценивается по правилам public boundary владельца, lifecycle и identity isolation.
**SLM-BASE-DATA-015 - ОБЯЗАН.** Cache instance должен иметь явного creator и scope owner в composition или runtime setup.
**SLM-BASE-DATA-016 - ОБЯЗАН.** Shared framework cache должен передаваться consumers через framework-supported runtime boundary, а не через import app-specific mutable singleton.
**SLM-BASE-DATA-017 - ОБЯЗАН.** Scope owner должен очищать или изолировать private cache при смене identity и завершении соответствующего scope.
## Presentation state
**SLM-BASE-DATA-010 - МОЖЕТ.** Composition или component может использовать concrete state manager для локального presentation state.
**SLM-BASE-DATA-011 - ЗАПРЕЩЕНО.** Presentation store не должен копировать canonical product state как второй source of truth.
## Serializable boundaries
**SLM-BASE-DATA-012 - ОБЯЗАН.** Через server/client boundary передаются только serializable product-owned data без functions, stores, clients, Context и resources.
**SLM-BASE-DATA-013 - ЗАПРЕЩЕНО.** Secrets, access tokens и request credentials не должны включаться в client bootstrap snapshot.
**SLM-BASE-DATA-014 - ОБЯЗАН.** Server и client initial snapshots должны быть согласованы, если framework выполняет hydration одного UI state.

View File

@@ -1,61 +0,0 @@
---
title: Терминология
status: draft
normative: true
---
# Терминология
## Base SLM
**Base SLM** - самостоятельная минимальная архитектура, применяемая без дополнительного overlay.
## Overlay
**Overlay** - независимое опциональное нормативное расширение, применяемое непосредственно поверх base SLM. Overlay не наследует правила другого overlay.
## Слой
**Layer** - верхнеуровневая зона `src`, определяющая вид ответственности и допустимые направления зависимостей.
Base SLM использует слои `app`, `compositions`, `infra`, `ui` и `shared`.
## Модуль
**Module** - минимальный самостоятельный владелец ответственности с public boundary. Модуль может содержать код разных технических типов, если весь этот код принадлежит одной ответственности.
## Product owner
**Product owner** - module, владеющий product semantics, model, behavior, data boundary и public API одной ответственности.
## Группа
**Group** - навигационная папка, классифицирующая модули или другие группы. Группа не является модулем, не имеет public API и не владеет runtime.
## Composition
**Composition** - product module, связывающий public APIs и technical capabilities в page, route, layout, screen, widget или другой application flow.
## Scope owner
**Scope owner** - composition, request setup, provider setup или test setup, которое выбирает runtime instances и resources, их lifetime, activation и cleanup.
## Segment
**Segment** - внутренняя папка модуля, группирующая файлы по роли, например `hooks`, `services`, `types`, `styles` или `lib`.
## Компонент
**Component** - presentation unit внутри владеющего module. Компонент не является самостоятельным архитектурным owner и не выбирает application dependencies самостоятельно.
## Продуктовые данные
**Product data** - данные, состояние и outcomes, имеющие смысл в предметной области продукта. Transport DTO, raw SDK response и browser storage schema не являются product model автоматически.
## Runtime dependency
**Runtime dependency** - dependency, необходимая выполняемому коду: API другого объекта, external source, store, query runtime, event source, clock, environment или platform capability.
`import type` не создаёт runtime dependency, но может создавать статическую связанность contracts.
Термины, вводимые `SLM Advanced` или `SLM Pro`, определяются и имеют нормативную силу только внутри соответствующего overlay.

View File

@@ -1,58 +0,0 @@
---
title: Тестирование и соответствие
status: draft
normative: true
---
# Тестирование и Соответствие
Тесты проверяют public boundaries и runtime risks каждого owner. Base SLM не требует создавать неиспользуемые архитектурные конструкции ради тестовой формы.
## Risk-based tests
**SLM-BASE-TEST-018 - ОБЯЗАН.** Tests изменённого module должны покрывать применимые риски его public behavior, data boundaries и lifecycle.
Типичные риски:
- public behavior;
- malformed external data;
- rejected dependencies;
- state transitions;
- lifecycle activation и cleanup;
- request и identity isolation;
- client/server boundary;
- отсутствие import-time I/O.
**SLM-BASE-TEST-008 - ОБЯЗАН.** Client/server import boundary должна проверяться инструментом, понимающим реальный framework module graph, если application имеет раздельные environment entries. DOM unit test не заменяет production build probe.
Mode-specific test suites принадлежат overlay, который вводит соответствующие конструкции.
## Architecture conformance
Типичные mechanically enforceable checks:
- направление imports;
- deep imports;
- public entrypoints;
- runtime cycles;
- заявленный overlay и его rule set;
- unique rule IDs документации;
- generated artifacts, если они используются.
**SLM-BASE-TEST-011 - ЗАПРЕЩЕНО.** Документированное правило не считается mechanically enforced, если repository tooling его фактически не проверяет.
## Единица соответствия
**SLM-BASE-TEST-014 - ОБЯЗАН.** Application соответствует base SLM, если выполняет все base-правила. Соответствие заявленному overlay оценивается как base-правила с учётом точного scope каждой замены плюс полный rule set выбранного overlay.
**SLM-BASE-TEST-015 - ОБЯЗАН.** Изменение соответствует заявленной архитектуре, если новые и изменённые modules не создают новых нарушений применимых base-правил или правил выбранного overlay и проходят существующие checks.
**SLM-BASE-TEST-016 - ОБЯЗАН.** Отступление от правила `СЛЕДУЕТ` должно быть зафиксировано в архитектурном review или принятом decision с указанием причины и scope.
**SLM-BASE-TEST-017 - ОБЯЗАН.** Manual conformance и mechanical enforcement должны различаться явно; отсутствие автоматической проверки не отменяет применимое нормативное правило.
## Completion gate
**SLM-BASE-TEST-012 - ОБЯЗАН.** Изменение считается завершённым только после выполнения ближайших tests, typecheck, lint, build и architecture checks, существующих в repository.
**SLM-BASE-TEST-013 - ОБЯЗАН.** Невыполненная проверка и остаточный риск должны быть явно указаны в результате работы.

219
draft-rules.js Normal file
View File

@@ -0,0 +1,219 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDirectory = dirname(fileURLToPath(import.meta.url))
const draftDirectory = join(rootDirectory, 'DRAFT')
const rulesDirectory = join(draftDirectory, 'rules')
const ruleCodeSource = 'SLM-L\\d+-[A-Z][A-Z_]{1,31}-[AR]\\d{3}'
const ruleHeadingPattern = new RegExp(`^###\\s+(?<code>SLM-L(?<level>\\d+)-(?<group>[A-Z][A-Z_]{1,31})-(?<classification>[AR])(?<number>\\d{3}))$`)
const ruleCodePattern = new RegExp(`\\b${ruleCodeSource}\\b`, 'g')
const ruleReferencePattern = new RegExp(
'\\[`(?<code>' + ruleCodeSource + ')`\\]\\((?<target>[^)\\s]+)\\)',
'g',
)
const ruleTitlePattern = /^>\s+\*\*(?<title>\S(?:.*\S)?)\*\*\s*$/
const ruleDescriptionPattern = /^>\s+(?<description>\S(?:.*\S)?)\s*$/
const quoteSeparatorPattern = /^>\s*$/
const getMarkdownFiles = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true })
const files = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...(await getMarkdownFiles(path)))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(path)
}
}
return files
}
const visitMarkdownLines = (lines, visitor) => {
let fence = null
for (const [index, line] of lines.entries()) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/)
if (fenceMatch) {
const marker = fenceMatch[1]
if (fence === null) {
fence = marker
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
fence = null
}
continue
}
if (fence !== null) {
continue
}
visitor(line, index)
}
}
const parseRules = async (file) => {
const content = await readFile(file, 'utf8')
const lines = content.split(/\r?\n/)
const rules = []
visitMarkdownLines(lines, (line, index) => {
const match = line.match(ruleHeadingPattern)
if (match?.groups) {
const source = `${relative(rootDirectory, file)}:${index + 1}`
const titleMatch = lines[index + 2]?.match(ruleTitlePattern)
const descriptionMatch = lines[index + 4]?.match(ruleDescriptionPattern)
if (lines[index + 1]?.trim() !== '') {
throw new Error(`SLM rule ${match.groups.code} must be followed by a blank line at ${source}`)
}
if (!titleMatch?.groups) {
throw new Error(`SLM rule ${match.groups.code} must have a one-line bold title in blockquote at ${source}`)
}
if (!quoteSeparatorPattern.test(lines[index + 3] ?? '')) {
throw new Error(`SLM rule ${match.groups.code} must separate title and description with a quoted blank line at ${source}`)
}
if (!descriptionMatch?.groups || /^>/.test(lines[index + 5] ?? '')) {
throw new Error(`SLM rule ${match.groups.code} must have a one-line blockquote description at ${source}`)
}
rules.push({
code: match.groups.code,
classification: match.groups.classification,
description: descriptionMatch.groups.description.trim(),
file,
level: Number(match.groups.level),
number: Number(match.groups.number),
title: titleMatch.groups.title.trim(),
source,
})
} else if (/^#{1,6}\s+SLM-L/.test(line)) {
throw new Error(`Invalid SLM rule heading at ${relative(rootDirectory, file)}:${index + 1}`)
}
})
return rules
}
const parseReferences = async (file) => {
const content = await readFile(file, 'utf8')
const lines = content.split(/\r?\n/)
const references = []
visitMarkdownLines(lines, (line, index) => {
const source = `${relative(rootDirectory, file)}:${index + 1}`
if (/^#{1,6}\s+SLM-L/.test(line)) {
throw new Error(`SLM rule declaration is only allowed in DRAFT/rules: ${source}`)
}
const codeMatches = [...line.matchAll(ruleCodePattern)]
const linkMatches = [...line.matchAll(ruleReferencePattern)]
for (const codeMatch of codeMatches) {
const linkMatch = linkMatches.find((candidate) => (
candidate.groups?.code === codeMatch[0]
&& codeMatch.index >= candidate.index
&& codeMatch.index < candidate.index + candidate[0].length
))
if (!linkMatch?.groups) {
throw new Error(`SLM rule reference must be a Markdown link at ${source}: ${codeMatch[0]}`)
}
}
for (const linkMatch of linkMatches) {
const { code, target } = linkMatch.groups
const hashIndex = target.lastIndexOf('#')
if (hashIndex < 1 || target.slice(hashIndex + 1) !== code.toLowerCase()) {
throw new Error(`Invalid anchor for SLM rule ${code} at ${source}`)
}
references.push({
code,
file,
source,
targetFile: resolve(dirname(file), target.slice(0, hashIndex)),
})
}
})
return references
}
const printSection = (title, rules) => {
console.log(`${title} (${rules.length})`)
for (const [index, rule] of rules.entries()) {
console.log(`${rule.code}: ${rule.title}`)
console.log(` ${rule.description}`)
if (index < rules.length - 1) {
console.log()
}
}
}
const ruleFiles = await getMarkdownFiles(rulesDirectory)
const draftFiles = (await getMarkdownFiles(draftDirectory))
.filter((file) => !ruleFiles.includes(file))
const rules = (await Promise.all(ruleFiles.map(parseRules))).flat()
const rulesByCode = new Map()
for (const rule of rules) {
const duplicate = rulesByCode.get(rule.code)
if (duplicate) {
throw new Error(`Duplicate SLM rule ${rule.code}: ${duplicate.source}, ${rule.source}`)
}
rulesByCode.set(rule.code, rule)
}
const references = (await Promise.all(draftFiles.map(parseReferences))).flat()
const referencedCodes = new Set()
for (const reference of references) {
const rule = rulesByCode.get(reference.code)
if (!rule) {
throw new Error(`Unknown SLM rule ${reference.code} at ${reference.source}`)
}
if (reference.targetFile !== rule.file) {
throw new Error(`SLM rule ${reference.code} points to a non-canonical file at ${reference.source}`)
}
referencedCodes.add(reference.code)
}
for (const rule of rules) {
if (!referencedCodes.has(rule.code)) {
throw new Error(`SLM rule ${rule.code} is not referenced by any draft`)
}
}
rules.sort((left, right) => (
left.level - right.level
|| left.number - right.number
|| left.code.localeCompare(right.code)
))
const automaticRules = rules.filter((rule) => rule.classification === 'A')
const reviewRules = rules.filter((rule) => rule.classification === 'R')
printSection('АВТОМАТИЧЕСКИЕ', automaticRules)
console.log()
printSection('ДЛЯ РЕВЬЮ', reviewRules)

View File

@@ -0,0 +1,10 @@
SIMPLE_PORT=3001
COMPLEX_PORT=3002
JWT_ACCESS_SECRET=demo-access-secret-change-me
JWT_REFRESH_SECRET=demo-refresh-secret-change-me
JWT_ACCESS_TTL=60s
JWT_REFRESH_TTL=7d
COOKIE_SESSION_TTL_MS=1800000
COOKIE_SECURE=false
MOCK_SLOW_DELAY_MS=1500
MOCK_TIMEOUT_DELAY_MS=30000

6
examples/demo-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
.env
npm-debug.log*
*.tsbuildinfo

View File

@@ -0,0 +1,117 @@
# Demo backend
An isolated NestJS package containing two independently launched HTTP applications and two OpenAPI contracts. Data is deterministic and stored in memory; no database or external service is required.
## Applications
| Application | Port | Authentication | Swagger UI | OpenAPI JSON |
| ----------- | -----: | ------------------------------ | ---------------------------- | ------------------------------------ |
| Simple | `3001` | JWT access/refresh | `http://localhost:3001/docs` | `http://localhost:3001/openapi.json` |
| Complex | `3002` | HttpOnly cookie session + CSRF | `http://localhost:3002/docs` | `http://localhost:3002/openapi.json` |
Committed specifications are generated at `openapi/simple.json` and `openapi/complex.json`.
## Requirements
- Node.js 20 or newer
- npm 10 or newer
## Start
```bash
npm install
npm run start:dev
```
Run only one application:
```bash
npm run dev:simple
npm run dev:complex
```
Production-style build and start:
```bash
npm run build
npm run start:simple
npm run start:complex
```
## Demo users
All passwords are `demo1234`.
### Simple API
| Email | Role |
| --------------------- | -------- |
| `admin@demo.local` | admin |
| `customer@demo.local` | customer |
### Complex API
| Email | Role | Organizations |
| ---------------------- | ------- | ------------------------ |
| `admin@complex.demo` | admin | `org-acme`, `org-globex` |
| `manager@complex.demo` | manager | `org-acme` |
| `support@complex.demo` | support | `org-acme` |
| `viewer@complex.demo` | viewer | `org-acme` |
## Simple authentication example
```bash
curl -s http://localhost:3001/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@demo.local","password":"demo1234"}'
```
Use `data.tokens.accessToken` as a Bearer token.
## Complex authentication example
```bash
curl -i -c /tmp/demo-cookies.txt http://localhost:3002/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@complex.demo","password":"demo1234"}'
```
The response body contains `data.csrfToken`. Protected reads require cookies and tenant context:
```bash
curl -b /tmp/demo-cookies.txt http://localhost:3002/api/v1/products \
-H 'X-Organization-Id: org-acme'
```
Mutations also require `X-CSRF-Token` with the value returned by login.
## OpenAPI
Generate and validate both specifications:
```bash
npm run openapi:generate
npm run openapi:validate
```
The validator checks OpenAPI validity, unique operation IDs, expected security schemes and route isolation between the two applications.
## Verification
```bash
npm run typecheck
npm run build
npm run test:e2e
npm run openapi:generate
npm run openapi:validate
```
## Built-in frontend cases
The intentionally supported cases are documented in [docs/CASES.md](./docs/CASES.md). They include controlled latency and errors, JWT refresh races, cookie expiration, CSRF, RBAC, tenant switching, offset and cursor pagination, ETag, optimistic locking, idempotency, background jobs, multipart files, polymorphic DTOs, audit events and realtime reconnection/deduplication.
The Socket.IO event contract is documented separately in [docs/WEBSOCKET.md](./docs/WEBSOCKET.md).
## Important limitation
This is a frontend architecture fixture, not a production identity or commerce service. Passwords, sessions, files and mutations live only in process memory. Restart or `POST /api/v1/testing/reset` restores deterministic data.

View File

@@ -0,0 +1,133 @@
# Frontend architecture cases
This document is the contract for behaviors intentionally built into the demo backend. The APIs are deterministic: reset state before a demo with `POST /api/v1/testing/reset`.
## Project-size mapping
| Frontend type | Recommended API surface |
| -------------------- | ------------------------------------------------------------------------------------ |
| Landing or small SPA | Public Simple products and categories |
| Medium application | Full Simple API with JWT, user profile and orders |
| Large application | Complex API with cookie session, tenant context, RBAC, jobs, files and realtime chat |
## Controlled network scenarios
Send `X-Demo-Scenario` with any HTTP request. The response repeats the selected value in `X-Demo-Scenario`.
| Value | Behavior | Frontend concern |
| --------------- | --------------------------------------------- | --------------------------------------- |
| `normal` | Normal deterministic response | Happy path |
| `slow` | Delays for `MOCK_SLOW_DELAY_MS` | Loading states, cancellation, skeletons |
| `timeout` | Delays for `MOCK_TIMEOUT_DELAY_MS` | Client timeout and abort handling |
| `server-error` | Returns `500` | Error boundaries and retry UX |
| `rate-limited` | Returns `429` and `Retry-After: 3` | Backoff and retry policy |
| `empty` | Changes a list response to a valid empty page | Empty states |
| `expired-auth` | Protected endpoint returns `401` | Refresh or re-login flow |
| `forbidden` | Protected endpoint returns `403` | Permission UI |
| `conflict` | Mutation returns `409` | Conflict UX and rollback |
| `large-dataset` | Expands a list response to 250 items | Rendering and virtualization |
Scenario effects are request-local. They do not introduce random failures or make automated tests flaky.
## Authentication cases
### Simple JWT
- Access and refresh tokens are returned by `POST /api/v1/auth/login`.
- Access tokens are sent as `Authorization: Bearer <token>`.
- Refresh tokens rotate. Reusing an already rotated refresh token returns `401 REFRESH_TOKEN_REUSED`.
- Logout revokes the supplied refresh token and is idempotent.
- Access and refresh lifetimes are configurable through environment variables.
- Protected requests support forced `401` and `403` scenarios.
### Complex cookie session
- Login sets HttpOnly `demo_session` and readable `demo_csrf` cookies.
- Browser requests must use `credentials: 'include'`.
- Mutations additionally send the `demo_csrf` value in `X-CSRF-Token`.
- Session refresh rotates both the session ID and CSRF token.
- `POST /api/v1/testing/session/expire` expires the current session without changing frontend state.
- Roles can change while the session remains active.
- Socket.IO authenticates using the same `demo_session` cookie.
## Data fetching cases
| Case | Endpoint example |
| -------------------- | ------------------------------------------------------ |
| Offset pagination | Simple products, customers, audit events |
| Cursor pagination | Complex products, orders, notifications, chat messages |
| Search and filtering | Products and customers |
| Sorting | Simple products |
| Nested resources | Organization members and conversation messages |
| Nullable values | Avatar, category parent, publish date, file/job result |
| Decimal strings | Complex prices, discounts and totals |
| Polymorphic union | Order, inventory and system notification payloads |
| Tree data | Complex categories with parent/child IDs |
| Large seed | `POST /api/v1/testing/seed/large` |
## Cache case
Product details return a weak `ETag`. Repeat the request with `If-None-Match`; unchanged data returns `304 Not Modified`. Product updates increment `version` and produce a new ETag.
## Mutation and concurrency cases
- Simple and Complex product updates require the last-read `version`.
- Inventory adjustments require `version` and reject negative stock.
- Stale writes return `409` with a stable error code.
- Complex order creation requires `Idempotency-Key`.
- Repeating the same order request and key returns the original order instead of creating a duplicate.
- Invalid nested forms return field-oriented validation details.
- Order cancellation validates allowed state transitions.
- Mutations are added to the Complex audit log.
## RBAC and multitenancy cases
- Complex domain routes require `X-Organization-Id`.
- Missing tenant context returns `400 ORGANIZATION_REQUIRED`.
- A tenant unavailable to the current user returns `403 ORGANIZATION_FORBIDDEN`.
- Admin and manager can mutate catalog/inventory and start exports.
- Support can create and cancel orders but cannot change products.
- Viewer is read-only.
- Test users and memberships are documented in the main README.
## Background work
`POST /api/v1/exports/orders` returns `202` and a job. Poll `GET /api/v1/jobs/:id` to observe:
```text
pending -> processing -> completed
```
Progress and `resultUrl` change over time. Downloading the result before completion returns `409 JOB_NOT_COMPLETED`.
## Files
- Multipart upload accepts a `file` field up to 5 MiB.
- Metadata and bytes remain in memory until reset or restart.
- Download returns binary content and `Content-Disposition`.
- The API supports missing-file validation, loading progress and cancellation testing.
## Realtime chat
- REST supplies conversation and cursor-paginated message history.
- Socket.IO supplies joins, typing state and messages.
- `clientMessageId` deduplicates retried sends.
- Invalid or expired cookie sessions are disconnected.
- REST-created messages are also broadcast to the Socket.IO room.
- The complete event contract is in [WEBSOCKET.md](./WEBSOCKET.md).
## Observability and errors
Every HTTP response exposes `X-Request-Id`, `X-Response-Time` and `X-Demo-Scenario`. A frontend can supply its own `X-Request-Id`. Error bodies include that request ID, a stable machine-readable `code`, human-readable `message`, field `details`, timestamp and path.
## Reset behavior
| Endpoint | Effect |
| ------------------------------------- | ------------------------------------- |
| `POST /api/v1/testing/reset` | Restores the small deterministic seed |
| `POST /api/v1/testing/seed/small` | Loads normal fixtures |
| `POST /api/v1/testing/seed/large` | Loads 250 products and customers |
| `POST /api/v1/testing/users/:id/role` | Changes access without a new login |
| `POST /api/v1/testing/session/expire` | Expires the current Complex session |
All mutations are process-local and disappear after restart.

View File

@@ -0,0 +1,79 @@
# Socket.IO chat contract
## Connection
Connect to the Complex API namespace:
```text
http://localhost:3002/chat
```
The browser must already have a valid `demo_session` cookie and use credentials. Both `websocket` and `polling` transports are enabled.
```ts
import { io } from "socket.io-client";
const socket = io("http://localhost:3002/chat", {
withCredentials: true,
});
```
An invalid session receives `chat:error` and is disconnected.
## Client events
### `chat:join`
```json
{
"organizationId": "org-acme",
"conversationId": "conversation-support"
}
```
Response event: `chat:joined`.
### `chat:leave`
Uses the same payload. Response event: `chat:left`.
### `message:send`
```json
{
"organizationId": "org-acme",
"conversationId": "conversation-support",
"text": "Can you check this order?",
"clientMessageId": "frontend-generated-uuid"
}
```
Response event: `message:ack`. Room broadcast: `message:created`.
Sending the same `clientMessageId` again returns the existing message. Frontends should also deduplicate incoming `message:created` by server message `id`.
### `typing:start` and `typing:stop`
Use the `chat:join` payload. Other room members receive `typing:started` or `typing:stopped`:
```json
{
"conversationId": "conversation-support",
"userId": "complex-user-support"
}
```
## Error event
`chat:error` always contains a stable shape:
```json
{
"code": "CHAT_OPERATION_FAILED",
"message": "Conversation not found."
}
```
## Reconnect expectations
After reconnect, the frontend should join active conversations again and reload messages after its last known cursor. A session expired by `POST /api/v1/testing/session/expire` rejects the next Socket.IO connection.

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8654
examples/demo-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
{
"name": "demo-backend",
"version": "1.0.0",
"private": true,
"description": "Two isolated NestJS demo APIs for testing frontend application architecture",
"license": "UNLICENSED",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start:dev": "concurrently -k -n simple,complex -c blue,magenta \"npm:dev:simple\" \"npm:dev:complex\"",
"dev:simple": "nest start --watch --entryFile apps/simple/main",
"dev:complex": "nest start --watch --entryFile apps/complex/main",
"start:simple": "node dist/apps/simple/main.js",
"start:complex": "node dist/apps/complex/main.js",
"openapi:generate": "npm run build && node dist/scripts/generate-openapi.js",
"openapi:validate": "node dist/scripts/validate-openapi.js",
"test": "jest",
"test:e2e": "jest --runInBand",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.{json,md}\" \"docs/**/*.md\""
},
"dependencies": {
"@nestjs/common": "^11.1.6",
"@nestjs/core": "^11.1.6",
"@nestjs/jwt": "^11.0.1",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/platform-socket.io": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/websockets": "^11.1.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@nestjs/cli": "^11.0.10",
"@nestjs/testing": "^11.1.6",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^24.0.15",
"@types/supertest": "^6.0.3",
"concurrently": "^9.2.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4",
"ts-jest": "^29.4.0",
"typescript": "^5.9.2"
},
"overrides": {
"@nestjs/swagger": {
"js-yaml": "5.2.2"
}
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "tsconfig.json"
}
]
},
"collectCoverageFrom": [
"src/**/*.(t|j)s"
],
"coverageDirectory": "coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,20 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { OpenAPIObject } from "@nestjs/swagger";
import { configureApplication } from "../../common/configure-application";
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
import { ComplexAppModule } from "./complex.module";
export async function createComplexApplication(
logger: false | undefined = undefined,
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
const app = await NestFactory.create<NestExpressApplication>(
ComplexAppModule,
{ logger },
);
configureApplication(app);
const port = Number(process.env.COMPLEX_PORT ?? 3002);
const document = createOpenApiDocument(app, { kind: "complex", port });
mountOpenApi(app, document, "Demo Complex API");
return { app, document };
}

View File

@@ -0,0 +1,175 @@
import { parse as parseCookie } from "cookie";
import { Logger } from "@nestjs/common";
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import type { Server, Socket } from "socket.io";
import { ComplexSessionService, SESSION_COOKIE } from "./complex.auth";
import { ComplexStore } from "./complex.store";
import type { ChatMessageDto, SendMessageDto } from "./dto/chat.dto";
interface ChatSocketData {
userId: string;
organizationIds: string[];
}
interface ChatContextPayload {
organizationId: string;
conversationId: string;
}
interface SocketMessagePayload extends ChatContextPayload, SendMessageDto {}
@WebSocketGateway({
namespace: "/chat",
cors: { origin: true, credentials: true },
transports: ["websocket", "polling"],
})
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(ChatGateway.name);
constructor(
private readonly sessions: ComplexSessionService,
private readonly store: ComplexStore,
) {}
handleConnection(client: Socket): void {
const cookies = parseCookie(client.handshake.headers.cookie ?? "");
const session = this.sessions.get(cookies[SESSION_COOKIE]);
const user = session ? this.store.findUserById(session.userId) : undefined;
if (!session || !user) {
client.emit("chat:error", {
code: "SESSION_EXPIRED",
message: "Valid demo_session cookie is required.",
});
client.disconnect(true);
return;
}
client.data = {
userId: user.id,
organizationIds: user.organizationIds,
} satisfies ChatSocketData;
this.logger.debug(`Socket ${client.id} connected as ${user.id}`);
}
@SubscribeMessage("chat:join")
join(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
) {
try {
const data = client.data as ChatSocketData;
this.assertOrganization(data, payload.organizationId);
this.store.getConversation(
payload.organizationId,
data.userId,
payload.conversationId,
);
void client.join(this.room(payload.conversationId));
return {
event: "chat:joined",
data: { conversationId: payload.conversationId },
};
} catch (error) {
return { event: "chat:error", data: this.socketError(error) };
}
}
@SubscribeMessage("chat:leave")
leave(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
) {
void client.leave(this.room(payload.conversationId));
return {
event: "chat:left",
data: { conversationId: payload.conversationId },
};
}
@SubscribeMessage("message:send")
send(
@ConnectedSocket() client: Socket,
@MessageBody() payload: SocketMessagePayload,
) {
try {
const data = client.data as ChatSocketData;
this.assertOrganization(data, payload.organizationId);
const message = this.store.sendMessage(
payload.organizationId,
data.userId,
payload.conversationId,
payload,
);
this.broadcastMessage(message);
return { event: "message:ack", data: message };
} catch (error) {
return { event: "chat:error", data: this.socketError(error) };
}
}
@SubscribeMessage("typing:start")
typingStart(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
): void {
this.broadcastTyping(client, payload, true);
}
@SubscribeMessage("typing:stop")
typingStop(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
): void {
this.broadcastTyping(client, payload, false);
}
broadcastMessage(message: ChatMessageDto): void {
this.server
.to(this.room(message.conversationId))
.emit("message:created", message);
}
private broadcastTyping(
client: Socket,
payload: ChatContextPayload,
active: boolean,
): void {
const data = client.data as ChatSocketData;
if (!data.organizationIds.includes(payload.organizationId)) return;
client
.to(this.room(payload.conversationId))
.emit(active ? "typing:started" : "typing:stopped", {
conversationId: payload.conversationId,
userId: data.userId,
});
}
private assertOrganization(
data: ChatSocketData,
organizationId: string,
): void {
if (!data.organizationIds.includes(organizationId))
throw new Error("Organization is unavailable to this user.");
}
private room(conversationId: string): string {
return `conversation:${conversationId}`;
}
private socketError(error: unknown): { code: string; message: string } {
return {
code: "CHAT_OPERATION_FAILED",
message:
error instanceof Error ? error.message : "Chat operation failed.",
};
}
}

View File

@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { CookieOptions, Request } from "express";
import type { DemoRequest } from "../../common/request.types";
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
import {
ComplexRole,
type ComplexLoginDto,
type ComplexUserDto,
type CookieSessionResponseDto,
} from "./dto/identity.dto";
import { ComplexStore } from "./complex.store";
export const SESSION_COOKIE = "demo_session";
export const CSRF_COOKIE = "demo_csrf";
const ROLES_METADATA = "complex-roles";
export interface ComplexDemoRequest extends DemoRequest {
sessionToken?: string;
}
interface SessionRecord {
token: string;
userId: string;
csrfToken: string;
expiresAt: number;
}
export interface CreatedSession {
token: string;
csrfToken: string;
expiresAt: string;
user: ComplexUserDto;
}
export const ComplexRoles = (...roles: ComplexRole[]) =>
SetMetadata(ROLES_METADATA, roles);
export function sessionCookieOptions(): CookieOptions {
return {
httpOnly: true,
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
path: "/",
};
}
export function csrfCookieOptions(): CookieOptions {
return {
httpOnly: false,
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
path: "/",
};
}
@Injectable()
export class ComplexSessionService {
private readonly sessions = new Map<string, SessionRecord>();
constructor(private readonly store: ComplexStore) {}
login(dto: ComplexLoginDto): CreatedSession {
const user = this.store.findUserByEmail(dto.email);
if (!user || user.password !== dto.password) {
throw new UnauthorizedException({
code: "INVALID_CREDENTIALS",
message: "Email or password is incorrect.",
});
}
return this.create(user.id);
}
get(token: string | undefined): SessionRecord | undefined {
if (!token) return undefined;
const session = this.sessions.get(token);
if (!session) return undefined;
if (session.expiresAt <= Date.now()) {
this.sessions.delete(token);
return undefined;
}
return session;
}
rotate(token: string): CreatedSession {
const existing = this.get(token);
if (!existing)
throw new UnauthorizedException({
code: "SESSION_EXPIRED",
message: "Cookie session is missing or expired.",
});
this.sessions.delete(token);
return this.create(existing.userId);
}
revoke(token: string | undefined): void {
if (token) this.sessions.delete(token);
}
expire(token: string | undefined): void {
const session = token ? this.sessions.get(token) : undefined;
if (session) session.expiresAt = 0;
}
reset(): void {
this.sessions.clear();
}
response(session: CreatedSession): CookieSessionResponseDto {
return {
data: {
user: session.user,
csrfToken: session.csrfToken,
expiresAt: session.expiresAt,
},
};
}
private create(userId: string): CreatedSession {
const user = this.store.findUserById(userId);
if (!user)
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Session user no longer exists.",
});
const ttl = Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000);
const record: SessionRecord = {
token: randomUUID(),
userId,
csrfToken: randomUUID(),
expiresAt: Date.now() + ttl,
};
this.sessions.set(record.token, record);
return {
token: record.token,
csrfToken: record.csrfToken,
expiresAt: new Date(record.expiresAt).toISOString(),
user: this.store.publicUser(user),
};
}
}
@Injectable()
export class ComplexCookieGuard implements CanActivate {
constructor(
private readonly sessions: ComplexSessionService,
private readonly store: ComplexStore,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<Request>() as ComplexDemoRequest;
assertNoForcedAuthFailure(request);
const cookies = request.cookies as Record<string, string> | undefined;
const token = cookies?.[SESSION_COOKIE];
const session = this.sessions.get(token);
if (!session)
throw new UnauthorizedException({
code: "SESSION_EXPIRED",
message: "Cookie session is missing or expired.",
});
const user = this.store.findUserById(session.userId);
if (!user)
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Session user no longer exists.",
});
request.user = this.store.publicUser(user);
request.sessionToken = token;
return true;
}
}
@Injectable()
export class ComplexCsrfGuard implements CanActivate {
constructor(private readonly sessions: ComplexSessionService) {}
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<Request>() as ComplexDemoRequest;
const session = this.sessions.get(request.sessionToken);
const headerToken = request.headers["x-csrf-token"];
const cookieToken = (
request.cookies as Record<string, string> | undefined
)?.[CSRF_COOKIE];
if (
!session ||
typeof headerToken !== "string" ||
headerToken !== session.csrfToken ||
cookieToken !== session.csrfToken
) {
throw new ForbiddenException({
code: "CSRF_INVALID",
message:
"A matching X-CSRF-Token header and demo_csrf cookie are required.",
});
}
return true;
}
}
@Injectable()
export class ComplexOrganizationGuard implements CanActivate {
constructor(private readonly store: ComplexStore) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
const organizationId = request.headers["x-organization-id"];
if (typeof organizationId !== "string" || organizationId.length === 0) {
throw new BadRequestException({
code: "ORGANIZATION_REQUIRED",
message: "X-Organization-Id header is required.",
});
}
if (
!request.user ||
!this.store.isMember(request.user.id, organizationId)
) {
throw new ForbiddenException({
code: "ORGANIZATION_FORBIDDEN",
message: "The user is not an active member of this organization.",
});
}
request.organizationId = organizationId;
return true;
}
}
@Injectable()
export class ComplexRolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<ComplexRole[]>(
ROLES_METADATA,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
if (!request.user || !required.includes(request.user.role as ComplexRole)) {
throw new ForbiddenException({
code: "ROLE_FORBIDDEN",
message: `One of these roles is required: ${required.join(", ")}.`,
});
}
return true;
}
}

View File

@@ -0,0 +1,71 @@
import { Module } from "@nestjs/common";
import { InfrastructureModule } from "../../common/infrastructure.module";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRolesGuard,
ComplexSessionService,
} from "./complex.auth";
import { ComplexStore } from "./complex.store";
import { ChatGateway } from "./chat.gateway";
import { ChatController } from "./controllers/chat.controller";
import {
ComplexCatalogController,
ComplexInventoryController,
ComplexProductsController,
} from "./controllers/catalog.controllers";
import {
ComplexOrdersController,
CustomersController,
PaymentsController,
ReviewsController,
} from "./controllers/commerce.controllers";
import {
ComplexAuthController,
ComplexUsersController,
OrganizationsController,
} from "./controllers/identity.controllers";
import {
AuditController,
FilesController,
JobsController,
NotificationsController,
} from "./controllers/operations.controllers";
import {
ComplexHealthController,
ComplexTestingController,
} from "./controllers/system.controllers";
@Module({
imports: [InfrastructureModule],
controllers: [
ComplexHealthController,
ComplexAuthController,
ComplexUsersController,
OrganizationsController,
ComplexProductsController,
ComplexCatalogController,
ComplexInventoryController,
CustomersController,
ComplexOrdersController,
PaymentsController,
ReviewsController,
NotificationsController,
FilesController,
AuditController,
JobsController,
ChatController,
ComplexTestingController,
],
providers: [
ComplexStore,
ComplexSessionService,
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRolesGuard,
ChatGateway,
],
})
export class ComplexAppModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
import {
Body,
Controller,
Get,
Headers,
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiCreatedResponse,
ApiNotModifiedResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
AdjustInventoryDto,
BrandsResponseDto,
ComplexCategoriesResponseDto,
ComplexProductResponseDto,
ComplexProductsResponseDto,
CreateComplexProductDto,
CursorProductQueryDto,
InventoryItemResponseDto,
InventoryResponseDto,
UpdateComplexProductDto,
WarehousesResponseDto,
} from "../dto/catalog.dto";
import { ComplexRole } from "../dto/identity.dto";
@ApiTags("Products")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("products")
export class ComplexProductsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List tenant products using cursor pagination" })
@ApiOkResponse({ type: ComplexProductsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CursorProductQueryDto,
): ComplexProductsResponseDto {
return this.store.listProducts(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({
summary: "Get a rich product with variants and ETag support",
})
@ApiOkResponse({ type: ComplexProductResponseDto })
@ApiNotModifiedResponse()
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res({ passthrough: true }) response: Response,
): ComplexProductResponseDto | undefined {
const product = this.store.getProduct(request.organizationId!, id);
const etag = `W/\"${product.id}-v${product.version}\"`;
response.setHeader("ETag", etag);
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (etag === ifNoneMatch) {
response.status(304);
return undefined;
}
return { data: product };
}
@Post()
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Create a tenant product with variants" })
@ApiCreatedResponse({ type: ComplexProductResponseDto })
@ApiStandardErrors({ auth: true })
create(
@Req() request: ComplexDemoRequest,
@Body() dto: CreateComplexProductDto,
): ComplexProductResponseDto {
return {
data: this.store.createProduct(
request.organizationId!,
request.user!.id,
dto,
),
};
}
@Patch(":id")
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Update a product using optimistic locking" })
@ApiOkResponse({ type: ComplexProductResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
update(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: UpdateComplexProductDto,
): ComplexProductResponseDto {
return {
data: this.store.updateProduct(
request.organizationId!,
request.user!.id,
id,
dto,
),
};
}
}
@ApiTags("Catalog")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class ComplexCatalogController {
constructor(private readonly store: ComplexStore) {}
@Get("categories")
@ApiOperation({ summary: "List a recursive category tree" })
@ApiOkResponse({ type: ComplexCategoriesResponseDto })
@ApiStandardErrors({ auth: true })
categories(@Req() request: ComplexDemoRequest): ComplexCategoriesResponseDto {
return { data: this.store.listCategories(request.organizationId!) };
}
@Get("brands")
@ApiOperation({ summary: "List product brands" })
@ApiOkResponse({ type: BrandsResponseDto })
@ApiStandardErrors({ auth: true })
brands(@Req() request: ComplexDemoRequest): BrandsResponseDto {
return { data: this.store.listBrands(request.organizationId!) };
}
}
@ApiTags("Inventory")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class ComplexInventoryController {
constructor(private readonly store: ComplexStore) {}
@Get("warehouses")
@ApiOperation({ summary: "List tenant warehouses" })
@ApiOkResponse({ type: WarehousesResponseDto })
@ApiStandardErrors({ auth: true })
warehouses(@Req() request: ComplexDemoRequest): WarehousesResponseDto {
return { data: this.store.listWarehouses(request.organizationId!) };
}
@Get("inventory")
@ApiOperation({ summary: "List inventory with optional product filter" })
@ApiOkResponse({ type: InventoryResponseDto })
@ApiStandardErrors({ auth: true })
inventory(
@Req() request: ComplexDemoRequest,
@Query("productId") productId?: string,
): InventoryResponseDto {
return {
data: this.store.listInventory(request.organizationId!, productId),
};
}
@Post("inventory/:id/adjust")
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Adjust stock using optimistic locking" })
@ApiOkResponse({ type: InventoryItemResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
adjust(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: AdjustInventoryDto,
): InventoryItemResponseDto {
return {
data: this.store.adjustInventory(
request.organizationId!,
request.user!.id,
id,
dto,
),
};
}
}

View File

@@ -0,0 +1,125 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import { ChatGateway } from "../chat.gateway";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ConversationResponseDto,
ConversationsResponseDto,
MessageResponseDto,
MessagesResponseDto,
SendMessageDto,
} from "../dto/chat.dto";
import { CursorQueryDto } from "../dto/operations.dto";
@ApiTags("Chat")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("conversations")
export class ChatController {
constructor(
private readonly store: ComplexStore,
private readonly gateway: ChatGateway,
) {}
@Get()
@ApiOperation({ summary: "List conversations available to the current user" })
@ApiOkResponse({ type: ConversationsResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): ConversationsResponseDto {
return {
data: this.store.listConversations(
request.organizationId!,
request.user!.id,
),
};
}
@Get(":id")
@ApiOperation({ summary: "Get one conversation" })
@ApiOkResponse({ type: ConversationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ConversationResponseDto {
return {
data: this.store.getConversation(
request.organizationId!,
request.user!.id,
id,
),
};
}
@Get(":id/messages")
@ApiOperation({ summary: "Load message history using cursor pagination" })
@ApiOkResponse({ type: MessagesResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
messages(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Query() query: CursorQueryDto,
): MessagesResponseDto {
return this.store.listMessages(
request.organizationId!,
request.user!.id,
id,
query.cursor,
query.limit,
);
}
@Post(":id/messages")
@HttpCode(200)
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({
summary:
"Send an idempotent message through REST and broadcast it over Socket.IO",
})
@ApiOkResponse({ type: MessageResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
send(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: SendMessageDto,
): MessageResponseDto {
const message = this.store.sendMessage(
request.organizationId!,
request.user!.id,
id,
dto,
);
this.gateway.broadcastMessage(message);
return { data: message };
}
}

View File

@@ -0,0 +1,227 @@
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
HttpCode,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiIdempotencyHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ComplexOrderResponseDto,
ComplexOrdersResponseDto,
CreateComplexOrderDto,
CreateReviewDto,
CustomerQueryDto,
CustomerResponseDto,
CustomersResponseDto,
OrderCursorQueryDto,
PaymentsResponseDto,
PromotionsResponseDto,
ReviewResponseDto,
ReviewsResponseDto,
} from "../dto/commerce.dto";
import { ComplexRole } from "../dto/identity.dto";
@ApiTags("Customers")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("customers")
export class CustomersController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List customers using offset pagination and search",
})
@ApiOkResponse({ type: CustomersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CustomerQueryDto,
): CustomersResponseDto {
return this.store.listCustomers(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({ summary: "Get one customer with nested address" })
@ApiOkResponse({ type: CustomerResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): CustomerResponseDto {
return { data: this.store.getCustomer(request.organizationId!, id) };
}
}
@ApiTags("Orders")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("orders")
export class ComplexOrdersController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List tenant orders using cursor pagination" })
@ApiOkResponse({ type: ComplexOrdersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: OrderCursorQueryDto,
): ComplexOrdersResponseDto {
return this.store.listOrders(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({ summary: "Get one order and its state" })
@ApiOkResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ComplexOrderResponseDto {
return { data: this.store.getOrder(request.organizationId!, id) };
}
@Post()
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
@ApiSecurity("csrf")
@ApiIdempotencyHeader()
@ApiOperation({ summary: "Create an order idempotently" })
@ApiCreatedResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, conflict: true })
create(
@Req() request: ComplexDemoRequest,
@Headers("idempotency-key") idempotencyKey: string,
@Body() dto: CreateComplexOrderDto,
): ComplexOrderResponseDto {
if (!idempotencyKey) {
throw new BadRequestException({
code: "IDEMPOTENCY_KEY_REQUIRED",
message: "Idempotency-Key header is required.",
});
}
return {
data: this.store.createOrder(
request.organizationId!,
request.user!.id,
idempotencyKey,
dto,
),
};
}
@Post(":id/cancel")
@HttpCode(200)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Apply a validated order state transition" })
@ApiOkResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
cancel(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ComplexOrderResponseDto {
return {
data: this.store.cancelOrder(
request.organizationId!,
request.user!.id,
id,
),
};
}
}
@ApiTags("Payments and promotions")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class PaymentsController {
constructor(private readonly store: ComplexStore) {}
@Get("payments")
@ApiOperation({ summary: "List payments for tenant orders" })
@ApiOkResponse({ type: PaymentsResponseDto })
@ApiStandardErrors({ auth: true })
payments(@Req() request: ComplexDemoRequest): PaymentsResponseDto {
return { data: this.store.listPayments(request.organizationId!) };
}
@Get("promotions")
@ApiOperation({ summary: "List active and expired promotion contracts" })
@ApiOkResponse({ type: PromotionsResponseDto })
@ApiStandardErrors({ auth: true })
promotions(): PromotionsResponseDto {
return { data: this.store.listPromotions() };
}
}
@ApiTags("Reviews")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("reviews")
export class ReviewsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List reviews with moderation state" })
@ApiOkResponse({ type: ReviewsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query("productId") productId?: string,
): ReviewsResponseDto {
return { data: this.store.listReviews(request.organizationId!, productId) };
}
@Post()
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Create a review in pending moderation state" })
@ApiCreatedResponse({ type: ReviewResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
create(
@Req() request: ComplexDemoRequest,
@Body() dto: CreateReviewDto,
): ReviewResponseDto {
return { data: this.store.createReview(request.organizationId!, dto) };
}
}

View File

@@ -0,0 +1,196 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import type { ComplexDemoRequest } from "../complex.auth";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
ComplexSessionService,
CSRF_COOKIE,
csrfCookieOptions,
SESSION_COOKIE,
sessionCookieOptions,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ComplexLoginDto,
ComplexRole,
ComplexUsersResponseDto,
ComplexUserResponseDto,
CookieSessionResponseDto,
MembersResponseDto,
OrganizationResponseDto,
OrganizationsResponseDto,
} from "../dto/identity.dto";
@ApiTags("Auth")
@ApiDemoScenarioHeader()
@Controller("auth")
export class ComplexAuthController {
constructor(private readonly sessions: ComplexSessionService) {}
@Post("login")
@HttpCode(200)
@ApiOperation({ summary: "Login and establish HttpOnly cookie session" })
@ApiOkResponse({
type: CookieSessionResponseDto,
headers: {
"Set-Cookie": {
description: "Sets demo_session (HttpOnly) and demo_csrf cookies.",
schema: { type: "string" },
},
},
})
@ApiStandardErrors()
login(
@Body() dto: ComplexLoginDto,
@Res({ passthrough: true }) response: Response,
): CookieSessionResponseDto {
const session = this.sessions.login(dto);
this.setCookies(response, session.token, session.csrfToken);
return this.sessions.response(session);
}
@Post("refresh")
@HttpCode(200)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({ summary: "Rotate the current cookie session and CSRF token" })
@ApiOkResponse({ type: CookieSessionResponseDto })
@ApiStandardErrors({ auth: true })
refresh(
@Req() request: ComplexDemoRequest,
@Res({ passthrough: true }) response: Response,
): CookieSessionResponseDto {
const session = this.sessions.rotate(request.sessionToken!);
this.setCookies(response, session.token, session.csrfToken);
return this.sessions.response(session);
}
@Post("logout")
@HttpCode(204)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({
summary: "Revoke cookie session and clear authentication cookies",
})
@ApiNoContentResponse()
@ApiStandardErrors({ auth: true })
logout(
@Req() request: ComplexDemoRequest,
@Res({ passthrough: true }) response: Response,
): void {
this.sessions.revoke(request.sessionToken);
response.clearCookie(SESSION_COOKIE, { path: "/" });
response.clearCookie(CSRF_COOKIE, { path: "/" });
}
private setCookies(
response: Response,
token: string,
csrfToken: string,
): void {
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
response.cookie(CSRF_COOKIE, csrfToken, csrfCookieOptions());
}
}
@ApiTags("Users")
@ApiCookieAuth("cookieSession")
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard)
@Controller("users")
export class ComplexUsersController {
constructor(private readonly store: ComplexStore) {}
@Get("me")
@ApiOperation({
summary: "Get the authenticated user and available organizations",
})
@ApiOkResponse({ type: ComplexUserResponseDto })
@ApiStandardErrors({ auth: true })
me(@Req() request: ComplexDemoRequest): ComplexUserResponseDto {
const user = this.store.findUserById(request.user!.id)!;
return { data: this.store.publicUser(user) };
}
@Get()
@ApiOrganizationHeader()
@UseGuards(ComplexOrganizationGuard, ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiOperation({ summary: "List users in the current tenant" })
@ApiOkResponse({ type: ComplexUsersResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): ComplexUsersResponseDto {
return { data: this.store.listUsers(request.organizationId!) };
}
}
@ApiTags("Organizations")
@ApiCookieAuth("cookieSession")
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard)
@Controller("organizations")
export class OrganizationsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List organizations available to the current user" })
@ApiOkResponse({ type: OrganizationsResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): OrganizationsResponseDto {
return { data: this.store.listOrganizations(request.user!.id) };
}
@Get(":id")
@ApiOperation({ summary: "Get one available organization" })
@ApiOkResponse({ type: OrganizationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): OrganizationResponseDto {
return { data: this.store.getOrganization(id, request.user!.id) };
}
@Get(":id/members")
@UseGuards(ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiOperation({ summary: "List organization members and their roles" })
@ApiOkResponse({ type: MembersResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
members(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): MembersResponseDto {
this.store.getOrganization(id, request.user!.id);
return { data: this.store.listMembers(id) };
}
}

View File

@@ -0,0 +1,254 @@
import {
BadRequestException,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiAcceptedResponse,
ApiBody,
ApiConsumes,
ApiCookieAuth,
ApiCreatedResponse,
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiProduces,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiBinaryResponse,
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import { ComplexRole } from "../dto/identity.dto";
import {
AuditEventsResponseDto,
AuditQueryDto,
CursorQueryDto,
FileResponseDto,
FilesResponseDto,
InventoryNotificationPayloadDto,
JobResponseDto,
NotificationResponseDto,
NotificationsResponseDto,
OrderNotificationPayloadDto,
SystemNotificationPayloadDto,
} from "../dto/operations.dto";
@ApiTags("Notifications")
@ApiExtraModels(
OrderNotificationPayloadDto,
InventoryNotificationPayloadDto,
SystemNotificationPayloadDto,
)
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("notifications")
export class NotificationsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List polymorphic notifications using cursor pagination",
})
@ApiOkResponse({ type: NotificationsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CursorQueryDto,
): NotificationsResponseDto {
return this.store.listNotifications(
request.organizationId!,
query.cursor,
query.limit,
);
}
@Post(":id/read")
@HttpCode(200)
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Mark a notification as read" })
@ApiOkResponse({ type: NotificationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
markRead(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): NotificationResponseDto {
return {
data: this.store.markNotificationRead(request.organizationId!, id),
};
}
}
@ApiTags("Files")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("files")
export class FilesController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List uploaded file metadata" })
@ApiOkResponse({ type: FilesResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): FilesResponseDto {
return { data: this.store.listFiles(request.organizationId!) };
}
@Post()
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }),
)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Upload a file up to 5 MiB and retain it in memory",
})
@ApiCreatedResponse({ type: FileResponseDto })
@ApiStandardErrors({ auth: true })
upload(
@Req() request: ComplexDemoRequest,
@UploadedFile() file?: Express.Multer.File,
): FileResponseDto {
if (!file)
throw new BadRequestException({
code: "FILE_REQUIRED",
message: 'Multipart field "file" is required.',
});
return { data: this.store.addFile(request.organizationId!, file) };
}
@Get(":id/download")
@ApiProduces("application/octet-stream")
@ApiOperation({ summary: "Download an in-memory file" })
@ApiBinaryResponse("Binary file content.")
@ApiStandardErrors({ auth: true, notFound: true })
download(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Res({ passthrough: true }) response: Response,
): StreamableFile {
const file = this.store.getFile(request.organizationId!, id);
response.setHeader("Content-Type", file.metadata.mimeType);
response.setHeader(
"Content-Disposition",
`attachment; filename="${file.metadata.name.replace(/"/g, "")}"`,
);
return new StreamableFile(file.content);
}
}
@ApiTags("Audit")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard, ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@Controller("audit-events")
export class AuditController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List immutable audit events using offset pagination",
})
@ApiOkResponse({ type: AuditEventsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: AuditQueryDto,
): AuditEventsResponseDto {
return this.store.listAudit(request.organizationId!, query);
}
}
@ApiTags("Background jobs")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class JobsController {
constructor(private readonly store: ComplexStore) {}
@Post("exports/orders")
@HttpCode(202)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Start an asynchronous orders export" })
@ApiAcceptedResponse({ type: JobResponseDto })
@ApiStandardErrors({ auth: true })
startExport(@Req() request: ComplexDemoRequest): JobResponseDto {
return { data: this.store.startOrdersExport(request.organizationId!) };
}
@Get("jobs/:id")
@ApiOperation({ summary: "Poll background job progress" })
@ApiOkResponse({ type: JobResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
getJob(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): JobResponseDto {
return { data: this.store.getJob(request.organizationId!, id) };
}
@Get("jobs/:id/result")
@ApiProduces("text/csv")
@ApiOperation({
summary: "Download a completed export; returns 409 while processing",
})
@ApiBinaryResponse("Generated orders CSV.", "text/csv")
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
result(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Res({ passthrough: true }) response: Response,
): StreamableFile {
const content = this.store.jobResult(request.organizationId!, id);
response.setHeader("Content-Type", "text/csv");
response.setHeader(
"Content-Disposition",
`attachment; filename="orders-${id}.csv"`,
);
return new StreamableFile(content);
}
}

View File

@@ -0,0 +1,177 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
HealthResponseDto,
ScenariosResponseDto,
TestingActionResponseDto,
} from "../../../common/api.dto";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexSessionService,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ChangeComplexRoleDto,
ComplexUserResponseDto,
} from "../dto/identity.dto";
@ApiTags("Health")
@ApiDemoScenarioHeader()
@Controller("health")
export class ComplexHealthController {
@Get()
@ApiOperation({ summary: "Check Complex API availability" })
@ApiOkResponse({ type: HealthResponseDto })
health(): HealthResponseDto {
return {
data: {
application: "complex",
status: "ok",
timestamp: new Date().toISOString(),
version: "1.0.0",
},
};
}
}
@ApiTags("Testing")
@ApiDemoScenarioHeader()
@Controller("testing")
export class ComplexTestingController {
constructor(
private readonly store: ComplexStore,
private readonly sessions: ComplexSessionService,
) {}
@Get("scenarios")
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
@ApiOkResponse({ type: ScenariosResponseDto })
scenarios(): ScenariosResponseDto {
return {
data: [
{ name: "normal", description: "Default behavior." },
{
name: "slow",
description:
"Delays the response to exercise loading and cancellation states.",
},
{
name: "timeout",
description:
"Delays long enough for a frontend timeout or cancellation.",
},
{
name: "server-error",
description: "Returns a deterministic 500 error.",
},
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
{
name: "empty",
description: "Turns list responses into a valid empty state.",
},
{
name: "expired-auth",
description: "Makes protected routes return 401.",
},
{
name: "forbidden",
description: "Makes protected routes return 403.",
},
{ name: "conflict", description: "Makes mutation routes return 409." },
{
name: "large-dataset",
description: "Expands list responses to 250 deterministic items.",
},
],
};
}
@Post("reset")
@HttpCode(200)
@ApiOperation({ summary: "Reset all Complex API data and active sessions" })
@ApiOkResponse({ type: TestingActionResponseDto })
reset(): TestingActionResponseDto {
this.store.reset("small");
this.sessions.reset();
return {
data: {
success: true,
message:
"Complex API state and sessions reset to the default deterministic seed.",
},
};
}
@Post("seed/:preset")
@HttpCode(200)
@ApiParam({ name: "preset", enum: ["small", "large"] })
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
@ApiOkResponse({ type: TestingActionResponseDto })
seed(@Param("preset") preset: string): TestingActionResponseDto {
if (preset !== "small" && preset !== "large") {
throw new BadRequestException({
code: "UNKNOWN_SEED_PRESET",
message: "Preset must be small or large.",
});
}
this.store.reset(preset);
return {
data: {
success: true,
message: `Complex API loaded the ${preset} dataset.`,
},
};
}
@Post("session/expire")
@HttpCode(200)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({
summary: "Expire the current cookie session after this response",
})
@ApiOkResponse({ type: TestingActionResponseDto })
@ApiStandardErrors({ auth: true })
expireSession(@Req() request: ComplexDemoRequest): TestingActionResponseDto {
this.sessions.expire(request.sessionToken);
return {
data: { success: true, message: "Current cookie session expired." },
};
}
@Post("users/:userId/role")
@HttpCode(200)
@ApiOperation({ summary: "Change a role while sessions remain active" })
@ApiOkResponse({ type: ComplexUserResponseDto })
@ApiStandardErrors({ notFound: true })
changeRole(
@Param("userId") userId: string,
@Body() dto: ChangeComplexRoleDto,
): ComplexUserResponseDto {
return { data: this.store.changeUserRole(userId, dto.role) };
}
}

View File

@@ -0,0 +1,329 @@
import { Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { CursorMetaDto } from "../../../common/api.dto";
export class MoneyDto {
@ApiProperty({
example: "129.90",
pattern: "^\\d+\\.\\d{2}$",
description:
"Decimal string; never parse money as a floating-point number.",
})
amount!: string;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
}
export enum ComplexProductStatus {
Draft = "draft",
Active = "active",
Archived = "archived",
}
export class ProductVariantDto {
@ApiProperty({ example: "variant-keyboard-black" })
id!: string;
@ApiProperty({ example: "KEYBOARD-BLACK-US" })
sku!: string;
@ApiProperty({
type: "object",
additionalProperties: { type: "string" },
example: { color: "black", layout: "US" },
})
attributes!: Record<string, string>;
@ApiProperty({ type: MoneyDto })
price!: MoneyDto;
}
export class ComplexProductDto {
@ApiProperty({ example: "complex-product-keyboard" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Pro Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: "pro-mechanical-keyboard" })
slug!: string;
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
description!: string;
@ApiProperty({ enum: ComplexProductStatus })
status!: ComplexProductStatus;
@ApiProperty({ example: "complex-category-electronics" })
categoryId!: string;
@ApiProperty({ example: "brand-northstar" })
brandId!: string;
@ApiProperty({ type: MoneyDto })
price!: MoneyDto;
@ApiProperty({ type: [ProductVariantDto] })
variants!: ProductVariantDto[];
@ApiProperty({ type: [String], example: ["featured", "office"] })
tags!: string[];
@ApiProperty({ format: "date-time", nullable: true })
publishedAt!: string | null;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 3, description: "Optimistic-lock version." })
version!: number;
}
export class CursorProductQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "complex-product-mouse" })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ example: "keyboard" })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ enum: ComplexProductStatus })
@IsEnum(ComplexProductStatus)
@IsOptional()
status?: ComplexProductStatus;
}
export class ComplexProductsResponseDto {
@ApiProperty({ type: [ComplexProductDto] })
data!: ComplexProductDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class ComplexProductResponseDto {
@ApiProperty({ type: ComplexProductDto })
data!: ComplexProductDto;
}
export class CreateVariantDto {
@ApiProperty({ example: "KEYBOARD-WHITE-US" })
@IsString()
sku!: string;
@ApiProperty({
type: "object",
additionalProperties: { type: "string" },
example: { color: "white", layout: "US" },
})
@IsObject()
attributes!: Record<string, string>;
@ApiProperty({ example: "139.90" })
@IsString()
priceAmount!: string;
}
export class CreateComplexProductDto {
@ApiProperty({ example: "Pro Mechanical Keyboard" })
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
@IsString()
@MinLength(10)
@MaxLength(2000)
description!: string;
@ApiProperty({
enum: ComplexProductStatus,
default: ComplexProductStatus.Draft,
})
@IsEnum(ComplexProductStatus)
status!: ComplexProductStatus;
@ApiProperty({ example: "complex-category-electronics" })
@IsString()
categoryId!: string;
@ApiProperty({ example: "brand-northstar" })
@IsString()
brandId!: string;
@ApiProperty({ example: "129.90" })
@IsString()
priceAmount!: string;
@ApiProperty({ type: [CreateVariantDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateVariantDto)
variants!: CreateVariantDto[];
@ApiPropertyOptional({ type: [String], example: ["featured"] })
@IsArray()
@IsString({ each: true })
@IsOptional()
tags: string[] = [];
}
export class UpdateComplexProductDto extends PartialType(
CreateComplexProductDto,
) {
@ApiProperty({
minimum: 1,
example: 3,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}
export class ComplexCategoryDto {
@ApiProperty({ example: "complex-category-electronics" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Electronics" })
name!: string;
@ApiProperty({ nullable: true, example: null })
parentId!: string | null;
@ApiProperty({ type: [String], example: ["complex-category-keyboards"] })
childIds!: string[];
}
export class ComplexCategoriesResponseDto {
@ApiProperty({ type: [ComplexCategoryDto] })
data!: ComplexCategoryDto[];
}
export class BrandDto {
@ApiProperty({ example: "brand-northstar" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Northstar" })
name!: string;
@ApiProperty({ format: "uri", nullable: true })
logoUrl!: string | null;
}
export class BrandsResponseDto {
@ApiProperty({ type: [BrandDto] })
data!: BrandDto[];
}
export class WarehouseDto {
@ApiProperty({ example: "warehouse-berlin" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Berlin Warehouse" })
name!: string;
@ApiProperty({ example: "DE" })
countryCode!: string;
@ApiProperty({ enum: ["active", "maintenance"], example: "active" })
status!: "active" | "maintenance";
}
export class WarehousesResponseDto {
@ApiProperty({ type: [WarehouseDto] })
data!: WarehouseDto[];
}
export class InventoryItemDto {
@ApiProperty({ example: "inventory-keyboard-berlin" })
id!: string;
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
variantId!: string;
@ApiProperty({ example: "warehouse-berlin" })
warehouseId!: string;
@ApiProperty({ example: 42 })
available!: number;
@ApiProperty({ example: 5 })
reserved!: number;
@ApiProperty({ example: 10 })
reorderPoint!: number;
@ApiProperty({ example: 2 })
version!: number;
}
export class InventoryResponseDto {
@ApiProperty({ type: [InventoryItemDto] })
data!: InventoryItemDto[];
}
export class InventoryItemResponseDto {
@ApiProperty({ type: InventoryItemDto })
data!: InventoryItemDto;
}
export class AdjustInventoryDto {
@ApiProperty({ example: -2, description: "Signed stock adjustment." })
@IsInt()
delta!: number;
@ApiProperty({ example: "Damaged during delivery" })
@IsString()
@MinLength(3)
reason!: string;
@ApiProperty({
example: 2,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}

View File

@@ -0,0 +1,87 @@
import { IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { CursorMetaDto } from "../../../common/api.dto";
export class ConversationDto {
@ApiProperty({ example: "conversation-support" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Order support" })
title!: string;
@ApiProperty({
type: [String],
example: ["complex-user-admin", "complex-user-support"],
})
participantIds!: string[];
@ApiProperty({
nullable: true,
example: "Can you check order complex-order-001?",
})
lastMessagePreview!: string | null;
@ApiProperty({ format: "date-time" })
updatedAt!: string;
}
export class ConversationsResponseDto {
@ApiProperty({ type: [ConversationDto] })
data!: ConversationDto[];
}
export class ConversationResponseDto {
@ApiProperty({ type: ConversationDto })
data!: ConversationDto;
}
export class ChatMessageDto {
@ApiProperty({ example: "message-001" })
id!: string;
@ApiProperty({ example: "conversation-support" })
conversationId!: string;
@ApiProperty({ example: "complex-user-support" })
senderId!: string;
@ApiProperty({ example: "The order has already been packed." })
text!: string;
@ApiProperty({
example: "client-message-4c08",
description: "Frontend-generated key used to deduplicate retries.",
})
clientMessageId!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class MessagesResponseDto {
@ApiProperty({ type: [ChatMessageDto] })
data!: ChatMessageDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class SendMessageDto {
@ApiProperty({ example: "The order has already been packed." })
@IsString()
@MinLength(1)
text!: string;
@ApiProperty({ example: "client-message-4c08" })
@IsString()
@MinLength(3)
clientMessageId!: string;
}
export class MessageResponseDto {
@ApiProperty({ type: ChatMessageDto })
data!: ChatMessageDto;
}

View File

@@ -0,0 +1,324 @@
import { Type } from "class-transformer";
import {
IsArray,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
import { MoneyDto } from "./catalog.dto";
export class AddressDto {
@ApiProperty({ example: "Friedrichstrasse 100" })
line1!: string;
@ApiProperty({ nullable: true, example: null })
line2!: string | null;
@ApiProperty({ example: "Berlin" })
city!: string;
@ApiProperty({ example: "10117" })
postalCode!: string;
@ApiProperty({ example: "DE" })
countryCode!: string;
}
export class CustomerDto {
@ApiProperty({ example: "customer-ada" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Ada Lovelace" })
name!: string;
@ApiProperty({ format: "email", example: "ada@example.test" })
email!: string;
@ApiProperty({ type: AddressDto })
defaultAddress!: AddressDto;
@ApiProperty({ type: [String], example: ["vip", "newsletter"] })
tags!: string[];
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class CustomerQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "ada" })
@IsString()
@IsOptional()
search?: string;
}
export class CustomersResponseDto {
@ApiProperty({ type: [CustomerDto] })
data!: CustomerDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class CustomerResponseDto {
@ApiProperty({ type: CustomerDto })
data!: CustomerDto;
}
export enum ComplexOrderStatus {
Draft = "draft",
AwaitingPayment = "awaiting-payment",
Paid = "paid",
Fulfillment = "fulfillment",
Shipped = "shipped",
Cancelled = "cancelled",
}
export class ComplexOrderItemDto {
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
variantId!: string;
@ApiProperty({ example: "Pro Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: 1 })
quantity!: number;
@ApiProperty({ type: MoneyDto })
unitPrice!: MoneyDto;
}
export class ComplexOrderDto {
@ApiProperty({ example: "complex-order-001" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "customer-ada" })
customerId!: string;
@ApiProperty({ enum: ComplexOrderStatus })
status!: ComplexOrderStatus;
@ApiProperty({ type: [ComplexOrderItemDto] })
items!: ComplexOrderItemDto[];
@ApiProperty({ type: MoneyDto })
subtotal!: MoneyDto;
@ApiProperty({ type: MoneyDto })
discount!: MoneyDto;
@ApiProperty({ type: MoneyDto })
total!: MoneyDto;
@ApiProperty({ type: AddressDto })
shippingAddress!: AddressDto;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 1 })
version!: number;
}
export class OrderCursorQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "complex-order-001" })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ enum: ComplexOrderStatus })
@IsString()
@IsOptional()
status?: ComplexOrderStatus;
}
export class ComplexOrdersResponseDto {
@ApiProperty({ type: [ComplexOrderDto] })
data!: ComplexOrderDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class ComplexOrderResponseDto {
@ApiProperty({ type: ComplexOrderDto })
data!: ComplexOrderDto;
}
export class CreateComplexOrderItemDto {
@ApiProperty({ example: "complex-product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
@IsString()
variantId!: string;
@ApiProperty({ example: 1, minimum: 1, maximum: 50 })
@IsInt()
@Min(1)
@Max(50)
quantity!: number;
}
export class CreateComplexOrderDto {
@ApiProperty({ example: "customer-ada" })
@IsString()
customerId!: string;
@ApiProperty({ type: [CreateComplexOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateComplexOrderItemDto)
items!: CreateComplexOrderItemDto[];
@ApiPropertyOptional({ example: "WELCOME10" })
@IsString()
@IsOptional()
promotionCode?: string;
}
export class PaymentDto {
@ApiProperty({ example: "payment-001" })
id!: string;
@ApiProperty({ example: "complex-order-001" })
orderId!: string;
@ApiProperty({
enum: ["pending", "succeeded", "failed", "refunded"],
example: "succeeded",
})
status!: "pending" | "succeeded" | "failed" | "refunded";
@ApiProperty({ enum: ["card", "bank-transfer"], example: "card" })
method!: "card" | "bank-transfer";
@ApiProperty({ type: MoneyDto })
amount!: MoneyDto;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class PaymentsResponseDto {
@ApiProperty({ type: [PaymentDto] })
data!: PaymentDto[];
}
export class PromotionDto {
@ApiProperty({ example: "promotion-welcome" })
id!: string;
@ApiProperty({ example: "WELCOME10" })
code!: string;
@ApiProperty({ enum: ["percentage", "fixed"], example: "percentage" })
type!: "percentage" | "fixed";
@ApiProperty({ example: "10.00" })
value!: string;
@ApiProperty({ format: "date-time" })
validUntil!: string;
@ApiProperty({ example: true })
active!: boolean;
}
export class PromotionsResponseDto {
@ApiProperty({ type: [PromotionDto] })
data!: PromotionDto[];
}
export class ReviewDto {
@ApiProperty({ example: "review-001" })
id!: string;
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "customer-ada" })
customerId!: string;
@ApiProperty({ example: 5, minimum: 1, maximum: 5 })
rating!: number;
@ApiProperty({ example: "Excellent keyboard for daily development." })
comment!: string;
@ApiProperty({
enum: ["pending", "published", "rejected"],
example: "published",
})
status!: "pending" | "published" | "rejected";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class ReviewsResponseDto {
@ApiProperty({ type: [ReviewDto] })
data!: ReviewDto[];
}
export class CreateReviewDto {
@ApiProperty({ example: "complex-product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: "customer-ada" })
@IsString()
customerId!: string;
@ApiProperty({ minimum: 1, maximum: 5, example: 5 })
@IsInt()
@Min(1)
@Max(5)
rating!: number;
@ApiProperty({ example: "Excellent keyboard for daily development." })
@IsString()
comment!: string;
}
export class ReviewResponseDto {
@ApiProperty({ type: ReviewDto })
data!: ReviewDto;
}

View File

@@ -0,0 +1,139 @@
import { IsEmail, IsEnum, IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export enum ComplexRole {
Admin = "admin",
Manager = "manager",
Support = "support",
Viewer = "viewer",
}
export class ComplexLoginDto {
@ApiProperty({ format: "email", example: "admin@complex.demo" })
@IsEmail()
email!: string;
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
}
export class ComplexUserDto {
@ApiProperty({ example: "complex-user-admin" })
id!: string;
@ApiProperty({ format: "email", example: "admin@complex.demo" })
email!: string;
@ApiProperty({ example: "Complex Admin" })
name!: string;
@ApiProperty({ enum: ComplexRole })
role!: ComplexRole;
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=20" })
avatarUrl!: string | null;
@ApiProperty({ type: [String], example: ["org-acme", "org-globex"] })
organizationIds!: string[];
}
export class ComplexUserResponseDto {
@ApiProperty({ type: ComplexUserDto })
data!: ComplexUserDto;
}
export class ComplexUsersResponseDto {
@ApiProperty({ type: [ComplexUserDto] })
data!: ComplexUserDto[];
}
export class CookieSessionDataDto {
@ApiProperty({ type: ComplexUserDto })
user!: ComplexUserDto;
@ApiProperty({
example: "5f2642ca-2ba4-4ee3-bf2e-d5d37a97686c",
description: "Send this value in X-CSRF-Token for authenticated mutations.",
})
csrfToken!: string;
@ApiProperty({ format: "date-time" })
expiresAt!: string;
}
export class CookieSessionResponseDto {
@ApiProperty({ type: CookieSessionDataDto })
data!: CookieSessionDataDto;
}
export enum OrganizationPlan {
Starter = "starter",
Business = "business",
Enterprise = "enterprise",
}
export class OrganizationDto {
@ApiProperty({ example: "org-acme" })
id!: string;
@ApiProperty({ example: "Acme Commerce" })
name!: string;
@ApiProperty({ enum: OrganizationPlan })
plan!: OrganizationPlan;
@ApiProperty({ example: "Europe/Berlin" })
timezone!: string;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class OrganizationsResponseDto {
@ApiProperty({ type: [OrganizationDto] })
data!: OrganizationDto[];
}
export class OrganizationResponseDto {
@ApiProperty({ type: OrganizationDto })
data!: OrganizationDto;
}
export class MemberDto {
@ApiProperty({ example: "member-001" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "complex-user-manager" })
userId!: string;
@ApiProperty({ example: "Complex Manager" })
userName!: string;
@ApiProperty({ format: "email", example: "manager@complex.demo" })
email!: string;
@ApiProperty({ enum: ComplexRole })
role!: ComplexRole;
@ApiProperty({ enum: ["active", "invited", "suspended"], example: "active" })
status!: "active" | "invited" | "suspended";
}
export class MembersResponseDto {
@ApiProperty({ type: [MemberDto] })
data!: MemberDto[];
}
export class ChangeComplexRoleDto {
@ApiProperty({ enum: ComplexRole, example: ComplexRole.Viewer })
@IsEnum(ComplexRole)
role!: ComplexRole;
}

View File

@@ -0,0 +1,244 @@
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import {
ApiExtraModels,
ApiProperty,
ApiPropertyOptional,
getSchemaPath,
} from "@nestjs/swagger";
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
export class OrderNotificationPayloadDto {
@ApiProperty({ enum: ["order"], example: "order" })
type!: "order";
@ApiProperty({ example: "complex-order-001" })
orderId!: string;
@ApiProperty({ enum: ["paid", "shipped", "cancelled"], example: "shipped" })
status!: string;
}
export class InventoryNotificationPayloadDto {
@ApiProperty({ enum: ["inventory"], example: "inventory" })
type!: "inventory";
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: 4 })
remaining!: number;
}
export class SystemNotificationPayloadDto {
@ApiProperty({ enum: ["system"], example: "system" })
type!: "system";
@ApiProperty({ example: "Scheduled maintenance begins at 02:00 UTC." })
text!: string;
}
export enum NotificationKind {
Order = "order",
Inventory = "inventory",
System = "system",
}
@ApiExtraModels(
OrderNotificationPayloadDto,
InventoryNotificationPayloadDto,
SystemNotificationPayloadDto,
)
export class NotificationDto {
@ApiProperty({ example: "notification-001" })
id!: string;
@ApiProperty({ enum: NotificationKind })
kind!: NotificationKind;
@ApiProperty({ example: "Order shipped" })
title!: string;
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(OrderNotificationPayloadDto) },
{ $ref: getSchemaPath(InventoryNotificationPayloadDto) },
{ $ref: getSchemaPath(SystemNotificationPayloadDto) },
],
discriminator: {
propertyName: "type",
mapping: {
order: getSchemaPath(OrderNotificationPayloadDto),
inventory: getSchemaPath(InventoryNotificationPayloadDto),
system: getSchemaPath(SystemNotificationPayloadDto),
},
},
})
payload!:
| OrderNotificationPayloadDto
| InventoryNotificationPayloadDto
| SystemNotificationPayloadDto;
@ApiProperty({ example: false })
read!: boolean;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class NotificationsResponseDto {
@ApiProperty({ type: [NotificationDto] })
data!: NotificationDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class NotificationResponseDto {
@ApiProperty({ type: NotificationDto })
data!: NotificationDto;
}
export class CursorQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "notification-020" })
@IsString()
@IsOptional()
cursor?: string;
}
export class FileMetadataDto {
@ApiProperty({ example: "file-001" })
id!: string;
@ApiProperty({ example: "products.csv" })
name!: string;
@ApiProperty({ example: "text/csv" })
mimeType!: string;
@ApiProperty({ example: 18432 })
size!: number;
@ApiProperty({ format: "uri", example: "/api/v1/files/file-001/download" })
downloadUrl!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class FileResponseDto {
@ApiProperty({ type: FileMetadataDto })
data!: FileMetadataDto;
}
export class FilesResponseDto {
@ApiProperty({ type: [FileMetadataDto] })
data!: FileMetadataDto[];
}
export class AuditEventDto {
@ApiProperty({ example: "audit-001" })
id!: string;
@ApiProperty({ example: "product.updated" })
action!: string;
@ApiProperty({ example: "complex-user-admin" })
actorId!: string;
@ApiProperty({ example: "product" })
resourceType!: string;
@ApiProperty({ example: "complex-product-keyboard" })
resourceId!: string;
@ApiProperty({
type: "object",
additionalProperties: true,
example: { version: 4 },
})
metadata!: Record<string, unknown>;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class AuditQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "product.updated" })
@IsString()
@IsOptional()
action?: string;
}
export class AuditEventsResponseDto {
@ApiProperty({ type: [AuditEventDto] })
data!: AuditEventDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export enum JobStatus {
Pending = "pending",
Processing = "processing",
Completed = "completed",
Failed = "failed",
}
export class JobDto {
@ApiProperty({ example: "job-001" })
id!: string;
@ApiProperty({ enum: ["orders-export"], example: "orders-export" })
type!: "orders-export";
@ApiProperty({ enum: JobStatus })
status!: JobStatus;
@ApiProperty({ example: 75, minimum: 0, maximum: 100 })
progress!: number;
@ApiProperty({
format: "uri",
nullable: true,
example: "/api/v1/jobs/job-001/result",
})
resultUrl!: string | null;
@ApiProperty({ nullable: true, example: null })
error!: string | null;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ format: "date-time" })
updatedAt!: string;
}
export class JobResponseDto {
@ApiProperty({ type: JobDto })
data!: JobDto;
}

View File

@@ -0,0 +1,12 @@
import { createComplexApplication } from "./bootstrap";
async function bootstrap(): Promise<void> {
const { app } = await createComplexApplication();
const port = Number(process.env.COMPLEX_PORT ?? 3002);
await app.listen(port);
console.log(`Complex API: http://localhost:${port}/api/v1`);
console.log(`Complex Swagger: http://localhost:${port}/docs`);
console.log(`Complex Socket.IO namespace: ws://localhost:${port}/chat`);
}
void bootstrap();

View File

@@ -0,0 +1,20 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { OpenAPIObject } from "@nestjs/swagger";
import { configureApplication } from "../../common/configure-application";
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
import { SimpleAppModule } from "./simple.module";
export async function createSimpleApplication(
logger: false | undefined = undefined,
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
const app = await NestFactory.create<NestExpressApplication>(
SimpleAppModule,
{ logger },
);
configureApplication(app);
const port = Number(process.env.SIMPLE_PORT ?? 3001);
const document = createOpenApiDocument(app, { kind: "simple", port });
mountOpenApi(app, document, "Demo Simple API");
return { app, document };
}

View File

@@ -0,0 +1,11 @@
import { createSimpleApplication } from "./bootstrap";
async function bootstrap(): Promise<void> {
const { app } = await createSimpleApplication();
const port = Number(process.env.SIMPLE_PORT ?? 3001);
await app.listen(port);
console.log(`Simple API: http://localhost:${port}/api/v1`);
console.log(`Simple Swagger: http://localhost:${port}/docs`);
}
void bootstrap();

View File

@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import type { Request } from "express";
import type { DemoRequest } from "../../common/request.types";
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
import {
type JwtAuthResponseDto,
type JwtTokensDto,
type LoginDto,
type RefreshTokenDto,
SimpleRole,
} from "./simple.dto";
import { SimpleStore } from "./simple.store";
interface DemoJwtPayload {
sub: string;
type: "access" | "refresh";
jti: string;
}
@Injectable()
export class SimpleAuthService {
private readonly revokedRefreshTokens = new Set<string>();
constructor(
private readonly jwtService: JwtService,
private readonly store: SimpleStore,
) {}
async login(dto: LoginDto): Promise<JwtAuthResponseDto> {
const user = this.store.findUserByEmail(dto.email);
if (!user || user.password !== dto.password) {
throw new UnauthorizedException({
code: "INVALID_CREDENTIALS",
message: "Email or password is incorrect.",
});
}
return {
data: {
tokens: await this.issueTokens(user.id),
user: this.store.publicUser(user),
},
};
}
async refresh(dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
let payload: DemoJwtPayload;
try {
payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
dto.refreshToken,
{ secret: this.refreshSecret },
);
} catch {
throw new UnauthorizedException({
code: "INVALID_REFRESH_TOKEN",
message: "Refresh token is invalid or expired.",
});
}
if (
payload.type !== "refresh" ||
this.revokedRefreshTokens.has(payload.jti)
) {
throw new UnauthorizedException({
code: "REFRESH_TOKEN_REUSED",
message: "Refresh token was revoked or already used.",
});
}
const user = this.store.findUserById(payload.sub);
if (!user) {
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Token user no longer exists.",
});
}
this.revokedRefreshTokens.add(payload.jti);
return {
data: {
tokens: await this.issueTokens(user.id),
user: this.store.publicUser(user),
},
};
}
async logout(dto: RefreshTokenDto): Promise<void> {
try {
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
dto.refreshToken,
{ secret: this.refreshSecret },
);
this.revokedRefreshTokens.add(payload.jti);
} catch {
// Logout remains idempotent even when the token has already expired.
}
}
reset(): void {
this.revokedRefreshTokens.clear();
}
private async issueTokens(userId: string): Promise<JwtTokensDto> {
const accessJti = randomUUID();
const refreshJti = randomUUID();
const accessTtl = process.env.JWT_ACCESS_TTL ?? "60s";
const refreshTtl = process.env.JWT_REFRESH_TTL ?? "7d";
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ sub: userId, type: "access", jti: accessJti },
{ secret: this.accessSecret, expiresIn: accessTtl as never },
),
this.jwtService.signAsync(
{ sub: userId, type: "refresh", jti: refreshJti },
{ secret: this.refreshSecret, expiresIn: refreshTtl as never },
),
]);
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(accessTtl),
tokenType: "Bearer",
};
}
private accessTtlSeconds(value: string): number {
const match = /^(\d+)([smhd])$/.exec(value);
if (!match) return 60;
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
return Number(match[1]) * multipliers[match[2] as keyof typeof multipliers];
}
private get accessSecret(): string {
return process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me";
}
private get refreshSecret(): string {
return process.env.JWT_REFRESH_SECRET ?? "demo-refresh-secret-change-me";
}
}
@Injectable()
export class SimpleJwtGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly store: SimpleStore,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>() as DemoRequest;
assertNoForcedAuthFailure(request);
const authorization = request.headers.authorization;
if (!authorization?.startsWith("Bearer ")) {
throw new UnauthorizedException({
code: "JWT_MISSING",
message: "Bearer access token is required.",
});
}
try {
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
authorization.slice(7),
{
secret:
process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me",
},
);
if (payload.type !== "access") throw new Error("Wrong token type");
const user = this.store.findUserById(payload.sub);
if (!user) throw new Error("Unknown user");
request.user = this.store.publicUser(user);
return true;
} catch {
throw new UnauthorizedException({
code: "JWT_INVALID",
message: "Access token is invalid or expired.",
});
}
}
}
@Injectable()
export class SimpleAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<DemoRequest>();
if (request.user?.role !== SimpleRole.Admin) {
throw new ForbiddenException({
code: "ADMIN_REQUIRED",
message: "Administrator role is required.",
});
}
return true;
}
}

View File

@@ -0,0 +1,399 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiNotModifiedResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiStandardErrors,
} from "../../common/api.decorators";
import {
HealthResponseDto,
MutationResponseDto,
ScenariosResponseDto,
TestingActionResponseDto,
} from "../../common/api.dto";
import type { DemoRequest } from "../../common/request.types";
import {
SimpleAdminGuard,
SimpleAuthService,
SimpleJwtGuard,
} from "./simple.auth";
import {
CategoriesResponseDto,
CategoryResponseDto,
ChangeSimpleRoleDto,
CreateOrderDto,
CreateProductDto,
JwtAuthResponseDto,
LoginDto,
OrderResponseDto,
OrdersResponseDto,
PageQueryDto,
ProductQueryDto,
ProductResponseDto,
ProductsResponseDto,
RefreshTokenDto,
SimpleRole,
SimpleUserResponseDto,
UpdateProductDto,
} from "./simple.dto";
import { SimpleStore } from "./simple.store";
@ApiTags("Health")
@ApiDemoScenarioHeader()
@Controller("health")
export class SimpleHealthController {
@Get()
@ApiOperation({ summary: "Check Simple API availability" })
@ApiOkResponse({ type: HealthResponseDto })
health(): HealthResponseDto {
return {
data: {
application: "simple",
status: "ok",
timestamp: new Date().toISOString(),
version: "1.0.0",
},
};
}
}
@ApiTags("Auth")
@ApiDemoScenarioHeader()
@Controller("auth")
export class SimpleAuthController {
constructor(private readonly auth: SimpleAuthService) {}
@Post("login")
@HttpCode(200)
@ApiOperation({ summary: "Login and receive JWT access/refresh tokens" })
@ApiOkResponse({ type: JwtAuthResponseDto })
@ApiStandardErrors()
login(@Body() dto: LoginDto): Promise<JwtAuthResponseDto> {
return this.auth.login(dto);
}
@Post("refresh")
@HttpCode(200)
@ApiOperation({
summary: "Rotate a refresh token and issue a new token pair",
})
@ApiOkResponse({ type: JwtAuthResponseDto })
@ApiStandardErrors({ auth: true })
refresh(@Body() dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
return this.auth.refresh(dto);
}
@Post("logout")
@HttpCode(204)
@ApiOperation({
summary: "Revoke a refresh token; the operation is idempotent",
})
@ApiNoContentResponse()
@ApiStandardErrors()
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
await this.auth.logout(dto);
}
}
@ApiTags("Users")
@ApiBearerAuth("jwt")
@ApiDemoScenarioHeader()
@UseGuards(SimpleJwtGuard)
@Controller("users")
export class SimpleUsersController {
constructor(private readonly store: SimpleStore) {}
@Get("me")
@ApiOperation({ summary: "Get the authenticated user" })
@ApiOkResponse({ type: SimpleUserResponseDto })
@ApiStandardErrors({ auth: true })
me(@Req() request: DemoRequest): SimpleUserResponseDto {
const user = this.store.findUserById(request.user!.id)!;
return { data: this.store.publicUser(user) };
}
}
@ApiTags("Products")
@ApiDemoScenarioHeader()
@Controller("products")
export class SimpleProductsController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({
summary: "List products using offset pagination, filters and sorting",
})
@ApiOkResponse({ type: ProductsResponseDto })
@ApiStandardErrors()
list(@Query() query: ProductQueryDto): ProductsResponseDto {
return this.store.listProducts(query);
}
@Get(":id")
@ApiOperation({ summary: "Get one product with ETag support" })
@ApiOkResponse({ type: ProductResponseDto })
@ApiNotModifiedResponse({
description: "The supplied If-None-Match value is current.",
})
@ApiStandardErrors({ notFound: true })
get(
@Param("id") id: string,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res({ passthrough: true }) response: Response,
): ProductResponseDto | undefined {
const product = this.store.getProduct(id);
const etag = `W/\"${product.id}-v${product.version}\"`;
response.setHeader("ETag", etag);
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (ifNoneMatch === etag) {
response.status(304);
return undefined;
}
return { data: product };
}
@Post()
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Create a product as an administrator" })
@ApiCreatedResponse({ type: ProductResponseDto })
@ApiStandardErrors({ auth: true })
create(@Body() dto: CreateProductDto): ProductResponseDto {
return { data: this.store.createProduct(dto) };
}
@Patch(":id")
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Update a product using optimistic locking" })
@ApiOkResponse({ type: ProductResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
update(
@Param("id") id: string,
@Body() dto: UpdateProductDto,
): ProductResponseDto {
return { data: this.store.updateProduct(id, dto) };
}
@Delete(":id")
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Delete a product as an administrator" })
@ApiOkResponse({ type: MutationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
remove(@Param("id") id: string): MutationResponseDto {
this.store.deleteProduct(id);
return { data: { id, success: true } };
}
}
@ApiTags("Categories")
@ApiDemoScenarioHeader()
@Controller("categories")
export class SimpleCategoriesController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({ summary: "List product categories" })
@ApiOkResponse({ type: CategoriesResponseDto })
list(): CategoriesResponseDto {
return { data: this.store.listCategories() };
}
@Get(":id")
@ApiOperation({ summary: "Get one category" })
@ApiOkResponse({ type: CategoryResponseDto })
@ApiStandardErrors({ notFound: true })
get(@Param("id") id: string): CategoryResponseDto {
return { data: this.store.getCategory(id) };
}
}
@ApiTags("Orders")
@ApiBearerAuth("jwt")
@ApiDemoScenarioHeader()
@UseGuards(SimpleJwtGuard)
@Controller("orders")
export class SimpleOrdersController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({ summary: "List orders visible to the current user" })
@ApiOkResponse({ type: OrdersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: DemoRequest,
@Query() query: PageQueryDto,
): OrdersResponseDto {
return this.store.listOrders(
request.user!.id,
request.user!.role,
query.page,
query.limit,
);
}
@Get(":id")
@ApiOperation({ summary: "Get one visible order" })
@ApiOkResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(@Param("id") id: string, @Req() request: DemoRequest): OrderResponseDto {
return {
data: this.store.getOrder(id, request.user!.id, request.user!.role),
};
}
@Post()
@ApiOperation({ summary: "Create an order and validate product stock" })
@ApiCreatedResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, conflict: true })
create(
@Req() request: DemoRequest,
@Body() dto: CreateOrderDto,
): OrderResponseDto {
return { data: this.store.createOrder(request.user!.id, dto) };
}
@Post(":id/cancel")
@HttpCode(200)
@ApiOperation({
summary: "Cancel an order if its state permits the transition",
})
@ApiOkResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
cancel(
@Param("id") id: string,
@Req() request: DemoRequest,
): OrderResponseDto {
return {
data: this.store.cancelOrder(id, request.user!.id, request.user!.role),
};
}
}
@ApiTags("Testing")
@ApiDemoScenarioHeader()
@Controller("testing")
export class SimpleTestingController {
constructor(
private readonly store: SimpleStore,
private readonly auth: SimpleAuthService,
) {}
@Get("scenarios")
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
@ApiOkResponse({ type: ScenariosResponseDto })
scenarios(): ScenariosResponseDto {
return {
data: [
{ name: "normal", description: "Default behavior." },
{
name: "slow",
description:
"Delays the response to exercise loading and cancellation states.",
},
{
name: "timeout",
description:
"Delays long enough for a frontend timeout or cancellation.",
},
{
name: "server-error",
description: "Returns a deterministic 500 error.",
},
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
{
name: "empty",
description: "Turns list responses into a valid empty state.",
},
{
name: "expired-auth",
description: "Makes protected routes return 401.",
},
{
name: "forbidden",
description: "Makes protected routes return 403.",
},
{ name: "conflict", description: "Makes mutation routes return 409." },
{
name: "large-dataset",
description: "Expands list responses to 250 deterministic items.",
},
],
};
}
@Post("reset")
@HttpCode(200)
@ApiOperation({ summary: "Reset all Simple API state and token revocations" })
@ApiOkResponse({ type: TestingActionResponseDto })
reset(): TestingActionResponseDto {
this.store.reset("small");
this.auth.reset();
return {
data: {
success: true,
message: "Simple API state reset to the default deterministic seed.",
},
};
}
@Post("seed/:preset")
@HttpCode(200)
@ApiParam({ name: "preset", enum: ["small", "large"] })
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
@ApiOkResponse({ type: TestingActionResponseDto })
seed(@Param("preset") preset: string): TestingActionResponseDto {
if (preset !== "small" && preset !== "large") {
throw new BadRequestException({
code: "UNKNOWN_SEED_PRESET",
message: "Preset must be small or large.",
});
}
this.store.reset(preset);
return {
data: {
success: true,
message: `Simple API loaded the ${preset} dataset.`,
},
};
}
@Post("users/:userId/role")
@HttpCode(200)
@ApiOperation({
summary: "Change a user role to exercise dynamic access control",
})
@ApiOkResponse({ type: SimpleUserResponseDto })
@ApiStandardErrors({ notFound: true })
changeRole(
@Param("userId") userId: string,
@Body() dto: ChangeSimpleRoleDto,
): SimpleUserResponseDto {
return { data: this.store.changeRole(userId, dto.role) };
}
}

View File

@@ -0,0 +1,365 @@
import { Type } from "class-transformer";
import {
IsArray,
IsEmail,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { PageMetaDto } from "../../common/api.dto";
export enum SimpleRole {
Admin = "admin",
Customer = "customer",
}
export class SimpleUserDto {
@ApiProperty({ example: "user-admin" })
id!: string;
@ApiProperty({ format: "email", example: "admin@demo.local" })
email!: string;
@ApiProperty({ example: "Demo Admin" })
name!: string;
@ApiProperty({ enum: SimpleRole })
role!: SimpleRole;
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=12" })
avatarUrl!: string | null;
}
export class SimpleUserResponseDto {
@ApiProperty({ type: SimpleUserDto })
data!: SimpleUserDto;
}
export class LoginDto {
@ApiProperty({ format: "email", example: "admin@demo.local" })
@IsEmail()
email!: string;
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
}
export class RefreshTokenDto {
@ApiProperty({
description:
"Refresh token returned by login or the previous refresh call.",
})
@IsString()
@IsNotEmpty()
refreshToken!: string;
}
export class JwtTokensDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty({
example: 60,
description: "Access-token lifetime in seconds.",
})
expiresIn!: number;
@ApiProperty({ enum: ["Bearer"], example: "Bearer" })
tokenType!: "Bearer";
}
export class JwtAuthDataDto {
@ApiProperty({ type: JwtTokensDto })
tokens!: JwtTokensDto;
@ApiProperty({ type: SimpleUserDto })
user!: SimpleUserDto;
}
export class JwtAuthResponseDto {
@ApiProperty({ type: JwtAuthDataDto })
data!: JwtAuthDataDto;
}
export enum ProductSort {
Newest = "newest",
PriceAsc = "price-asc",
PriceDesc = "price-desc",
Name = "name",
}
export class ProductQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "keyboard" })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ example: "category-electronics" })
@IsString()
@IsOptional()
categoryId?: string;
@ApiPropertyOptional({ enum: ProductSort, default: ProductSort.Newest })
@IsEnum(ProductSort)
@IsOptional()
sort = ProductSort.Newest;
}
export class PageQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
}
export class SimpleProductDto {
@ApiProperty({ example: "product-keyboard" })
id!: string;
@ApiProperty({ example: "Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: "mechanical-keyboard" })
slug!: string;
@ApiProperty({ example: "Hot-swappable compact keyboard." })
description!: string;
@ApiProperty({
example: 12990,
description: "Price in the smallest currency unit.",
})
priceCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ example: "category-electronics" })
categoryId!: string;
@ApiProperty({ example: 24, minimum: 0 })
stock!: number;
@ApiProperty({ example: 4.8, minimum: 0, maximum: 5 })
rating!: number;
@ApiProperty({
format: "uri",
example: "https://picsum.photos/seed/keyboard/640/480",
})
imageUrl!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 1, description: "Optimistic-lock version." })
version!: number;
}
export class ProductsResponseDto {
@ApiProperty({ type: [SimpleProductDto] })
data!: SimpleProductDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class ProductResponseDto {
@ApiProperty({ type: SimpleProductDto })
data!: SimpleProductDto;
}
export class CreateProductDto {
@ApiProperty({ example: "USB-C Dock" })
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@ApiProperty({ example: "Dock with HDMI, Ethernet and power delivery." })
@IsString()
@MinLength(10)
@MaxLength(1000)
description!: string;
@ApiProperty({ example: 8990, minimum: 0 })
@IsInt()
@Min(0)
priceCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
@IsEnum(["USD", "EUR"])
currency!: "USD" | "EUR";
@ApiProperty({ example: "category-electronics" })
@IsString()
categoryId!: string;
@ApiProperty({ example: 15, minimum: 0 })
@IsInt()
@Min(0)
stock!: number;
@ApiProperty({
format: "uri",
example: "https://picsum.photos/seed/dock/640/480",
})
@IsString()
imageUrl!: string;
}
export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiProperty({
example: 1,
minimum: 1,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}
export class SimpleCategoryDto {
@ApiProperty({ example: "category-electronics" })
id!: string;
@ApiProperty({ example: "Electronics" })
name!: string;
@ApiProperty({ example: "electronics" })
slug!: string;
@ApiProperty({ example: 4 })
productCount!: number;
}
export class CategoriesResponseDto {
@ApiProperty({ type: [SimpleCategoryDto] })
data!: SimpleCategoryDto[];
}
export class CategoryResponseDto {
@ApiProperty({ type: SimpleCategoryDto })
data!: SimpleCategoryDto;
}
export enum OrderStatus {
Pending = "pending",
Paid = "paid",
Shipped = "shipped",
Cancelled = "cancelled",
}
export class CreateOrderItemDto {
@ApiProperty({ example: "product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: 1, minimum: 1, maximum: 20 })
@IsInt()
@Min(1)
@Max(20)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ type: [CreateOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
items!: CreateOrderItemDto[];
}
export class SimpleOrderItemDto {
@ApiProperty({ example: "product-keyboard" })
productId!: string;
@ApiProperty({ example: "Mechanical Keyboard" })
productName!: string;
@ApiProperty({ example: 1 })
quantity!: number;
@ApiProperty({ example: 12990 })
unitPriceCents!: number;
}
export class SimpleOrderDto {
@ApiProperty({ example: "order-001" })
id!: string;
@ApiProperty({ example: "user-customer" })
userId!: string;
@ApiProperty({ enum: OrderStatus })
status!: OrderStatus;
@ApiProperty({ type: [SimpleOrderItemDto] })
items!: SimpleOrderItemDto[];
@ApiProperty({ example: 17980 })
totalCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class OrdersResponseDto {
@ApiProperty({ type: [SimpleOrderDto] })
data!: SimpleOrderDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class OrderResponseDto {
@ApiProperty({ type: SimpleOrderDto })
data!: SimpleOrderDto;
}
export class ChangeSimpleRoleDto {
@ApiProperty({ enum: SimpleRole, example: SimpleRole.Customer })
@IsEnum(SimpleRole)
role!: SimpleRole;
}

View File

@@ -0,0 +1,33 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { InfrastructureModule } from "../../common/infrastructure.module";
import {
SimpleAdminGuard,
SimpleAuthService,
SimpleJwtGuard,
} from "./simple.auth";
import {
SimpleAuthController,
SimpleCategoriesController,
SimpleHealthController,
SimpleOrdersController,
SimpleProductsController,
SimpleTestingController,
SimpleUsersController,
} from "./simple.controllers";
import { SimpleStore } from "./simple.store";
@Module({
imports: [InfrastructureModule, JwtModule.register({})],
controllers: [
SimpleHealthController,
SimpleAuthController,
SimpleUsersController,
SimpleProductsController,
SimpleCategoriesController,
SimpleOrdersController,
SimpleTestingController,
],
providers: [SimpleStore, SimpleAuthService, SimpleJwtGuard, SimpleAdminGuard],
})
export class SimpleAppModule {}

View File

@@ -0,0 +1,432 @@
import {
ConflictException,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from "@nestjs/common";
import {
type CreateOrderDto,
type CreateProductDto,
OrderStatus,
ProductSort,
type ProductQueryDto,
type SimpleCategoryDto,
type SimpleOrderDto,
type SimpleProductDto,
SimpleRole,
type SimpleUserDto,
type UpdateProductDto,
} from "./simple.dto";
interface SimpleUserRecord extends SimpleUserDto {
password: string;
}
@Injectable()
export class SimpleStore {
private users: SimpleUserRecord[] = [];
private categories: SimpleCategoryDto[] = [];
private products: SimpleProductDto[] = [];
private orders: SimpleOrderDto[] = [];
private productSequence = 10;
private orderSequence = 10;
constructor() {
this.reset("small");
}
reset(preset: "small" | "large" = "small"): void {
this.users = [
{
id: "user-admin",
email: "admin@demo.local",
password: "demo1234",
name: "Demo Admin",
role: SimpleRole.Admin,
avatarUrl: "https://i.pravatar.cc/160?img=12",
},
{
id: "user-customer",
email: "customer@demo.local",
password: "demo1234",
name: "Demo Customer",
role: SimpleRole.Customer,
avatarUrl: null,
},
];
this.categories = [
{
id: "category-electronics",
name: "Electronics",
slug: "electronics",
productCount: 0,
},
{
id: "category-home",
name: "Home office",
slug: "home-office",
productCount: 0,
},
{ id: "category-books", name: "Books", slug: "books", productCount: 0 },
];
const baseProducts: SimpleProductDto[] = [
this.product(
"product-keyboard",
"Mechanical Keyboard",
12990,
"category-electronics",
24,
4.8,
),
this.product(
"product-mouse",
"Ergonomic Mouse",
6990,
"category-electronics",
42,
4.6,
),
this.product(
"product-desk",
"Standing Desk",
45900,
"category-home",
8,
4.9,
),
this.product(
"product-lamp",
"Focus Desk Lamp",
5490,
"category-home",
31,
4.5,
),
this.product(
"product-book",
"Frontend Systems Handbook",
3990,
"category-books",
100,
4.7,
),
this.product(
"product-headphones",
"Studio Headphones",
18990,
"category-electronics",
12,
4.4,
),
];
this.products =
preset === "large"
? Array.from({ length: 250 }, (_, index) => {
const source = baseProducts[index % baseProducts.length];
const number = index + 1;
return {
...source,
id: `product-${String(number).padStart(3, "0")}`,
name: `${source.name} ${number}`,
slug: `${source.slug}-${number}`,
};
})
: baseProducts;
this.orders = [
{
id: "order-001",
userId: "user-customer",
status: OrderStatus.Paid,
items: [
{
productId: "product-keyboard",
productName: "Mechanical Keyboard",
quantity: 1,
unitPriceCents: 12990,
},
],
totalCents: 12990,
currency: "USD",
createdAt: "2026-07-20T10:30:00.000Z",
},
{
id: "order-002",
userId: "user-customer",
status: OrderStatus.Shipped,
items: [
{
productId: "product-book",
productName: "Frontend Systems Handbook",
quantity: 1,
unitPriceCents: 3990,
},
],
totalCents: 3990,
currency: "USD",
createdAt: "2026-07-22T14:00:00.000Z",
},
];
this.productSequence = this.products.length + 10;
this.orderSequence = 10;
this.updateCategoryCounts();
}
findUserByEmail(email: string): SimpleUserRecord | undefined {
return this.users.find(
(user) => user.email.toLowerCase() === email.toLowerCase(),
);
}
findUserById(id: string): SimpleUserRecord | undefined {
return this.users.find((user) => user.id === id);
}
publicUser(user: SimpleUserRecord): SimpleUserDto {
const { password: _password, ...publicUser } = user;
return publicUser;
}
changeRole(userId: string, role: SimpleRole): SimpleUserDto {
const user = this.findUserById(userId);
if (!user) {
throw new NotFoundException({
code: "USER_NOT_FOUND",
message: "User not found.",
});
}
user.role = role;
return this.publicUser(user);
}
listProducts(query: ProductQueryDto): {
data: SimpleProductDto[];
meta: { page: number; limit: number; total: number; totalPages: number };
} {
let products = [...this.products];
if (query.search) {
const search = query.search.toLowerCase();
products = products.filter((product) =>
`${product.name} ${product.description}`.toLowerCase().includes(search),
);
}
if (query.categoryId) {
products = products.filter(
(product) => product.categoryId === query.categoryId,
);
}
products.sort((left, right) => {
if (query.sort === ProductSort.PriceAsc)
return left.priceCents - right.priceCents;
if (query.sort === ProductSort.PriceDesc)
return right.priceCents - left.priceCents;
if (query.sort === ProductSort.Name)
return left.name.localeCompare(right.name);
return right.createdAt.localeCompare(left.createdAt);
});
const total = products.length;
const start = (query.page - 1) * query.limit;
return {
data: products.slice(start, start + query.limit),
meta: {
page: query.page,
limit: query.limit,
total,
totalPages: Math.ceil(total / query.limit),
},
};
}
getProduct(id: string): SimpleProductDto {
const product = this.products.find((item) => item.id === id);
if (!product) {
throw new NotFoundException({
code: "PRODUCT_NOT_FOUND",
message: "Product not found.",
});
}
return product;
}
createProduct(dto: CreateProductDto): SimpleProductDto {
if (!this.categories.some((category) => category.id === dto.categoryId)) {
throw new UnprocessableEntityException({
code: "CATEGORY_NOT_FOUND",
message: "Selected category does not exist.",
details: [{ field: "categoryId", message: "Unknown category." }],
});
}
const id = `product-${this.productSequence++}`;
const product: SimpleProductDto = {
id,
name: dto.name,
slug: `${dto.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${id}`,
description: dto.description,
priceCents: dto.priceCents,
currency: dto.currency,
categoryId: dto.categoryId,
stock: dto.stock,
rating: 0,
imageUrl: dto.imageUrl,
createdAt: new Date().toISOString(),
version: 1,
};
this.products.unshift(product);
this.updateCategoryCounts();
return product;
}
updateProduct(id: string, dto: UpdateProductDto): SimpleProductDto {
const product = this.getProduct(id);
if (product.version !== dto.version) {
throw new ConflictException({
code: "PRODUCT_VERSION_CONFLICT",
message: "Product was changed by another user.",
details: [
{
field: "version",
message: `Expected ${product.version}, received ${dto.version}.`,
},
],
});
}
const { version: _version, ...changes } = dto;
Object.assign(product, changes, { version: product.version + 1 });
this.updateCategoryCounts();
return product;
}
deleteProduct(id: string): void {
this.getProduct(id);
this.products = this.products.filter((product) => product.id !== id);
this.updateCategoryCounts();
}
listCategories(): SimpleCategoryDto[] {
return this.categories;
}
getCategory(id: string): SimpleCategoryDto {
const category = this.categories.find((item) => item.id === id);
if (!category) {
throw new NotFoundException({
code: "CATEGORY_NOT_FOUND",
message: "Category not found.",
});
}
return category;
}
listOrders(userId: string, role: string, page = 1, limit = 20) {
const orders =
role === SimpleRole.Admin
? this.orders
: this.orders.filter((order) => order.userId === userId);
const total = orders.length;
const start = (page - 1) * limit;
return {
data: orders.slice(start, start + limit),
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
}
getOrder(id: string, userId: string, role: string): SimpleOrderDto {
const order = this.orders.find(
(item) =>
item.id === id && (role === SimpleRole.Admin || item.userId === userId),
);
if (!order) {
throw new NotFoundException({
code: "ORDER_NOT_FOUND",
message: "Order not found.",
});
}
return order;
}
createOrder(userId: string, dto: CreateOrderDto): SimpleOrderDto {
if (dto.items.length === 0) {
throw new UnprocessableEntityException({
code: "EMPTY_ORDER",
message: "Order must contain at least one item.",
details: [{ field: "items", message: "Add at least one item." }],
});
}
const items = dto.items.map(({ productId, quantity }) => {
const product = this.getProduct(productId);
if (product.stock < quantity) {
throw new ConflictException({
code: "INSUFFICIENT_STOCK",
message: `Not enough stock for ${product.name}.`,
});
}
return {
productId,
productName: product.name,
quantity,
unitPriceCents: product.priceCents,
};
});
const order: SimpleOrderDto = {
id: `order-${String(this.orderSequence++).padStart(3, "0")}`,
userId,
status: OrderStatus.Pending,
items,
totalCents: items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
),
currency: "USD",
createdAt: new Date().toISOString(),
};
this.orders.unshift(order);
return order;
}
cancelOrder(id: string, userId: string, role: string): SimpleOrderDto {
const order = this.getOrder(id, userId, role);
if (
order.status === OrderStatus.Shipped ||
order.status === OrderStatus.Cancelled
) {
throw new ConflictException({
code: "ORDER_CANNOT_BE_CANCELLED",
message: `Order in ${order.status} status cannot be cancelled.`,
});
}
order.status = OrderStatus.Cancelled;
return order;
}
private product(
id: string,
name: string,
priceCents: number,
categoryId: string,
stock: number,
rating: number,
): SimpleProductDto {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return {
id,
name,
slug,
description: `${name} is a deterministic demo product used by frontend examples.`,
priceCents,
currency: "USD",
categoryId,
stock,
rating,
imageUrl: `https://picsum.photos/seed/${slug}/640/480`,
createdAt: `2026-07-${String(10 + this.products.length).padStart(2, "0")}T09:00:00.000Z`,
version: 1,
};
}
private updateCategoryCounts(): void {
for (const category of this.categories) {
category.productCount = this.products.filter(
(product) => product.categoryId === category.id,
).length;
}
}
}

View File

@@ -0,0 +1,79 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
} from "@nestjs/common";
import type { Request, Response } from "express";
import type { DemoRequest } from "./request.types";
interface HttpErrorBody {
code?: string;
message?: string | string[];
details?: unknown[];
error?: string;
}
const STATUS_CODES: Record<number, string> = {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "CONFLICT",
413: "PAYLOAD_TOO_LARGE",
422: "UNPROCESSABLE_ENTITY",
429: "RATE_LIMITED",
500: "INTERNAL_SERVER_ERROR",
};
@Injectable()
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<Request>() as DemoRequest;
const response = context.getResponse<Response>();
const externalCode =
typeof exception === "object" && exception !== null && "code" in exception
? String((exception as { code: unknown }).code)
: undefined;
const isOversizedFile = externalCode === "LIMIT_FILE_SIZE";
const status =
exception instanceof HttpException
? exception.getStatus()
: isOversizedFile
? HttpStatus.PAYLOAD_TOO_LARGE
: HttpStatus.INTERNAL_SERVER_ERROR;
const rawBody =
exception instanceof HttpException ? exception.getResponse() : undefined;
const body: HttpErrorBody =
typeof rawBody === "object" && rawBody !== null
? (rawBody as HttpErrorBody)
: {};
const validationMessages = Array.isArray(body.message) ? body.message : [];
const publicMessage = isOversizedFile
? "Uploaded file exceeds the 5 MiB limit"
: status === 500 && !(exception instanceof HttpException)
? "Internal server error"
: Array.isArray(body.message)
? "Request validation failed"
: (body.message ??
(typeof rawBody === "string" ? rawBody : body.error) ??
"Request failed");
response.status(status).json({
statusCode: status,
code: isOversizedFile || status === HttpStatus.PAYLOAD_TOO_LARGE
? "FILE_TOO_LARGE"
: (body.code ?? STATUS_CODES[status] ?? `HTTP_${status}`),
message: publicMessage,
details:
body.details ?? validationMessages.map((message) => ({ message })),
timestamp: new Date().toISOString(),
path: request.originalUrl,
requestId: request.requestId ?? "unknown",
});
}
}

View File

@@ -0,0 +1,122 @@
import { applyDecorators } from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiForbiddenResponse,
ApiHeader,
ApiInternalServerErrorResponse,
ApiNotFoundResponse,
ApiResponse,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { ErrorResponseDto } from "./api.dto";
export function ApiStandardErrors(
options: { auth?: boolean; notFound?: boolean; conflict?: boolean } = {},
) {
const decorators: Array<
ClassDecorator | MethodDecorator | PropertyDecorator
> = [
ApiBadRequestResponse({
description: "Invalid request or validation error.",
type: ErrorResponseDto,
}),
ApiTooManyRequestsResponse({
description: "Demo rate limit scenario.",
type: ErrorResponseDto,
}),
ApiInternalServerErrorResponse({
description: "Unexpected or simulated server error.",
type: ErrorResponseDto,
}),
];
if (options.auth) {
decorators.push(
ApiUnauthorizedResponse({
description: "Authentication is missing or expired.",
type: ErrorResponseDto,
}),
ApiForbiddenResponse({
description: "The current user lacks permission.",
type: ErrorResponseDto,
}),
);
}
if (options.notFound) {
decorators.push(
ApiNotFoundResponse({
description: "The requested resource does not exist.",
type: ErrorResponseDto,
}),
);
}
if (options.conflict) {
decorators.push(
ApiConflictResponse({
description: "Business or optimistic-lock conflict.",
type: ErrorResponseDto,
}),
);
}
return applyDecorators(...decorators);
}
export function ApiDemoScenarioHeader() {
return ApiHeader({
name: "X-Demo-Scenario",
required: false,
enum: [
"normal",
"slow",
"timeout",
"server-error",
"rate-limited",
"empty",
"expired-auth",
"forbidden",
"conflict",
"large-dataset",
],
description:
"Forces a deterministic frontend-testing scenario for this request.",
});
}
export function ApiOrganizationHeader() {
return ApiHeader({
name: "X-Organization-Id",
required: true,
example: "org-acme",
description: "Current tenant. The authenticated user must be a member.",
});
}
export function ApiIdempotencyHeader() {
return ApiHeader({
name: "Idempotency-Key",
required: true,
example: "checkout-6c5f92ad",
description:
"Repeating a request with the same key returns the original order.",
});
}
export function ApiBinaryResponse(
description: string,
mediaType = "application/octet-stream",
) {
return ApiResponse({
status: 200,
description,
content: {
[mediaType]: {
schema: { type: "string", format: "binary" },
},
},
});
}

View File

@@ -0,0 +1,135 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class ErrorDetailDto {
@ApiPropertyOptional({ example: "email" })
field?: string;
@ApiProperty({ example: "must be an email" })
message!: string;
@ApiPropertyOptional({ example: "isEmail" })
code?: string;
}
export class ErrorResponseDto {
@ApiProperty({ example: 404 })
statusCode!: number;
@ApiProperty({ example: "PRODUCT_NOT_FOUND" })
code!: string;
@ApiProperty({ example: "Product not found" })
message!: string;
@ApiProperty({ type: [ErrorDetailDto] })
details!: ErrorDetailDto[];
@ApiProperty({ format: "date-time", example: "2026-07-30T12:00:00.000Z" })
timestamp!: string;
@ApiProperty({ example: "/api/v1/products/product-404" })
path!: string;
@ApiProperty({ example: "req-5c9f7a3d" })
requestId!: string;
}
export class PageMetaDto {
@ApiProperty({ example: 1, minimum: 1 })
page!: number;
@ApiProperty({ example: 20, minimum: 1 })
limit!: number;
@ApiProperty({ example: 48, minimum: 0 })
total!: number;
@ApiProperty({ example: 3, minimum: 0 })
totalPages!: number;
}
export class CursorMetaDto {
@ApiProperty({ example: 20, minimum: 1 })
limit!: number;
@ApiPropertyOptional({ nullable: true, example: "product-020" })
nextCursor!: string | null;
@ApiProperty({ example: true })
hasMore!: boolean;
}
export class MutationResultDto {
@ApiProperty({ example: "product-001" })
id!: string;
@ApiProperty({ example: true })
success!: boolean;
}
export class MutationResponseDto {
@ApiProperty({ type: MutationResultDto })
data!: MutationResultDto;
}
export class HealthDataDto {
@ApiProperty({ enum: ["simple", "complex"], example: "simple" })
application!: "simple" | "complex";
@ApiProperty({ enum: ["ok"], example: "ok" })
status!: "ok";
@ApiProperty({ format: "date-time" })
timestamp!: string;
@ApiProperty({ example: "1.0.0" })
version!: string;
}
export class HealthResponseDto {
@ApiProperty({ type: HealthDataDto })
data!: HealthDataDto;
}
export const DEMO_SCENARIOS = [
"normal",
"slow",
"timeout",
"server-error",
"rate-limited",
"empty",
"expired-auth",
"forbidden",
"conflict",
"large-dataset",
] as const;
export type DemoScenario = (typeof DEMO_SCENARIOS)[number];
export class ScenarioDto {
@ApiProperty({ example: "slow" })
name!: string;
@ApiProperty({
example: "Delays the response to exercise loading and cancellation states.",
})
description!: string;
}
export class ScenariosResponseDto {
@ApiProperty({ type: [ScenarioDto] })
data!: ScenarioDto[];
}
export class TestingActionDataDto {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: "State reset to the default deterministic seed." })
message!: string;
}
export class TestingActionResponseDto {
@ApiProperty({ type: TestingActionDataDto })
data!: TestingActionDataDto;
}

View File

@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { ValidationPipe } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import cookieParser from "cookie-parser";
import type { NextFunction, Request, Response } from "express";
import { ApiExceptionFilter } from "./api-exception.filter";
import { ObservabilityInterceptor } from "./observability.interceptor";
import type { DemoRequest } from "./request.types";
import { ScenarioInterceptor } from "./scenario.interceptor";
export function configureApplication(app: NestExpressApplication): void {
app.setGlobalPrefix("api/v1");
app.use(cookieParser());
app.use((request: Request, response: Response, next: NextFunction) => {
const demoRequest = request as DemoRequest;
demoRequest.requestId = String(
request.headers["x-request-id"] ?? `req-${randomUUID()}`,
);
response.setHeader("X-Request-Id", demoRequest.requestId);
next();
});
app.enableCors({
origin: true,
credentials: true,
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-CSRF-Token",
"X-Demo-Scenario",
"X-Organization-Id",
"X-Request-Id",
"Idempotency-Key",
"If-None-Match",
],
exposedHeaders: [
"ETag",
"Retry-After",
"X-Request-Id",
"X-Response-Time",
"X-Demo-Scenario",
],
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
app.useGlobalFilters(app.get(ApiExceptionFilter));
app.useGlobalInterceptors(
app.get(ObservabilityInterceptor),
app.get(ScenarioInterceptor),
);
app.enableShutdownHooks();
}

View File

@@ -0,0 +1,15 @@
import { Global, Module } from "@nestjs/common";
import { ApiExceptionFilter } from "./api-exception.filter";
import { ObservabilityInterceptor } from "./observability.interceptor";
import { ScenarioInterceptor } from "./scenario.interceptor";
@Global()
@Module({
providers: [
ApiExceptionFilter,
ObservabilityInterceptor,
ScenarioInterceptor,
],
exports: [ApiExceptionFilter, ObservabilityInterceptor, ScenarioInterceptor],
})
export class InfrastructureModule {}

View File

@@ -0,0 +1,26 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from "@nestjs/common";
import type { Response } from "express";
import { Observable, tap } from "rxjs";
@Injectable()
export class ObservabilityInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = performance.now();
const response = context.switchToHttp().getResponse<Response>();
const setTiming = () => {
if (!response.headersSent) {
response.setHeader(
"X-Response-Time",
`${Math.round(performance.now() - startedAt)}ms`,
);
}
};
return next.handle().pipe(tap({ next: setTiming, error: setTiming }));
}
}

View File

@@ -0,0 +1,71 @@
import type { INestApplication } from "@nestjs/common";
import {
DocumentBuilder,
SwaggerModule,
type OpenAPIObject,
} from "@nestjs/swagger";
interface OpenApiOptions {
kind: "simple" | "complex";
port: number;
}
export function createOpenApiDocument(
app: INestApplication,
options: OpenApiOptions,
): OpenAPIObject {
const isSimple = options.kind === "simple";
const builder = new DocumentBuilder()
.setTitle(isSimple ? "Demo Simple API" : "Demo Complex API")
.setDescription(
isSimple
? "JWT-based API for landing pages and medium frontend applications."
: "Cookie-session, multitenant and realtime API for large frontend applications.",
)
.setVersion("1.0.0")
.addServer(`http://localhost:${options.port}`, "Local development");
if (isSimple) {
builder.addBearerAuth(
{ type: "http", scheme: "bearer", bearerFormat: "JWT" },
"jwt",
);
} else {
builder
.addCookieAuth(
"demo_session",
{ type: "apiKey", in: "cookie" },
"cookieSession",
)
.addApiKey(
{
type: "apiKey",
in: "header",
name: "X-CSRF-Token",
description: "Required for authenticated mutations.",
},
"csrf",
);
}
return SwaggerModule.createDocument(app, builder.build(), {
operationIdFactory: (controllerKey, methodKey) =>
`${controllerKey.replace(/Controller$/, "")}_${methodKey}`,
});
}
export function mountOpenApi(
app: INestApplication,
document: OpenAPIObject,
title: string,
): void {
SwaggerModule.setup("docs", app, document, {
jsonDocumentUrl: "/openapi.json",
customSiteTitle: title,
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
},
});
}

View File

@@ -0,0 +1,14 @@
import type { Request } from "express";
export interface AuthenticatedUser {
id: string;
email: string;
name: string;
role: string;
}
export interface DemoRequest extends Request {
requestId: string;
user?: AuthenticatedUser;
organizationId?: string;
}

Some files were not shown because too many files have changed in this diff Show More