diff --git a/editorial/production/README.md b/editorial/production/README.md index 37890df..9ad7e99 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 184 из 358 созданных материалов. Остальные 174 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 187 из 358 созданных материалов. Остальные 171 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2023-02-draft.md b/editorial/reviews/2023-02-draft.md new file mode 100644 index 0000000..48e117a --- /dev/null +++ b/editorial/reviews/2023-02-draft.md @@ -0,0 +1,69 @@ +# П60 · 2023-02 · Аутентификация и сессии — три независимых self-review + +## Рамка партии + +- Slug: `editorial-2023-02-practice-sessions-auth`, `editorial-2023-02-mechanism-sessions-auth`, `editorial-2023-02-field-sessions-auth`. +- Голос: М6, февраль 2023. Системный практик называет владельца состояния, цену ошибки, условие отказа и отдельный способ проверки; без выдуманных инцидентов, метрик и опыта эксплуатации. +- Общая модель: browser cookie переносит непрозрачный ID; server record и lineage pointer решают, какой ID current. Rotation переводит predecessor в `rotated`; logout-current переводит current record в `revoked` и возвращает clear cookie. Другой scope (`logout-lineage`, `logout-all`) намеренно не подменяется этим правилом. +- Граница: sidecar не делает HTTP, browser storage/network trace, browser cookie-jar, криптографию, random ID, clock, базу, конкурентный доступ, authz, CSRF или production проверку. Все ID, время и cookie policy заданы в памяти; `fixture-s-*` не являются образцом production secret. +- Созданы ровно пять файлов этой партии: этот review, `web/scripts/upgrade-2023-02.mjs` и три SVG с префиксом `sessions-auth-2023-`. Registry, README, `articles.json`, очередь, Git, существующие материалы и чужие untracked-файлы не менялись. + +## Проход 1 — факты и модель + +| Статья | Главный тезис | Первичные или официальные источники | Ограничение модели | Fixture | +| --- | --- | --- | --- | --- | +| `editorial-2023-02-practice-sessions-auth` | Cookie — носитель ID, а server record — источник валидности; rotation и logout меняют оба слоя по одному контракту. | IETF RFC 6265 (04.2011); IETF draft-ietf-httpbis-rfc6265bis-11 (07.11.2022, work in progress); NIST SP 800-63B (06.2017, updates through 02.03.2020). | Нет браузера, сети, таймера, crypto, secret store и гонок. | да | +| `editorial-2023-02-mechanism-sessions-auth` | Secure, HttpOnly, SameSite, Path и `__Host-` задают узкий scope доставки; server status/current остаются отдельной проверкой. | Те же три датированных первичных/официальных документа. | Flags не доказывают authz, CSRF-защиту, реальный TTL или browser compatibility. | да | +| `editorial-2023-02-field-sessions-auth` | Для stale ID надо различать unknown, rotated и revoked, а logout scope назвать до изменения endpoint. | Те же три датированных первичных/официальных документа. | Нет incident/browser trace, production HTTP response или данных о конкретном сервисе. | да | + +- Фактологические оговорки сверены с официальными страницами: RFC 6265 — Standards Track от апреля 2011; draft-11 опубликован 7 ноября 2022 и везде назван work in progress, а не финальным RFC; PDF NIST SP 800-63B датирован июнем 2017 с обновлениями до 2 марта 2020. Все источники существовали не позднее 2023-02. +- `createTeachingSessionState()` создаёт один active record, а `rotateTeachingSession()` принимает только current ID, переводит predecessor в `rotated`, создаёт successor и двигает pointer lineage. Fixture проверяет 15 invariants, включая отсутствие `fixture-s-3` после stale rotation и сохранение `fixture-s-2` active после stale logout. +- `logoutTeachingSession()` в выбранной политике принимает только current active ID, revoke-ит server record, удаляет pointer и создаёт clear cookie той же формы с `Expires` в прошлом. Stale logout старого ID не заявлен как универсальное правило: статьи явно отделяют `logout-current` от `logout-lineage` и `logout-all`. +- Не заявлены browser trace, запуск endpoint, настоящая конкуренция, эффект в production, измерение, инцидент или безопасность конкретной системы. Код и текст прямо ограничивают in-memory fixture. + +Вердикт: пройти. Источники, статусы документов, переходы модели и границы утверждений совпадают. + +## Проход 2 — язык, полнота и объём + +| Статья | Знаки основного текста по `audit:draft` | Проблема и цена в первых 2 абзацах | Артефакты | Голос и анахронизмы | +| --- | ---: | --- | --- | --- | +| `editorial-2023-02-practice-sessions-auth` | 8 903 | да | таблица, JS fixture, lifecycle SVG, ordered route, ограничения, 3 источника | М6: контракт state/cookie, без метрик и выдуманного опыта | +| `editorial-2023-02-mechanism-sessions-auth` | 8 547 | да | таблица слоёв, JS example, cookie-contract SVG, ordered route, ограничения, 3 источника | М6: условия и границы flags, draft назван draft | +| `editorial-2023-02-field-sessions-auth` | 8 158 | да | диагностическая таблица, JS fixture, stale-events SVG, ordered route, ограничения, 3 источника | М6: не выдана гипотеза о вкладках за trace | + +- Все три текста находятся в требуемом узком диапазоне 8–10 тыс. знаков основного текста и в общем стандарте 5–15 тыс.; источники не учитываются в объёме. +- В каждой статье есть упорядоченный маршрут «симптом → причина → проверка → действие», а также отдельный шаг отката, который не оживляет stale ID. +- Каждый обязательный артефакт присутствует: доступная таблица, воспроизводимый фрагмент/fixture, содержательная figure с `alt` и caption, ограничения модели и минимум две ссылки на первичные/официальные источники. +- Удалены общие оценки и шаблонные обороты. Термины `current`, `lineage`, `stale`, `rotation` привязаны к полям и переходам, а `__Host-`, Secure, HttpOnly, SameSite и Path объяснены через конкретную область действия и то, что они не решают. + +Вердикт: пройти. Тон соответствует системному практику 2022–2024: короткая прагматичная техническая речь, проверяемый следующий шаг и честные ограничения. + +## Проход 3 — SVG, безопасность и выпуск + +| Asset | Назначение | `alt` и caption | XML и safety scan | Sharp 375 px | +| --- | --- | --- | --- | --- | +| `sessions-auth-2023-lifecycle.svg` | active → rotated → successor → revoked lifecycle | да | PASS | PASS, 375×238 | +| `sessions-auth-2023-cookie-contract.svg` | browser delivery против server validation | да | PASS | PASS, 375×238 | +| `sessions-auth-2023-stale-events.svg` | stale rotation/logout не меняют successor | да | PASS | PASS, 375×238; заголовок сокращён после первого просмотра, обрезания нет | + +- `node --check web/scripts/upgrade-2023-02.mjs` — PASS. +- `node web/scripts/upgrade-2023-02.mjs --verify-fixture` — PASS, 15/15 assertions. +- `cd web && npm run audit:draft -- scripts/upgrade-2023-02.mjs` — PASS для всех трёх slug. `npm` вывел три предупреждения о существующих unsupported user config, но аудит завершился успешно. +- Import-safe export — PASS: ровно 3 revisions, у каждой отсутствуют поля `date` и `author`. +- `xmllint --noout` — PASS для всех SVG. Safety scan не нашёл `script`, `foreignObject`, event attributes, `href`, `javascript:`, `data:` или ``. +- Sharp отрендерил все SVG на ширине 375 px; выполнен ручной просмотр PNG. В SVG нет внешних URL, интерактивного поведения, пользовательского ввода или raster content. + +Вердикт: пройти. Проверки подтверждают только sidecar-пакет и статические visual assets; публикация, registry update, production build, commit и push не выполнялись. + +## Интеграционная приёмка + +- Основной редактор заменил учебное удаление cookie с `Max-Age=0` на `Expires` в прошлом: это соответствует примеру RFC 6265 для удаления и не выдаёт спорную производительскую форму за универсальный шаблон. Fixture после изменения — PASS 15/15. +- Ревизии добавлены в `web/data/editorial-revisions.mjs` поверх трёх исходных slug. `web/data/articles.json`, даты и авторы не изменялись; в registry 178 уникальных ревизий без дубликатов. +- Строгий аудит `npm run audit:articles -- editorial-2023-02-practice-sessions-auth editorial-2023-02-mechanism-sessions-auth editorial-2023-02-field-sessions-auth` — PASS: у каждой статьи 1 figure, 1 table и 1 code example. +- Production-сборка `cd web && npm run build` — PASS: 374 статические страницы. README обновлён с 184/358 до 187/358 принятых материалов; осталось 171. + +## Итог + +Статус: готово к отдельному коммиту. + +Изменения после self-review: уменьшен основной текст до 8–10 тыс. знаков; после Sharp-проверки сокращены title/subtitle третьей SVG, чтобы они не обрезались на 375 px. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index ffbd6dc..bf929a7 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -56,6 +56,7 @@ import { revisions as october2022Revisions } from '../scripts/upgrade-2022-10.mj import { revisions as november2022Revisions } from '../scripts/upgrade-2022-11.mjs'; import { revisions as december2022Revisions } from '../scripts/upgrade-2022-12.mjs'; import { revisions as january2023Revisions } from '../scripts/upgrade-2023-01.mjs'; +import { revisions as february2023Revisions } from '../scripts/upgrade-2023-02.mjs'; import { revisions as april2023Revisions } from '../scripts/upgrade-2023-04.mjs'; // This layer replaces archived source entries without losing their stable slug and date. @@ -118,5 +119,6 @@ export const editorialRevisions = [ ...november2022Revisions, ...december2022Revisions, ...january2023Revisions, + ...february2023Revisions, ...april2023Revisions, ]; diff --git a/web/public/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg b/web/public/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg new file mode 100644 index 0000000..d2a5c4f --- /dev/null +++ b/web/public/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg @@ -0,0 +1,51 @@ + + Контракт cookie и серверной сессии + Cookie с именем __Host-session несёт непрозрачный ID. Secure, HttpOnly, SameSite, Path и отсутствие Domain ограничивают доставку в браузере. Сервер отдельно проверяет status, current lineage и expiry. + + + + + + Cookie delivery и server validation — разные слои + Flags ограничивают область возврата ID; сервер решает, принимать ли его сейчас. + + Browser cookie + __Host-session=opaque-ID + + + Secure + secure channel + + HttpOnly + no JS API + + SameSite + Lax scope + + Path=/ + no Domain + + Cookie header carries ID + + Server session record + ID → record + + status: active? + lineage points here? + server expiry valid? + authorization passes? + + accept or reject protected effect + diff --git a/web/public/assets/editorial/2023/sessions-auth-2023-lifecycle.svg b/web/public/assets/editorial/2023/sessions-auth-2023-lifecycle.svg new file mode 100644 index 0000000..b270bcd --- /dev/null +++ b/web/public/assets/editorial/2023/sessions-auth-2023-lifecycle.svg @@ -0,0 +1,50 @@ + + Жизненный цикл одной учебной сессии + Вход создаёт active session fixture-s-1. Rotation выводит её в rotated и создаёт fixture-s-2 active. Stale операции старого ID отклоняются. Logout текущего ID revoke-ит запись и возвращает clear cookie. + + + + + + + Одна сессия: issue → rotation → logout + Серверный record принимает решение; cookie переносит только текущий ID. + + 1. Вход + fixture-s-1 + status: active + generation: 1 + + + 2. Rotation + fixture-s-1 + status: rotated + successor: s-2 + + + 3. Current + fixture-s-2 + status: active + generation: 2 + + stale rotate/logout + s-1 → reject, s-2 не меняется + + + + 4. Logout current s-2 + revoked + clear cookie + diff --git a/web/public/assets/editorial/2023/sessions-auth-2023-stale-events.svg b/web/public/assets/editorial/2023/sessions-auth-2023-stale-events.svg new file mode 100644 index 0000000..4c7c810 --- /dev/null +++ b/web/public/assets/editorial/2023/sessions-auth-2023-stale-events.svg @@ -0,0 +1,45 @@ + + Stale rotation и logout в одной учебной lineage + Сервер заменяет fixture-s-1 на fixture-s-2. Повторная rotation и logout старого ID отвергаются и не меняют successor. Logout нового ID revoke-ит его и формирует clear cookie. + + + + + + + Stale-события не меняют current successor + logout-current: old ID отклонён, active successor остаётся прежним. + + Server lineage + + fixture-s-1 + rotated + + + fixture-s-2 + active current + + stale rotation s-1 + reject; no s-3 + + + stale logout s-1 + reject; s-2 stays active + + + logout current s-2 + revoke + clear cookie + + diff --git a/web/scripts/upgrade-2023-02.mjs b/web/scripts/upgrade-2023-02.mjs new file mode 100644 index 0000000..1f49a22 --- /dev/null +++ b/web/scripts/upgrade-2023-02.mjs @@ -0,0 +1,438 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +const sources = [ + { + title: 'IETF RFC 6265: HTTP State Management Mechanism, April 2011', + url: 'https://datatracker.ietf.org/doc/html/rfc6265', + note: 'стандарт IETF, опубликованный в 2011 году. Он задаёт передачу Cookie/Set-Cookie, scope, Secure и HttpOnly; он не задаёт прикладной срок валидности серверной сессии.', + }, + { + title: 'IETF draft-ietf-httpbis-rfc6265bis-11: Cookies: HTTP State Management Mechanism, November 2022', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-11', + note: 'датированный Internet-Draft, опубликованный 7 ноября 2022 года и доступный к февралю 2023. Это work in progress, а не финальный RFC; здесь он нужен для SameSite и префикса __Host-.', + }, + { + title: 'NIST SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management, June 2017 with updates through 2 March 2020', + url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf', + note: 'официальный датированный документ NIST, доступный к февралю 2023. Его требования относятся к заданному NIST контексту; он помогает отделить cookie от серверного timeout, logout и reauthentication.', + }, +]; + +function sourceList() { + return ''; +} + +function revision(meta, parts) { + const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 8000 || proseLength > 10000) { + throw new Error(meta.slug + ': основной текст вне целевого диапазона 8 000–10 000 знаков: ' + proseLength); + } + return { ...meta, contentHtml, proseLength }; +} + +/** + * Детерминированная учебная модель одной сессии. + * Она не создаёт криптографические идентификаторы, не читает cookie jar, + * не отправляет HTTP, не моделирует вкладки и не проверяет конфигурацию. + * Все идентификаторы, время и политика намеренно заданы строками в памяти. + */ +const COOKIE_POLICY = Object.freeze({ + name: '__Host-session', + path: '/', + domain: null, + secure: true, + httpOnly: true, + sameSite: 'Lax', + maxAgeSeconds: 900, +}); + +function freezeRecord(record) { + return Object.freeze({ ...record, cookie: COOKIE_POLICY }); +} + +function freezeState(snapshot) { + const records = Object.fromEntries( + Object.entries(snapshot.records).map(([id, record]) => [id, freezeRecord(record)]), + ); + return Object.freeze({ + kind: 'in-memory-session-fixture-v1', + modelLimit: 'no-browser-no-network-no-crypto-no-clock', + records: Object.freeze(records), + currentByLineage: Object.freeze({ ...snapshot.currentByLineage }), + nextId: snapshot.nextId, + }); +} + +function serializeTeachingCookie(value, maxAgeSeconds) { + const base = COOKIE_POLICY.name + '=' + value + + '; Path=' + COOKIE_POLICY.path + + '; Secure; HttpOnly; SameSite=' + COOKIE_POLICY.sameSite; + if (maxAgeSeconds === 0) return base + '; Expires=Thu, 01 Jan 1970 00:00:00 GMT'; + return base + '; Max-Age=' + maxAgeSeconds; +} + +function stale(state, reason) { + return Object.freeze({ accepted: false, reason, state }); +} + +function currentRecord(state, sessionId) { + const record = state?.records?.[sessionId]; + if (!record) return { reason: 'unknown-session' }; + if (record.status !== 'active' || state.currentByLineage[record.lineage] !== sessionId) { + return { reason: 'stale-session' }; + } + return { record }; +} + +export function createTeachingSessionState() { + const first = { + id: 'fixture-s-1', + lineage: 'fixture-lineage-a', + generation: 1, + status: 'active', + issuedAt: 'fixture-t0', + }; + return freezeState({ + records: { [first.id]: first }, + currentByLineage: { [first.lineage]: first.id }, + nextId: 2, + }); +} + +export function rotateTeachingSession(state, sessionId) { + const current = currentRecord(state, sessionId); + if (!current.record) return stale(state, current.reason); + + const successorId = 'fixture-s-' + state.nextId; + const retired = { + ...current.record, + status: 'rotated', + successorId, + }; + const successor = { + id: successorId, + lineage: current.record.lineage, + generation: current.record.generation + 1, + status: 'active', + issuedAt: 'fixture-t' + state.nextId, + issuedFrom: current.record.id, + }; + const next = freezeState({ + records: { + ...state.records, + [retired.id]: retired, + [successor.id]: successor, + }, + currentByLineage: { + ...state.currentByLineage, + [successor.lineage]: successor.id, + }, + nextId: state.nextId + 1, + }); + return Object.freeze({ + accepted: true, + reason: 'rotated', + state: next, + setCookie: serializeTeachingCookie(successor.id, COOKIE_POLICY.maxAgeSeconds), + }); +} + +export function logoutTeachingSession(state, sessionId) { + const current = currentRecord(state, sessionId); + if (!current.record) return stale(state, current.reason); + + const revoked = { ...current.record, status: 'revoked', revokedAt: 'fixture-logout' }; + const { [current.record.lineage]: removedCurrent, ...remainingCurrent } = state.currentByLineage; + void removedCurrent; + const next = freezeState({ + records: { ...state.records, [revoked.id]: revoked }, + currentByLineage: remainingCurrent, + nextId: state.nextId, + }); + return Object.freeze({ + accepted: true, + reason: 'logged-out', + state: next, + clearCookie: serializeTeachingCookie('', 0), + }); +} + +export function runSessionFixture() { + const initial = createTeachingSessionState(); + const rotated = rotateTeachingSession(initial, 'fixture-s-1'); + const staleRotation = rotateTeachingSession(rotated.state, 'fixture-s-1'); + const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1'); + const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2'); + const rotateAfterLogout = rotateTeachingSession(currentLogout.state, 'fixture-s-2'); + + return Object.freeze({ + assertions: Object.freeze({ + startsWithOneActiveSession: initial.currentByLineage['fixture-lineage-a'] === 'fixture-s-1', + cookieUsesHostPrefixAndNoDomain: initial.records['fixture-s-1'].cookie.name === '__Host-session' && initial.records['fixture-s-1'].cookie.domain === null, + cookieHasTransportScriptAndSiteFlags: initial.records['fixture-s-1'].cookie.secure && initial.records['fixture-s-1'].cookie.httpOnly && initial.records['fixture-s-1'].cookie.sameSite === 'Lax', + rotationIsAccepted: rotated.accepted === true && rotated.reason === 'rotated', + rotationRetiresPriorIdentifier: rotated.state.records['fixture-s-1'].status === 'rotated' && rotated.state.records['fixture-s-1'].successorId === 'fixture-s-2', + rotationMovesLineagePointer: rotated.state.currentByLineage['fixture-lineage-a'] === 'fixture-s-2' && rotated.state.records['fixture-s-2'].generation === 2, + rotationSetsOnlySuccessorCookie: rotated.setCookie.includes('__Host-session=fixture-s-2') && rotated.setCookie.includes('Max-Age=900'), + staleRotationIsRejected: staleRotation.accepted === false && staleRotation.reason === 'stale-session', + staleRotationDoesNotCreateAnotherSession: staleRotation.state === rotated.state && !Object.hasOwn(staleRotation.state.records, 'fixture-s-3'), + staleLogoutIsRejected: staleLogout.accepted === false && staleLogout.reason === 'stale-session', + staleLogoutDoesNotRevokeSuccessor: staleLogout.state === rotated.state && staleLogout.state.records['fixture-s-2'].status === 'active', + currentLogoutRevokesServerRecord: currentLogout.accepted === true && currentLogout.state.records['fixture-s-2'].status === 'revoked' && !Object.hasOwn(currentLogout.state.currentByLineage, 'fixture-lineage-a'), + currentLogoutClearsMatchingCookieShape: currentLogout.clearCookie === '__Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT', + loggedOutSessionCannotRotate: rotateAfterLogout.accepted === false && rotateAfterLogout.reason === 'stale-session', + fixtureDeclaresItsBoundary: initial.modelLimit === 'no-browser-no-network-no-crypto-no-clock', + }), + samples: Object.freeze({ initial, rotated, staleRotation, staleLogout, currentLogout, rotateAfterLogout }), + }); +} + +const fixtureExample = `import { + createTeachingSessionState, + rotateTeachingSession, + logoutTeachingSession, +} from './upgrade-2023-02.mjs'; + +const initial = createTeachingSessionState(); +const rotated = rotateTeachingSession(initial, 'fixture-s-1'); +const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1'); +const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2'); + +console.log(rotated.accepted); // true +console.log(staleLogout.reason); // stale-session +console.log(currentLogout.clearCookie); +// __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; + +const practice = revision({ + slug: 'editorial-2023-02-practice-sessions-auth', + title: 'Одна сессия от входа до logout: короткий контракт cookie и сервера', + categories: ['Безопасность', 'Аутентификация'], + cover: '/assets/editorial/2023/sessions-auth-2023-lifecycle.svg', + excerpt: 'Учебный контракт одной сессии: cookie выбирает идентификатор, сервер хранит состояние, rotation выводит старый ID из обращения, logout завершает оба слоя.', + readingMinutes: 12, +}, [ + p('В обработчике входа часто видны четыре независимые строчки: выдать cookie, поставить Secure, обновить ID и сделать logout. Проблема начинается, когда каждая строчка живёт по своей логике. Старый идентификатор продолжает проходить после rotation, cookie очищается без серверного revoke или срок в браузере принимают за срок доступа. Цена ошибки конкретна: пользователь считает сессию закрытой, а сервер ещё принимает её; либо новый вход ломается поздним ответом со старым состоянием.'), + p('Ниже — один контракт учебной сессии: браузер хранит непрозрачный ID, сервер хранит статус, и оба слоя меняются одним решением. После входа запись active; rotation выпускает successor и выводит predecessor из обращения; logout revoke-ит current ID и просит клиент удалить cookie. Это не browser trace и не запуск приложения, а in-memory fixture с явно заданными состояниями и идентификаторами.'), + h2('Сначала разделите носитель и источник истины'), + p('Cookie не является сессией. Это способ вернуть серверу строку в заголовке Cookie, если браузер решил, что её scope подходит запросу. RFC 6265 описывает именно такую передачу и допускает, что user agent удалит cookie раньше срока. Поэтому Max-Age полезен как клиентский предел хранения, но не должен быть единственной проверкой timeout. В контракте active, rotated и revoked принадлежат серверной записи; cookie только выбирает запись, которую сервер ещё обязан проверить.'), + p('Это разделение меняет и формулировку logout. Удалить cookie недостаточно: другой клиент, сохранённый запрос или уже отправленный заголовок может всё ещё предъявить идентификатор. Сервер должен решить, принимает ли он его. В учебной модели после logout запись получает статус revoked и исчезает из currentByLineage. Отдельный Set-Cookie с Expires в прошлом просит браузер убрать прежнее значение. Оба действия нужны, но они доказывают разное: первое закрывает доступ в модели, второе убирает удобный носитель на клиенте.'), + table('Контракт одной сессии: кто владеет решением', ['Объект', 'Минимальное поле', 'Кто проверяет', 'Что происходит при logout', 'Чего этого недостаточно'], [ + ['Cookie', '__Host-session=ID; Path=/; Secure; HttpOnly; SameSite=Lax', 'user agent решает, приложить ли его к запросу', 'получает Set-Cookie с Expires в прошлом той же формы', 'доказать, что сервер уже revoke-ил ID'], + ['Session record', 'id, lineage, generation, status', 'сервер перед обработкой защищённого действия', 'current ID становится revoked', 'принять решение за другой сервис без общей политики'], + ['Lineage pointer', 'lineage → active ID', 'операция rotation/logout', 'ссылка на active ID удаляется', 'описать logout всех устройств'], + ['Set-Cookie при rotation', 'successor ID и та же политика flags', 'сервер формирует ответ', 'не выдаётся при stale reference', 'синхронизировать вкладки или порядок сетевых ответов'], + ]), + h2('Минимальный fixture: проверить переходы, не притворяясь браузером'), + p('Скрипт этой партии экспортирует createTeachingSessionState, rotateTeachingSession и logoutTeachingSession. Первый вызов создаёт fixture-s-1 с состоянием active. Rotation принимает только текущий ID для линии fixture-lineage-a, помечает его rotated и создаёт fixture-s-2. Поздняя операция со старым ID возвращает stale-session и получает тот же объект state. Это намеренно узкая проверка: нет HTTP, хранилища браузера, случайных чисел, конкурентных запросов, clock skew, пользователя или настоящего сервера.'), + code(fixtureExample), + p('Здесь stale logout для fixture-s-1 не revoke-ит fixture-s-2: это политика logout только предъявленной current-сессии. Другой продукт может выбрать logout всей lineage или всех устройств, но это должна быть отдельная операция с собственным scope и проверкой. Иначе поздний запрос молча закроет другую активную сессию.'), + h2('Rotation — замена права, а не продление строки'), + p('Rotation начинается с проверки текущей записи, а не с Set-Cookie. Предшественник остаётся rotated для диагностики, но уже не current; successor получает новый ID, generation и ту же lineage. Так сервер различает unknown и уже устаревший ID. В production это изменение требует реальной гарантии хранилища; синхронный fixture не называет себя atomic.'), + p('Для двух одновременных запросов заранее определите один исход: один создаёт successor, другой получает stale/rejected. Без этого появятся два successor. Fixture не моделирует гонку, но фиксирует инвариант для реализации: после rotation один current ID, а повторный старый ID не создаёт fixture-s-3.'), + figure('/assets/editorial/2023/sessions-auth-2023-lifecycle.svg', 'Жизненный цикл учебной сессии: после входа fixture-s-1 active, rotation переводит его в rotated и выпускает fixture-s-2 active, stale операции со старым ID отклоняются, logout текущего ID делает запись revoked и очищает cookie.', 'Схема показывает контракт одного идентификатора и одной lineage в памяти. Это не trace браузера, не последовательность настоящих HTTP-ответов и не модель нескольких устройств.'), + h2('Cookie flags описывают область доставки, не авторизацию'), + p('Для учебного ID выбран __Host-session. В draft-ietf-httpbis-rfc6265bis-11 префикс __Host- требует Secure, Path=/ и отсутствия Domain, то есть фиксирует host-only область. Он не обещает пользователя, права, logout или серверный срок. В феврале 2023 это был Internet-Draft ноября 2022, не финальный RFC. __Host- не годится, если cookie обязана жить на нескольких поддоменах.'), + p('Secure ограничивает канал доставки, HttpOnly — доступ из browser API, SameSite=Lax — часть cross-site доставок. Ни один не заменяет серверную проверку ID, прав и запроса. Path задаёт маршрут, но не является security boundary. Поэтому каждый flag записывают с его узкой причиной, а не как список безопасности.'), + h2('Logout: две синхронные обязанности'), + p('Logout имеет серверную и клиентскую ветви. Сервер revoke-ит только current active ID и снимает pointer; клиент получает то же имя и Path=/ с пустым значением и Expires в прошлом. RFC 6265 связывает замену/удаление с name, Domain и Path: если issue содержал Domain, а clear нет, браузер может оставить другую cookie. Учебный контракт исключает Domain с самого начала.'), + p('Удаление cookie не доказывает, что logout дошёл до сервера: ответ мог не примениться, а другой запрос уже нести ID. Критерий доступа — server reject после revoke. Очищение остаётся нужно для UX. CSRF-защита logout, если она есть, — отдельный контракт; SameSite не отменяет его.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. В выписке change найдите одно из трёх наблюдений: старый ID принимается после renewal, logout меняет только интерфейс или cookie живёт дольше серверной записи. Не называйте это инцидентом, пока нет проверяемых данных.', + 'Причина. Для одного ID нарисуйте record, lineage pointer, Set-Cookie при issue/rotation и Set-Cookie при clear. Если один из объектов не имеет владельца, контракт уже разорван.', + 'Проверка fixture. Запустите node web/scripts/upgrade-2023-02.mjs --verify-fixture. Он проверяет только 15 предзаданных assertions учебной модели, включая stale rotation и stale logout; он не проверяет браузер, endpoint или конфигурацию.', + 'Проверка реализации. Отдельно выберите разрешённую среду и проверьте, что сервер отклоняет rotated/revoked ID, а clear cookie повторяет name, Domain при наличии и Path исходной cookie. Не переносите в отчёт реальные session ID.', + 'Действие. Сделайте server-side status обязательным до защищённой операции; rotation обновляет current pointer, logout revoke-ит current pointer. Добавьте явное отдельное действие, если бизнесу нужен logout всех сессий.', + 'Откат. Не используйте fixture как план отката. Для production заранее опишите совместимость старых ID, миграцию хранилища, ожидаемый reject и способ вернуть change без повторной легитимации stale идентификатора.', + ]), + h2('Границы и следующий проверяемый шаг'), + p('Модель не генерирует секреты: fixture-s-1 и fixture-s-2 нельзя копировать как production ID. В ней нет шифрования, CSRF, пользователя, reauthentication, browser Network/Storage и реальной гонки. NIST SP 800-63B помогает отделить session secret, минимальный cookie scope и server timeout; это контекст документа NIST, а не готовая политика продукта.'), + p('Следующий шаг — зафиксировать для команды status, lineage, reject rotated/revoked ID и отдельный план logout всех устройств. Затем провести разрешённую интеграционную проверку вне fixture. Если нельзя назвать current ID после rotation, cookie-конфигурации ещё рано доверять.'), + h2('Историческая граница февраля 2023'), + p('К февралю 2023 уже существовали RFC 6265 от апреля 2011, NIST SP 800-63B от июня 2017 с обновлениями до 2 марта 2020 и draft-ietf-httpbis-rfc6265bis-11 от 7 ноября 2022. Последний в статье назван work in progress, а не стандартом. Источники объясняют свойства cookie и сессий, но не подтверждают состояние конкретного браузера, балансировщика, базы данных или auth-платформы.'), +]); + +const mechanism = revision({ + slug: 'editorial-2023-02-mechanism-sessions-auth', + title: 'Cookie, rotation и logout: модель одной сессии без скрытого источника истины', + categories: ['Безопасность', 'Аутентификация'], + cover: '/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg', + excerpt: 'Разбор границы между cookie и серверной записью: что проверяют Secure, HttpOnly и SameSite, почему rotation меняет current ID, а logout не заканчивается удалением строки в браузере.', + readingMinutes: 12, +}, [ + p('Ошибка в сессиях часто выглядит спором о flag: добавить SameSite, HttpOnly или Max-Age. Проблема глубже, если непонятно, где решается валидность ID после rotation/logout. Тогда один handler верит cookie, другой TTL в базе, третий очищает только клиент. Цена — противоречивый доступ или потеря новой сессии поздней операцией со старым ID.'), + p('Достаточно выбрать один поток и владельца каждого факта. Здесь cookie переносит непрозрачный session ID; server record хранит status и generation; lineage указывает на один active ID. Rotation меняет record и выдаёт cookie, logout revoke-ит record и возвращает clear cookie. Fixture проверяет это в памяти, без браузера, storage/network trace и вывода о реальной платформе.'), + h2('Пять состояний, которые не стоит склеивать'), + p('Фраза «сессия истекла» скрывает разные факты: cookie нет, но record ещё жив; cookie есть, но record revoked; ID известен, но уже не current; доступ отклонён по правам при active сессии. Поздний Set-Cookie тоже не доказывает порядок его применения браузером. Эти состояния нельзя склеивать без риска случайно изменить authorization.'), + p('Модель не выбирает SQL, cache, signed token или framework store. Она требует лишь отличить current от rotated/revoked до защищённого эффекта. Порядок Cookie и похожее имя не должны решать доступ: RFC 6265 не советует полагаться на сериализацию одинаковых cookie.'), + table('Слои одной сессии и вопрос, на который отвечает каждый', ['Слой', 'Вопрос', 'Данные в учебной модели', 'Нормальный переход', 'Неверный вывод'], [ + ['Аутентификация', 'кто прошёл вход?', 'в fixture не представлена', 'создаёт initial session record', 'cookie сама доказывает личность'], + ['Cookie delivery', 'какую строку браузер приложит?', '__Host-session и flags', 'issue, rotate, clear', 'Max-Age решает серверный timeout'], + ['Server record', 'принимать ли этот ID сейчас?', 'status и generation', 'active → rotated или revoked', 'наличие ID равно доступу'], + ['Lineage', 'какой ID является current?', 'fixture-lineage-a → fixture-s-2', 'pointer сдвигается при rotation', 'старый ID можно повторно продлить'], + ['Authorization', 'можно ли выполнить действие?', 'в fixture не представлена', 'проверяется отдельно после ID', 'active session даёт любое право'], + ]), + h2('Flags: точные границы вместо списка галочек'), + p('Secure ограничивает канал доставки с точки зрения user agent, но не подписывает server record. HttpOnly закрывает значение от не-HTTP API, но не лечит XSS и не отменяет запросы браузера с cookie. Оба flag описывают рядом с их узким эффектом.'), + p('SameSite=Lax ограничивает часть cross-site доставок. Draft-11 различает Strict, Lax и None; Lax допускает safe top-level navigation помимо same-site запросов. Это защита в глубину, не доказательство корректности mutation endpoint. Embed или federation могут потребовать другой контекст и отдельную проверку server request.'), + p('Max-Age/Expires задают максимум browser хранения, но user agent может удалить cookie раньше. В draft-11 Max-Age приоритетнее Expires; сервер не обязан принимать ID до того же момента. NIST SP 800-63B не предлагает полагаться на expiry cookie для timeout: browser lifetime и server expiry хранят отдельно.'), + h2('Почему __Host- полезен только при подходящем scope'), + p('Имя __Host-session означает не расширять cookie на поддомены. Для conformant user agent префикс требует Secure, Path=/ и отсутствия Domain, поэтому issue и clear имеют один scope. Это не namespace безопасности и не замена ревью hostnames. Если cookie нужна на нескольких поддоменах, __Host- не подходит: Domain и его владельцев надо описать явно.'), + p('Path=/ даёт cookie доступ на всём host и выполняет условие префикса, но не делает путь security boundary. Разделение административной области обеспечивает server authz и архитектура hostnames.'), + figure('/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg', 'Контракт учебной cookie __Host-session: Secure ограничивает отправку защищённым каналом, HttpOnly скрывает значение от JavaScript API, SameSite=Lax ограничивает часть cross-site доставок, Path=/ и отсутствие Domain нужны для __Host-, а серверная запись отдельно проверяет active status.', 'Рисунок разделяет browser delivery и server validation. Он не показывает совместимость конкретного браузера, реальный заголовок ответа или достаточность защиты от CSRF.'), + h2('Rotation: сохранить lineage, заменить предъявляемый ID'), + p('Rotation меняет значение, которое сервер считает current. Fixture оставляет fixture-s-1 rotated, добавляет fixture-s-2 generation=2 и переписывает pointer. Старый ID получает stale-session, а не новый TTL: сервер не смешивает известный, но заменённый ID с действующим.'), + p('Реализация должна определить линейзацию: транзакцию, conditional update, compare-and-swap или другую гарантию хранилища. Иначе два запроса выпустят два successor. Fixture не моделирует параллелизм, но его staleRotationDoesNotCreateAnotherSession — требование к реальному механизму.'), + h2('Logout: явный scope важнее красивого 204'), + p('Scope logout называют до кода. Logout-current revoke-ит один record; logout-all ищет все records identity; logout-lineage лежит между ними. Их нельзя скрыть за единым endpoint без явного правила: stale запрос иначе либо оставит доступ, либо выключит новую сессию.'), + p('Fixture выбирает logout-current: fixture-s-1 после rotation получает stale-session и не трогает fixture-s-2. Это инвариант выбранного contract, не правило для любого сервиса. Если нужен другой эффект, создают logout-lineage/logout-all и проверяют его последствия отдельно.'), + h2('Проверяемый код и ожидаемые границы'), + p('Пример воспроизводим, потому что не зависит от времени, сети или secret store. В нём нет пользователя, браузерных API и настоящего Set-Cookie parser. Он проверяет только переходы: rotation меняет pointer, stale операции не изменяют successor, current logout revoke-ит record и формирует clear cookie.'), + code(`import { + createTeachingSessionState, + rotateTeachingSession, + logoutTeachingSession, +} from './upgrade-2023-02.mjs'; + +const before = createTeachingSessionState(); +const rotation = rotateTeachingSession(before, 'fixture-s-1'); +const oldIdLogout = logoutTeachingSession(rotation.state, 'fixture-s-1'); +const newIdLogout = logoutTeachingSession(rotation.state, 'fixture-s-2'); + +if (oldIdLogout.accepted) throw new Error('stale logout changed successor'); +if (newIdLogout.state.records['fixture-s-2'].status !== 'revoked') { + throw new Error('current logout did not revoke record'); +}`), + p('Для полного набора assertions используется команда node web/scripts/upgrade-2023-02.mjs --verify-fixture. До запуска в проекте прочитайте сами assertions: fixture не умеет обнаружить неверный reverse proxy, заголовки framework, маршрут logout, race в базе или поведение browser cookie jar. Он удобен как короткий договор на review, но не как тест, который заменяет интеграционный сценарий. Производственный тест должен иметь разрешённую среду, безопасные тестовые данные и отдельно названные наблюдения.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. Найдите место, где код заключает «cookie есть — сессия валидна», или «Max-Age истёк — сервер уже всё закрыл». Это наблюдаемый пробел контракта, а не диагноз всей системы.', + 'Причина. Разделите browser delivery, server record, lineage и authorization. Для каждого напишите один владелец и один момент изменения.', + 'Проверка flags. Сверьте name, Secure, HttpOnly, SameSite, Path, Domain и client lifetime с нужным scope. Отдельно запишите, что каждый флаг не решает.', + 'Проверка rotation. В разрешённом тесте отправьте старый ID после successful replacement и убедитесь, что он не становится current. Для маленького договора сначала выполните fixture.', + 'Действие. Сделайте серверную проверку status/current обязательной перед защищённым эффектом; определите один явный механизм линейзации rotation и отдельные операции logout-current, logout-lineage или logout-all.', + 'Откат. Не продлевайте retired ID ради быстрого отката. Подготовьте совместимую ветку или migration plan, которые сохраняют reject старого ID и позволяют объяснить состояние сессии пользователю.', + ]), + h2('Ограничения модели и следующий шаг'), + p('Статья не утверждает, что __Host- или SameSite=Lax подходят каждому приложению. В ней нет оценки AAL, криптографии, устройств, distributed store, CORS/CSRF, browser trace и измерения. fixture-s-1, fixture-t0 и 900 секунд учебные. IETF/NIST задают словарь и ограничения, не принимают за команду решение о scope, UX или гонках.'), + p('Следующий шаг — выписать для одного protected handler допустимые status, смысл rotated, владельца lineage и logout scope, затем добавить отдельный интеграционный сценарий с безопасными тестовыми данными. Если client expiry смешан с server revocation, сначала исправляют эту границу.'), + h2('Историческая граница февраля 2023'), + p('Все три источника в конце существовали не позднее февраля 2023: RFC 6265 опубликован в 2011, NIST SP 800-63B — в 2017 с обновлениями 2020 года, а draft-ietf-httpbis-rfc6265bis-11 — 7 ноября 2022. В частности, draft-11 остаётся work in progress и приведён именно с этим статусом. Ссылки нужны для проверки описанной модели, а не как свидетельство запуска или конфигурации неизвестного сервиса.'), +]); + +const field = revision({ + slug: 'editorial-2023-02-field-sessions-auth', + title: 'Старый session ID после rotation: маршрут проверки logout без выдуманного trace', + categories: ['Безопасность', 'Аутентификация', 'Практика'], + cover: '/assets/editorial/2023/sessions-auth-2023-stale-events.svg', + excerpt: 'Диагностический маршрут для случая, когда logout, renewal и cookie выглядят как разные события: как собрать факты, отделить stale ID от текущего и не объявить fixture browser trace.', + readingMinutes: 12, +}, [ + p('Симптом часто звучит так: «в одной вкладке вышли, в другой ещё есть доступ» или «после renewal разлогинило». Из этого не видно, какой ID пришёл на сервер, какой был current и какой logout scope выбран. Цена правки наугад — старая сессия остаётся принятой либо поздний logout закрывает новую.'), + p('Ниже — маршрут, не отчёт об инциденте: нет пользователя, вкладок, Network/Storage или HTTP-запуска. Есть fixture одной lineage: fixture-s-1 становится rotated, fixture-s-2 active, старый ID stale, logout нового ID revoke-ит его. Это задаёт вопросы для реального расследования, не выдумывает trace.'), + h2('Соберите четыре факта до изменения флага'), + p('Начинать стоит с формата записи: безопасный surrogate ID, server-side status в момент обработки, lineage/current ID и тип операции. Полный bearer secret в лог не попадает. Нужна корреляция, по которой reviewer сопоставит request с решением, но не повторит сессию.'), + p('Отделяйте факт от интерпретации. «Cookie не удалена» требует разрешённой проверки клиента; «logout не дошёл» — evidence запроса или server record; «старый ID прошёл» — статус именно старого ID. Если данных нет, причина не подтверждена: fixture подсказывает инвариант, не историю браузера.'), + table('Симптом → причина → проверка → действие для одной lineage', ['Симптом', 'Вероятная причина', 'Проверка без секрета', 'Действие'], [ + ['После renewal старый ID ещё выполняет защищённое действие', 'record не стал rotated или handler не проверяет current', 'сопоставить безопасный ID записи, status и lineage pointer в разрешённой среде', 'отклонять rotated ID до эффекта и линейризовать rotation'], + ['Logout меняет UI, но следующий запрос принят', 'очищена только cookie или revoke не дошёл до server record', 'проверить audit event logout и status того же record', 'revoke server record и вернуть clear cookie одинакового scope'], + ['Поздний logout отключает новую сессию', 'не определён scope, stale ID трактуется как current', 'сравнить предъявленный ID с currentByLineage на момент обработки', 'выбрать явный logout-current или logout-lineage и добавить invariant'], + ['После clear cookie видна другая cookie того же имени', 'issue/clear расходятся в Path или Domain', 'сравнить атрибуты Set-Cookie без значения bearer ID', 'удалять cookie теми же name, Path и Domain при наличии'], + ['Проблема есть только в одном браузере', 'политика хранения или порядок ответов отличается', 'повторить разрешённый минимальный сценарий на конкретной версии', 'не менять server contract до подтверждения различия'], + ]), + h2('Старый ID — это не обязательно неизвестный ID'), + p('Различайте unknown, rotated и revoked. Unknown — store не нашёл ID; rotated — запись известна, но не current и имеет successor; revoked — сессия завершена. Внешне они могут дать один 401, но внутри должны отличаться, иначе нельзя доказать, что rotation вывела старый ID из обращения.'), + p('Fixture хранит predecessor ради различия, не как совет хранить records вечно. На период возможных повторов сервер должен отклонять старый ID. RFC 6265 задаёт browser scope доставки; прикладную семантику Cookie определяет сервер.'), + h2('Воспроизводимый fixture и invariants stale событий'), + p('Fixture sidecar создаёт current record, делает accepted rotation, затем повторяет rotate/logout со старым ID. Обе stale операции возвращают accepted=false и оставляют successor active. Logout current ID делает record revoked, удаляет pointer и формирует clear cookie. Есть и положительная, и отрицательная ветви.'), + code(`import { + createTeachingSessionState, + rotateTeachingSession, + logoutTeachingSession, +} from './upgrade-2023-02.mjs'; + +const first = createTeachingSessionState(); +const replacement = rotateTeachingSession(first, 'fixture-s-1'); +const staleRotation = rotateTeachingSession(replacement.state, 'fixture-s-1'); +const staleLogout = logoutTeachingSession(replacement.state, 'fixture-s-1'); + +if (staleRotation.accepted || staleLogout.accepted) { + throw new Error('old ID changed current session'); +} +if (replacement.state.records['fixture-s-2'].status !== 'active') { + throw new Error('successor is not active'); +}`), + p('Команда node web/scripts/upgrade-2023-02.mjs --verify-fixture запускает полный набор assertions скрипта. Он воспроизводим только потому, что значения и порядок вызовов фиксированы. Его PASS не подтверждает, что web server выдал Set-Cookie, что браузер применил ответ, что база сделала compare-and-swap или что logout endpoint защищён от подделки. В review укажите этот предел рядом с результатом. Иначе маленький тест станет ложным evidence для нескольких неиспытанных уровней системы.'), + figure('/assets/editorial/2023/sessions-auth-2023-stale-events.svg', 'Маршрут stale событий в учебной lineage: rotation заменяет fixture-s-1 на fixture-s-2, повторная rotation и поздний logout со старым ID получают reject и не меняют successor, logout текущего ID revoke-ит fixture-s-2 и выдаёт clear cookie.', 'Схема задаёт проектное правило для одного ID и синхронных вызовов в памяти. Она не является browser trace, журналом реального инцидента или доказательством порядка HTTP-ответов.'), + h2('Порядок ответа и порядок решения — разные задачи'), + p('Даже с правильным server reject клиент может получить ответы в неудобном порядке. Два запроса ушли со старым ID, один принёс новый Set-Cookie, другой ошибку. Browser cookie jar и fetch/navigation имеют свои правила; fixture их не моделирует. Реальный дефект требует отдельного разрешённого repro с версиями, шагами, маскированными значениями и HTTP-метаданными.'), + p('Серверный инвариант остаётся: принимается только current active ID; stale событие не создаёт successor и не revoke-ит нового при logout-current. UX после reject выбирает продукт. Не прячьте его бесконечным retry без определения перехода пользователя.'), + h2('Проверьте clear cookie как договор, а не как строку в шаблоне'), + p('Удаление cookie требует того же имени и scope, что issue. В модели clear cookie: __Host-session, Path=/, Secure, HttpOnly, SameSite=Lax и Expires в прошлом. __Host- исключает Domain, поэтому issue/clear не расходятся по поддоменному scope. Если Domain нужен в другом контракте, он обязан быть в документе и проверке очистки; не добавляйте его только на logout.'), + p('Flags clear cookie не отменяют server revoke. RFC 6265 связывает замену с name, domain и path и не советует полагаться на порядок одинаковых Cookie. При нескольких Set-Cookie нужен один owner, один scope и проверка отсутствия дубликата в разрешённом сценарии.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. Запишите наблюдаемое последствие без вывода: какой экран, какой внешний status, какая безопасная корреляция операции. Не называйте браузерный trace, если его не собирали.', + 'Причина. Проверьте гипотезы по очереди: server record не изменился, handler не сверил current, stale logout имеет неясный scope или issue/clear cookies различаются по атрибутам.', + 'Проверка модели. Выполните fixture и прочитайте assertions staleRotationDoesNotCreateAnotherSession и staleLogoutDoesNotRevokeSuccessor. Это проверка выбранного правила, не production доказательство.', + 'Проверка реализации. В разрешённой среде соберите ID surrogate, record status, lineage pointer и Set-Cookie attributes без bearer значения. Сверьте момент обработки, а не только время на клиенте.', + 'Действие. Сделайте status/current обязательным перед защищённым side effect, выберите понятный logout scope и проверяйте clear cookie на совпадение с issue policy.', + 'Откат. При проблеме UX не возвращайте старый ID в active. Сначала сохраните reject stale ID, затем примените отдельный совместимый rollback для экрана, маршрута или новой политики cookie.', + ]), + h2('Ограничения и безопасный результат расследования'), + p('Fixture не хранит секреты, не реализует random ID/TLS/часы и не знает browser API, пользователей, устройств, cache, CSRF или конкурентности. Она не доказывает stale logout в продукте и не выбирает HTTP-ответ. Она фиксирует лишь правило logout-current: старый ID не оживляет и не отменяет successor. Другой scope требует другой fixture.'), + p('Следующий шаг — добавить audit contract: корреляция без bearer secret, server status до protected effect, logout scope и причина reject. Затем проводить отдельный repro только в разрешённой среде. Несобранный факт остаётся неизвестным; это безопаснее правдоподобной, но неповторяемой истории про вкладки.'), + h2('Историческая граница февраля 2023'), + p('Этот маршрут опирается на документы, которые существовали к февралю 2023: RFC 6265 от 2011 года, NIST SP 800-63B от 2017 года с обновлениями до 2020 и draft-ietf-httpbis-rfc6265bis-11 от ноября 2022. Draft не объявляется финальной спецификацией. Ни один из источников не даёт trace конкретного браузера и не заменяет целевую проверку реального logout flow.'), +]); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); + +function verifyFixture() { + const report = runSessionFixture(); + const failed = Object.entries(report.assertions) + .filter(([, value]) => value !== true) + .map(([key]) => key); + if (failed.length) { + process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); + process.exitCode = 1; + return; + } + const count = Object.keys(report.assertions).length; + process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n'); +} + +if (process.argv.includes('--verify-fixture')) verifyFixture(); +if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');