Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions relay/.changes/260717-2005.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: patch
section: Fixed
---
eslint (unicorn/prefer-uint8array-base64, unicorn/prefer-iterator-to-array, max-classes-per-file) на релей-модулях v4: DevPushSink винесено у push-sink.mjs, Buffer base64 → Uint8Array.fromBase64/toBase64, regex-літерали тестів — у module-scope, iterator.toArray() замість spread
17 changes: 9 additions & 8 deletions relay/lib/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ title: relay/lib
resource: relay/lib/
---

| Файл | Тип |
| ------------------------- | --------- |
| [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 |
| Файл | Тип |
| ----------------------------- | --------- |
| [push-sink.mjs](push-sink.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 |
31 changes: 31 additions & 0 deletions relay/lib/docs/push-sink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
type: JS Module
title: push-sink.mjs
resource: relay/lib/push-sink.mjs
docgen:
crc: 1c998579
model: openai-codex/gpt-5.4-mini
tier: cloud-min
score: 100
issues: judge:inaccurate:0.98
judgeModel: openai-codex/gpt-5.4-mini
---

## Огляд

`DevPushSink` існує як dev-накопичувач push-сповіщень: він складає нотифікації в памʼять як `magic tokens` в auth і приймає доставку до акаунта через спільний контракт `deliver`, за яким окремою задачею підключається реальний FCM-transport.

## Поведінка

1. `DevPushSink` створює dev-накопичувач для push-сповіщень, щоб окремо від реального транспортного шару зберігати факти доставки в пам’яті.
2. `DevPushSink` приймає повідомлення про доставку до акаунта і фіксує їх як запис про отримувача, причину, кореневу подію та, за наявності, посилання на джерело.
3. `DevPushSink` не виконує реальну доставку push і не звертається до зовнішніх систем; це лише тимчасова точка накопичення для подальшого підключення transport-рівня за тим самим контрактом.
4. `DevPushSink` працює без змін у ФС чи БД, тому підходить для локального dev-сценарію та перевірки бізнес-потоків доставки.

## Публічний API

- DevPushSink — dev-варіант sink-а для push-доставки, що приймає і передає push-повідомлення у робочому середовищі розробки.

## Гарантії поведінки

- Read-only: не виконує операцій запису (ФС/БД).
2 changes: 1 addition & 1 deletion relay/lib/docs/push.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type: JS Module
title: push.mjs
resource: relay/lib/push.mjs
docgen:
crc: a554bd6d
crc: d6df4c66
model: openai-codex/gpt-5.5
score: 100
issues: judge:inaccurate:0.98
Expand Down
3 changes: 1 addition & 2 deletions relay/lib/docs/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ type: JS Module
title: signing.mjs
resource: relay/lib/signing.mjs
docgen:
crc: 9228feae
crc: 0b7d8ba8
model: openai-codex/gpt-5.5
tier: cloud-avg
score: 100
issues: judge:inaccurate:0.99
judgeModel: openai-codex/gpt-5.4-mini
Expand Down
23 changes: 23 additions & 0 deletions relay/lib/push-sink.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Dev-sink push-доставки: складає нотифікації в памʼять (як magic tokens
* в auth) — реальний FCM-транспорт підключається за тим самим інтерфейсом
* `deliver(accountId, note)` окремою задачею (stack.md, «Push»).
*/

/** Dev-реалізація sink-а push-доставки. */
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 })
}
}
22 changes: 2 additions & 20 deletions relay/lib/push.mjs
Original file line number Diff line number Diff line change
@@ -1,36 +1,18 @@
/**
* Push-нотифікації relay (access.md, «Push-нотифікації»): «вас запрошено»
* (тип 2) і «задача потребує уваги» (тип 3). FCM-доставка — окрема задача;
* тут інтерфейс sink-а і dev-реалізація з чергою в памʼяті (як magic tokens
* тут інтерфейс sink-а (dev-реалізація — `push-sink.mjs`, як 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 залежності
* @param {{ store: import('./store.mjs').InMemoryStore, sink: import('./push-sink.mjs').DevPushSink }} deps залежності
*/
constructor({ store, sink }) {
this.store = store
Expand Down
2 changes: 1 addition & 1 deletion relay/lib/signing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function verifySignature(pubkeyHex, message, signatureBase64) {
if (!PUBKEY_RE.test(pubkeyHex)) return false
let signature
try {
signature = Buffer.from(signatureBase64, 'base64')
signature = Uint8Array.fromBase64(signatureBase64)
} catch {
return false
}
Expand Down
16 changes: 11 additions & 5 deletions relay/lib/tests/relay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { generateKeyPairSync, sign } from 'node:crypto'

import { beforeEach, describe, expect, test } from 'vitest'

import { DevPushSink, PushRouter } from '../push.mjs'
import { DevPushSink } from '../push-sink.mjs'
import { PushRouter } from '../push.mjs'
import { RelayCore } from '../relay.mjs'
import { Rooms } from '../rooms.mjs'
import { transferMessage } from '../signing.mjs'
Expand All @@ -14,6 +15,8 @@ const RE_VIEWER = /viewer/
const RE_OWNER_ONLY = /owner/
const RE_FOREIGN_ACCOUNT = /іншому акаунту/
const RE_ALREADY_PROCESSED = /оброблене/
const RE_HEX = /hex/
const RE_SIGNATURE = /підпис/

/** @type {InMemoryStore} */
let store
Expand Down Expand Up @@ -183,7 +186,7 @@ describe('pubkeys', () => {
test('registerDevice відхиляє pubkey не у hex-32 форматі', () => {
expect(() =>
store.registerDevice(accounts.owner.account_id, { name: 'bad', role: 'client', pubkey: 'pk-bad' })
).toThrow(/hex/)
).toThrow(RE_HEX)
})
})

Expand All @@ -203,7 +206,7 @@ describe('підписаний transfer ownership', () => {
})
return {
device: store.deviceByToken(device_token),
signTransfer: payload => sign(null, transferMessage(payload), privateKey).toString('base64')
signTransfer: payload => sign(null, transferMessage(payload), privateKey).toBase64()
}
}

