Завдання/Специфікація клієнта
Зоря.нет: специфікація клієнта учасника
Версія 1, частина 1. Поля, формати й коди відповідей задає OpenAPI: у разі розбіжності в полі чи коді правий контракт. Поведінку сервера й клієнта задає цей документ. Умови допуску - ТЗ частини 1.
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 відхиляється: 403origin-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.
| Маршрут | static | window | after |
|---|---|---|---|
GET /ether | 200 конверт або перешкода | 200 конверт | 200 конверт |
GET /ether/roll | 200 конверт (журнал порожній) або перешкода | 200 конверт із журналом | 200 конверт із журналом |
GET /ether/key | ключ | ключ | ключ |
GET /openapi.json | контракт | контракт | контракт |
POST /auth/login | звичайна відповідь; акаунтів ще немає | звичайна відповідь | звичайна відповідь |
POST /auth/register | 503 конверт або перешкода; тіло не читається | реєстрація | 403 registration-closed |
| решта маршрутів частини 1 | 503 конверт або перешкода | звичайна відповідь | звичайна відповідь |
Фазу клієнт бере з поля phase перевіреного кадру (розділ 5.2). Об'єкт у відповіді зі статусом 2xx перешкодою не приходить: розібрана 200 /public/config з registrationOpen: true теж означає просвіт.
4Вимоги
4.1Ефір і перешкоди
обов'язковоКлієнт витримує будь-яку відповідь сервера без падіння і без втрати введеного: тіло не JSON, порожнє тіло, HTML, JSON чужої форми, будь-який статус, обрив з'єднання, відповідь через 8 с.
якщо слухає ефірДо просвіту клієнт діє тільки за кадром із правильною печаткою: показ фази, відлік, надсилання заявки, уривки листа. Відповіді без правильної печатки, зокрема правдоподібні помилки API на кшталт 422 repository-not-eligible чи 409 username-unavailable, до просвіту - шум, і висновків із них клієнт не робить.
якщо слухає ефірПечатка перевіряється до розбору кадру: підпис Ed25519 над байтами UTF-8 рядка frame у тому вигляді, в якому він прийшов (розділ 5.2).
якщо слухає ефірВідкритий ключ клієнт бере з GET /ether/key один раз, звіряє його відбиток із відбитком із ТЗ і зберігає. Ключ для кадру обирається за полем key конверта.
якщо слухає ефірСтарий кадр не відкочує стан: ехо і затримка - справжні кадри з минулим at. Фаза і відлік беруться з кадру з найбільшим at.
якщо слухає ефірНезнайомі поля конверта і кадру ігноруються.
4.2Глушіння і повтори
обов'язковоНа 429 клієнт чекає не менше Retry-After секунд. Після 429 jammed він не надсилає цьому серверу жодного запиту, доки Retry-After не минув: запит під час глушіння подвоює його строк.
обов'язковоЧастота опитування: /ether не частіше ніж раз на 15 с, /ether/roll не частіше ніж раз на хвилину, /me/summary приблизно раз на 15 с.
обов'язковоЗбій (5xx без конверта, обрив, тайм-аут, відмова мережі) клієнт повторює з експоненційною паузою і повним джитером (exponential backoff with full jitter): пауза = випадкове від 0 до min(60 с, 1 с x 2^n), n - номер повтору поспіль; лічильник скидається після успішної відповіді. 503 з конвертом до просвіту - відповідь ефіру, а не збій: клієнт продовжує звичайне опитування.
обов'язковоКлієнти за однією адресою ділять ліміт ефіру (розділ 5.6). Клієнт не розподіляє запити між кількома адресами, щоб обійти ліміт.
4.3Провісник
рекомендованоКлієнт показує відлік до відкриття просвіту. Момент відкриття = at кадру + harbinger.opensIn секунд. Відлік іде за годинником сервера: поправка = at кадру мінус локальний час його отримання. Голос провісника harbinger.text клієнт показує як є мовою учасника або не показує; чисел у ньому немає, відлік береться тільки з opensIn.
рекомендованоУ просвіт клієнт показує його кінець window.closesAt і оновлює його під час подовження.
4.4Реєстрація
обов'язковоФорма реєстрації: логін, відображуване ім'я, пароль, репозиторій; необов'язково - мова текстів сервера (CL-42). Клієнт перевіряє поля за правилами розділу 5.6 до надсилання.
обов'язковоРепозиторій приймається як owner/repo або як адреса на github.com: зі схемою і без, з www., SSH-вигляд git@github.com:owner/repo.git, з .git або / у кінці. Посилання всередину репозиторію (файл, гілка, вкладка, ?, #) клієнт відхиляє до надсилання і просить адресу самого репозиторію.
обов'язковоКлієнт попереджає, що відображуване ім'я бачать усі в журналі виходу на зв'язок.
рекомендованоЗаявку можна заготувати заздалегідь і надіслати автоматично: за першим перевіреним кадром із phase: "window" або в момент відкриття з провісника. Пароль заготовки зберігається тільки в учасника, не в репозиторії.
обов'язковоЗагублену відповідь на реєстрацію (тайм-аут, обрив, 5xx) клієнт не повторює наосліп. Спершу POST /auth/login із тими самими логіном і паролем: 200 - реєстрація відбулася; 401 invalid-credentials - реєстрацію можна повторити. 409 username-unavailable на повторі - теж спершу вхід.
обов'язковоПомилку реєстрації клієнт показує біля поля з field, введене не стирає (коди - розділ 5.5).
4.5Сесія
Сесія входу - сесія, видана POST /auth/register або POST /auth/login; іменовані токени для PUT /me/repository не годяться, відповідь 403 session-required.
обов'язковоТокен зберігається в пам'яті, у сховищі браузера або у файлі з правами власника. В URL, журнали і репозиторій він не потрапляє.
обов'язковоКлієнт передає Authorization: Bearer <token>. На 401 він веде на вхід і після входу повертає учасника на той самий екран із введеним.
обов'язковоВхід діє добу (expiresAt); до закінчення клієнт пропонує увійти знову. Вихід - POST /auth/logout, токен після нього недійсний.
4.6Репозиторій і доступ
обов'язковоКлієнт показує: адресу репозиторію; GitHub-акаунт для запрошення (repositoryAccess.githubUsername з /public/config), право та інструкцію (permission, instructions); що покласти в .zoria - логін учасника в мережі.
обов'язковоКлієнт показує accessStatus, reason і checkedAt, а при verifiedElsewhere: true - окреме попередження, що репозиторій підтверджено за іншим акаунтом. Замість reason клієнт може показати свій текст за машинною причиною reasonCode (розділ 5.4); при reasonCode: "organizer" показується reason як є.
обов'язково/me/repository перечитується, коли у зведенні зсунувся repositoryAt, і після зміни репозиторію.
рекомендованоЗміна репозиторію - PUT /me/repository із сесії входу; статус після неї - pending.
4.7Новини, листи, частини завдання
обов'язковоКлієнт опитує /me/summary і перечитує те, що змінилося:
| Поле зведення зсунулося | Що перечитати |
|---|---|
unread | /inbox, потім /stages |
latestNews | /news |
releasedStages | /stages |
repositoryAt | /me/repository |
обов'язковоСписки /news і /inbox читаються посторінково до nextOffset: null, елементи звіряються за id, дублів немає. Якщо під час гортання прийшли нові елементи, гортання починається з нуля.
обов'язковоКлієнт показує листи; лист із notice - сповіщення сервера, незнайомий тип notice показується як звичайний лист. Позначка прочитання - POST /inbox/{id}/read; читання GET /inbox/{id} її не ставить.
обов'язковоТекст новин, листів і частин показується безпечно: як текст або як підмножина Markdown із розділу 5.6. HTML із тіла не виконується, посилання відкриваються тільки зі схемою http або https.
обов'язковоКлієнт показує список виданих частин завдання і повний текст частини (GET /stages/{id}). Уточнення (errata) показується поруч із текстом і окремо від нього; зсунутий errataAt у списку - сигнал перечитати частину. Уточнення приходить і листом.
обов'язковоМайбутні частини клієнт не вгадує: невидана частина відповідає 404.
рекомендованоПідтвердження отримання частини - явна дія учасника, POST /stages/{id}/ack. Відкриття тексту отримання не підтверджує.
4.8Журнал і уривки листа
рекомендованоЖурнал виходу на зв'язок: GET /ether/roll не частіше ніж раз на хвилину; номер, час у місцевій зоні, відображуване ім'я; учасник знаходить у ньому себе.
рекомендованоУривки листа Доглядача збираються за номером n з of, тільки з кадрів із правильною печаткою; повтор номера відкидається.
4.9Помилки, мережа, час
обов'язковоРеакція на статуси - за розділом 5.5: 400 - помилка клієнта; 401 - вхід; 403 - немає прав, закрито або чужий origin із cookie; 404 - немає або ще не видано; 409 і 422 - конфлікт або хибне поле; 429 - чекати Retry-After; 5xx - повтор із паузою (CL-09).
обов'язковоПомилка не стирає введене. У відповіді 5xx клієнт показує X-Request-Id: його додають до повідомлення про збій.
обов'язковоКлієнт показує час у місцевій зоні пристрою і називає її. Сервер приймає і віддає тільки UTC.
4.10Збирання
обов'язковоReact + TypeScript, у репозиторії заявки.
обов'язковоREADME: встановлення за lockfile і запуск задокументованою командою. Адреса сервера - налаштування: бойовий сервер і стенд перемикаються без правки коду.
обов'язковоВ історії репозиторію немає токенів, паролів і закритих ключів.
4.11Мова текстів сервера
рекомендованоКлієнт дає учасникові обрати мову текстів сервера: поле lang під час реєстрації або PATCH /me пізніше; uk за замовчуванням або en. Цією мовою приходять сповіщення сервера і причини рішення щодо репозиторію. Новини й частини завдання мова акаунта не перекладає.
5Інтерфейси і контракти
5.1Загальні правила
| Правило | Значення |
|---|---|
| Адреса API | https://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. Тіло одне й те саме, де б конверт не впіймали.
{
"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 |
kind | signal на /ether та інших маршрутах, roll на /ether/roll |
station | zoria |
at | момент випуску кадру за годинником сервера |
phase | static, 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) |
Кадр може отримати нові необов'язкові поля; клієнт їх ігнорує.
Перевірка кадру.
- Прочитати тіло як текст. Не розбирається як JSON або це не об'єкт із полями
frame,seal,keyпотрібної форми - перешкода. - Обрати відкритий ключ за
key. Ключ незнайомий - перечитатиGET /ether/keyодин раз; однаково незнайомий - перешкода. - Перевірити
sealнад байтами UTF-8 рядкаframeтаким, яким він прийшов. Підпис неправильний - перешкода (підробка). - Розібрати
frameяк JSON. Перевіритиv: 1іstation: "zoria". - Порівняти
atз останнім прийнятим кадром: старіший - ехо або затримка, фазу за ним не відкочувати.
Канонізації немає: печатка стоїть на переданих байтах. Рядок frame не можна розбирати і збирати заново перед перевіркою.
Відкритий ключ. GET /ether/key не потребує входу, не шумить у жодній фазі й не рахується в ліміті ефіру:
{
"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:
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 старий |
Гарантії:
- Сигнал - тільки конверт із правильною печаткою. Ехо і затримка - справжні кадри; старе від нового відрізняє
at. - Перешкода не змінює стан сервера: тіло запиту не читається, акаунт не створюється, список заявок не перевіряється.
- Тіло 2xx-перешкоди - не JSON, JSON без об'єкта на верхньому рівні або конверт. Об'єкт, схожий на відповідь API, перешкодою не приходить.
- 429 і 3xx перешкодою не бувають.
- У фазах
windowіafterперешкод немає. - Перешкоди, крім обриву, несуть ті самі заголовки CORS, що й справжня відповідь.
Навчальний ключ і контрольні кадри. Для тестів клієнта без сервера. Навчальний ключ нічого не підписує в мережі: закрита частина відкрита.
| Значення | |
|---|---|
| Відкритий ключ (base64url) | GQgsc8G1w5aXEnExpTBE597_7acc0vGKMAT5wUW4L5g |
| SHA-256 відкритого ключа | f3a436df65614f08635f18fe0f64f145285203f1faa2905ecb25ffb90969a7de |
key | f3a436df65614f08 |
| Закритий ключ, seed 32 байти (base64url) | bJrTquHp22_7HTNSbyX62-wRZ6_-2fACAvvPx3FY-ew |
Для WebCrypto закритий ключ імпортується як JWK {"kty":"OKP","crv":"Ed25519","d":"<seed>","x":"<відкритий ключ>"}.
Кадр A, справжній: шум, провісник на 6 год із голосом, уривок. Очікувано: сигнал, відкриття о T09:00:00Z.
{"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, підробка: хибний просвіт. Очікувано: печатка неправильна, кадр відкинуто.
{"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, справжній журнал у просвіт. Очікувано: сигнал, два записи.
{"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; необов'язковий lang | 201 Session | 403 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, password | 200 Session | 401 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} з readAt | 404 |
GET /stages | так | - | 200 {items: StageSummary[]} | - |
GET /stages/{id} | так | - | 200 {stage: Stage} | 404: немає або не видано |
POST /stages/{id}/ack | так | {} | 200 {stage} з acknowledgedAt | 404 |
POST /developer/check | так | {echo}, до 4096 знаків | 200 DeveloperCheck | 422 invalid-field |
GET /ether | ні | - | 200 Envelope | 429 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.
{
"apiVersion": 1, "title": "Зоря.нет", "season": "Зоря 2026",
"registrationOpen": true, "inviteRequired": false, "repositoryRequired": true,
"repositoryAccess": {
"githubUsername": "zoria-net-bot",
"permission": "Write в особистому репозиторії, Read у репозиторії організації",
"instructions": "Запросіть акаунт і покладіть у корінь файл .zoria з логіном у мережі."
},
"webClient": false
}| Поле | Значення |
|---|---|
apiVersion | 1 |
title, season | назви для показу |
registrationOpen | true тільки в просвіт |
inviteRequired | false: коди запрошення в цьому сезоні не використовуються |
repositoryRequired | true: реєстрація потребує repositoryUrl |
repositoryAccess | кого запросити: githubUsername, permission і instructions - текст для показу; null, якщо допуск за репозиторієм вимкнено |
webClient | false: вбудований вебклієнт сервера закритий |
Значення title, season, permission і instructions задає організатор; у прикладі - ілюстрація.
Session - відповідь реєстрації і входу.
{
"token": "fJp9HfUeN_fA6pcc7H0hQoGerwUCs7lfTDkZDltKPeE",
"sessionId": "3f0c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f",
"expiresAt": "2026-09-25T09:00:04.311Z",
"user": { "...": "User" }
}| Поле | Значення |
|---|---|
token | 43 знаки base64url; передається в Authorization |
sessionId | UUID сесії |
expiresAt | кінець сесії, через добу після входу |
user | User |
User - GET /me, user у сесії.
{
"id": "8d7e6f50-4a3b-4c2d-9e1f-001122334455", "username": "north",
"displayName": "Північна вахта", "role": "participant", "disabled": false,
"createdAt": "2026-09-24T09:00:04.311Z", "lang": "uk"
}| Поле | Значення |
|---|---|
id | UUID акаунта |
username | логін у нижньому регістрі; його пишуть у .zoria |
displayName | відображуване ім'я, видно в журналі виходу на зв'язок |
role | participant; admin - організатор |
disabled | акаунт вимкнено організатором |
createdAt | час реєстрації |
lang | мова текстів сервера: uk (за замовчуванням) або en |
Repository - GET /me/repository, PUT /me/repository.
{
"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
}| Поле | Значення |
|---|---|
url | https://github.com/owner/repo у нижньому регістрі |
accessStatus | pending, 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, дешеве опитування.
{
"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.
{
"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
}| Поле | Значення |
|---|---|
id | UUID новини |
title, body | заголовок; текст у підмножині Markdown |
createdAt, updatedAt | створення й остання правка |
publishedAt | публікація |
archivedAt | зняття зі стрічки або null |
Message - елемент /inbox, message у відповідях листа.
{
"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"
}
}| Поле | Значення |
|---|---|
id | UUID доставки: за ним читають і позначають лист |
messageId | спільний UUID розсилки або null |
stageId | UUID частини завдання в листі про видачу частини, інакше null |
kind | message або stage (видано частину завдання) |
title, body | заголовок; текст у підмножині Markdown |
createdAt | доставка |
readAt | позначка прочитання або null |
notice | null або сповіщення сервера з полем type |
У частині 1 буває notice.type: "repository" з полями url, accessStatus, reason, reasonCode - лист на кожну зміну статусу чи причини заявки. Заголовок і текст такого листа - мовою акаунта. Уточнення до частини завдання приходить звичайним листом.
StageSummary - елемент /stages, Stage - GET /stages/{id}.
{
"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
}| Поле | Значення |
|---|---|
id | UUID частини |
position | порядковий номер |
title | заголовок |
body | повний текст, до 64 000 знаків; тільки в Stage |
excerpt | замість body у StageSummary: до 240 знаків без розмітки |
publishedAt | публікація частини |
releasedAt | видача частини цьому акаунту |
acknowledgedAt | підтвердження отримання або null |
errata | текст уточнення або null; тільки в Stage |
errataAt | час уточнення або null; є і в StageSummary |
Виданий текст не змінюється; виправлення йдуть уточненням.
RollEntry - запис журналу виходу на зв'язок у кадрі kind: "roll".
{ "n": 1, "at": "2026-09-24T09:00:04.311Z", "sinceOpen": 4, "name": "Північна вахта" }| Поле | Значення |
|---|---|
n | номер виходу на зв'язок, не змінюється |
at | час реєстрації |
sinceOpen | цілих секунд від відкриття просвіту до реєстрації |
name | відображуване ім'я |
Логін, репозиторій та id акаунта до журналу не входять. Записи вимкнених організатором акаунтів приховано, їхні номери лишаються пропусками.
Error.
{ "error": { "code": "repository-not-eligible", "field": "repositoryUrl" } }| Поле | Значення |
|---|---|
error.code | стабільний код із розділу 5.5 |
error.field | поле запиту, якщо помилка в полі |
error.requestId | тільки в 5xx; те саме, що X-Request-Id |
DeveloperCheck - POST /developer/check: перевірка обміну після входу.
{
"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-json | 400 | тіло не JSON-об'єкт | виправити запит |
invalid-fields | 400 | зайве або пропущене поле | виправити запит |
invalid-query | 400 | хибні limit або offset | виправити запит |
authentication-required | 401 | токена немає, він хибний або прострочений | вхід |
invalid-credentials | 401 | хибний логін або пароль | повідомити біля форми входу |
registration-closed | 403 | реєстрацію закрито: просвіт минув | повідомити; реєстрації більше немає |
session-required | 403 | дія тільки із сесії входу | увійти логіном і паролем |
origin-not-allowed | 403 | запит із cookie з чужого origin | надсилати credentials: 'omit' і Authorization |
not-found | 404 | ресурсу немає або частину ще не видано | показати "немає"; не вгадувати id |
username-unavailable | 409 | логін зайнятий | інший логін; після загубленої відповіді - спершу вхід (CL-17) |
repository-already-verified | 409 | репозиторій підтверджено за іншим акаунтом | перевірити репозиторій; пошта |
registration-capacity | 409 | досягнуто межі акаунтів сервера | пошта |
session-limit | 409 | забагато активних сесій | вийти із зайвих |
body-too-large | 413 | тіло більше за 96 КіБ | виправити запит |
json-required | 415 | немає Content-Type: application/json | виправити запит |
invalid-field | 422 | поле field не пройшло перевірку | показати біля поля |
invalid-repository | 422 | не адреса репозиторію GitHub | показати біля поля |
repository-not-root | 422 | посилання всередину репозиторію | попросити адресу самого репозиторію |
repository-not-eligible | 422 | репозиторію немає в списку заявок | показати біля поля; пошта, якщо в заявці помилка |
jammed | 429 | адресу заглушено за перевищення ліміту ефіру | мовчати Retry-After секунд (CL-07) |
rate-limited | 429 | перевищено ліміт акаунта або спроб входу | чекати Retry-After (60 с) |
authentication-busy | 429 | черга перевірки паролів зайнята, не порушення | повторити через Retry-After (5 с) |
internal-error | 500 | помилка сервера | повтор із паузою; requestId - у повідомлення про збій |
stopping | 503 | сервер перезапускається | повтор із паузою |
Код - стабільний рядок; текстів помилок сервер не віддає, їх пише клієнт. Незнайомий код клієнт обробляє за статусом.
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Приклад сеансу
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, у корені гілки за замовчуванням:
echo north > .zoria && git add .zoria && git commit -m "Зоря.нет: логін у мережі" && git push