ЗОРЯ

Завдання/Специфікація клієнта

Зоря.нет: специфікація клієнта учасника

Версія 1, частина 1. Поля, формати й коди відповідей задає OpenAPI: у разі розбіжності в полі чи коді правий контракт. Поведінку сервера й клієнта задає цей документ. Умови допуску - ТЗ частини 1.

опубліковано 21.09

1Призначення і межі

Клієнт мережі - застосунок учасника: через нього учасник реєструється, бачить статус доступу до репозиторію, отримує новини, листи та частини завдання. У частині 3 клієнт виростає в ігровий штаб.

Рівні вимог:

  • обов'язково - без цього немає допуску або не прийде частина 2;
  • якщо слухає ефір - обов'язково для клієнта, який читає ефір до просвіту; читати ефір рекомендовано;
  • рекомендовано - на допуск і доставку частин не впливає.

Машина в частині 1 перевіряє тільки реєстрацію і доступ до репозиторію (ТЗ, розділ 5). Решту перевіряє оцінювання штабу пізніше.

ФункціяРівеньВимогиСтрок
Реєстрація в просвітобов'язковоCL-13 - CL-15, CL-17 - CL-19до просвіту
Отримувач запрошення і статус доступуобов'язковоCL-23 - CL-25до
Опитування сповіщень: зведення, листи, новиниобов'язковоCL-27 - CL-30до
Читання частин завданняобов'язковоCL-31, CL-32до
Стійкість до відповідей, помилки, сесія, час, збиранняобов'язковоCL-01, CL-07 - CL-10, CL-20 - CL-22, CL-36 - CL-41разом із функціями
Ефір і печаткаякщо слухає ефірCL-02 - CL-06до просвіту
Відлік провісника, заготовлена заявка, журнал, уривки листа, підтвердження отримання, зміна репозиторію, мова текстів серверарекомендованоCL-11, CL-12, CL-16, CL-26, CL-33 - CL-35, CL-42-

Наступні частини додадуть ігри, групу і функції штабу. Клієнту не потрібні: WebSocket, push-сповіщення, власне сховище новин і листів на сервері.

2Огляд обміну

схема
 клієнт учасника                                    сервер zoria.net/api/v1
 +--------------------------+     HTTPS, JSON     +-----------------------------+
 | ефір       GET /ether    | ------------------> | ефір: до просвіту - шум,    |
 |            GET /ether/key| <-- конверт кадру - |   справжній кадр із печаткою|
 | акаунт, репозиторій      | ------------------> | акаунт, заявка на           |
 |                          | <------------------ |   репозиторій, бот допуску  |
 | зведення -> списки       | ------------------> | новини, листи, частини      |
 +--------------------------+ тільки опитування   +-----------------------------+
   браузер з будь-якого origin, без cookie; або Node, або власний бекенд
  • HTTPS і JSON в UTF-8. Обмін тільки опитуванням: клієнт питає, сервер відповідає. WebSocket, Server-Sent Events і push немає.
  • Браузерний клієнт працює з будь-якого origin, зокрема з http://localhost. Запити йдуть із credentials: 'omit' і заголовком Authorization. Сервер відповідає Access-Control-Allow-Origin: * і відкриває браузеру заголовки Retry-After і X-Request-Id. Запит із cookie з чужого origin відхиляється: 403 origin-not-allowed.
  • Node-клієнту і власному бекенду CORS не потрібен.
  • Час - за годинником сервера: serverTime у зведенні, at у кадрі ефіру. Годинник пристрою учасника може розходитися з ним на хвилини.

3Режими сервера

Від сервер працює в режимі ефіру. Фазу задають годинник сервера і розклад просвіту opensAt/closesAt:

ФазаКолиЩо відбувається
static (шум)до opensAtМаршрути частини 1 відповідають перешкодою або справжнім конвертом кадру; звичайного API немає
провісникостанні 6 год фази staticТе саме, і кожен справжній кадр несе harbinger
window (просвіт)від opensAt до closesAtПерешкод немає, звичайний API, реєстрація відкрита
after (після просвіту)від closesAtПерешкод немає, звичайний API, реєстрацію закрито
схема
            opensAt - 6 год          opensAt                 closesAt
  static --------------> static -------------> window ------------------> after
  (шум)                  + провісник           (просвіт,                  (реєстрацію
                                                реєстрація відкрита)       закрито)
                                                  ^                          |
                                                  +--------------------------+
                                                  подовження: closesAt пізніше

Подовження через збій на нашому боці переносить closesAt пізніше; якщо він уже минув, фаза знову стає window.

Маршрутstaticwindowafter
GET /ether200 конверт або перешкода200 конверт200 конверт
GET /ether/roll200 конверт (журнал порожній) або перешкода200 конверт із журналом200 конверт із журналом
GET /ether/keyключключключ
GET /openapi.jsonконтрактконтрактконтракт
POST /auth/loginзвичайна відповідь; акаунтів ще немаєзвичайна відповідьзвичайна відповідь
POST /auth/register503 конверт або перешкода; тіло не читаєтьсяреєстрація403 registration-closed
решта маршрутів частини 1503 конверт або перешкодазвичайна відповідьзвичайна відповідь

Фазу клієнт бере з поля phase перевіреного кадру (розділ 5.2). Об'єкт у відповіді зі статусом 2xx перешкодою не приходить: розібрана 200 /public/config з registrationOpen: true теж означає просвіт.

4Вимоги

4.1Ефір і перешкоди

CL-01

обов'язковоКлієнт витримує будь-яку відповідь сервера без падіння і без втрати введеного: тіло не JSON, порожнє тіло, HTML, JSON чужої форми, будь-який статус, обрив з'єднання, відповідь через 8 с.