Expand All @@ -222,7 +225,7 @@ describe('підписаний transfer ownership', () => {
device: signer,
signature: bad
})
).toThrow(/підпис/)
).toThrow(RE_SIGNATURE)
expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('owner')

core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id, {
Expand Down Expand Up @@ -307,7 +310,10 @@ describe('bootstrapMembers', () => {
// Повторний прогін: без нових запрошень і без зміни ролей.
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')
const pending = store.invitations
.values()
.filter(i => i.to_email === 'ghost@x')
.toArray()
expect(pending).toHaveLength(1)
})
})
Expand Down
8 changes: 5 additions & 3 deletions relay/lib/tests/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ function fakeKey(name) {

const RE_HELLO = /hello/
const RE_VIEWER = /viewer/
const RE_HEX_KEY = /^[0-9a-f]{64}$/
const RE_SIGNATURE = /підпис/
// Тести ходять на локальний loopback без TLS; sdl-правило про insecure-URL
// націлене на продакшн-адреси, тому схему складаємо окремо від хоста.
const WS_SCHEME = 'ws:'
Expand Down Expand Up @@ -136,7 +138,7 @@ test('pubkeys-кадр: pubkey-и approver+ пристроїв для перев
expect(reply.root).toBe('root-1')
// Owner (approver+) — так; viewer — ні.
expect(reply.pubkeys.map(k => k.account_id)).toEqual([owner.account_id])
expect(reply.pubkeys[0].pubkey).toMatch(/^[0-9a-f]{64}$/)
expect(reply.pubkeys[0].pubkey).toMatch(RE_HEX_KEY)
socket.close()
})

Expand Down Expand Up @@ -171,13 +173,13 @@ test('transfer_ownership через WS: без підпису — error, з пі
to_account: approver.account_id
})
expect(unsigned.kind).toBe('error')
expect(unsigned.message).toMatch(/підпис/)
expect(unsigned.message).toMatch(RE_SIGNATURE)

const signature = sign(
null,
transferMessage({ root: 'root-1', fromAccount: owner.account_id, toAccount: approver.account_id }),
ownerPrivateKey
).toString('base64')
).toBase64()
const transferred = await roundtrip(socket, {
kind: 'transfer_ownership',
root: 'root-1',
Expand Down
Loading