diff --git a/.cspell.json b/.cspell.json index cf243da..14d1bc8 100644 --- a/.cspell.json +++ b/.cspell.json @@ -26,6 +26,7 @@ "backpressure", "Backpressure", "badkey", + "bubblewrap", "cdylib", "chrono", "claimом", @@ -230,6 +231,7 @@ "десеріалізує", "десеріалізується", "джиттер", + "джобом", "дзеркалює", "диспатч", "діючий", @@ -423,6 +425,8 @@ "непарсибельний", "непарсибельні", "непровалідованому", + "нерезолвлений", + "Нерезолвлений", "нерозпарсюваний", "нерозрізнюваними", "нерозрізнювані", @@ -457,6 +461,7 @@ "перемаршрутовуються", "переносиме", "переносимості", + "переоткривати", "переоткриття", "переперевірки", "перепідключаються", @@ -597,12 +602,14 @@ "Спавнимо", "Спавнить", "спавну", + "спавняться", "спайком", "спуфінг", "стрипає", "стрипається", "стрімом", "стріму", + "суперлінійний", "схвалювачів", "схлопнутий", "таргети", diff --git a/docs/conformance.md b/docs/conformance.md index 31ea250..e31f819 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -1,8 +1,8 @@ # Відповідність специфікації: карта реалізації -**Дата зрізу:** 2026-08-09 -**Специфікація:** `@7n/mt` — [цільова архітектура 0.3.0-draft](https://github.com/nitra/mt/blob/main/docs/architecture/index.md) -**Метод:** прочитання глав архітектури проти коду `crates/`, `relay/`, `layers/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі. +**Дата зрізу:** 2026-08-17 (попередній — 2026-08-09) +**Специфікація:** `@7n/mt` 0.30.8 — [цільова архітектура 0.3.0-draft](https://github.com/nitra/mt/blob/main/docs/architecture/index.md) +**Метод:** прочитання глав архітектури проти коду `crates/`, `relay/`, `layers/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі. Зріз 2026-08-17 додатково звірив заявлені шляхи з деревом, прогнав обидва набори тестів і перевірив, що саме з цього ганяє CI. > Живий документ. Правило: зміна вердикту йде тим самим PR, що й код, який його змінює. Зріз без дати й методу — не зріз. @@ -12,6 +12,14 @@ **Критерій зняття `-draft` з 0.3.0:** усі рядки таблиць нижче в стані РЕАЛІЗОВАНО, кожна вимога має тест у `cargo test --workspace` або `bun test`, і demo-критерії M0–M6 із [roadmap](https://github.com/nitra/mt/blob/main/docs/roadmap.md) відтворюються. +> **Стан сторожів (2026-08-17).** Критерій вище спирається на тести, тож їхній власний стан — частина зрізу, а не деталь: +> +> - `bun run test` — 197 passed, стабільно. +> - `cargo test --workspace` — 306 тестів; **серійно падає 1** (`runner::tests::cascade_exhausted_or_plain_error_paths`), **паралельно падає до 10** — решта 9 чутливі до навантаження машини (`budget_hard_sec` вимірює реальний час, а шими спавняться процесами). +> - **`cargo test` не запускається в CI взагалі** — жоден workflow його не містить. Тобто Rust-половина критерію не має автоматичного сторожа: її тримає лише ручний прогін. +> +> Поки це так, «усі рядки РЕАЛІЗОВАНО» не дорівнює «доведено». + ## Зведення за мілстоунами | Мілстоун | Стан | Головне, чого бракує | @@ -38,7 +46,7 @@ | Claims: CAS, lease, grace, takeover | РЕАЛІЗОВАНО | `claims.rs`; читач — `lib.rs` `scan_tasks_with_claims`, CLI `mt status` | — | | Fenced publish + failure-сімейство | РЕАЛІЗОВАНО | `publish.rs` `fenced_publish`/`publish_failure_run`, `runner.rs` `terminal_conflict_reason` | Батчинг кількох результатів одним push | | Run-wrapper, watchdog | РЕАЛІЗОВАНО | `runner.rs` | — | -| Retry ladder + каскад CLI | РЕАЛІЗОВАНО | `runner.rs` | Телеметрія `tokens_in/out`/`cost_usd` не збирається | +| Retry ladder + каскад CLI | РЕАЛІЗОВАНО | `runner.rs` | Телеметрія `tokens_in/out`/`cost_usd` не збирається; тести драбини чутливі до навантаження — див. «Стан сторожів» | | Stage 1 — inline-планування | РЕАЛІЗОВАНО | `runner.rs` `needs_planning`/`build_plan_prompt`/`latest_plan_decision` | — (Етап 1 пропускається за `hint: atomic` або наявного плану; порожній результат читається як неявний atomic) | | Контекст агента (system-prompt, deps, plan, prior attempts) | РЕАЛІЗОВАНО | `runner.rs` `build_agent_prompt`/`dep_facts`/`prior_attempts` | — (другий шар стискання читає `run-summary.md`, якщо він є; генератора самого файлу ще немає — окремий рядок) | | `run-summary.md` (другий шар стискання) | РЕАЛІЗОВАНО | `runner.rs` (генерація за `run_summary_threshold`), читач — `build_agent_prompt` | — | @@ -95,7 +103,7 @@ | Гейт 2 — plan-review з підписом | ЧАСТКОВО | `spawn.rs` | У фронтматері рішення немає блоку `approved_by` з підписом | | Гейт 3 — аудит-вердикт людини | ЧАСТКОВО | `signal.rs` | Немає блоку підпису в `audit-result_NNN.md` | | Push типів 1/2/3 | РЕАЛІЗОВАНО | маршрутизація — `relay/lib/push.mjs`; транспорт — `fcm-sink.mjs` (FCM HTTP v1) і `push-sink.mjs` (dev), вибір — `create-push.mjs`; контракт — `tests/push-sink-contract.test.mjs` | — (APNs окремо не робимо: FCM доставляє на iOS; доставка живим FCM не перевірена — stub-`fetch` доводить лише форму запитів) | -| PII-directory | ЧАСТКОВО | `mt-core/directory.rs` | Мертвий код: парсер є, викликів немає → `Escalation.to_account_id` ніхто не заповнює | +| PII-directory | ВІДСУТНЄ (мертвий код) | `mt-core/directory.rs` | Парсер є, викликів немає **жодного** — звірено 2026-08-17. Наслідок: `Escalation.to_account_id` порожній, адресний push типу 3 для ескалацій не працює. Рішення відкладене — «Відкриті питання» №6 | ## i18n, retro, мандати @@ -103,7 +111,7 @@ | --- | --- | --- | | i18n: контрактне ядро (base-мова, `source_hash`, «що перекладається», contract-aware сегментація) | РЕАЛІЗОВАНО | `mt-core/i18n.rs`: `I18nConfig`, `TranslationMeta`/`is_fresh`, `is_translatable` (триступенева схема), `segment` (fail-closed), `materialize` (read path, лише свіжі); неоднозначність спеки щодо `## Task`/`## Done when` вирішена явно — див. «Закриті питання» | | i18n: сховище `refs/mt/i18n`, write path, черга регенерації, live-шар | ВІДСУТНЄ | Ядро є (рядок вище); запис/читання i18n-ref разом із fenced publish; компіляція authored-правки в base; фонова черга agent-server; live-переклад Envelope за capability `self-translate`; `layers/` лишається суміжною задачею з іншим контейнером і конфігом | -| i18n: `lang` у ClientHello | РЕАЛІЗОВАНО | `agent-protocol/handshake.rs`; далі хендшейку `lang` не використовується | +| i18n: `lang` у ClientHello | ЧАСТКОВО | `agent-protocol/handshake.rs`; звірено 2026-08-17 — далі хендшейку `lang` не читає ніхто, live-переклад стрічки не реалізований («Відкриті питання» №5) | | retro: датасет і детерміновані пропозиції | РЕАЛІЗОВАНО | `mt-core/retro.rs` (`collect_runs`, `analyze`, приватний звіт `~/.nitra/retro/.md`); CLI — `mt retro`/`mt retro --show`; — (opt-in `retro.enabled`, дефолт `false` — контрактна вимога глави) | | retro: LLM-крок, innovation, impact | ВІДСУТНЄ | Детермінований датасет є (рядок вище); LLM-аналіз поверх датасету; `innovation_NNN.md` і baseline; impact-зрізи; фоновий прогін за `schedule_days`; агрегатор компетенцій як другий вихід | | Мандати: карта, валідація змін, квіз-гейт, ШІ-мандати | РЕАЛІЗОВАНО | `crates/mt-mandates`: `parse_mandates`, `effective_owner`, `validate_mandate_change` (generation fencing, подвійний підпис `escalates_to`, «остання константа»), `validate_approval` | @@ -147,4 +155,20 @@ ## Відкриті питання -Немає — усі три конфлікти зрізу 2026-08-09 закриті. +Питання, а не залишок роботи: кожне з них потребує **рішення**, і поки його немає, робити наступний крок наосліп дорожче, ніж спитати. + +1. **Пісочниця лише радить, а називається sandbox.** `mt-core/sandbox.rs` жорстко відмовляє там, де агент **питає** дозволу (ACP-гейт), і експортує політику в ENV. Але автономний хід виконує підписочний CLI власним процесом — MT не перехоплює його syscall-и. Питання: чи заводимо реальну ізоляцію (`sandbox-exec` на macOS, `bubblewrap` на Linux) і платимо сумісністю з CLI, чи фіксуємо в спеці, що `skill_profiles` — **декларативна** політика для кооперативних виконавців. Зараз назва обіцяє більше за реалізацію. + +2. **Гейт не знає вузла кімнати.** Політика в ACP-гейті — стеля **хоста** (обʼєднання всіх налаштованих профілів), бо інтерактивна сесія підхоплює вузол уже під час розмови. Щоб звузити до `a.md.skills` конкретного вузла, гейт має отримати вузол кімнати. Питання чисто про сигнатуру `PermissionFactory` — але це зміна публічного API `agent-server`. + +3. **MCP: набір проєкту проти набору ходу.** Спека каже «схеми тулів потрапляють у контекст агента лише для ходів цього surface — спеціалізація і є економія контексту». Зараз оголошується обʼєднання профілів проєкту, бо ACP-сесія кімнати відкривається один раз. Звуження вимагає переоткривати сесію на кожному перемиканні surface — тобто рвати контекст розмови заради економії контексту. Питання: що дорожче. + +4. **`layers/` і `mt-core/i18n.rs` — дві різні відповіді на одне питання.** Обидва перекладають markdown, але різними контейнерами (`x..md` у робочому дереві проти `refs/mt/i18n`), різними конфігами (`layers.json` проти `.mt.json`) і різними моделями свіжості. Поки i18n не має сховища, вони не конфліктують. Щойно зʼявиться — треба вирішити, чи `layers/` стає споживачем ядра, чи лишається окремим інструментом для доків. + +5. **Live-переклад Envelope нікому не належить.** `i18n.md` каже, що клієнт без capability `self-translate` отримує текстові події вже перекладеними. Це означає виклик моделі на кожну текстову подію на боці хоста — вартість і затримка в гарячому шляху стрічки. Питання: чи це справді хост, чи relay, чи це відкладається до появи дешевого локального перекладача. Зараз `lang` далі хендшейку не використовується взагалі. + +6. **`directory.rs` — мертвий код десятий тиждень.** Парсер `.mt/directory.json` є, викликів немає, тому `Escalation.to_account_id` ніхто не заповнює — і адресний push типу 3 для ескалацій де-факто не працює, хоча маршрутизація його чекає. Або дописати резолв у емітер, або видалити крейт-модуль (прецедент `mt-napi`). Тримати мертвим — найгірший із трьох варіантів. + +7. **Хто вирішує «вибір, а не баг».** `mandates.md` каже, що `decision-request` **ніколи не пише виконавець** — його пакує агент escalation-intake. Механіка розвилки готова, але судження робить людина, яка викликає `mt escalate`. Питання: чи заводимо цього агента як окремого актора (як зробили з аудитором і EngineerAgent), чи лишаємо ручний виклик як штатний шлях фази 0. + +8. **Rust-тести без CI.** Питання не «чи додати `cargo test` у workflow», а що робити з тим, що 9 із 306 тестів чутливі до навантаження: додати їх у CI як є — означає завести червоний, який навчаться ігнорувати. Треба або розвʼязати їх від реального часу (інʼєкція годинника, як у `presence`/`push`), або позначити `#[ignore]` і ганяти окремим повільним джобом. diff --git a/relay/.changes/260817-1037.md b/relay/.changes/260817-1037.md new file mode 100644 index 0000000..a697a90 --- /dev/null +++ b/relay/.changes/260817-1037.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Fixed +--- +eslint і cspell на повному наборі файлів (CI-набір, не дельта): ReDoS-схильні regex замінено циклом, node-глобали імпортовано явно, base64url через Uint8Array diff --git a/relay/lib/auth.mjs b/relay/lib/auth.mjs index 014d97d..4c44ebd 100644 --- a/relay/lib/auth.mjs +++ b/relay/lib/auth.mjs @@ -19,6 +19,20 @@ */ import { randomUUID } from 'node:crypto' +/** + * Прибирає кінцеві слеші без регулярного виразу. + * + * Наївний `/\/+$/` дає суперлінійний бектрекінг на рядку з довгим хвостом + * слешів — дешевий вектор ReDoS, тому це цикл, а не regex. + * @param {string} value адреса + * @returns {string} адреса без кінцевих слешів + */ +function stripTrailingSlashes(value) { + let end = value.length + while (end > 0 && value[end - 1] === '/') end -= 1 + return value.slice(0, end) +} + /** Дефолтний час життя сесії — доба (реалізація, не контракт). */ export const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000 @@ -121,7 +135,7 @@ export class KratosAuth { */ constructor({ store, baseUrl, fetch = globalThis.fetch }) { this.store = store - this.baseUrl = String(baseUrl ?? '').replace(/\/+$/, '') + this.baseUrl = stripTrailingSlashes(String(baseUrl ?? '')) this.fetch = fetch } diff --git a/relay/lib/create-auth.mjs b/relay/lib/create-auth.mjs index d0959fa..e1dc597 100644 --- a/relay/lib/create-auth.mjs +++ b/relay/lib/create-auth.mjs @@ -7,6 +7,7 @@ * `NODE_ENV`), щоб продакшн-режим не можна було ввімкнути наполовину: * без адреси Kratos перевіряти сесію нічим. */ +import process from 'node:process' import { DevMagicAuth, KratosAuth } from './auth.mjs' /** diff --git a/relay/lib/create-push.mjs b/relay/lib/create-push.mjs index c93d6c0..9829269 100644 --- a/relay/lib/create-push.mjs +++ b/relay/lib/create-push.mjs @@ -7,6 +7,7 @@ * без будь-якої з частин непрацездатний, і краще лишитись у dev-режимі * явно, ніж падати на першій нотифікації. */ +import process from 'node:process' import { FcmPushSink, GoogleAccessToken } from './fcm-sink.mjs' import { DevPushSink } from './push-sink.mjs' diff --git a/relay/lib/create-store.mjs b/relay/lib/create-store.mjs index 47d3192..e64ba7a 100644 --- a/relay/lib/create-store.mjs +++ b/relay/lib/create-store.mjs @@ -6,6 +6,7 @@ * перемикання не змінює поведінки relay — лише те, чи переживають дані * рестарт процесу. */ +import process from 'node:process' import { InMemoryStore } from './store.mjs' /** diff --git a/relay/lib/fcm-sink.mjs b/relay/lib/fcm-sink.mjs index 9b99009..233cb03 100644 --- a/relay/lib/fcm-sink.mjs +++ b/relay/lib/fcm-sink.mjs @@ -16,6 +16,21 @@ */ import { Buffer } from 'node:buffer' import { createSign } from 'node:crypto' +import { URLSearchParams } from 'node:url' + +/** + * Прибирає кінцеві слеші без регулярного виразу. + * + * Наївний `/\/+$/` дає суперлінійний бектрекінг на рядку з довгим хвостом + * слешів — дешевий вектор ReDoS, тому це цикл, а не regex. + * @param {string} value адреса + * @returns {string} адреса без кінцевих слешів + */ +function stripTrailingSlashes(value) { + let end = value.length + while (end > 0 && value[end - 1] === '/') end -= 1 + return value.slice(0, end) +} /** Скоуп, якого вимагає FCM HTTP v1. */ const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' @@ -32,7 +47,7 @@ const ASSERTION_TTL_SEC = 3600 * @returns {string} base64url */ function base64url(value) { - return Buffer.from(value).toString('base64url') + return Buffer.from(value).toBase64({ alphabet: 'base64url', omitPadding: true }) } /** @@ -123,7 +138,7 @@ export class FcmPushSink { this.projectId = projectId this.accessToken = accessToken this.fetch = fetch - this.endpoint = String(endpoint).replace(/\/+$/, '') + this.endpoint = stripTrailingSlashes(String(endpoint)) } /** diff --git a/relay/lib/presence.mjs b/relay/lib/presence.mjs index 9821238..cae703f 100644 --- a/relay/lib/presence.mjs +++ b/relay/lib/presence.mjs @@ -97,6 +97,6 @@ export class Presence { this.rooms.delete(root) return [] } - return [...room.values()] + return room.values().toArray() } } diff --git a/relay/lib/push.mjs b/relay/lib/push.mjs index 28ea107..69f9de2 100644 --- a/relay/lib/push.mjs +++ b/relay/lib/push.mjs @@ -28,6 +28,20 @@ const WAKE_COOLDOWN_MS = 30_000 /** Маршрутизатор push поверх store, кімнат і sink-а. */ export class PushRouter { + /** + * Класифікує подію: тип 3 (потребує уваги) чи ні. + * @param {object} event подія Envelope + * @returns {boolean} чи потребує уваги + */ + static needsAttention(event) { + if (ATTENTION_TYPES.has(event.type)) return true + if (event.type !== 'NodeState') return false + // `pending` — увага лише для явно призначеної людини з `notify: true` + // (h.md): інакше кожен вузол, що став до черги, будив би всю кімнату. + if (event.state === 'pending') return event.notify === true + return ATTENTION_STATES.has(event.state) + } + /** * @param {{ store: object, sink: object, rooms?: object, wakeCooldownMs?: number, now?: () => number }} deps залежності */ @@ -55,20 +69,6 @@ export class PushRouter { return true } - /** - * Класифікує подію: тип 3 (потребує уваги) чи ні. - * @param {object} event подія Envelope - * @returns {boolean} чи потребує уваги - */ - static needsAttention(event) { - if (ATTENTION_TYPES.has(event.type)) return true - if (event.type !== 'NodeState') return false - // `pending` — увага лише для явно призначеної людини з `notify: true` - // (h.md): інакше кожен вузол, що став до черги, будив би всю кімнату. - if (event.state === 'pending') return event.notify === true - return ATTENTION_STATES.has(event.state) - } - /** * Акаунти, що зараз мають живу підписку на кімнату. * @param {string} root кореневий вузол задачі diff --git a/relay/lib/tests/auth-contract.test.mjs b/relay/lib/tests/auth-contract.test.mjs index 6f83ee6..0f8a75b 100644 --- a/relay/lib/tests/auth-contract.test.mjs +++ b/relay/lib/tests/auth-contract.test.mjs @@ -81,7 +81,8 @@ authContract('kratos', () => { return { auth, tokenFor: async email => { - const token = `kratos-${(seq += 1)}` + seq += 1 + const token = `kratos-${seq}` stub.register(token, { active: true, identity: { traits: { email } } }) return token } @@ -92,7 +93,7 @@ describe('dev-magic: час життя сесії', () => { test('прострочена сесія відхиляється так само, як невідома', async () => { // «Прострочено» і «невідомо» навмисно нерозрізнювані для викликача: // різні повідомлення дали б оракул на існування токена. - let clock = 1_000 + let clock = 1000 const auth = new DevMagicAuth({ store: new InMemoryStore(), ttlMs: 100, now: () => clock }) const { token } = await auth.issueSession('ttl@x') expect(await auth.verifySession(token)).toBeTruthy() diff --git a/relay/lib/tests/membership-admin.test.mjs b/relay/lib/tests/membership-admin.test.mjs index a7902e6..fd119b1 100644 --- a/relay/lib/tests/membership-admin.test.mjs +++ b/relay/lib/tests/membership-admin.test.mjs @@ -44,7 +44,7 @@ beforeEach(async () => { describe('PATCH role', () => { test('owner міняє роль; у кімнату йде MemberChanged', async () => { const inbox = [] - await core.subscribe(ownerDevice, 'root-1', frame => inbox.push(frame)) + await core.subscribe(ownerDevice, 'root-1', frame => void inbox.push(frame)) await core.changeMemberRole(accounts.owner.account_id, 'root-1', accounts.host.account_id, 'approver') expect(await store.memberRole('root-1', accounts.host.account_id)).toBe('approver') @@ -91,7 +91,7 @@ describe('DELETE учасника', () => { test('owner прибирає учасника; MemberChanged несе role: null', async () => { // `role: null` — саме те, що протокол задає для видалення. const inbox = [] - await core.subscribe(ownerDevice, 'root-1', frame => inbox.push(frame)) + await core.subscribe(ownerDevice, 'root-1', frame => void inbox.push(frame)) await core.removeMember(accounts.owner.account_id, 'root-1', accounts.host.account_id) expect(await store.memberRole('root-1', accounts.host.account_id)).toBeNull() diff --git a/relay/lib/tests/presence.test.mjs b/relay/lib/tests/presence.test.mjs index 9d5e20c..dc030c5 100644 --- a/relay/lib/tests/presence.test.mjs +++ b/relay/lib/tests/presence.test.mjs @@ -114,7 +114,7 @@ describe('presence у ядрі relay', () => { test('announce транслює PresenceChanged учасникам кімнати', async () => { const inbox = [] - await core.subscribe(ownerDevice, 'root-1', frame => inbox.push(frame)) + await core.subscribe(ownerDevice, 'root-1', frame => void inbox.push(frame)) await core.announcePresence(ownerDevice, 'root-1', { hostname: 'mac-vitalii', projects: ['mt'], @@ -135,7 +135,7 @@ describe('presence у ядрі relay', () => { test('dropPresence транслює gone і прибирає із переліку', async () => { const inbox = [] await core.announcePresence(ownerDevice, 'root-1', { hostname: 'mac' }) - await core.subscribe(ownerDevice, 'root-1', frame => inbox.push(frame)) + await core.subscribe(ownerDevice, 'root-1', frame => void inbox.push(frame)) core.dropPresence(ownerDevice, 'root-1') expect(inbox.at(-1)).toMatchObject({ diff --git a/relay/lib/tests/push-sink-contract.test.mjs b/relay/lib/tests/push-sink-contract.test.mjs index 8577685..5e7952d 100644 --- a/relay/lib/tests/push-sink-contract.test.mjs +++ b/relay/lib/tests/push-sink-contract.test.mjs @@ -9,8 +9,8 @@ * relay формує документовані запити і правильно реагує на документовані * відповіді, і НЕ доводить доставки живим FCM. */ -import { Buffer } from 'node:buffer' import { generateKeyPairSync } from 'node:crypto' +import { TextDecoder } from 'node:util' import { describe, expect, test } from 'vitest' @@ -21,6 +21,15 @@ import { InMemoryStore } from '../store.mjs' const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) const PEM = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() +/** + * base64url → текст. + * @param {string} value сегмент JWT + * @returns {string} декодований текст + */ +function decodeBase64url(value) { + return new TextDecoder().decode(Uint8Array.fromBase64(value, { alphabet: 'base64url' })) +} + /** * Акаунт із зареєстрованим пристроєм і push-токеном. * @param {InMemoryStore} store store @@ -203,8 +212,8 @@ describe('fcm: форма запиту', () => { test('JWT-assertion підписаний і несе скоуп FCM', async () => { const { sink } = setup(OK_ROUTE) const [header, claims, signature] = sink.accessToken.assertion().split('.') - expect(JSON.parse(Buffer.from(header, 'base64url').toString())).toEqual({ alg: 'RS256', typ: 'JWT' }) - expect(JSON.parse(Buffer.from(claims, 'base64url').toString())).toMatchObject({ + expect(JSON.parse(decodeBase64url(header))).toEqual({ alg: 'RS256', typ: 'JWT' }) + expect(JSON.parse(decodeBase64url(claims))).toMatchObject({ iss: 'svc@proj.iam', scope: 'https://www.googleapis.com/auth/firebase.messaging', aud: 'https://oauth2.googleapis.com/token' diff --git a/relay/lib/tests/server.test.mjs b/relay/lib/tests/server.test.mjs index b7216b3..7f9db79 100644 --- a/relay/lib/tests/server.test.mjs +++ b/relay/lib/tests/server.test.mjs @@ -84,11 +84,11 @@ async function collectingSocket(port) { await once(socket, 'open') const waitFor = async matches => { for (;;) { - const index = inbox.findIndex(matches) + const index = inbox.findIndex(frame => matches(frame)) if (index >= 0) return inbox.splice(index, 1)[0] - await new Promise(resolve => { - notify = resolve - }) + const pending = Promise.withResolvers() + notify = pending.resolve + await pending.promise } } return { socket, waitFor } diff --git a/relay/lib/tests/store-contract.test.mjs b/relay/lib/tests/store-contract.test.mjs index 1f9fdeb..465dee0 100644 --- a/relay/lib/tests/store-contract.test.mjs +++ b/relay/lib/tests/store-contract.test.mjs @@ -10,6 +10,7 @@ * Обидві прогоняються завжди: SQLite бере `:memory:`-базу, тож перевірка * не потребує ні інфраструктури, ні змінних середовища. */ +import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -128,8 +129,8 @@ function storeContract(label, makeStore) { role: 'host', pubkey: key('new') }) - expect((await store.pubkeysFor(root)).map(k => k.device_id).sort()).toEqual( - [old.device_id, fresh.device_id].sort() + expect((await store.pubkeysFor(root)).map(k => k.device_id).toSorted()).toEqual( + [old.device_id, fresh.device_id].toSorted() ) await store.retireDevice(old.device_id, '2026-08-14T00:00:00Z')