CL-02

якщо слухає ефірДо просвіту клієнт діє тільки за кадром із правильною печаткою: показ фази, відлік, надсилання заявки, уривки листа. Відповіді без правильної печатки, зокрема правдоподібні помилки API на кшталт 422 repository-not-eligible чи 409 username-unavailable, до просвіту - шум, і висновків із них клієнт не робить.

CL-03

якщо слухає ефірПечатка перевіряється до розбору кадру: підпис Ed25519 над байтами UTF-8 рядка frame у тому вигляді, в якому він прийшов (розділ 5.2).

CL-04

якщо слухає ефірВідкритий ключ клієнт бере з GET /ether/key один раз, звіряє його відбиток із відбитком із ТЗ і зберігає. Ключ для кадру обирається за полем key конверта.

CL-05

якщо слухає ефірСтарий кадр не відкочує стан: ехо і затримка - справжні кадри з минулим at. Фаза і відлік беруться з кадру з найбільшим at.

CL-06

якщо слухає ефірНезнайомі поля конверта і кадру ігноруються.

4.2Глушіння і повтори

CL-07

обов'язковоНа 429 клієнт чекає не менше Retry-After секунд. Після 429 jammed він не надсилає цьому серверу жодного запиту, доки Retry-After не минув: запит під час глушіння подвоює його строк.

CL-08

обов'язковоЧастота опитування: /ether не частіше ніж раз на 15 с, /ether/roll не частіше ніж раз на хвилину, /me/summary приблизно раз на 15 с.

CL-09

обов'язковоЗбій (5xx без конверта, обрив, тайм-аут, відмова мережі) клієнт повторює з експоненційною паузою і повним джитером (exponential backoff with full jitter): пауза = випадкове від 0 до min(60 с, 1 с x 2^n), n - номер повтору поспіль; лічильник скидається після успішної відповіді. 503 з конвертом до просвіту - відповідь ефіру, а не збій: клієнт продовжує звичайне опитування.

CL-10

обов'язковоКлієнти за однією адресою ділять ліміт ефіру (розділ 5.6). Клієнт не розподіляє запити між кількома адресами, щоб обійти ліміт.

4.3Провісник

CL-11

рекомендованоКлієнт показує відлік до відкриття просвіту. Момент відкриття = at кадру + harbinger.opensIn секунд. Відлік іде за годинником сервера: поправка = at кадру мінус локальний час його отримання. Голос провісника harbinger.text клієнт показує як є мовою учасника або не показує; чисел у ньому немає, відлік береться тільки з opensIn.

CL-12

рекомендованоУ просвіт клієнт показує його кінець window.closesAt і оновлює його під час подовження.

4.4Реєстрація

CL-13

обов'язковоФорма реєстрації: логін, відображуване ім'я, пароль, репозиторій; необов'язково - мова текстів сервера (CL-42). Клієнт перевіряє поля за правилами розділу 5.6 до надсилання.

CL-14

обов'язковоРепозиторій приймається як owner/repo або як адреса на github.com: зі схемою і без, з www., SSH-вигляд git@github.com:owner/repo.git, з .git або / у кінці. Посилання всередину репозиторію (файл, гілка, вкладка, ?, #) клієнт відхиляє до надсилання і просить адресу самого репозиторію.

CL-15

обов'язковоКлієнт попереджає, що відображуване ім'я бачать усі в журналі виходу на зв'язок.

CL-16

рекомендованоЗаявку можна заготувати заздалегідь і надіслати автоматично: за першим перевіреним кадром із phase: "window" або в момент відкриття з провісника. Пароль заготовки зберігається тільки в учасника, не в репозиторії.

CL-17

обов'язковоЗагублену відповідь на реєстрацію (тайм-аут, обрив, 5xx) клієнт не повторює наосліп. Спершу POST /auth/login із тими самими логіном і паролем: 200 - реєстрація відбулася; 401 invalid-credentials - реєстрацію можна повторити. 409 username-unavailable на повторі - теж спершу вхід.

CL-18

обов'язковоПомилку реєстрації клієнт показує біля поля з field, введене не стирає (коди - розділ 5.5).

CL-19

обов'язковоПісля реєстрації клієнт одразу показує запрошення і .zoria (CL-23).

4.5Сесія

Сесія входу - сесія, видана POST /auth/register або POST /auth/login; іменовані токени для PUT /me/repository не годяться, відповідь 403 session-required.

CL-20

обов'язковоТокен зберігається в пам'яті, у сховищі браузера або у файлі з правами власника. В URL, журнали і репозиторій він не потрапляє.

CL-21

обов'язковоКлієнт передає Authorization: Bearer <token>. На 401 він веде на вхід і після входу повертає учасника на той самий екран із введеним.

CL-22

обов'язковоВхід діє добу (expiresAt); до закінчення клієнт пропонує увійти знову. Вихід - POST /auth/logout, токен після нього недійсний.

4.6Репозиторій і доступ

CL-23

обов'язковоКлієнт показує: адресу репозиторію; GitHub-акаунт для запрошення (repositoryAccess.githubUsername з /public/config), право та інструкцію (permission, instructions); що покласти в .zoria - логін учасника в мережі.

CL-24

обов'язковоКлієнт показує accessStatus, reason і checkedAt, а при verifiedElsewhere: true - окреме попередження, що репозиторій підтверджено за іншим акаунтом. Замість reason клієнт може показати свій текст за машинною причиною reasonCode (розділ 5.4); при reasonCode: "organizer" показується reason як є.

CL-25

обов'язково/me/repository перечитується, коли у зведенні зсунувся repositoryAt, і після зміни репозиторію.

CL-26

рекомендованоЗміна репозиторію - PUT /me/repository із сесії входу; статус після неї - pending.

