From 11b2da99b6032f0b138fe735c5ef2d33627c562a Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Fri, 17 Jul 2026 14:30:55 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(relay,protocol,mt-core):?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20v4=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20multi-owner=20=E2=80=94=20membership=20?= =?UTF-8?q?=D0=BF=D0=BE=20WS,=20=D0=BF=D1=96=D0=B4=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20transfer,=20push,=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закриває чотири потреби owner-app (спека task/docs/specs/260714): - relay: WS-кадри invite/accept/decline/transfer_ownership/bootstrap_owners (ядро вміло, транспорту не було); transfer через WS — лише з Ed25519-підписом canonical-акта (домен mt-transfer-v4, верифікація node:crypto без залежностей); bootstrap_owners — ідемпотентний сідинг task_members з owner:-розмітки (зареєстровані → учасники + MemberChanged, решта → pending-запрошення без дублікатів) - relay push: інтерфейс sink + dev-реалізація; тип 2 «вас запрошено», тип 3 «потребує уваги» (PlanReview/AuditPending/NodeState:unresolvable — всім, крім автора; Escalation — адресно за to_account_id) - agent-protocol: Event::Escalation {from, to, to_account_id?, reason_ref} (handles як у git, PII не тече — account_id непрозорий); transfers.rs — TransferPayload + sign/verify (байт-у-байт дзеркало relay/lib/signing.mjs, закріплено тестом формату) - mt-core: directory.rs — канонічний парсер .mt/directory.json (git-ignored, handle → email/імʼя; PII-політика operations.md); .gitignore-запис - store: валідація hex-32 pubkey при реєстрації пристрою (формат, який очікує pubkey-кеш agent-server) Тести: relay 67/67 (vitest), agent-protocol 17/17, mt-core directory. Відомі преіснуючі падіння поза скоупом: agent-server failing_check_blocks_done_until_fixed і npm config.test (env-залежні, падають і на чистому origin/main). Co-Authored-By: Claude Fable 5 --- .cspell.json | 269 ++++++++++---------- .cursor/rules/n-bun.mdc | 1 + .cursor/rules/n-ga.mdc | 7 + .cursor/rules/n-js-run.mdc | 2 + .cursor/rules/n-js.mdc | 3 + .cursor/rules/n-npm-module.mdc | 3 + .cursor/rules/n-security.mdc | 1 + .cursor/rules/n-text.mdc | 8 + .gitignore | 3 + CLAUDE.md | 1 + crates/agent-protocol/src/docs/envelope.md | 2 +- crates/agent-protocol/src/docs/index.md | 1 + crates/agent-protocol/src/docs/lib.md | 3 +- crates/agent-protocol/src/docs/transfers.md | 35 +++ crates/agent-protocol/src/envelope.rs | 19 ++ crates/agent-protocol/src/lib.rs | 2 + crates/agent-protocol/src/transfers.rs | 140 ++++++++++ crates/mt-core/src/directory.rs | 109 ++++++++ crates/mt-core/src/docs/directory.md | 33 +++ crates/mt-core/src/docs/index.md | 1 + crates/mt-core/src/docs/lib.md | 2 +- crates/mt-core/src/docs/runner.md | 2 +- crates/mt-core/src/docs/signal.md | 2 +- crates/mt-core/src/lib.rs | 1 + mt/lint-fix-js-eslint-04a66bfd/a.md | 8 + mt/lint-fix-js-eslint-04a66bfd/task.md | 27 ++ mt/m0-dogfood-smoke/run_008.md | 1 + npm/.changes/260717-0000.md | 5 + npm/.changes/260717-1430.md | 5 + relay/.changes/260717-1412.md | 5 + relay/.changes/260717-1430.md | 5 + relay/lib/docs/index.md | 14 +- relay/lib/docs/push.md | 29 +++ relay/lib/docs/relay.md | 2 +- relay/lib/docs/server.md | 2 +- relay/lib/docs/signing.md | 34 +++ relay/lib/docs/store.md | 2 +- relay/lib/push.mjs | 84 ++++++ relay/lib/relay.mjs | 72 +++++- relay/lib/server.mjs | 41 ++- relay/lib/signing.mjs | 53 ++++ relay/lib/store.mjs | 29 +++ relay/lib/tests/relay.test.mjs | 152 ++++++++++- relay/lib/tests/server.test.mjs | 101 +++++++- 44 files changed, 1159 insertions(+), 162 deletions(-) create mode 100644 crates/agent-protocol/src/docs/transfers.md create mode 100644 crates/agent-protocol/src/transfers.rs create mode 100644 crates/mt-core/src/directory.rs create mode 100644 crates/mt-core/src/docs/directory.md create mode 100644 mt/lint-fix-js-eslint-04a66bfd/a.md create mode 100644 mt/lint-fix-js-eslint-04a66bfd/task.md create mode 100644 npm/.changes/260717-0000.md create mode 100644 npm/.changes/260717-1430.md create mode 100644 relay/.changes/260717-1412.md create mode 100644 relay/.changes/260717-1430.md create mode 100644 relay/lib/docs/push.md create mode 100644 relay/lib/docs/signing.md create mode 100644 relay/lib/push.mjs create mode 100644 relay/lib/signing.mjs diff --git a/.cspell.json b/.cspell.json index 5828d99..20ce678 100644 --- a/.cspell.json +++ b/.cspell.json @@ -19,51 +19,48 @@ "Cargo.lock" ], "words": [ - "AQID", - "Backpressure", - "CLIS", - "Deque", - "Dicklesworthstone", - "EEXIST", - "Gitea", - "Homebrew", - "Kratos", - "MSVC", - "NAPI", - "NCURSOR", - "Ollama", - "TOCTOU", - "Tauri", - "Worktree", "aaif", "agentclientprotocol", "alphanum", + "AQID", "backpressure", + "Backpressure", "cdylib", "chrono", "clippy", + "CLIS", "dalek", "deadbe", "deadbeefdeadbeefdead", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "Deque", + "Dicklesworthstone", "dlopen", "dogfooding", + "EEXIST", "eprintln", "fract", "fzf", "getw", + "Gitea", "hexdigit", + "Homebrew", "journaled", "keypair", + "Kratos", "libc", "libmt", "mktemp", "mktree", "msvc", + "MSVC", "napi", + "NAPI", + "NCURSOR", "nitralabs", "nowarn", "ollama", + "Ollama", "omlx", "oneshot", "pubkeys", @@ -84,7 +81,9 @@ "svkozak", "taskish", "tauri", + "Tauri", "taze", + "TOCTOU", "toplevel", "topo", "tungstenite", @@ -92,87 +91,57 @@ "vitaliytv", "vkozlov", "worktree", + "Worktree", "worktrees", "zigbuild", "ziglang", "Δcost", "Δfailed", "Δwall", - "Ідемпотентно", - "Інвалідувати", - "Ітеративно", - "Аддон", - "Активн", - "Антиінфляція", - "Аудитовний", - "Багатотенантний", - "Батчинг", - "Биндінг", - "Блокуючий", - "Бінарники", - "Валідує", - "Високоважільні", - "Гудхарт", - "Деривує", - "Задачно", - "Заскафолдити", - "Кворумних", - "Кворумні", - "Мерджимо", - "Мерджить", - "Мутабельні", - "Мікроменеджер", - "Мітигація", - "Оркестрація", - "Парсимо", - "Пасивн", - "Підпакет", - "Підписочний", - "Підписочні", - "Раннер", - "Резолвер", - "Резолвери", - "Резолвить", - "Санітизує", - "Скаффолд", - "Скриптований", - "Скіл", - "Спавнимо", - "Спавнить", "автозростання", "автопідйом", "автофіксом", "агрегуюча", "аддон", + "Аддон", "аддона", + "Активн", "алерт", "алерти", "алерту", + "Антиінфляція", "апрув", "апрувить", "арбітрує", "аудитний", "аудитовне", "аудитовний", + "Аудитовний", "аудитовними", + "Багатотенантний", "бампи", "батчинг", - "батчу", + "Батчинг", "батчі", + "батчу", "биндинги", - "блокуючий", - "бриджить", + "Биндінг", "білінгує", + "бінарі", "бінарника", "бінарники", + "Бінарники", "бінарником", "бінарнику", - "бінарі", "біндінги", "біндінгом", - "валідуючи", + "блокуючий", + "Блокуючий", + "бриджить", "валідує", + "Валідує", "валідується", + "валідуючи", "вендорський", "вендорських", "вербатим", @@ -182,21 +151,23 @@ "викликач", "викликача", "викликачів", - "воркспейс", - "воркспейсу", - "ворктрі", - "вузлово", + "Високоважільні", "відкатом", "відкату", "відповідається", "відправкою", "відправці", + "воркспейс", + "воркспейсу", + "ворктрі", + "вузлово", "гейт", "гейтах", "гейти", "гейтити", "гейтований", "гейтом", + "Гудхарт", "датасет", "двонаправлена", "девайси", @@ -208,6 +179,7 @@ "деплой", "деривованими", "деривує", + "Деривує", "десеріалізований", "десеріалізує", "десеріалізується", @@ -231,6 +203,7 @@ "журналюється", "журналяться", "завузити", + "Задачно", "задеприкейченого", "заділ", "заділом", @@ -238,15 +211,38 @@ "закомітив", "запрошувач", "запушеного", + "Заскафолдити", "збілджений", "збілдити", "змержити", + "Ідемпотентно", + "ін'єктується", + "ін'єктуються", + "інвалідація", + "інвалідована", + "інвалідований", + "інвалідованого", + "інвалідовує", + "інвалідовуються", + "інвалідування", + "Інвалідувати", + "інвалідує", + "інвентаря", + "інжект", + "інжектить", + "інжектиться", + "інжекту", + "інкрементує", + "інлайн", + "інтейк", + "ітеративно", + "Ітеративно", "каскадує", "кворумних", - "кворумної", + "Кворумних", "кворумні", - "комміти", - "компактує", + "Кворумні", + "кворумної", "коміт", "коміта", "коміти", @@ -254,28 +250,30 @@ "комітити", "комітить", "комітиться", + "комітів", "комітом", "комітяться", - "комітів", + "комміти", + "компактує", "конкуруючого", "консистентніший", "консюмер", "консюмери", "контеншн", - "конформанс", "конфігурованій", "конфігурює", + "конформанс", "креденшели", "крейти", "крейті", "крейтів", "курування", + "лінкує", "логер", "логіниться", - "лінкує", - "мапляться", "мапінг", "мапінгом", + "мапляться", "маршрутизує", "матчингу", "матчить", @@ -283,12 +281,17 @@ "машиночитаний", "мердж", "мерджимо", + "Мерджимо", "мерджить", + "Мерджить", "мерджі", "мерж", "мержаться", "мержить", "метасимволів", + "мідфлайт", + "Мікроменеджер", + "Мітигація", "мокати", "монорепа", "мультипартійним", @@ -297,9 +300,9 @@ "мультиучасники", "мультихост", "мутабельний", - "мутабельного", "мутабельні", - "мідфлайт", + "Мутабельні", + "мутабельного", "накочано", "невалідне", "невалідних", @@ -313,11 +316,11 @@ "незастейджено", "незмердженим", "незнайшли", + "неідемпотентний", + "неіснуючий", "непарсибельний", "непарсибельні", "неучасників", - "неідемпотентний", - "неіснуючий", "низьковажільне", "низьковажільні", "ноди", @@ -326,10 +329,13 @@ "однопострільні", "опробувані", "оркестрація", + "Оркестрація", "орфанованих", "падаюча", "падаючим", "паралелить", + "Парсимо", + "Пасивн", "патчить", "патчів", "перiод", @@ -343,6 +349,26 @@ "переперевірки", "перепідключаються", "персиститься", + "підвузлам", + "підвузлів", + "підграфових", + "підпакет", + "Підпакет", + "підпакета", + "підпакети", + "підпакетів", + "підпакетом", + "підписочний", + "Підписочний", + "підписочних", + "підписочні", + "Підписочні", + "підписочного", + "пінги", + "пінінг", + "пінуються", + "піскованi", + "пісковані", "платформенні", "платформозалежному", "побрейнштормимо", @@ -354,10 +380,10 @@ "програмкова", "програмковий", "продакшні", - "прокидуються", "прокидується", - "проксюються", + "прокидуються", "проксіює", + "проксюються", "промпт", "промпта", "промпти", @@ -367,78 +393,77 @@ "протоколний", "пулюються", "пушиться", - "підвузлам", - "підвузлів", - "підграфових", - "підпакет", - "підпакета", - "підпакети", - "підпакетом", - "підпакетів", - "підписочний", - "підписочних", - "підписочного", - "підписочні", - "пінги", - "пінуються", - "пінінг", - "піскованi", - "пісковані", + "ранів", "раннер", + "Раннер", "рантайму", - "ранів", "ребазом", "резолв", "резолвер", + "Резолвер", "резолвера", + "Резолвери", "резолверів", "резолвити", "резолвить", + "Резолвить", "резолвиться", "резолвляться", "резолвнуту", "резолву", + "реімплементації", + "реімплементовано", + "реімплементує", + "реімплементується", "реконект", "реконектиться", "реконекту", "реконектяться", "релея", "рендерять", - "реплей", - "реплею", "реплеєм", "реплеїть", "реплеїться", + "реплей", + "реплею", "ресканить", - "ретраю", "ретраєм", "ретраїв", "ретраїть", "ретраїться", + "ретраю", "рефетч", "рефетчем", "рефрешу", - "реімплементації", - "реімплементовано", - "реімплементує", - "реімплементується", "розпарсених", "розсинхрон", "роутінг", + "роутінговими", "роутінгових", + "роутінгові", "самопокращення", "санітизоване", + "Санітизує", "серіалізатором", "сесійність", "синхр", + "сіву", + "сідинг", + "Сідинг", + "сідить", "скаффолд", + "Скаффолд", + "скіл", + "Скіл", + "скіла", "скоуплені", "скриптований", + "Скриптований", "скриптованим", "скриптує", "скріншоти", - "скіл", - "скіла", + "Спавнимо", + "Спавнить", "спавну", "спайком", "спуфінг", @@ -448,7 +473,6 @@ "стріму", "схвалювачів", "схлопнутий", - "сіву", "таргети", "тенанта", "тенанти", @@ -456,12 +480,12 @@ "термінацію", "термінація", "типо", + "тікеті", + "тікетом", "топосорт", "трейдофів", - "тулчейну", "тулчейнів", - "тікетом", - "тікеті", + "тулчейну", "уточнюючі", "фактум", "фейл", @@ -469,16 +493,16 @@ "фенсить", "фенситься", "фетчиться", + "фікстури", "флагів", "флоу", "фолбек", "фолбеком", "форвардить", "фронтенд", + "фронтматері", "фронтматером", "фронтматеру", - "фронтматері", - "фікстури", "хелперами", "хелпери", "хендшейк", @@ -488,26 +512,7 @@ "чекаючий", "шима", "шимом", - "юзає", - "ін'єктуються", - "ін'єктується", - "інвалідація", - "інвалідована", - "інвалідований", - "інвалідованого", - "інвалідовуються", - "інвалідовує", - "інвалідування", - "інвалідує", - "інвентаря", - "інжект", - "інжектить", - "інжектиться", - "інжекту", - "інкрементує", - "інлайн", - "інтейк", - "ітеративно" + "юзає" ], "useGitignore": true, "gitignoreRoot": "." diff --git a/.cursor/rules/n-bun.mdc b/.cursor/rules/n-bun.mdc index 46e2fb6..0b05078 100644 --- a/.cursor/rules/n-bun.mdc +++ b/.cursor/rules/n-bun.mdc @@ -15,6 +15,7 @@ version: '2.1' "dependencies": "кореневий package.json не повинен містити dependencies — додай у workspace-пакети (bun.mdc)" } ``` + - `bunfig.toml`: ```toml diff --git a/.cursor/rules/n-ga.mdc b/.cursor/rules/n-ga.mdc index 932992c..7b6eb23 100644 --- a/.cursor/rules/n-ga.mdc +++ b/.cursor/rules/n-ga.mdc @@ -12,6 +12,7 @@ alwaysApply: false ```json { "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } } ``` + - `git-ai.yml`: ```yaml @@ -46,6 +47,7 @@ jobs: git config --global user.email "github-actions[bot]@users.noreply.github.com" git-ai ci github run ``` + - `clean-ga-workflows.yml`: ```yaml @@ -76,6 +78,7 @@ jobs: save_period: 31 save_min_runs_number: 0 ``` + - `lint-ga.yml`: ```yaml @@ -121,6 +124,7 @@ jobs: - name: Lint GA run: bunx n-rules lint ga --no-fix ``` + - `zizmor.yml`: ```yaml @@ -130,6 +134,7 @@ rules: policies: '*': ref-pin ``` + - `clean-merged-branch.yml`: ```yaml @@ -171,11 +176,13 @@ jobs: run: | echo "Deleted branches: ${DELETED_BRANCHES}" ``` + - `extensions.json`: ```json { "recommendations": ["github.vscode-github-actions"] } ``` + - `uses-min-versions`: ```json diff --git a/.cursor/rules/n-js-run.mdc b/.cursor/rules/n-js-run.mdc index 7877a2c..ab8bbc0 100644 --- a/.cursor/rules/n-js-run.mdc +++ b/.cursor/rules/n-js-run.mdc @@ -25,6 +25,7 @@ Rego-пакети, які запускає `npx @7n/rules fix js-run` / `npx @7n "include": ["src/**/*"] } ``` + - `configmap.yaml`: ```yaml @@ -33,6 +34,7 @@ data: - 'service.name=' - 'service.namespace=' ``` + - `package.json`: ```json diff --git a/.cursor/rules/n-js.mdc b/.cursor/rules/n-js.mdc index 4e15448..53cce84 100644 --- a/.cursor/rules/n-js.mdc +++ b/.cursor/rules/n-js.mdc @@ -57,6 +57,7 @@ jobs: - name: Eslint run: bunx n-rules lint js --no-fix ``` + - `.jscpd.json`: ```json @@ -68,6 +69,7 @@ jobs: "ignore": [".claude/worktrees/**", "**/dist/**", "**/CHANGELOG.md"] } ``` + - `extensions.json`: ```json @@ -75,6 +77,7 @@ jobs: "recommendations": ["dbaeumer.vscode-eslint", "github.vscode-github-actions", "oxc.oxc-vscode"] } ``` + - `package.json`: ```json diff --git a/.cursor/rules/n-npm-module.mdc b/.cursor/rules/n-npm-module.mdc index babc087..d383a91 100644 --- a/.cursor/rules/n-npm-module.mdc +++ b/.cursor/rules/n-npm-module.mdc @@ -37,6 +37,7 @@ Rego-пакети (запускаються через `npx @7n/rules fix`): ```json { "files": ["types"] } ``` + - `npm-publish.yml`: ```yaml @@ -90,11 +91,13 @@ jobs: with: package: npm/package.json ``` + - `package.json`: ```json { "workspaces": ["npm"] } ``` + - `tsconfig.emit-types.json`: ```json diff --git a/.cursor/rules/n-security.mdc b/.cursor/rules/n-security.mdc index 83ae85b..9fc38b9 100644 --- a/.cursor/rules/n-security.mdc +++ b/.cursor/rules/n-security.mdc @@ -46,6 +46,7 @@ jobs: with: extra_args: --results=verified,unknown ``` + - `package.json`: ```json diff --git a/.cursor/rules/n-text.mdc b/.cursor/rules/n-text.mdc index 6b49fc0..e4d2b60 100644 --- a/.cursor/rules/n-text.mdc +++ b/.cursor/rules/n-text.mdc @@ -23,6 +23,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis "[html]": { "editor.defaultFormatter": "oxc.oxc-vscode" } } ``` + - `.oxfmtrc.json`: ```json @@ -35,6 +36,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis "ignorePatterns": ["**/hasura/metadata/**", "**/schema.graphql", "**/auto-imports.d.ts"] } ``` + - `.markdownlint-cli2.jsonc`: ``` @@ -50,6 +52,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis } } ``` + - `.cspell.json`: ```json @@ -69,6 +72,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis ] } ``` + - `.cspell.json`: ```json @@ -78,6 +82,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis } } ``` + - `.cspell.json`: ```json @@ -85,6 +90,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis "import": ["@nitra/cspell-dict"] } ``` + - `extensions.json`: ```json @@ -92,6 +98,7 @@ Rego-пакети, що перевіряються через conftest (auto-dis "recommendations": ["DavidAnson.vscode-markdownlint", "oxc.oxc-vscode", "timonwong.shellcheck"] } ``` + - `lint-text.yml`: ```yaml @@ -157,6 +164,7 @@ jobs: - name: Lint text run: bunx n-rules lint text --no-fix ``` + - `package.json`: ```json diff --git a/.gitignore b/.gitignore index c03b3ca..2465cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ packages/*/*.node # Python bytecode (випадкові артефакти, джерел .py у репо немає) __pycache__/ *.pyc + +# PII-мапінг handle → email (operations.md) — ніколи в git +.mt/directory.json diff --git a/CLAUDE.md b/CLAUDE.md index 67bd2a8..90e28b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ ## Захищені директорії Ніколи не змінюй, не видаляй і не створюй файли у цих директоріях: + - `.worktrees/` @.cursor/rules/n-adr.mdc diff --git a/crates/agent-protocol/src/docs/envelope.md b/crates/agent-protocol/src/docs/envelope.md index f06063a..bbbb5c0 100644 --- a/crates/agent-protocol/src/docs/envelope.md +++ b/crates/agent-protocol/src/docs/envelope.md @@ -3,7 +3,7 @@ type: Rust Module title: envelope.rs resource: crates/agent-protocol/src/envelope.rs docgen: - crc: 7f5cb0ad + crc: 56f75017 model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.99 diff --git a/crates/agent-protocol/src/docs/index.md b/crates/agent-protocol/src/docs/index.md index d01e37a..c25ea9a 100644 --- a/crates/agent-protocol/src/docs/index.md +++ b/crates/agent-protocol/src/docs/index.md @@ -10,3 +10,4 @@ resource: crates/agent-protocol/src/ | [envelope.rs](envelope.md) | Rust Module | | [handshake.rs](handshake.md) | Rust Module | | [lib.rs](lib.md) | Rust Module | +| [transfers.rs](transfers.md) | Rust Module | diff --git a/crates/agent-protocol/src/docs/lib.md b/crates/agent-protocol/src/docs/lib.md index 03ea05d..bf3cef0 100644 --- a/crates/agent-protocol/src/docs/lib.md +++ b/crates/agent-protocol/src/docs/lib.md @@ -3,9 +3,8 @@ type: Rust Module title: lib.rs resource: crates/agent-protocol/src/lib.rs docgen: - crc: 78efc0e6 + crc: 4844f2b9 model: openai-codex/gpt-5.4-mini - tier: cloud-min score: 100 issues: judge:inaccurate:0.97 judgeModel: openai-codex/gpt-5.4-mini diff --git a/crates/agent-protocol/src/docs/transfers.md b/crates/agent-protocol/src/docs/transfers.md new file mode 100644 index 0000000..241c693 --- /dev/null +++ b/crates/agent-protocol/src/docs/transfers.md @@ -0,0 +1,35 @@ +--- +type: Rust Module +title: transfers.rs +resource: crates/agent-protocol/src/transfers.rs +docgen: + crc: d2fec6bc + model: openai-codex/gpt-5.4-mini + score: 100 + issues: judge:inaccurate:0.99 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Файл описує canonical акт transfer ownership у Membership API та його byte-for-byte сумісність із relay через доменний префікс і NUL-роздільник полів. Він потрібен, щоб пристрій поточного owner-а міг підписати цей акт Ed25519, а relay — перевірити підпис проти pubkey пристрою поточного owner-а за тим самим форматом повідомлення. `TransferPayload`, `message`, `sign_transfer`, `verify_transfer` працюють read-only, fail-safe, не кидають винятків назовні й за окремих помилок повертають порожнє значення замість винятку. + +## Поведінка + +- `TransferPayload` — описує акт передачі ownership між поточним і новим owner для кореневої задачі. +- `message` — формує canonical bytes акта transfer для підпису й перевірки, сумісні з relay. +- `sign_transfer` — створює Ed25519-підпис акта transfer приватним ключем пристрою owner-а. +- `verify_transfer` — перевіряє підпис акта transfer проти public key пристрою та повертає помилку, якщо підпис хибний або має некоректну довжину. + +## Публічний API + +- TransferPayload — Фіксує акт передачі права власності на кореневу задачу. +- message — Формує канонічні байти акта передачі для підпису й звірки. +- sign_transfer — Підписує акт transfer ключем пристрою власника. +- verify_transfer — Звіряє підпис акта з pubkey пристрою, що ініціював передачу. + +## Гарантії поведінки + +- Read-only: не виконує операцій запису (ФС/БД). +- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-protocol/src/envelope.rs b/crates/agent-protocol/src/envelope.rs index 3571260..bf3550c 100644 --- a/crates/agent-protocol/src/envelope.rs +++ b/crates/agent-protocol/src/envelope.rs @@ -151,6 +151,19 @@ pub enum Event { AuditPending { fact_ref: String, }, + /// Ескалація «вгору»: записка власника гілки замовникові вузла + /// (owner-app, спека 260714). `from`/`to` — handles, як у git-файлах + /// (`escalation_NNN.md`); `to_account_id` резолвиться емітером через + /// git-ignored `.mt/directory.json` (PII у стрічку не тече — account_id + /// непрозорий) і потрібен relay для адресного push «потребує уваги». + Escalation { + from: String, + to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + to_account_id: Option, + /// Шлях записки у теці вузла, напр. `escalation_001.md`. + reason_ref: String, + }, Error { message: String, }, @@ -277,6 +290,12 @@ mod tests { Event::AuditPending { fact_ref: "refs/mt/runs/x/fact".into(), }, + Event::Escalation { + from: "olena".into(), + to: "vkozlov".into(), + to_account_id: Some(Uuid::from_u128(6)), + reason_ref: "escalation_001.md".into(), + }, Event::Error { message: "boom".into(), }, diff --git a/crates/agent-protocol/src/lib.rs b/crates/agent-protocol/src/lib.rs index 253e32d..4f4e5aa 100644 --- a/crates/agent-protocol/src/lib.rs +++ b/crates/agent-protocol/src/lib.rs @@ -9,6 +9,7 @@ pub mod approvals; pub mod envelope; pub mod handshake; +pub mod transfers; pub use approvals::{ sign_approval, verify_approval, ApprovalError, ApprovalPayload, Signature, SigningKey, @@ -16,6 +17,7 @@ pub use approvals::{ }; pub use envelope::{ClaimInfo, Envelope, Event, Rect}; pub use handshake::{check_protocol_version, ClientHello, ProtocolError, ServerHello, SessionInfo}; +pub use transfers::{sign_transfer, verify_transfer, TransferPayload}; /// Поточна версія протоколу подій. v1/v2 — історія scaffold-spec; v3 — /// проміжний draft без `lang`. Несумісна версія відхиляється на хендшейку diff --git a/crates/agent-protocol/src/transfers.rs b/crates/agent-protocol/src/transfers.rs new file mode 100644 index 0000000..f78dfd8 --- /dev/null +++ b/crates/agent-protocol/src/transfers.rs @@ -0,0 +1,140 @@ +//! Ed25519-підпис акта transfer ownership (access.md, «Membership API»). +//! +//! Передача власності задачі — криптографічний факт, а не лише право +//! device_token-а: пристрій поточного owner-а підписує canonical-акт, +//! relay перевіряє підпис проти pubkey пристрою (relay/lib/signing.mjs — +//! байт-у-байт той самий формат повідомлення). Механіка віддзеркалює +//! approvals: доменний префікс + NUL-роздільник полів. + +use ed25519_dalek::Signer; +use uuid::Uuid; + +pub use crate::approvals::ApprovalError; +use crate::approvals::{Signature, SigningKey, VerifyingKey}; + +/// Доменний префікс canonical-акта transfer — підпис не переноситься +/// в контекст approvals і навпаки. +const DOMAIN: &[u8] = b"mt-transfer-v4"; + +/// Акт передачі власності кореневої задачі. +#[derive(Debug, Clone, PartialEq)] +pub struct TransferPayload { + /// Кореневий вузол задачі (кімната relay). + pub root_node_hash: String, + /// Поточний owner (акаунт-ініціатор). + pub from_account: Uuid, + /// Новий owner (мусить бути учасником задачі). + pub to_account: Uuid, +} + +impl TransferPayload { + /// Canonical-байти акта: `DOMAIN \0 root \0 from(hyphenated) \0 + /// to(hyphenated)` — дзеркало `transferMessage` у relay (JS). + pub fn message(&self) -> Vec { + let from = self.from_account.hyphenated().to_string(); + let to = self.to_account.hyphenated().to_string(); + let mut message = Vec::with_capacity( + DOMAIN.len() + self.root_node_hash.len() + from.len() + to.len() + 3, + ); + message.extend_from_slice(DOMAIN); + message.push(0); + message.extend_from_slice(self.root_node_hash.as_bytes()); + message.push(0); + message.extend_from_slice(from.as_bytes()); + message.push(0); + message.extend_from_slice(to.as_bytes()); + message + } +} + +/// Підписати акт transfer приватним ключем пристрою owner-а. +/// У WS-кадр `transfer_ownership` підпис їде як base64 від `to_bytes()`. +pub fn sign_transfer(key: &SigningKey, payload: &TransferPayload) -> Signature { + key.sign(&payload.message()) +} + +/// Перевірити підпис акта проти pubkey пристрою-ініціатора. +pub fn verify_transfer( + pubkey: &VerifyingKey, + payload: &TransferPayload, + signature: &[u8], +) -> Result<(), ApprovalError> { + let bytes: [u8; 64] = signature + .try_into() + .map_err(|_| ApprovalError::BadSignatureLength { + actual: signature.len(), + })?; + pubkey + .verify_strict(&payload.message(), &Signature::from_bytes(&bytes)) + .map_err(|_| ApprovalError::VerificationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> TransferPayload { + TransferPayload { + root_node_hash: "r".repeat(20), + from_account: Uuid::from_u128(1), + to_account: Uuid::from_u128(2), + } + } + + fn key() -> SigningKey { + SigningKey::from_bytes(&[7u8; 32]) + } + + #[test] + fn sign_then_verify_ok() { + let signature = sign_transfer(&key(), &payload()); + assert_eq!( + verify_transfer(&key().verifying_key(), &payload(), &signature.to_bytes()), + Ok(()) + ); + } + + /// Підміна отримувача інвалідовує підпис — transfer не «переадресувати». + #[test] + fn tampered_recipient_fails() { + let bytes = sign_transfer(&key(), &payload()).to_bytes(); + let tampered = TransferPayload { + to_account: Uuid::from_u128(3), + ..payload() + }; + assert_eq!( + verify_transfer(&key().verifying_key(), &tampered, &bytes), + Err(ApprovalError::VerificationFailed) + ); + } + + /// Домен відрізняє transfer від approval: підпис approval-повідомлення + /// тим самим ключем не проходить як transfer. + #[test] + fn approval_domain_signature_is_rejected() { + let approval = crate::approvals::ApprovalPayload { + request_id: "req".into(), + approved: true, + node_hash: payload().root_node_hash, + run_token: Uuid::from_u128(9), + }; + let bytes = crate::approvals::sign_approval(&key(), &approval).to_bytes(); + assert_eq!( + verify_transfer(&key().verifying_key(), &payload(), &bytes), + Err(ApprovalError::VerificationFailed) + ); + } + + /// Формат повідомлення — стабільний контракт із relay (signing.mjs): + /// domain і поля через NUL, uuid — hyphenated lowercase. + #[test] + fn message_matches_relay_canonical_format() { + let expected = format!( + "mt-transfer-v4\0{}\0{}\0{}", + "r".repeat(20), + Uuid::from_u128(1).hyphenated(), + Uuid::from_u128(2).hyphenated() + ); + assert_eq!(payload().message(), expected.as_bytes()); + } +} diff --git a/crates/mt-core/src/directory.rs b/crates/mt-core/src/directory.rs new file mode 100644 index 0000000..b5b89a3 --- /dev/null +++ b/crates/mt-core/src/directory.rs @@ -0,0 +1,109 @@ +//! Directory: мапінг handle → PII (`.mt/directory.json`, git-ignored). +//! +//! PII-політика (operations.md): у git-файлах вузлів живуть лише handles +//! (`assignee: vkozlov`, `owner: olena`, `from`/`to` ескалацій) — email та +//! імʼя людини лишаються поза історією, у локальному `.mt/directory.json` +//! і на relay (`accounts.email`). Цей модуль — канонічний парсер файлу; +//! читання ФС лишається на боці викликача (як у `config`). +//! +//! Формат — плоский обʼєкт: значення або рядок-email, або обʼєкт +//! `{ "email": "...", "name": "..." }`: +//! +//! ```json +//! { +//! "vkozlov": "v.kozlov@example.com", +//! "olena": { "email": "olena@example.com", "name": "Олена" } +//! } +//! ``` + +use std::collections::HashMap; + +use serde_json::Value; + +/// Канонічний шлях directory-файлу відносно кореня репо. +pub const DIRECTORY_PATH: &str = ".mt/directory.json"; + +/// PII одного handle: email — ключ мапінгу на relay-акаунт, імʼя — display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryEntry { + pub email: String, + pub name: Option, +} + +/// Розбирає сирий текст `.mt/directory.json` у мапінг handle → PII. +/// Відсутній файл (`None`), битий JSON чи не-обʼєкт → порожній мапінг +/// (нерозмічена directory — штатний стан, не помилка). Невалідні значення +/// (без email) пропускаються. +pub fn parse_directory(raw: Option<&str>) -> HashMap { + let Some(raw) = raw else { + return HashMap::new(); + }; + let Ok(Value::Object(entries)) = serde_json::from_str::(raw) else { + return HashMap::new(); + }; + entries + .into_iter() + .filter_map(|(handle, value)| { + let entry = match value { + Value::String(email) if !email.trim().is_empty() => DirectoryEntry { + email: email.trim().to_string(), + name: None, + }, + Value::Object(fields) => DirectoryEntry { + email: fields.get("email")?.as_str()?.trim().to_string(), + name: fields + .get("name") + .and_then(Value::as_str) + .map(str::to_string), + }, + _ => return None, + }; + (!entry.email.is_empty()).then_some((handle, entry)) + }) + .collect() +} + +/// Email за handle-ом (None — handle поза directory: емітер не може +/// резолвити адресний push, події їдуть без `to_account_id`). +pub fn resolve_email<'a>( + directory: &'a HashMap, + handle: &str, +) -> Option<&'a str> { + directory.get(handle).map(|entry| entry.email.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_string_and_object_entries() { + let raw = r#"{ + "vkozlov": "v.kozlov@example.com", + "olena": { "email": " olena@example.com ", "name": "Олена" }, + "broken": { "name": "без email" }, + "empty": " " + }"#; + let directory = parse_directory(Some(raw)); + assert_eq!(directory.len(), 2); + assert_eq!( + resolve_email(&directory, "vkozlov"), + Some("v.kozlov@example.com") + ); + assert_eq!( + directory.get("olena"), + Some(&DirectoryEntry { + email: "olena@example.com".into(), + name: Some("Олена".into()) + }) + ); + assert_eq!(resolve_email(&directory, "broken"), None); + } + + #[test] + fn missing_or_invalid_file_is_empty_mapping() { + assert!(parse_directory(None).is_empty()); + assert!(parse_directory(Some("не json")).is_empty()); + assert!(parse_directory(Some("[1,2]")).is_empty()); + } +} diff --git a/crates/mt-core/src/docs/directory.md b/crates/mt-core/src/docs/directory.md new file mode 100644 index 0000000..b309c63 --- /dev/null +++ b/crates/mt-core/src/docs/directory.md @@ -0,0 +1,33 @@ +--- +type: Rust Module +title: directory.rs +resource: crates/mt-core/src/directory.rs +docgen: + crc: 5ce11be5 + model: openai-codex/gpt-5.4-mini + tier: cloud-min + score: 100 + issues: judge:inaccurate:0.98 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Канонічний парсер вмісту `.mt/directory.json`: перетворює flat-мапінг `handle → PII` у структуру для подальшого використання в коді, щоб у git-файлах лишалися лише handles, а email та імʼя людини зберігалися поза історією. Для рішень, що спираються на цей формат, орієнтиром є конфіг `directory.json`. Публічний `resolve_email` повертає email для заданого handle або `null`, якщо запису немає чи вхідні дані не дають однозначного результату. Для частини некоректних або неповних даних модуль теж повертає порожнє значення замість винятку. + +## Поведінка + +- DirectoryEntry — зберігає PII для одного handle: email як основний ідентифікатор для relay та optional display name. +- parse_directory — перетворює вміст `.mt/directory.json` на мапінг handle → PII; відсутній або невалідний вміст дає порожній мапінг. +- resolve_email — повертає email для заданого handle або `null`, якщо handle відсутній у directory. + +## Публічний API + +- DirectoryEntry — запис directory для одного handle: email як ключ relay-акаунта, name як display-імʼя +- parse_directory — читає `.mt/directory.json` і будує мапінг handle → PII; якщо файла нема, JSON зламаний або корінь не обʼєкт, повертає порожній мапінг; записи без email ігнорує +- resolve_email — повертає email для handle; якщо handle не знайдено, віддає `None`, тож адресний push без `to_account_id` не формується + +## Гарантії поведінки + +- Read-only: не виконує операцій запису (ФС/БД). +- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/index.md b/crates/mt-core/src/docs/index.md index bedbbd3..d542776 100644 --- a/crates/mt-core/src/docs/index.md +++ b/crates/mt-core/src/docs/index.md @@ -9,6 +9,7 @@ resource: crates/mt-core/src/ | [artifacts.rs](artifacts.md) | Rust Module | | [claims.rs](claims.md) | Rust Module | | [config.rs](config.md) | Rust Module | +| [directory.rs](directory.md) | Rust Module | | [frontmatter.rs](frontmatter.md) | Rust Module | | [ledger.rs](ledger.md) | Rust Module | | [lib.rs](lib.md) | Rust Module | diff --git a/crates/mt-core/src/docs/lib.md b/crates/mt-core/src/docs/lib.md index f840cab..0c95a82 100644 --- a/crates/mt-core/src/docs/lib.md +++ b/crates/mt-core/src/docs/lib.md @@ -3,7 +3,7 @@ type: Rust Module title: lib.rs resource: crates/mt-core/src/lib.rs docgen: - crc: 13acb427 + crc: fb050001 model: omlx/gemma-4-e2b-it-4bit score: 85 --- diff --git a/crates/mt-core/src/docs/runner.md b/crates/mt-core/src/docs/runner.md index 50da499..5316034 100644 --- a/crates/mt-core/src/docs/runner.md +++ b/crates/mt-core/src/docs/runner.md @@ -3,7 +3,7 @@ type: Rust Module title: runner.rs resource: crates/mt-core/src/runner.rs docgen: - crc: 9451eb6c + crc: a9df4181 model: omlx/gemma-4-e2b-it-4bit score: 95 --- diff --git a/crates/mt-core/src/docs/signal.md b/crates/mt-core/src/docs/signal.md index 571b970..5859bac 100644 --- a/crates/mt-core/src/docs/signal.md +++ b/crates/mt-core/src/docs/signal.md @@ -3,7 +3,7 @@ type: Rust Module title: signal.rs resource: crates/mt-core/src/signal.rs docgen: - crc: 63679729 + crc: dc1e4917 model: omlx/gemma-4-e2b-it-4bit score: 100 --- diff --git a/crates/mt-core/src/lib.rs b/crates/mt-core/src/lib.rs index f8074b9..447ca41 100644 --- a/crates/mt-core/src/lib.rs +++ b/crates/mt-core/src/lib.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; pub mod artifacts; pub mod claims; pub mod config; +pub mod directory; pub mod frontmatter; pub mod ledger; pub mod lifecycle; diff --git a/mt/lint-fix-js-eslint-04a66bfd/a.md b/mt/lint-fix-js-eslint-04a66bfd/a.md new file mode 100644 index 0000000..ab5ea4f --- /dev/null +++ b/mt/lint-fix-js-eslint-04a66bfd/a.md @@ -0,0 +1,8 @@ +## Model tier + +AVG + +## Skills + +- bash +- write-files diff --git a/mt/lint-fix-js-eslint-04a66bfd/task.md b/mt/lint-fix-js-eslint-04a66bfd/task.md new file mode 100644 index 0000000..31b2a90 --- /dev/null +++ b/mt/lint-fix-js-eslint-04a66bfd/task.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +created_at: 2026-07-17T11:29:02.512Z +budget_sec: 1800 +audit: required +hint: atomic +--- + +## Task + +Виправити порушення правила `js` (concern `eslint`), які не закрила інлайн fix-драбина. + +## Done when + +- `js` не повідомляє порушень у target-файлах (див. ## Check). + +## Check + +npx @7n/rules lint --no-fix --cwd ../.. js + +## Inputs + +Target-файли: +- `relay/lib/push.mjs` +- `relay/lib/signing.mjs` +- `relay/lib/tests/relay.test.mjs` +- `relay/lib/tests/server.test.mjs` diff --git a/mt/m0-dogfood-smoke/run_008.md b/mt/m0-dogfood-smoke/run_008.md index 0607ba7..322bf80 100644 --- a/mt/m0-dogfood-smoke/run_008.md +++ b/mt/m0-dogfood-smoke/run_008.md @@ -14,6 +14,7 @@ fact записано, але ## Check не пройшов (fact відклик ## Blockers ## Check failed: `grep -q 'acp smoke ok' note.md` → exit 2 + grep: note.md: No such file or directory ## Next Attempt diff --git a/npm/.changes/260717-0000.md b/npm/.changes/260717-0000.md new file mode 100644 index 0000000..e900b65 --- /dev/null +++ b/npm/.changes/260717-0000.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Changed +--- +Зафіксовано поточні зміни npm-пакета. diff --git a/npm/.changes/260717-1430.md b/npm/.changes/260717-1430.md new file mode 100644 index 0000000..cf82d46 --- /dev/null +++ b/npm/.changes/260717-1430.md @@ -0,0 +1,5 @@ +--- +bump: minor +section: Added +--- +Протокол v4 для multi-owner (owner-app, спека 260714): WS-кадри membership (invite/accept/decline/transfer_ownership/bootstrap_owners), Ed25519-підписаний акт transfer (mt-transfer-v4, дзеркальні sign_transfer/verify_transfer у agent-protocol і signing.mjs relay), push-модуль (тип 2 «запрошено», тип 3 «потребує уваги» + адресна Escalation), Event::Escalation у протоколі, directory-модуль mt-core (.mt/directory.json, handle → email поза git), валідація hex-pubkey пристроїв diff --git a/relay/.changes/260717-1412.md b/relay/.changes/260717-1412.md new file mode 100644 index 0000000..a1ac9e6 --- /dev/null +++ b/relay/.changes/260717-1412.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Changed +--- +release: @7n/mt@0.26.1 diff --git a/relay/.changes/260717-1430.md b/relay/.changes/260717-1430.md new file mode 100644 index 0000000..cf82d46 --- /dev/null +++ b/relay/.changes/260717-1430.md @@ -0,0 +1,5 @@ +--- +bump: minor +section: Added +--- +Протокол v4 для multi-owner (owner-app, спека 260714): WS-кадри membership (invite/accept/decline/transfer_ownership/bootstrap_owners), Ed25519-підписаний акт transfer (mt-transfer-v4, дзеркальні sign_transfer/verify_transfer у agent-protocol і signing.mjs relay), push-модуль (тип 2 «запрошено», тип 3 «потребує уваги» + адресна Escalation), Event::Escalation у протоколі, directory-модуль mt-core (.mt/directory.json, handle → email поза git), валідація hex-pubkey пристроїв diff --git a/relay/lib/docs/index.md b/relay/lib/docs/index.md index 4963115..a22f527 100644 --- a/relay/lib/docs/index.md +++ b/relay/lib/docs/index.md @@ -4,9 +4,11 @@ title: relay/lib resource: relay/lib/ --- -| Файл | Тип | -| ----------------------- | --------- | -| [relay.mjs](relay.md) | JS Module | -| [rooms.mjs](rooms.md) | JS Module | -| [server.mjs](server.md) | JS Module | -| [store.mjs](store.md) | JS Module | +| Файл | Тип | +| ------------------------- | --------- | +| [push.mjs](push.md) | JS Module | +| [relay.mjs](relay.md) | JS Module | +| [rooms.mjs](rooms.md) | JS Module | +| [server.mjs](server.md) | JS Module | +| [signing.mjs](signing.md) | JS Module | +| [store.mjs](store.md) | JS Module | diff --git a/relay/lib/docs/push.md b/relay/lib/docs/push.md new file mode 100644 index 0000000..4f420e2 --- /dev/null +++ b/relay/lib/docs/push.md @@ -0,0 +1,29 @@ +--- +type: JS Module +title: push.mjs +resource: relay/lib/push.mjs +docgen: + crc: a554bd6d + model: openai-codex/gpt-5.5 + score: 100 + issues: judge:inaccurate:0.98 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Файл задає relay для push-нотифікацій типів `2` («вас запрошено») і `3` («задача потребує уваги»), щоб відокремити маршрутизацію подій від реальної FCM-доставки. `PushRouter` спрямовує push за `event.type` і адресним `event.to_account_id`, спираючись на конфіг `directory.json` для резолву отримувача, а `DevPushSink` як dev-реалізація зберігає події в памʼяті через sink-контракт. + +## Поведінка + +- `DevPushSink` накопичує dev-доставки push-нотифікацій у памʼяті замість реальної FCM-доставки, щоб relay мав той самий контракт sink-а для сценаріїв «вас запрошено» і «задача потребує уваги». +- `PushRouter` маршрутизує push-нотифікації за даними store та sink-а: надсилає запрошення на наявний акаунт, а attention-події — адресату або учасникам задачі без автора; для адресних подій спирається на резолв отримувача через `directory.json` і не розбирає payload далі роутінгових полів. + +## Публічний API + +- DevPushSink — накопичує push-доставки в памʼяті для dev-сценаріїв; спирається на `directory.json`. +- PushRouter — спрямовує push-повідомлення через сховище і sink для єдиного маршруту доставки; спирається на `directory.json`. + +## Гарантії поведінки + +- Read-only: не виконує операцій запису (ФС/БД). diff --git a/relay/lib/docs/relay.md b/relay/lib/docs/relay.md index 5242384..bc65368 100644 --- a/relay/lib/docs/relay.md +++ b/relay/lib/docs/relay.md @@ -3,7 +3,7 @@ type: JS Module title: relay.mjs resource: relay/lib/relay.mjs docgen: - crc: 7dec481f + crc: e8fe916b model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.98 diff --git a/relay/lib/docs/server.md b/relay/lib/docs/server.md index 4ac1297..636b94d 100644 --- a/relay/lib/docs/server.md +++ b/relay/lib/docs/server.md @@ -3,7 +3,7 @@ type: JS Module title: server.mjs resource: relay/lib/server.mjs docgen: - crc: f8050327 + crc: 445915fb model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.98 diff --git a/relay/lib/docs/signing.md b/relay/lib/docs/signing.md new file mode 100644 index 0000000..662f1b6 --- /dev/null +++ b/relay/lib/docs/signing.md @@ -0,0 +1,34 @@ +--- +type: JS Module +title: signing.mjs +resource: relay/lib/signing.mjs +docgen: + crc: 9228feae + model: openai-codex/gpt-5.5 + tier: cloud-avg + score: 100 + issues: judge:inaccurate:0.99 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Файл перевіряє Ed25519-підписи передавання ownership через `node:crypto` без залежностей. Він дзеркалить canonical-формат `crates/agent-protocol`: домен-префікс і NUL-розділені поля, щоб підпис, створений Rust-клієнтом через `sign_transfer`, перевірявся на relay байт-у-байт. Pubkey пристрою приймається як hex 32 байти, валідований через `PUBKEY_RE`, і загортається у `SPKI DER` для перевірки. `transferMessage` формує повідомлення для перевірки, а `verifySignature` fail-safe відхиляє невалідні підписи без винятків назовні. + +## Поведінка + +- `PUBKEY_RE` визначає, чи має pubkey пристрою очікуваний hex-формат Ed25519 для relay-перевірки. +- `transferMessage` формує canonical-повідомлення передачі ownership, сумісне з Rust-клієнтом байт-у-байт. +- `verifySignature` fail-safe перевіряє Ed25519-підпис пристрою для canonical-повідомлення й повертає негативний результат замість винятку для невалідних даних. + +## Публічний API + +- PUBKEY_RE — визначає hex-представлення Ed25519 pubkey пристрою довжиною 32 байти. +- transferMessage — формує canonical-повідомлення для transfer ownership з доменом і NUL-розділеними полями, щоб підпис був привʼязаний до конкретного контексту. +- verifySignature — звіряє Ed25519-підпис повідомлення з hex-pubkey пристрою. + +## Гарантії поведінки + +- Read-only: не виконує операцій запису (ФС/БД). +- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/relay/lib/docs/store.md b/relay/lib/docs/store.md index 647f08a..d701a61 100644 --- a/relay/lib/docs/store.md +++ b/relay/lib/docs/store.md @@ -3,7 +3,7 @@ type: JS Module title: store.mjs resource: relay/lib/store.mjs docgen: - crc: c760e7d8 + crc: c3b0fdd5 model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.97 diff --git a/relay/lib/push.mjs b/relay/lib/push.mjs new file mode 100644 index 0000000..5eb2679 --- /dev/null +++ b/relay/lib/push.mjs @@ -0,0 +1,84 @@ +/** + * Push-нотифікації relay (access.md, «Push-нотифікації»): «вас запрошено» + * (тип 2) і «задача потребує уваги» (тип 3). FCM-доставка — окрема задача; + * тут інтерфейс sink-а і dev-реалізація з чергою в памʼяті (як magic tokens + * в auth). Relay не парсить payload далі роутінгових полів — для push + * роутінговими є `event.type` і адресний `event.to_account_id`. + */ + +/** Типи подій Envelope, що означають «задача потребує уваги» (тип 3). */ +const ATTENTION_TYPES = new Set(['PlanReview', 'AuditPending', 'Escalation']) + +/** Dev-sink: складає доставки в памʼять (реальний FCM — за тим самим інтерфейсом). */ +export class DevPushSink { + constructor() { + /** @type {{account_id: string, root: string, reason: string, ref: string | null}[]} */ + this.deliveries = [] + } + + /** + * Доставляє push усім пристроям акаунта. + * @param {string} accountId акаунт-отримувач + * @param {{ root: string, reason: string, ref?: string | null }} note зміст + * @returns {void} + */ + deliver(accountId, note) { + this.deliveries.push({ account_id: accountId, root: note.root, reason: note.reason, ref: note.ref ?? null }) + } +} + +/** Маршрутизатор push поверх store і sink-а. */ +export class PushRouter { + /** + * @param {{ store: import('./store.mjs').InMemoryStore, sink: DevPushSink }} deps залежності + */ + constructor({ store, sink }) { + this.store = store + this.sink = sink + } + + /** + * Тип 2: «вас запрошено у задачу X». Незареєстрований email — тихо + * (запрошення pending до реєстрації, push наздожене при onboarding). + * @param {string} email email запрошеного + * @param {string} root кореневий вузол задачі + * @returns {boolean} true якщо доставлено (акаунт існує) + */ + invited(email, root) { + const account = this.store.accountByEmail(email) + if (!account) return false + this.sink.deliver(account.account_id, { root, reason: 'invited' }) + return true + } + + /** + * Тип 3: «задача X потребує уваги» з події Envelope. Адресна подія + * (`Escalation` з `to_account_id`) будить лише адресата; безадресні + * attention-події — всіх учасників, крім автора (він і так знає). + * @param {string} root кореневий вузол задачі + * @param {object} envelope конверт (роутінгові поля: event.type, event.to_account_id) + * @param {string} senderAccount акаунт-автор конверта + * @returns {void} + */ + onEnvelope(root, envelope, senderAccount) { + const event = envelope?.event + if (!event) return + const attention = ATTENTION_TYPES.has(event.type) || (event.type === 'NodeState' && event.state === 'unresolvable') + if (!attention) return + const ref = event.reason_ref ?? event.plan_ref ?? event.fact_ref ?? null + + if (event.type === 'Escalation') { + // Адресат резолвиться емітером (handle → account через .mt/directory.json); + // без резолву адресний push неможливий — не спамимо всю кімнату. + if (event.to_account_id) { + this.sink.deliver(event.to_account_id, { root, reason: 'escalation', ref }) + } + return + } + + for (const member of this.store.membersOf(root)) { + if (member.account_id === senderAccount) continue + this.sink.deliver(member.account_id, { root, reason: event.type, ref }) + } + } +} diff --git a/relay/lib/relay.mjs b/relay/lib/relay.mjs index bcb4901..ce808e2 100644 --- a/relay/lib/relay.mjs +++ b/relay/lib/relay.mjs @@ -7,16 +7,18 @@ * агентів. Транспортний шар (WS) — server.mjs; тут — чиста логіка. */ import { Rooms } from './rooms.mjs' +import { transferMessage, verifySignature } from './signing.mjs' import { roleAtLeast } from './store.mjs' -/** Ядро relay поверх store + rooms. */ +/** Ядро relay поверх store + rooms (+ опційний push-маршрутизатор). */ export class RelayCore { /** - * @param {{ store: import('./store.mjs').InMemoryStore, rooms?: Rooms }} deps залежності + * @param {{ store: import('./store.mjs').InMemoryStore, rooms?: Rooms, push?: import('./push.mjs').PushRouter }} deps залежності */ - constructor({ store, rooms = new Rooms() }) { + constructor({ store, rooms = new Rooms(), push = null }) { this.store = store this.rooms = rooms + this.push = push } /** @@ -67,6 +69,8 @@ export class RelayCore { throw new Error('envelope відхилено: роль viewer не шле клієнтські події') } this.rooms.publish(root, { kind: 'envelope', envelope, from_host: device.role === 'host' }) + // Тип 3 push («потребує уваги») — з роутінгових полів події (push.mjs). + this.push?.onEnvelope(root, envelope, device.account_id) } /** @@ -82,7 +86,10 @@ export class RelayCore { if (this.store.memberRole(root, ownerAccount) !== 'owner') { throw new Error('invite відхилено: запрошує лише owner') } - return this.store.createInvitation(root, ownerAccount, email, role) + const invitation = this.store.createInvitation(root, ownerAccount, email, role) + // Тип 2 push «вас запрошено»; незареєстрований email — pending мовчки. + this.push?.invited(email, root) + return invitation } /** @@ -129,20 +136,30 @@ export class RelayCore { /** * Transfer ownership: поточний owner передає роль; сам стає host - * (штатний шлях succession — access.md). + * (штатний шлях succession — access.md). Мережевий шлях (WS) додатково + * вимагає Ed25519-підпис canonical-акта пристроєм-ініціатором — передача + * власності стає криптографічним фактом, а не лише правом токена; + * прямий виклик без signed — локальний/адміністративний шлях. * @param {string} root кореневий вузол задачі * @param {string} fromAccount поточний owner * @param {string} toAccount новий owner (мусить бути учасником) + * @param {{ device: object, signature: string }} [signed] підписаний акт (WS-шлях) * @returns {void} - * @throws {Error} не owner / отримувач не учасник + * @throws {Error} не owner / отримувач не учасник / невалідний підпис */ - transferOwnership(root, fromAccount, toAccount) { + transferOwnership(root, fromAccount, toAccount, signed) { if (this.store.memberRole(root, fromAccount) !== 'owner') { throw new Error('transfer відхилено: передає лише owner') } if (!this.store.memberRole(root, toAccount)) { throw new Error('transfer відхилено: отримувач не учасник задачі') } + if (signed) { + const message = transferMessage({ root, fromAccount, toAccount }) + if (!verifySignature(signed.device.pubkey, message, signed.signature)) { + throw new Error('transfer відхилено: підпис акта не пройшов перевірку') + } + } this.store.setMemberRole(root, toAccount, 'owner') this.store.setMemberRole(root, fromAccount, 'host') this.rooms.publish(root, { @@ -151,6 +168,47 @@ export class RelayCore { }) } + /** + * Bootstrap членства з `owner:`-розмітки лісу (спека owner-app 260714): + * власник кореня подає перелік {email, role} — зареєстровані акаунти + * стають учасниками одразу, незареєстровані отримують pending-запрошення. + * Ідемпотентно: наявні ролі не змінюються (не понижуємо і не дублюємо), + * повторний прогін не плодить запрошення. + * @param {string} ownerAccount акаунт-ініціатор (owner кореня) + * @param {string} root кореневий вузол задачі + * @param {{ email: string, role?: string }[]} entries учасники з розмітки + * @returns {{ added: string[], invited: string[], kept: string[] }} підсумок за email + * @throws {Error} не owner + */ + bootstrapMembers(ownerAccount, root, entries) { + if (this.store.memberRole(root, ownerAccount) !== 'owner') { + throw new Error('bootstrap відхилено: сідить membership лише owner') + } + const result = { added: [], invited: [], kept: [] } + for (const { email, role = 'owner' } of entries ?? []) { + const account = this.store.accountByEmail(email) + if (account) { + if (this.store.memberRole(root, account.account_id)) { + result.kept.push(email) + continue + } + this.store.setMemberRole(root, account.account_id, role) + this.rooms.publish(root, { + kind: 'event', + event: { type: 'MemberChanged', account_id: account.account_id, role } + }) + result.added.push(email) + continue + } + if (!this.store.pendingInvitationFor(root, email)) { + this.store.createInvitation(root, ownerAccount, email, role) + this.push?.invited(email, root) + } + result.invited.push(email) + } + return result + } + /** * Pubkey-и пристроїв учасників approver+ — для перевірки підписів * approvals хостом. Доступ лише пристроям учасників (access.md). diff --git a/relay/lib/server.mjs b/relay/lib/server.mjs index dec6ff5..d04ef82 100644 --- a/relay/lib/server.mjs +++ b/relay/lib/server.mjs @@ -2,7 +2,9 @@ * WS-транспорт relay: hello за device_token → subscribe/envelope-кадри. * * Кадри — JSON: клієнт → `{kind:"hello", device_token}` (перший), - * `{kind:"subscribe", root}`, `{kind:"envelope", root, envelope}`; + * `{kind:"subscribe", root}`, `{kind:"envelope", root, envelope}`, + * membership-операції (`invite`, `accept`, `decline`, `transfer_ownership` + * з Ed25519-підписом акта, `bootstrap_owners`); * relay → `{kind:"ok"|"error", ...}`, `{kind:"envelope"|"event", ...}`. * Ліміт кадру — 2 МБ (stack.md). Помилки авторизації/ролей — `error`-кадр, * зʼєднання не рветься (клієнт може виправитись). @@ -47,6 +49,43 @@ function handleFrame(core, state, frame, send) { send({ kind: 'pubkeys', root: frame.root, pubkeys: core.pubkeys(state.device, frame.root) }) break } + case 'invite': { + const invitation = core.invite(state.device.account_id, frame.root, { + email: frame.email, + role: frame.role + }) + send({ kind: 'ok', invitation_id: invitation.invitation_id, status: invitation.status }) + break + } + case 'accept': { + const membership = core.accept(frame.invitation_id, state.device.account_id) + send({ kind: 'ok', root: membership.root_node_hash, role: membership.role }) + break + } + case 'decline': { + core.decline(frame.invitation_id, state.device.account_id) + send({ kind: 'ok', declined: frame.invitation_id }) + break + } + case 'transfer_ownership': { + // Мережевий transfer — лише з Ed25519-підписом canonical-акта + // пристроєм-ініціатором (fail-closed: без підпису — error-кадр). + if (!frame.signature) throw new Error('transfer відхилено: бракує підпису акта') + core.transferOwnership(frame.root, state.device.account_id, frame.to_account, { + device: state.device, + signature: frame.signature + }) + send({ kind: 'ok', transferred: frame.root, to_account: frame.to_account }) + break + } + case 'bootstrap_owners': { + // Сідинг membership з owner:-розмітки лісу (owner-gated, ідемпотентно). + send({ + kind: 'ok', + bootstrap: core.bootstrapMembers(state.device.account_id, frame.root, frame.entries) + }) + break + } // Невідомі kind ігноруються (forward-compatibility). default: } diff --git a/relay/lib/signing.mjs b/relay/lib/signing.mjs new file mode 100644 index 0000000..481e4b4 --- /dev/null +++ b/relay/lib/signing.mjs @@ -0,0 +1,53 @@ +/** + * Перевірка Ed25519-підписів на relay (node:crypto, без залежностей). + * + * Дзеркалить canonical-формат crates/agent-protocol: домен-префікс і + * NUL-розділені поля — підпис, зроблений Rust-клієнтом (`sign_transfer`), + * перевіряється тут байт-у-байт. Pubkey пристрою — hex 32 байти (як у + * agent-server relay_client), загортається у SPKI DER для node:crypto. + */ +import { Buffer } from 'node:buffer' +import { createPublicKey, verify } from 'node:crypto' + +/** ASN.1 SPKI-префікс для raw Ed25519 pubkey (RFC 8410). */ +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +/** Домен підпису transfer ownership (дзеркало agent-protocol). */ +const TRANSFER_DOMAIN = 'mt-transfer-v4' + +/** Hex-формат Ed25519 pubkey пристрою: рівно 32 байти. */ +export const PUBKEY_RE = /^[0-9a-f]{64}$/i + +/** + * Canonical-повідомлення transfer ownership: домен і поля через NUL — + * межі полів однозначні, підпис не переноситься між контекстами. + * @param {{ root: string, fromAccount: string, toAccount: string }} payload акт передачі + * @returns {Buffer} байти для підпису/перевірки + */ +export function transferMessage({ root, fromAccount, toAccount }) { + return Buffer.from([TRANSFER_DOMAIN, root, fromAccount, toAccount].join('\0'), 'utf8') +} + +/** + * Перевіряє Ed25519-підпис повідомлення проти hex-pubkey пристрою. + * @param {string} pubkeyHex pubkey пристрою (hex, 32 байти) + * @param {Buffer} message canonical-повідомлення + * @param {string} signatureBase64 підпис (base64, як в Envelope) + * @returns {boolean} true якщо підпис валідний + */ +export function verifySignature(pubkeyHex, message, signatureBase64) { + if (!PUBKEY_RE.test(pubkeyHex)) return false + let signature + try { + signature = Buffer.from(signatureBase64, 'base64') + } catch { + return false + } + if (signature.length !== 64) return false + const key = createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, Buffer.from(pubkeyHex, 'hex')]), + format: 'der', + type: 'spki' + }) + return verify(null, message, key, signature) +} diff --git a/relay/lib/store.mjs b/relay/lib/store.mjs index 2cd6922..81a949b 100644 --- a/relay/lib/store.mjs +++ b/relay/lib/store.mjs @@ -10,6 +10,8 @@ import { Buffer } from 'node:buffer' import { randomUUID, timingSafeEqual } from 'node:crypto' +import { PUBKEY_RE } from './signing.mjs' + /** * Порівняння секретних токенів за сталий час (захист від timing-атак). * @param {string} a перший токен @@ -75,11 +77,18 @@ export class InMemoryStore { /** * Реєструє пристрій акаунта: `{name, role, pubkey} → device_token` (access.md). + * Pubkey — hex 32-байтовий Ed25519 (формат, який очікує pubkey-кеш хоста + * в agent-server): невалідний формат відхиляється одразу, а не на першій + * невдалій перевірці підпису. * @param {string} accountId акаунт-власник * @param {{ name: string, role: 'host'|'client', pubkey: string }} params дані пристрою * @returns {{device_id: string, device_token: string}} ідентифікатор і токен пристрою + * @throws {Error} pubkey не hex-32 */ registerDevice(accountId, { name, role, pubkey }) { + if (!PUBKEY_RE.test(pubkey ?? '')) { + throw new Error('registerDevice відхилено: pubkey має бути hex Ed25519 (32 байти)') + } const device = { device_id: randomUUID(), account_id: accountId, @@ -198,6 +207,26 @@ export class InMemoryStore { return this.invitations.get(invitationId) ?? null } + /** + * Відкрите (pending) запрошення email-а у задачу — для ідемпотентного + * bootstrap: повторний прогін не плодить дублікати. + * @param {string} rootNodeHash кореневий вузол + * @param {string} toEmail email запрошеного + * @returns {object | null} pending-запрошення або null + */ + pendingInvitationFor(rootNodeHash, toEmail) { + for (const invitation of this.invitations.values()) { + if ( + invitation.root_node_hash === rootNodeHash && + invitation.to_email === toEmail && + invitation.status === 'pending' + ) { + return invitation + } + } + return null + } + /** * Pubkey-и пристроїв учасників із роллю approver+ (для перевірки підписів * approvals хостом; access.md «GET pubkeys»). diff --git a/relay/lib/tests/relay.test.mjs b/relay/lib/tests/relay.test.mjs index 265d0a5..39f2a96 100644 --- a/relay/lib/tests/relay.test.mjs +++ b/relay/lib/tests/relay.test.mjs @@ -1,7 +1,12 @@ +import { Buffer } from 'node:buffer' +import { generateKeyPairSync, sign } from 'node:crypto' + import { beforeEach, describe, expect, test } from 'vitest' +import { DevPushSink, PushRouter } from '../push.mjs' import { RelayCore } from '../relay.mjs' import { Rooms } from '../rooms.mjs' +import { transferMessage } from '../signing.mjs' import { InMemoryStore, roleAtLeast } from '../store.mjs' const RE_NOT_MEMBER = /не учасник/ @@ -19,6 +24,16 @@ let accounts /** @type {Record} пристрої фікстури (повні записи) */ let devices +/** + * Детермінований hex-pubkey (32 байти) з імені — задовольняє валідацію + * формату там, де криптографія тесту не потрібна. + * @param {string} name імʼя пристрою + * @returns {string} hex-рядок 64 символи + */ +function fakeKey(name) { + return Buffer.from(name, 'utf8').toString('hex').padEnd(64, '0').slice(0, 64) +} + /** * Реєструє пристрій і повертає повний запис (для викликів ядра). * @param {string} accountId акаунт-власник @@ -29,7 +44,7 @@ function device(accountId, name) { const { device_token } = store.registerDevice(accountId, { name, role: 'client', - pubkey: `pk-${name}` + pubkey: fakeKey(name) }) return store.deviceByToken(device_token) } @@ -145,7 +160,7 @@ describe('from_host', () => { store.registerDevice(accounts.owner.account_id, { name: 'host-mac', role: 'host', - pubkey: 'pk-host' + pubkey: fakeKey('host-mac') }).device_token ) const inbox = [] @@ -161,9 +176,140 @@ describe('from_host', () => { describe('pubkeys', () => { test('лише пристрої approver+; доступ лише учасникам', () => { const pubkeys = core.pubkeys(devices.viewer, 'root-1') - expect(pubkeys.map(k => k.pubkey).toSorted()).toEqual(['pk-mac-owner', 'pk-phone-approver']) + expect(pubkeys.map(k => k.pubkey).toSorted()).toEqual([fakeKey('mac-owner'), fakeKey('phone-approver')].toSorted()) expect(() => core.pubkeys(devices.outsider, 'root-1')).toThrow(RE_NOT_MEMBER) }) + + test('registerDevice відхиляє pubkey не у hex-32 форматі', () => { + expect(() => + store.registerDevice(accounts.owner.account_id, { name: 'bad', role: 'client', pubkey: 'pk-bad' }) + ).toThrow(/hex/) + }) +}) + +describe('підписаний transfer ownership', () => { + /** + * Реальна Ed25519-пара: пристрій із цим pubkey і функція підпису. + * @param {string} accountId акаунт-власник пристрою + * @returns {{ device: object, signTransfer: (payload: object) => string }} пристрій і підписувач + */ + function signingDevice(accountId) { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) + const { device_token } = store.registerDevice(accountId, { + name: 'signer', + role: 'client', + pubkey: raw.toString('hex') + }) + return { + device: store.deviceByToken(device_token), + signTransfer: payload => sign(null, transferMessage(payload), privateKey).toString('base64') + } + } + + test('валідний підпис акта проходить, зіпсований — відмова без зміни ролей', () => { + const { device: signer, signTransfer } = signingDevice(accounts.owner.account_id) + const payload = { + root: 'root-1', + fromAccount: accounts.owner.account_id, + toAccount: accounts.approver.account_id + } + const good = signTransfer(payload) + const bad = signTransfer({ ...payload, toAccount: accounts.viewer.account_id }) + + expect(() => + core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id, { + device: signer, + signature: bad + }) + ).toThrow(/підпис/) + expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('owner') + + core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id, { + device: signer, + signature: good + }) + expect(store.memberRole('root-1', accounts.approver.account_id)).toBe('owner') + expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('host') + }) +}) + +describe('push', () => { + /** @type {DevPushSink} */ + let sink + /** @type {RelayCore} */ + let pushCore + + beforeEach(() => { + sink = new DevPushSink() + pushCore = new RelayCore({ store, push: new PushRouter({ store, sink }) }) + }) + + test('invite: тип 2 зареєстрованому акаунту; незареєстрований email — тихо', () => { + pushCore.invite(accounts.owner.account_id, 'root-1', { email: 'viewer@x', role: 'host' }) + pushCore.invite(accounts.owner.account_id, 'root-1', { email: 'ghost@x', role: 'host' }) + expect(sink.deliveries).toEqual([ + { account_id: accounts.viewer.account_id, root: 'root-1', reason: 'invited', ref: null } + ]) + }) + + test('attention-подія будить учасників, крім автора; звичайні події — ні', () => { + pushCore.clientEnvelope(devices.owner, 'root-1', { seq: 0, event: { type: 'PlanReview', plan_ref: 'plan_001' } }) + pushCore.clientEnvelope(devices.owner, 'root-1', { seq: 1, event: { type: 'NodeState', state: 'running' } }) + const awakened = sink.deliveries.map(d => d.account_id).toSorted() + expect(awakened).toEqual([accounts.approver.account_id, accounts.viewer.account_id].toSorted()) + expect(sink.deliveries.every(d => d.reason === 'PlanReview' && d.ref === 'plan_001')).toBe(true) + }) + + test('Escalation: адресний push лише to_account_id; без резолву — нікому', () => { + pushCore.clientEnvelope(devices.owner, 'root-1', { + seq: 2, + event: { + type: 'Escalation', + from: 'olena', + to: 'vkozlov', + to_account_id: accounts.approver.account_id, + reason_ref: 'escalation_001.md' + } + }) + pushCore.clientEnvelope(devices.owner, 'root-1', { + seq: 3, + event: { type: 'Escalation', from: 'olena', to: 'petro', reason_ref: 'escalation_002.md' } + }) + expect(sink.deliveries).toEqual([ + { + account_id: accounts.approver.account_id, + root: 'root-1', + reason: 'escalation', + ref: 'escalation_001.md' + } + ]) + }) +}) + +describe('bootstrapMembers', () => { + test('owner-gated; зареєстровані стають учасниками, решта — pending; ідемпотентно', () => { + const registered = store.createAccount({ email: 'olena@x' }) + const entries = [ + { email: 'olena@x', role: 'owner' }, + { email: 'viewer@x', role: 'owner' }, // вже учасник — роль не чіпаємо + { email: 'ghost@x' } + ] + + expect(() => core.bootstrapMembers(accounts.viewer.account_id, 'root-1', entries)).toThrow(RE_OWNER_ONLY) + + const first = core.bootstrapMembers(accounts.owner.account_id, 'root-1', entries) + expect(first).toEqual({ added: ['olena@x'], invited: ['ghost@x'], kept: ['viewer@x'] }) + expect(store.memberRole('root-1', registered.account_id)).toBe('owner') + expect(store.memberRole('root-1', accounts.viewer.account_id)).toBe('viewer') + expect(store.pendingInvitationFor('root-1', 'ghost@x')).not.toBeNull() + + // Повторний прогін: без нових запрошень і без зміни ролей. + const again = core.bootstrapMembers(accounts.owner.account_id, 'root-1', entries) + expect(again).toEqual({ added: [], invited: ['ghost@x'], kept: ['olena@x', 'viewer@x'] }) + const pending = [...store.invitations.values()].filter(i => i.to_email === 'ghost@x') + expect(pending).toHaveLength(1) + }) }) describe('roleAtLeast', () => { diff --git a/relay/lib/tests/server.test.mjs b/relay/lib/tests/server.test.mjs index 3b3f823..8132ad9 100644 --- a/relay/lib/tests/server.test.mjs +++ b/relay/lib/tests/server.test.mjs @@ -1,3 +1,5 @@ +import { Buffer } from 'node:buffer' +import { generateKeyPairSync, sign } from 'node:crypto' import { once } from 'node:events' import { WebSocket } from 'ws' @@ -5,8 +7,18 @@ import { afterAll, beforeAll, expect, test } from 'vitest' import { RelayCore } from '../relay.mjs' import { startRelayServer } from '../server.mjs' +import { transferMessage } from '../signing.mjs' import { InMemoryStore } from '../store.mjs' +/** + * Детермінований hex-pubkey (32 байти) з імені. + * @param {string} name імʼя пристрою + * @returns {string} hex-рядок 64 символи + */ +function fakeKey(name) { + return Buffer.from(name, 'utf8').toString('hex').padEnd(64, '0').slice(0, 64) +} + const RE_HELLO = /hello/ const RE_VIEWER = /viewer/ // Тести ходять на локальний loopback без TLS; sdl-правило про insecure-URL @@ -44,20 +56,31 @@ async function roundtrip(socket, frame) { return JSON.parse(String(raw)) } +/** @type {object} */ +let owner +/** @type {object} */ +let approver +/** @type {import('node:crypto').KeyObject} */ +let ownerPrivateKey + beforeAll(async () => { - const owner = store.createAccount({ email: 'owner@x' }) + owner = store.createAccount({ email: 'owner@x' }) const viewer = store.createAccount({ email: 'viewer@x' }) + approver = store.createAccount({ email: 'approver@x' }) store.createTask('root-1', owner.account_id) store.setMemberRole('root-1', viewer.account_id, 'viewer') + store.setMemberRole('root-1', approver.account_id, 'approver') + const pair = generateKeyPairSync('ed25519') + ownerPrivateKey = pair.privateKey hostToken = store.registerDevice(owner.account_id, { name: 'mac', role: 'host', - pubkey: 'pk-mac' + pubkey: pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex') }).device_token viewerToken = store.registerDevice(viewer.account_id, { name: 'tab', role: 'client', - pubkey: 'pk-tab' + pubkey: fakeKey('tab') }).device_token server = await startRelayServer(new RelayCore({ store })) }) @@ -112,7 +135,77 @@ test('pubkeys-кадр: pubkey-и approver+ пристроїв для перев expect(reply.kind).toBe('pubkeys') expect(reply.root).toBe('root-1') // Owner (approver+) — так; viewer — ні. - expect(reply.pubkeys.map(k => k.pubkey)).toEqual(['pk-mac']) + expect(reply.pubkeys.map(k => k.account_id)).toEqual([owner.account_id]) + expect(reply.pubkeys[0].pubkey).toMatch(/^[0-9a-f]{64}$/) + socket.close() +}) + +test('membership через WS: invite → accept новим акаунтом', async () => { + const socket = await connect() + await roundtrip(socket, { kind: 'hello', device_token: hostToken }) + const invited = await roundtrip(socket, { kind: 'invite', root: 'root-1', email: 'new@x', role: 'host' }) + expect(invited).toMatchObject({ kind: 'ok', status: 'pending' }) + + const newcomer = store.createAccount({ email: 'new@x' }) + const token = store.registerDevice(newcomer.account_id, { + name: 'new-phone', + role: 'client', + pubkey: fakeKey('new-phone') + }).device_token + const other = await connect() + await roundtrip(other, { kind: 'hello', device_token: token }) + const accepted = await roundtrip(other, { kind: 'accept', invitation_id: invited.invitation_id }) + expect(accepted).toEqual({ kind: 'ok', root: 'root-1', role: 'host' }) + expect(store.memberRole('root-1', newcomer.account_id)).toBe('host') + socket.close() + other.close() +}) + +test('transfer_ownership через WS: без підпису — error, з підписом — передано', async () => { + const socket = await connect() + await roundtrip(socket, { kind: 'hello', device_token: hostToken }) + + const unsigned = await roundtrip(socket, { + kind: 'transfer_ownership', + root: 'root-1', + to_account: approver.account_id + }) + expect(unsigned.kind).toBe('error') + expect(unsigned.message).toMatch(/підпис/) + + const signature = sign( + null, + transferMessage({ root: 'root-1', fromAccount: owner.account_id, toAccount: approver.account_id }), + ownerPrivateKey + ).toString('base64') + const transferred = await roundtrip(socket, { + kind: 'transfer_ownership', + root: 'root-1', + to_account: approver.account_id, + signature + }) + expect(transferred).toEqual({ kind: 'ok', transferred: 'root-1', to_account: approver.account_id }) + expect(store.memberRole('root-1', approver.account_id)).toBe('owner') + expect(store.memberRole('root-1', owner.account_id)).toBe('host') + socket.close() +}) + +test('bootstrap_owners через WS: сідинг з owner:-розмітки (новим owner-ом)', async () => { + // Після transfer вище owner кореня — approver; реєструємо його пристрій. + const token = store.registerDevice(approver.account_id, { + name: 'approver-mac', + role: 'client', + pubkey: fakeKey('approver-mac') + }).device_token + const socket = await connect() + await roundtrip(socket, { kind: 'hello', device_token: token }) + const reply = await roundtrip(socket, { + kind: 'bootstrap_owners', + root: 'root-1', + entries: [{ email: 'viewer@x', role: 'owner' }, { email: 'ghost@x' }] + }) + expect(reply.kind).toBe('ok') + expect(reply.bootstrap).toEqual({ added: [], invited: ['ghost@x'], kept: ['viewer@x'] }) socket.close() }) From 2893506c2819a4e40e6547b3fe9ba2e8c1a28705 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Fri, 17 Jul 2026 14:37:18 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(lint):=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=BD=D1=96=D1=87=D0=BD=D0=B8=D0=B9=20sort=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D0=B8=D0=BA=D0=B0=20cspell=20?= =?UTF-8?q?=D0=BF=D1=96=D1=81=D0=BB=D1=8F=20merge=20+=20fix-=D0=B2=D1=83?= =?UTF-8?q?=D0=B7=D0=BE=D0=BB=20=D0=BB=D1=96=D0=BD=D1=82=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .cspell.json | 168 +++++++++++++------------ mt/lint-fix-js-eslint-04a66bfd/task.md | 1 + 2 files changed, 87 insertions(+), 82 deletions(-) diff --git a/.cspell.json b/.cspell.json index 4525d04..367ef8c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -19,6 +19,86 @@ "Cargo.lock" ], "words": [ + "aaif", + "agentclientprotocol", + "alphanum", + "AQID", + "backpressure", + "Backpressure", + "cdylib", + "chrono", + "clippy", + "CLIS", + "dalek", + "deadbe", + "deadbeefdeadbeefdead", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "Deque", + "Dicklesworthstone", + "dlopen", + "dogfooding", + "EEXIST", + "eprintln", + "fract", + "fzf", + "getw", + "Gitea", + "hexdigit", + "Homebrew", + "journaled", + "keypair", + "Kratos", + "libc", + "libmt", + "mktemp", + "mktree", + "msvc", + "MSVC", + "napi", + "NAPI", + "NCURSOR", + "nitralabs", + "nowarn", + "ollama", + "Ollama", + "omlx", + "oneshot", + "pubkeys", + "refspec", + "reqwest", + "rescan", + "rmcp", + "roundtrips", + "rsplit", + "rustls", + "rustup", + "sanit", + "schemars", + "serde", + "sonarjs", + "SPKI", + "subdirs", + "subsec", + "svkozak", + "taskish", + "tauri", + "Tauri", + "taze", + "TOCTOU", + "toplevel", + "topo", + "tungstenite", + "unrs", + "vitaliytv", + "vkozlov", + "worktree", + "Worktree", + "worktrees", + "zigbuild", + "ziglang", + "Δcost", + "Δfailed", + "Δwall", "автозростання", "автопідйом", "автофіксом", @@ -59,6 +139,7 @@ "блокуючий", "Блокуючий", "бриджить", + "валідований", "валідує", "Валідує", "валідується", @@ -114,6 +195,7 @@ "ендпоінтом", "ескалювати", "ескалює", + "ескалюють", "ехоїть", "журналити", "журналить", @@ -262,6 +344,7 @@ "Пасивн", "патчить", "патчів", + "перiод", "перебілд", "перебілджує", "перегенеруй", @@ -272,7 +355,6 @@ "переперевірки", "перепідключаються", "персиститься", - "перiод", "підвузлам", "підвузлів", "підграфових", @@ -291,8 +373,8 @@ "пінги", "пінінг", "пінуються", - "пісковані", "піскованi", + "пісковані", "платформенні", "платформозалежному", "побрейнштормимо", @@ -361,6 +443,7 @@ "рефрешу", "розпарсених", "розсинхрон", + "розсинхроні", "роутінг", "роутінговими", "роутінгових", @@ -437,86 +520,7 @@ "чекаючий", "шима", "шимом", - "юзає", - "aaif", - "agentclientprotocol", - "alphanum", - "AQID", - "backpressure", - "Backpressure", - "cdylib", - "chrono", - "clippy", - "CLIS", - "dalek", - "deadbe", - "deadbeefdeadbeefdead", - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - "Deque", - "Dicklesworthstone", - "dlopen", - "dogfooding", - "EEXIST", - "eprintln", - "fract", - "fzf", - "getw", - "Gitea", - "hexdigit", - "Homebrew", - "journaled", - "keypair", - "Kratos", - "libc", - "libmt", - "mktemp", - "mktree", - "msvc", - "MSVC", - "napi", - "NAPI", - "NCURSOR", - "nitralabs", - "nowarn", - "ollama", - "Ollama", - "omlx", - "oneshot", - "pubkeys", - "refspec", - "reqwest", - "rescan", - "rmcp", - "roundtrips", - "rsplit", - "rustls", - "rustup", - "sanit", - "schemars", - "serde", - "sonarjs", - "subdirs", - "subsec", - "svkozak", - "taskish", - "tauri", - "Tauri", - "taze", - "TOCTOU", - "toplevel", - "topo", - "tungstenite", - "unrs", - "vitaliytv", - "vkozlov", - "worktree", - "Worktree", - "worktrees", - "zigbuild", - "ziglang", - "Δcost", - "Δfailed", - "Δwall" + "юзає" ], "useGitignore": true, "gitignoreRoot": "." diff --git a/mt/lint-fix-js-eslint-04a66bfd/task.md b/mt/lint-fix-js-eslint-04a66bfd/task.md index 31b2a90..c1c1358 100644 --- a/mt/lint-fix-js-eslint-04a66bfd/task.md +++ b/mt/lint-fix-js-eslint-04a66bfd/task.md @@ -21,6 +21,7 @@ npx @7n/rules lint --no-fix --cwd ../.. js ## Inputs Target-файли: + - `relay/lib/push.mjs` - `relay/lib/signing.mjs` - `relay/lib/tests/relay.test.mjs`