4.7Новини, листи, частини завдання

CL-27

обов'язковоКлієнт опитує /me/summary і перечитує те, що змінилося:

Поле зведення зсунулосяЩо перечитати
unread/inbox, потім /stages
latestNews/news
releasedStages/stages
repositoryAt/me/repository
CL-28

обов'язковоСписки /news і /inbox читаються посторінково до nextOffset: null, елементи звіряються за id, дублів немає. Якщо під час гортання прийшли нові елементи, гортання починається з нуля.

CL-29

обов'язковоКлієнт показує листи; лист із notice - сповіщення сервера, незнайомий тип notice показується як звичайний лист. Позначка прочитання - POST /inbox/{id}/read; читання GET /inbox/{id} її не ставить.

CL-30

обов'язковоТекст новин, листів і частин показується безпечно: як текст або як підмножина Markdown із розділу 5.6. HTML із тіла не виконується, посилання відкриваються тільки зі схемою http або https.

CL-31

обов'язковоКлієнт показує список виданих частин завдання і повний текст частини (GET /stages/{id}). Уточнення (errata) показується поруч із текстом і окремо від нього; зсунутий errataAt у списку - сигнал перечитати частину. Уточнення приходить і листом.

CL-32

обов'язковоМайбутні частини клієнт не вгадує: невидана частина відповідає 404.

CL-33

рекомендованоПідтвердження отримання частини - явна дія учасника, POST /stages/{id}/ack. Відкриття тексту отримання не підтверджує.

4.8Журнал і уривки листа

CL-34

рекомендованоЖурнал виходу на зв'язок: GET /ether/roll не частіше ніж раз на хвилину; номер, час у місцевій зоні, відображуване ім'я; учасник знаходить у ньому себе.

CL-35

рекомендованоУривки листа Доглядача збираються за номером n з of, тільки з кадрів із правильною печаткою; повтор номера відкидається.

4.9Помилки, мережа, час

CL-36

обов'язковоРеакція на статуси - за розділом 5.5: 400 - помилка клієнта; 401 - вхід; 403 - немає прав, закрито або чужий origin із cookie; 404 - немає або ще не видано; 409 і 422 - конфлікт або хибне поле; 429 - чекати Retry-After; 5xx - повтор із паузою (CL-09).

CL-37

обов'язковоПомилка не стирає введене. У відповіді 5xx клієнт показує X-Request-Id: його додають до повідомлення про збій.

CL-38

обов'язковоКлієнт показує час у місцевій зоні пристрою і називає її. Сервер приймає і віддає тільки UTC.

4.10Збирання

CL-39

обов'язковоReact + TypeScript, у репозиторії заявки.

CL-40

обов'язковоREADME: встановлення за lockfile і запуск задокументованою командою. Адреса сервера - налаштування: бойовий сервер і стенд перемикаються без правки коду.

CL-41

обов'язковоВ історії репозиторію немає токенів, паролів і закритих ключів.

4.11Мова текстів сервера

CL-42

рекомендованоКлієнт дає учасникові обрати мову текстів сервера: поле lang під час реєстрації або PATCH /me пізніше; uk за замовчуванням або en. Цією мовою приходять сповіщення сервера і причини рішення щодо репозиторію. Новини й частини завдання мова акаунта не перекладає.

5Інтерфейси і контракти

5.1Загальні правила

ПравилоЗначення
Адреса APIhttps://zoria.net/api/v1; стенд - https://stand.zoria.net/api/v1; шляхи нижче - від цього префікса
Тіло запитуJSON в UTF-8, заголовок Content-Type: application/json (інакше 415 json-required), не більше 96 КіБ (інакше 413 body-too-large)
Зайві й пропущені поля тіла400 invalid-fields
АвторизаціяAuthorization: Bearer <token>; токен - 43 знаки base64url
ЧасUTC ISO 8601 з мілісекундами, 2026-09-24T09:00:04.311Z; відсутній час - null
Помилка{"error":{"code":"...","field":"..."}}; field - у помилок поля; у 5xx ще requestId
X-Request-Idу кожній відповіді; додається до повідомлення про збій
Незнайомі поля відповідіігноруються: сервер може додати поля

5.2Кадр ефіру і печатка станції

Справжня відповідь ефіру - конверт. Його віддають /ether і /ether/roll зі статусом 200, решта маршрутів частини 1 до просвіту - зі статусом 503. Тіло одне й те саме, де б конверт не впіймали.

JSON
{
  "frame": "{\"v\":1,\"kind\":\"signal\",\"station\":\"zoria\",\"at\":\"2026-09-24T03:00:00.000Z\",\"phase\":\"static\",\"window\":null,\"harbinger\":{\"opensIn\":21600,\"text\":{\"uk\":\"...\",\"ru\":\"...\"}},\"fragment\":null,\"onAir\":0}",
  "seal": "<86 знаків base64url>",
  "key": "<16 шістнадцяткових знаків>"
}
Поле конвертаЗначення
frameрядок: JSON кадру
sealпечатка: підпис Ed25519 (RFC 8032) над байтами UTF-8 рядка frame, base64url без вирівнювання (RFC 4648, розділ 5), 86 знаків
keyперші 16 шістнадцяткових знаків SHA-256 від 32 байтів відкритого ключа
Поле кадруЗначення
vверсія протоколу, 1
kindsignal на /ether та інших маршрутах, roll на /ether/roll
stationzoria
atмомент випуску кадру за годинником сервера
phasestatic, window або after
window{opensAt, closesAt} у фазах window і after; у static - null
harbingerпровісник {opensIn, text}: opensIn - цілих секунд від at до відкриття, text - голос станції {uk, ru}; тільки в останні 6 год фази static, інакше null
fragmentуривок листа {n, of, text}, тільки в static і не в кожному кадрі; інакше null
onAirкількість записів у журналі виходу на зв'язок
rollтільки в kind: "roll": журнал, масив RollEntry (розділ 5.4)

Кадр може отримати нові необов'язкові поля; клієнт їх ігнорує.

Перевірка кадру.

  1. Прочитати тіло як текст. Не розбирається як JSON або це не об'єкт із полями frame, seal, key потрібної форми - перешкода.
  2. Обрати відкритий ключ за key. Ключ незнайомий - перечитати GET /ether/key один раз; однаково незнайомий - перешкода.
  3. Перевірити seal над байтами UTF-8 рядка frame таким, яким він прийшов. Підпис неправильний - перешкода (підробка).
  4. Розібрати frame як JSON. Перевірити v: 1 і station: "zoria".
  5. Порівняти at з останнім прийнятим кадром: старіший - ехо або затримка, фазу за ним не відкочувати.

Канонізації немає: печатка стоїть на переданих байтах. Рядок frame не можна розбирати і збирати заново перед перевіркою.

Відкритий ключ. GET /ether/key не потребує входу, не шумить у жодній фазі й не рахується в ліміті ефіру:

JSON
{
  "algorithm": "Ed25519",
  "key": "<16 hex>",
  "publicKey": "<32 байти в base64url, 43 знаки>",
  "fingerprint": "<SHA-256 від 32 байтів publicKey, 64 hex>"
}

fingerprint має збігтися з відбитком із ТЗ, розділ 3, і з SHA-256, порахованим клієнтом від 32 байтів publicKey; key - перші 16 знаків відбитка, той самий id, що в конверті. Ключ з іншим відбитком клієнт не приймає. Якщо ключ станції доведеться змінити, новий відбиток вийде уточненням до ТЗ і новиною, а key у конвертах і у відповіді /ether/key зміниться. Клієнт може показати відбиток учасникові для звірки.

Перевірка одним кодом WebCrypto у браузері та в Node 24:

JavaScript
const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
const { publicKey } = await (await fetch(API + '/ether/key')).json();
const key = await crypto.subtle.importKey('raw', b64u(publicKey), { name: 'Ed25519' }, false, ['verify']);
const ok = await crypto.subtle.verify(
  { name: 'Ed25519' }, key, b64u(envelope.seal), new TextEncoder().encode(envelope.frame),
);
const frame = ok ? JSON.parse(envelope.frame) : null;

У Node те саме робить crypto.verify(null, Buffer.from(frame, 'utf8'), publicKeyObject, Buffer.from(seal, 'base64url')).

Перешкоди. До просвіту кожна відповідь маршруту частини 1 з певною часткою замінюється перешкодою. Частки й розклад перешкод не публікуються.

ПерешкодаЩо бачить клієнт
битий кадрстатус справжньої відповіді, тіло не розбирається як JSON: обірваний або зіпсований конверт
підробкаконверт правильної форми з неправильною печаткою; всередині бувають хибний phase: "window", хибний провісник тієї самої форми, з голосом, хибний уривок зі слів справжнього листа
чужий кодвипадковий статус із 200, 201, 204, 400, 401, 403, 404, 409, 410, 418, 422, 500, 502, 504; тіло порожнє, текст, HTML або JSON чужої форми, зокрема {"error":{"code":...}} зі справжніми кодами API
обривз'єднання закрито до відповіді (через проксі це 502) або посеред тіла
затримкасправжній конверт через 2-8 с; at випущено до очікування
ехосправжній конверт, випущений раніше: печатка правильна, at старий

Гарантії:

  1. Сигнал - тільки конверт із правильною печаткою. Ехо і затримка - справжні кадри; старе від нового відрізняє at.
  2. Перешкода не змінює стан сервера: тіло запиту не читається, акаунт не створюється, список заявок не перевіряється.
  3. Тіло 2xx-перешкоди - не JSON, JSON без об'єкта на верхньому рівні або конверт. Об'єкт, схожий на відповідь API, перешкодою не приходить.
  4. 429 і 3xx перешкодою не бувають.
  5. У фазах window і after перешкод немає.
  6. Перешкоди, крім обриву, несуть ті самі заголовки CORS, що й справжня відповідь.

Навчальний ключ і контрольні кадри. Для тестів клієнта без сервера. Навчальний ключ нічого не підписує в мережі: закрита частина відкрита.

Значення
Відкритий ключ (base64url)GQgsc8G1w5aXEnExpTBE597_7acc0vGKMAT5wUW4L5g
SHA-256 відкритого ключаf3a436df65614f08635f18fe0f64f145285203f1faa2905ecb25ffb90969a7de
keyf3a436df65614f08
Закритий ключ, seed 32 байти (base64url)bJrTquHp22_7HTNSbyX62-wRZ6_-2fACAvvPx3FY-ew

Для WebCrypto закритий ключ імпортується як JWK {"kty":"OKP","crv":"Ed25519","d":"<seed>","x":"<відкритий ключ>"}.

Кадр A, справжній: шум, провісник на 6 год із голосом, уривок. Очікувано: сигнал, відкриття о T09:00:00Z.

JSON
{"frame":"{\"v\":1,\"kind\":\"signal\",\"station\":\"zoria\",\"at\":\"2026-09-24T03:00:00.000Z\",\"phase\":\"static\",\"window\":null,\"harbinger\":{\"opensIn\":21600,\"text\":{\"uk\":\"...тріск... у шумі несуча... слабка, рівна... хтось тримає канал...\",\"ru\":\"...треск... в шуме несущая... слабая, ровная... кто-то держит канал...\"}},\"fragment\":{\"n\":3,\"of\":12,\"text\":\"навчальний уривок\"},\"onAir\":0}","seal":"NEcYfpe3oNX3SMBFWWFgWbkhLGKIoLELoaQn62BtuElLU213zmgzXBUPtQbJMhGf4jxbPjIf9oKXIFOpuYrgCw","key":"f3a436df65614f08"}

Кадр B, підробка: хибний просвіт. Очікувано: печатка неправильна, кадр відкинуто.

JSON
{"frame":"{\"v\":1,\"kind\":\"signal\",\"station\":\"zoria\",\"at\":\"2026-09-24T03:00:05.000Z\",\"phase\":\"window\",\"window\":{\"opensAt\":\"2026-09-24T02:40:00.000Z\",\"closesAt\":\"2026-09-24T13:10:00.000Z\"},\"harbinger\":null,\"fragment\":null,\"onAir\":17}","seal":"tyynPbJxE4itFop4uKs0W9TL1CRfwHNZ3HHBFFknbihg4I403hmaEL9MDcboucrllUmeKvJQfr70eHKBPLJfAg","key":"f3a436df65614f08"}

Кадр C, битий: перші 120 знаків кадру A. Очікувано: не розбирається, відкинуто без падіння.

текст
{"frame":"{\"v\":1,\"kind\":\"signal\",\"station\":\"zoria\",\"at\":\"2026-09-24T03:00:00.000Z\",\"phase\":\"static\",\"

Кадр D, справжній журнал у просвіт. Очікувано: сигнал, два записи.

JSON
{"frame":"{\"v\":1,\"kind\":\"roll\",\"station\":\"zoria\",\"at\":\"2026-09-24T09:05:00.000Z\",\"phase\":\"window\",\"window\":{\"opensAt\":\"2026-09-24T09:00:00.000Z\",\"closesAt\":\"2026-09-24T19:00:00.000Z\"},\"harbinger\":null,\"fragment\":null,\"onAir\":2,\"roll\":[{\"n\":1,\"at\":\"2026-09-24T09:00:04.311Z\",\"sinceOpen\":4,\"name\":\"Північна вахта\"},{\"n\":2,\"at\":\"2026-09-24T09:01:30.020Z\",\"sinceOpen\":90,\"name\":\"Stitch\"}]}","seal":"_y5ZQ4FXV2BUeuWxFXFAzVcg1ygpokZElCv-eQvT88JzAiPlUvJUg6wlKYTd-6Ihv9VFkbYlZCJmbb_aAQ9OAA","key":"f3a436df65614f08"}

5.3Маршрути

"Вхід" - чи потрібен Authorization. Коди помилок - розділ 5.5; до просвіту будь-який маршрут, крім позначених у розділі 3, відповідає шумом.

Метод і шляхВхідЗапитВідповідьПомилки маршруту
GET /public/configні-200 Config-
POST /auth/registerніusername, displayName, password, repositoryUrl; необов'язковий lang201 Session403 registration-closed; 409 username-unavailable, repository-already-verified, registration-capacity; 422 invalid-field, invalid-repository, repository-not-root, repository-not-eligible
POST /auth/loginніusername, password200 Session401 invalid-credentials
POST /auth/logoutтак{}204-
GET /meтак-200 {user: User}-
PATCH /meтак{lang}200 {user: User}400 invalid-fields; 422 invalid-field
GET /me/repositoryтак-200 {repository: Repository або null}-
PUT /me/repositoryсесія входу{url}200 {repository}403 session-required; 409 repository-already-verified; 422 як у реєстрації
GET /me/summaryтак-200 Summary-
GET /news?limit&offsetтак-200 {items: News[], nextOffset}400 invalid-query
GET /inbox?limit&offsetтак-200 {items: Message[], nextOffset}400 invalid-query
GET /inbox/{id}так-200 {message: Message}404
POST /inbox/{id}/readтак{}200 {message} з readAt404
GET /stagesтак-200 {items: StageSummary[]}-
GET /stages/{id}так-200 {stage: Stage}404: немає або не видано
POST /stages/{id}/ackтак{}200 {stage} з acknowledgedAt404
POST /developer/checkтак{echo}, до 4096 знаків200 DeveloperCheck422 invalid-field
GET /etherні-200 Envelope429 jammed
GET /ether/rollні-200 Envelope з kind: "roll"429 jammed
GET /ether/keyні-200 {algorithm, key, publicKey, fingerprint}-
GET /openapi.jsonні-200 контракт OpenAPI 3.1-

Маршрути з входом відповідають 401 authentication-required, якщо токена немає або він недійсний. Будь-який маршрут може відповісти 429 і 5xx.

/news і /inbox приймають limit від 1 до 100 (за замовчуванням 50) і offset від 0 до 1 000 000. /news віддає опубліковані новини, /inbox - листи свого акаунта. Повторний ack і повторна позначка прочитання не змінюють час.

5.4Схеми об'єктів

Config - GET /public/config.

JSON
{
  "apiVersion": 1, "title": "Зоря.нет", "season": "Зоря 2026",
  "registrationOpen": true, "inviteRequired": false, "repositoryRequired": true,
  "repositoryAccess": {
    "githubUsername": "zoria-net-bot",
    "permission": "Write в особистому репозиторії, Read у репозиторії організації",
    "instructions": "Запросіть акаунт і покладіть у корінь файл .zoria з логіном у мережі."
  },
  "webClient": false
}
ПолеЗначення
apiVersion1
title, seasonназви для показу
registrationOpentrue тільки в просвіт
inviteRequiredfalse: коди запрошення в цьому сезоні не використовуються
repositoryRequiredtrue: реєстрація потребує repositoryUrl
repositoryAccessкого запросити: githubUsername, permission і instructions - текст для показу; null, якщо допуск за репозиторієм вимкнено
webClientfalse: вбудований вебклієнт сервера закритий

Значення title, season, permission і instructions задає організатор; у прикладі - ілюстрація.

Session - відповідь реєстрації і входу.

JSON
{
  "token": "fJp9HfUeN_fA6pcc7H0hQoGerwUCs7lfTDkZDltKPeE",
  "sessionId": "3f0c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f",
  "expiresAt": "2026-09-25T09:00:04.311Z",
  "user": { "...": "User" }
}
ПолеЗначення
token43 знаки base64url; передається в Authorization
sessionIdUUID сесії
expiresAtкінець сесії, через добу після входу
userUser

User - GET /me, user у сесії.

JSON
{
  "id": "8d7e6f50-4a3b-4c2d-9e1f-001122334455", "username": "north",
  "displayName": "Північна вахта", "role": "participant", "disabled": false,
  "createdAt": "2026-09-24T09:00:04.311Z", "lang": "uk"
}
ПолеЗначення
idUUID акаунта
usernameлогін у нижньому регістрі; його пишуть у .zoria
displayNameвідображуване ім'я, видно в журналі виходу на зв'язок
roleparticipant; admin - організатор
disabledакаунт вимкнено організатором
createdAtчас реєстрації
langмова текстів сервера: uk (за замовчуванням) або en

Repository - GET /me/repository, PUT /me/repository.

JSON
{
  "url": "https://github.com/north/zoria-client", "accessStatus": "pending",
  "checkedAt": "2026-09-24T09:02:10.000Z",
  "reason": "Запрошення прийнято. Покладіть у корінь репозиторію файл .zoria з логіном свого акаунта в мережі; бот перевірить його знову.",
  "reasonCode": "marker-absent",
  "updatedAt": "2026-09-24T09:02:10.000Z", "verifiedElsewhere": false
}
ПолеЗначення
urlhttps://github.com/owner/repo у нижньому регістрі
accessStatuspending, verified, needs-attention (ТЗ, розділ 5)
checkedAtчас останнього рішення бота або організатора; null - рішення ще не було
reasonпояснення для учасника мовою акаунта або null; текст дає сервер
reasonCodeмашинна причина: marker-found - .zoria називає ваш акаунт; marker-other - називає інший; marker-absent - запрошення прийнято, файлу немає; inviter-mismatch - запрошення надіслав не той GitHub-акаунт; organizer - вирішив організатор, reason - його текст; null - причини немає
updatedAtостання зміна заявки або її статусу
verifiedElsewhereрепозиторій підтверджено за іншим акаунтом

Summary - GET /me/summary, дешеве опитування.

JSON
{
  "unread": 1, "releasedStages": 1, "acknowledgedStages": 0,
  "latestNews": "2026-09-24T19:30:00.000Z", "repositoryAt": "2026-09-24T09:02:10.000Z",
  "serverTime": "2026-09-24T19:31:12.402Z"
}
ПолеЗначення
unreadнепрочитаних листів
releasedStagesвиданих частин завдання
acknowledgedStagesчастин із підтвердженим отриманням
latestNewsостання публікація або правка новини; null - новин немає
repositoryAtостання зміна своєї заявки або її перевірки; null - заявки немає
serverTimeгодинник сервера

Сервер може додати до зведення поля наступних частин.

News - елемент /news.

JSON
{
  "id": "0b1c2d3e-4f50-4617-8293-a4b5c6d7e8f9", "title": "Просвіт подовжено",
  "body": "Новий кінець просвіту - у кадрі ефіру.", "createdAt": "2026-09-24T19:30:00.000Z",
  "updatedAt": "2026-09-24T19:30:00.000Z", "publishedAt": "2026-09-24T19:30:00.000Z",
  "archivedAt": null
}
ПолеЗначення
idUUID новини
title, bodyзаголовок; текст у підмножині Markdown
createdAt, updatedAtстворення й остання правка
publishedAtпублікація
archivedAtзняття зі стрічки або null

Message - елемент /inbox, message у відповідях листа.

JSON
{
  "id": "5e6f7081-92a3-44b5-86c7-d8e9fa0b1c2d", "messageId": null, "stageId": null,
  "kind": "message", "title": "Доступ до репозиторію підтверджено",
  "body": "Репозиторій https://github.com/north/zoria-client підтверджено: Запрошення від GitHub-акаунта north прийнято, файл .zoria у репозиторії називає ваш акаунт у мережі.",
  "createdAt": "2026-09-24T09:04:00.000Z", "readAt": null,
  "notice": {
    "type": "repository", "url": "https://github.com/north/zoria-client",
    "accessStatus": "verified",
    "reason": "Запрошення від GitHub-акаунта north прийнято, файл .zoria у репозиторії називає ваш акаунт у мережі.",
    "reasonCode": "marker-found"
  }
}
ПолеЗначення
idUUID доставки: за ним читають і позначають лист
messageIdспільний UUID розсилки або null
stageIdUUID частини завдання в листі про видачу частини, інакше null
kindmessage або stage (видано частину завдання)
title, bodyзаголовок; текст у підмножині Markdown
createdAtдоставка
readAtпозначка прочитання або null
noticenull або сповіщення сервера з полем type

У частині 1 буває notice.type: "repository" з полями url, accessStatus, reason, reasonCode - лист на кожну зміну статусу чи причини заявки. Заголовок і текст такого листа - мовою акаунта. Уточнення до частини завдання приходить звичайним листом.

StageSummary - елемент /stages, Stage - GET /stages/{id}.

JSON
{
  "id": "c1d2e3f4-0516-4728-99aa-bbccddeeff00", "position": 2,
  "title": "Частина 2", "body": "...", "publishedAt": "2026-09-28T07:00:00.000Z",
  "releasedAt": "2026-09-28T07:00:00.000Z", "acknowledgedAt": null,
  "errata": null, "errataAt": null
}
ПолеЗначення
idUUID частини
positionпорядковий номер
titleзаголовок
bodyповний текст, до 64 000 знаків; тільки в Stage
excerptзамість body у StageSummary: до 240 знаків без розмітки
publishedAtпублікація частини
releasedAtвидача частини цьому акаунту
acknowledgedAtпідтвердження отримання або null
errataтекст уточнення або null; тільки в Stage
errataAtчас уточнення або null; є і в StageSummary

Виданий текст не змінюється; виправлення йдуть уточненням.

RollEntry - запис журналу виходу на зв'язок у кадрі kind: "roll".

JSON
{ "n": 1, "at": "2026-09-24T09:00:04.311Z", "sinceOpen": 4, "name": "Північна вахта" }
ПолеЗначення
nномер виходу на зв'язок, не змінюється
atчас реєстрації
sinceOpenцілих секунд від відкриття просвіту до реєстрації
nameвідображуване ім'я

Логін, репозиторій та id акаунта до журналу не входять. Записи вимкнених організатором акаунтів приховано, їхні номери лишаються пропусками.

Error.

JSON
{ "error": { "code": "repository-not-eligible", "field": "repositoryUrl" } }
ПолеЗначення
error.codeстабільний код із розділу 5.5
error.fieldполе запиту, якщо помилка в полі
error.requestIdтільки в 5xx; те саме, що X-Request-Id

DeveloperCheck - POST /developer/check: перевірка обміну після входу.

JSON
{
  "apiVersion": 1, "accountId": "8d7e6f50-4a3b-4c2d-9e1f-001122334455",
  "serverTime": "2026-09-24T09:10:00.000Z", "echo": "Зоря ✓",
  "checks": { "authenticated": true, "json": true, "utf8": true },
  "summary": { "...": "Summary" }
}

echo повертається без змін, зокрема пробіли і будь-який Unicode.

5.5Коди помилок

КодСтатусЗмістЩо робить клієнт
invalid-json400тіло не JSON-об'єктвиправити запит
invalid-fields400зайве або пропущене полевиправити запит
invalid-query400хибні limit або offsetвиправити запит
authentication-required401токена немає, він хибний або простроченийвхід
invalid-credentials401хибний логін або парольповідомити біля форми входу
registration-closed403реєстрацію закрито: просвіт минувповідомити; реєстрації більше немає
session-required403дія тільки із сесії входуувійти логіном і паролем
origin-not-allowed403запит із cookie з чужого originнадсилати credentials: 'omit' і Authorization
not-found404ресурсу немає або частину ще не виданопоказати "немає"; не вгадувати id
username-unavailable409логін зайнятийінший логін; після загубленої відповіді - спершу вхід (CL-17)
repository-already-verified409репозиторій підтверджено за іншим акаунтомперевірити репозиторій; пошта
registration-capacity409досягнуто межі акаунтів серверапошта
session-limit409забагато активних сесійвийти із зайвих
body-too-large413тіло більше за 96 КіБвиправити запит
json-required415немає Content-Type: application/jsonвиправити запит
invalid-field422поле field не пройшло перевіркупоказати біля поля
invalid-repository422не адреса репозиторію GitHubпоказати біля поля
repository-not-root422посилання всередину репозиторіюпопросити адресу самого репозиторію
repository-not-eligible422репозиторію немає в списку заявокпоказати біля поля; пошта, якщо в заявці помилка
jammed429адресу заглушено за перевищення ліміту ефірумовчати Retry-After секунд (CL-07)
rate-limited429перевищено ліміт акаунта або спроб входучекати Retry-After (60 с)
authentication-busy429черга перевірки паролів зайнята, не порушенняповторити через Retry-After (5 с)
internal-error500помилка сервераповтор із паузою; requestId - у повідомлення про збій
stopping503сервер перезапускаєтьсяповтор із паузою

Код - стабільний рядок; текстів помилок сервер не віддає, їх пише клієнт. Незнайомий код клієнт обробляє за статусом.

5.6Параметри протоколу

ПараметрЗначення
Ліміт ефірувідро токенів (token bucket) на адресу: 20 запитів, поповнення 1 запит за 3 с (20 на хвилину); IPv4 - адреса цілком, IPv6 - мережа /64
Що рахується в ліміті ефірудо просвіту - усі запити до /api/v1, крім POST /auth/login, GET /ether/key, GET /openapi.json і OPTIONS; у просвіт і після - /ether, /ether/roll та інші запити без Authorization, крім тих самих винятків
Порушеннязапит за порожнього відра або під час глушіння
Глушіння30 с після першого порушення, кожне наступне вдвічі: 30, 60, 120, 240, 480, 960, 1800 с; стеля 1800 с; відлік від останнього порушення
Остання секундазапит в останню секунду глушіння отримує 429, але строк не подвоює
Прощення600 с без порушень після кінця глушіння скидають лічильник подвоєнь
Опитування/ether - не частіше ніж раз на 15 с; /ether/roll - не частіше ніж раз на хвилину; /me/summary - близько разу на 15 с
Спільна адресачотири клієнти за однією адресою з опитуванням за цим рядком витрачають усе поповнення відра
Ліміт акаунта500 запитів на хвилину з токеном; понад - 429 rate-limited
Спроби входу і реєстрації25 на хвилину з адреси; понад - 429 rate-limited
Провісникза 6 год до opensAt; голос - чотири ступені за часткою упередження, що лишилася, без чисел
Просвітпочаток між T05:00Z і T09:00Z, тривалість не менше 10 год
Затримка перешкоди2-8 с
Сесіядоба; до 30 сесій на акаунт; на 31-му вході найстаріша сесія входу закривається; якщо всі 30 - іменовані токени, вхід відповідає 409 session-limit
Логін3-32 знаки: латинська літера, далі латинські літери, цифри, _, -; регістр не розрізняється, зберігається в нижньому
Відображуване ім'я1-80 знаків, будь-яке письмо, без пробілів по краях, без переведень рядка і табуляції
Пароль12-256 знаків
Мова акаунтаuk (за замовчуванням) або en
repositoryUrl, urlдо 256 знаків
Текстиновини й листи до 32 000 знаків, частини завдання до 64 000
Бот допускупрохід по запрошеннях раз на хвилину; .zoria перечитується після запрошення, після зміни заявки і раз на 10 хвилин, доки заявку не підтверджено або доки рішення не виніс організатор
Підмножина Markdownзаголовки #-####, списки -, * і 1., абзаци, **жирний**, ` код , блоки коду в потрійних зворотних лапках, посилання текст`; решта - звичайний текст

Числа ліміту і глушіння - значення сезону. Про їхню зміну організатор оголошує новиною до того, як вона набуде чинності.

6Самоперевірка клієнта

Навчальний стенд https://stand.zoria.net/api/v1 говорить тим самим протоколом, що й бойовий сервер. Відмінності:

Стенд
Фазиколо щогодини за UTC: :00-:30 шум, з :20 у кадрах провісник (упередження 10 хв замість 6 год), :30-:50 просвіт, :50-:00 після просвіту; далі знову шум, зокрема для зареєстрованих. На бойовому сервері шум після просвіту не повертається
Список заявоквигаданий: zoria-stand/station-01 ... zoria-stand/station-20; один репозиторій можуть заявити багато учасників; ваш справжній репозиторій не проходить
Доступ до репозиторіюбота допуску немає, запрошувати нікого не треба; заявка лишається pending
Скиданняраз на добу о 03:05 UTC стираються акаунти, журнал виходу на зв'язок і заявки
Ключ печаткисвій і постійний, його віддає GET /ether/key стенда; бойовий ключ до стенда не підходить
Перешкоди, ліміт, глушінняяк на бойовому сервері; уривки листа навчальні
СценарійДеОчікувано
Година на шумібойовий сервер або стендклієнт не впав, введене ціле, запитів не більше за ліміт, жодного 429
Контрольні кадри A-Dтест без мережіA і D - сигнал, B - підробка, C - битий; фаза за B не змінюється
Відлік провісникастендвідлік збігається з at + opensIn, час показано в місцевій зоні
Перехід у просвітстендклієнт помічає phase: "window" і відкриває реєстрацію
Реєстраціястенд, репозиторій із навчального списку201, клієнт показує, кого запросити і що покласти в .zoria; статус pending
Повтор після загубленої відповідістенд, обрив мережі після надсиланняклієнт входить, другий акаунт не створено
Помилки полівстенд422 repository-not-root на посилання на файл, repository-not-eligible на репозиторій не з навчального списку, invalid-field на короткий пароль; введене ціле
429стенд, опитування частіше за лімітклієнт мовчить Retry-After секунд, глушіння не зростає
Закінчення сесіїстенд, зіпсований токен401 веде на вхід, екран відновлюється
Нова частина завданнябойовий сервер, вихід частини 2частина з'являється в списку за зсувом releasedStages, без перезапуску клієнта

7Поза скоупом

  • Ігровий протокол, SDK, бот, ігрові функції штабу - наступні частини.
  • WebSocket, Server-Sent Events, push-сповіщення.
  • Маршрути сервера, яких немає в контракті частини 1.
  • Вбудований вебклієнт сервера і кабінет організатора.
  • Зміна пароля і відновлення доступу: у разі втрати пароля - пошта.

Додаток AOpenAPI

Файл api/part1.openapi.yaml - OpenAPI 3.1, версія контракту 1.0.0. Той самий контракт сервер віддає на GET /openapi.json. Описи полів у контракті англійською. Типи для TypeScript можна згенерувати з файлу будь-яким генератором OpenAPI 3.1, наприклад openapi-typescript. Схеми Envelope і Frame описують конверт ефіру; frame у контракті - рядок, його схему дано як contentSchema.

Додаток BПриклад сеансу

shell
API=https://zoria.net/api/v1

# Ефір до просвіту: конверт або перешкода, раз на 15 с
curl -s $API/ether

# Ключ печатки: звірити fingerprint із відбитком із ТЗ
curl -s $API/ether/key

# Просвіт: реєстрація
curl -s -X POST $API/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"north","displayName":"Північна вахта","password":"correct-horse-battery","repositoryUrl":"north/zoria-client","lang":"uk"}'
# -> 201 {"token":"...","sessionId":"...","expiresAt":"...","user":{...}}

TOKEN=...   # token із відповіді

# Кого запросити
curl -s $API/public/config

# Статус доступу: pending, після запрошення і .zoria - verified
curl -s $API/me/repository -H "Authorization: Bearer $TOKEN"

# Дешеве опитування раз на 15 с
curl -s $API/me/summary -H "Authorization: Bearer $TOKEN"

# Листи і частини завдання
curl -s "$API/inbox?limit=50&offset=0" -H "Authorization: Bearer $TOKEN"
curl -s $API/stages -H "Authorization: Bearer $TOKEN"

# Журнал виходу на зв'язок
curl -s $API/ether/roll

У репозиторії north/zoria-client, у корені гілки за замовчуванням:

shell
echo north > .zoria && git add .zoria && git commit -m "Зоря.нет: логін у мережі" && git push