4 Commits
Author SHA1 Message Date
Yuriy Mayatnikov 6e31889f11 Merge pull request #8 from xmatic-squad/codex/release/v1.1.2-new-chat
v1.1.2: понятный выбор личного чата и комнаты
2026-08-24 16:49:28 +03:00
mayatnikov d0a7fab09c Новый чат v1.1.2: разделить личные чаты и комнаты 2026-08-24 16:48:39 +03:00
Yuriy Mayatnikov 3a30cc63d3 Merge pull request #7 from xmatic-squad/codex/docs/readme-hero
Оформить README и добавить hero Bare
2026-08-24 13:19:48 +03:00
mayatnikov a5955030bb Оформить README и добавить hero Bare 2026-08-24 13:18:12 +03:00
10 changed files with 277 additions and 76 deletions
+64 -17
View File
@@ -1,28 +1,75 @@
# Bare
<p align="center">
<img src="docs/identity/hero.png" alt="" width="100%">
</p>
Максимально простой веб-чат. Web-native PWA на ванильных технологиях: HTML, CSS, vanilla JS — и Go-сервер одним бинарём. Ноль npm-зависимостей, никакой сборки.
<h1 align="center">bare</h1>
Bare — маленький независимый инструмент, а не конкурент Discord, Slack или Telegram. Название буквальное: голый, без лишнего, ничего не спрятано за слоями абстракций.
<p align="center">
сообщения шифруются в браузере · история остаётся на устройстве · сервер доставляет шифротекст
</p>
Сервер — реле, а не архив: он передаёт зашифрованные сообщения и забывает их. История живёт только на устройстве. E2EE по умолчанию — оператор видит только шифротекст. Забыл пароль — значит забыл: восстановления нет, потому что пароль — материал ключа.
<p align="center">
<a href="https://bare.xmatic.team">открыть bare</a> ·
<a href="docs/philosophy.md">философия</a> ·
<a href="docs/threat-model.md">модель угроз</a>
</p>
## Документация
## что это
- [Философия](docs/philosophy.md) — манифест
- [Архитектура](docs/architecture.md) — обзор системы
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
- [Решения](docs/decisions/) — ADR по ключевым решениям
- [Криптография](docs/crypto.md), [протокол](docs/protocol.md), [хранение](docs/storage.md) — спецификации для кода
- [Интерфейс](docs/ui.md) и [айдентика](docs/identity/brief.md)
- [Деплой](docs/deploy.md) и [план реализации](docs/plan.md)
- [Открытые вопросы](docs/open-questions.md)
Bare — маленький независимый E2EE-чат, а не платформа. Он умеет личные чаты и комнаты, работает как PWA, присылает уведомления и переносит локальную историю через зашифрованный файл `.bare`.
## Статус
Клиент — HTML, CSS и vanilla JS без npm и сборки. Сервер — Go + SQLite, статика встроена в один бинарь.
Все шесть этапов `docs/plan.md` сделаны и работают на [bare.xmatic.team](https://bare.xmatic.team): аккаунты, чат 1:1, комнаты с ключом на комнату, TOFU и отпечатки, PWA и пуши, экспорт и импорт истории, лимиты и закалка.
- **без аккаунтной обвязки** — только ник и пароль, без email, телефона и OAuth;
- **локальная история** — сообщения после доставки живут в IndexedDB текущего устройства, а не в облачном архиве;
- **сервер-реле** — сервер хранит метаданные и временную очередь недоставленных шифротекстов, удаляя сообщения после подтверждения устройства;
- **криптография в браузере** — пароль не покидает клиент, личные чаты и комнаты шифруются через WebCrypto;
- **простая поверхность аудита** — клиент состоит из читаемых ES-модулей без бандлера и скрытого шага сборки.
Осталось ручное: прогон сценариев на iOS Safari (установленное на «Домой» приложение), Android Chrome, десктопных Firefox и Safari. Автоматика гоняла только Chrome. Чеклист — в `docs/plan.md`, этап 4.
## внутри
## Лицензия
| слой | решение |
|---|---|
| клиент | HTML, CSS, vanilla JS, PWA, IndexedDB |
| сервер | Go, SQLite, HTTPS, SSE |
| ключи | ECDH P-256, PBKDF2, HKDF, AES-GCM |
| доверие | TOFU и сверка отпечатков при смене ключа |
| история | только на устройстве; ручной экспорт и импорт `.bare` |
## запустить локально
Нужен Go 1.27.
```sh
git clone https://github.com/xmatic-squad/bare.git
cd bare
go run ./cmd/bare serve
```
Откройте [http://127.0.0.1:8411](http://127.0.0.1:8411). Без переменных окружения Bare создаст `bare.db` в текущей папке, разрешит регистрацию и запустится без пушей.
```sh
go test ./...
```
Production-настройка, VAPID, nginx и systemd описаны в [руководстве по развёртыванию](docs/deploy.md).
## честные ограничения
- Забыл пароль — значит забыл: восстановления нет, потому что пароль участвует в выводе ключей.
- История в IndexedDB не зашифрована at rest и доступна коду, работающему на устройстве.
- E2EE не скрывает метаданные, не защищает скомпрометированное устройство и не спасает от оператора, который подменил веб-клиент.
- Forward secrecy в v1 нет. На iOS пуши работают только у PWA, установленного на экран «Домой».
Полная граница обещаний и допущений — в [модели угроз](docs/threat-model.md).
## документация
- [Философия](docs/philosophy.md), [архитектура](docs/architecture.md), [модель угроз](docs/threat-model.md)
- [Криптография](docs/crypto.md), [HTTP-протокол](docs/protocol.md), [хранение](docs/storage.md)
- [Интерфейс](docs/ui.md), [айдентика](docs/identity/brief.md), [архитектурные решения](docs/decisions/)
- [Деплой](docs/deploy.md), [план v1](docs/plan.md), [открытые вопросы](docs/open-questions.md)
## лицензия
[MIT](LICENSE)
@@ -0,0 +1,20 @@
# ADR-078: «Новый чат» — выбор действия
## Контекст
На экране `#/new` одновременно стояли две одинаковые строки: `@ник` и `#имя комнаты`. Это две независимые формы, но экран не говорил «или», а на десктопе их кнопки `>` скрывал общий стиль строки чата. Человек мог заполнить обе строки и ожидать, что ник станет участником комнаты. Enter при этом отправлял только форму поля в фокусе: свой ник давал «нельзя писать себе», а название комнаты создавало комнату только с владельцем.
Название комнаты не является адресом. Оно не уникально, сервер не даёт поиск комнат и возвращает пользователю только комнаты, где он уже состоит. Из-за сквозного шифрования новый участник получает доступ после того, как владелец добавит его по нику и раздаст новый ключ (ADR-018). Самостоятельно войти по одному открытому названию нельзя.
## Решение
- `#/new` показывает переключатель «личный чат / новая комната» и только одну форму за раз. У каждой формы есть видимая подпись, текстовая кнопка и своя строка ошибки. Начальное поле не фокусируется автоматически.
- Сценарий комнаты прямо называет её новой и приватной: сначала в ней только создатель; если владельцу другой комнаты нужен этот пользователь, владелец добавляет его, после чего комната сама появляется в списке.
- После создания открывается `#/room/<id>/members`, а не пустая лента. Создатель сразу видит себя владельцем и форму добавления людей.
- В шапке комнаты рядом с именем стоит подпись `· участники N` внутри той же кнопки. Число — размер состава, не присутствие онлайн; кнопка ведёт на экран участников.
## Последствия
Заполнить личный чат и комнату как одну составную форму больше нельзя. Кнопки действий видны и на десктопе, и на мобильном; Enter относится только к показанной форме.
Вход по имени, публичный каталог и ссылки-приглашения не добавляются. Такой сценарий потребует отдельного адреса или уникальности, правил одобрения и раздачи ключа; это не изменение текста формы.
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

+1 -1
View File
@@ -5,4 +5,4 @@
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — попытки убрать системную панель ввода iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. ADR-076 фиксирует системное ограничение панели iOS и возврат к `textarea`, ADR-077 — стороны сообщений отдельно для touch и десктопа.
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — попытки убрать системную панель ввода iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. ADR-076 фиксирует системное ограничение панели iOS и возврат к `textarea`, ADR-077 — стороны сообщений отдельно для touch и десктопа, ADR-078 — выбор между личным чатом и новой комнатой.
+7 -3
View File
@@ -32,11 +32,15 @@
## Новый чат (`#/new`)
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
Один экран с переключателем двух взаимоисключающих действий: «личный чат / новая комната». Видна только форма выбранного действия; начальный режим — личный чат, но поле само не получает фокус.
«Личный чат»: текст «напишите человеку по нику.», поле «ник человека» с подсказкой `@ник`, кнопка «написать». Ошибки: «такого ника нет», «нельзя писать себе».
«Новая комната»: текст «создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.» и пояснение «если вас добавят в чужую комнату, она появится в списке сама.». Поле «название новой комнаты» с подсказкой `#название`, кнопка «создать комнату». Имя не ищет и не открывает существующую комнату: оно не уникально, а доступ даёт владелец добавлением по нику. После создания открывается экран участников новой комнаты (ADR-078).
## Чат
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. У комнаты в той же кнопке стоит подпись «· участники N»; это число состава, не присутствующих онлайн. Без темы и «N онлайн».
Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
@@ -62,7 +66,7 @@
## Участники (`#/room/<id>/members`)
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
Заголовок «участники» и список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
## Настройки (`#/settings`)
+49 -3
View File
@@ -306,6 +306,36 @@ input[type="password"] {
font-size: 13px;
}
/* новый чат — одно действие за раз (ADR-078). Подсказки стоят до формы,
чтобы смысл выбора был понятен до фокуса в поле */
.new {
max-width: 460px;
}
.new__tabs {
margin-bottom: 18px;
}
.new__panel[hidden] {
display: none;
}
.new__copy {
max-width: 420px;
margin: 0 0 18px;
color: var(--text2);
font-size: 12px;
line-height: 1.6;
}
.new__aside {
display: block;
margin-top: 8px;
color: var(--mute);
font-size: 11px;
}
/* каркас: сайдбар и экран */
.shell {
@@ -717,6 +747,9 @@ input[type="password"] {
/* чат: шапка, лента, ввод — docs/identity/screens.html */
.chat-title {
display: flex;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
border-radius: 0;
@@ -728,6 +761,19 @@ input[type="password"] {
cursor: pointer;
}
.chat-title__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title__members {
flex: none;
color: var(--mute);
font-size: 11px;
}
/* лента прижата к низу: короткая переписка не висит под шапкой */
.feed {
@@ -1055,9 +1101,9 @@ input[type="password"] {
--field-pad: 13px;
}
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
строке `@ник`: в рамке ввода «>» остаётся один, слева
(docs/identity/brief.md, «Компоновка») */
/* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке
ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»).
Остальные формы используют обычные текстовые `.button` */
.input__send {
display: none;
+21 -2
View File
@@ -167,11 +167,21 @@ function head(view) {
const back = el("button", "back back--chat", "назад");
back.type = "button";
back.addEventListener("click", () => view.ctx.go("#/"));
view.title = el("button", "chat-title", titleText(view));
view.title = el("button", "chat-title");
view.title.type = "button";
view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null
? `#/room/${view.roomId}/members`
: `#/contact/${view.peer}`));
if (view.roomId !== null) {
// Состав был спрятан за неочевидным нажатием на имя. Одна составная
// кнопка сохраняет место в мобильной шапке: имя сокращается, а смысл
// действия и число участников остаются видны (ADR-078).
view.titleName = el("span", "chat-title__name", titleText(view));
view.titleMembers = el("span", "chat-title__members", "· участники");
view.title.append(view.titleName, view.titleMembers);
} else {
view.title.textContent = titleText(view);
}
bar.append(back, view.title);
return bar;
}
@@ -208,7 +218,16 @@ async function refreshRoom(view, known) {
return;
}
view.name = record?.title || view.roomId;
view.title.textContent = titleText(view);
const title = titleText(view);
view.titleName.textContent = title;
const count = record?.hidden === true || !Array.isArray(record?.members)
? null
: record.members.length;
view.titleMembers.textContent = count === null ? "· участники" : `· участники ${count}`;
view.title.setAttribute(
"aria-label",
count === null ? `${title}, открыть участников` : `${title}, открыть участников: ${count}`,
);
hint(view, `сообщение в #${shortName(view.name)}`);
// Скрытая запись комнаты — это room_left или собственный выход:
// отправлять больше некуда, и сервер ответил бы not_member.
+1 -1
View File
@@ -32,7 +32,7 @@ export function renderMembers(root, ctx, roomId) {
const people = el("section", "block block--first");
view.list = el("ul", "members");
view.note = message();
people.append(view.list, add(view), view.note);
people.append(el("h2", "section", "участники"), view.list, add(view), view.note);
body.append(view.warn, people, exit(view));
root.append(body);
+113 -48
View File
@@ -1,5 +1,5 @@
// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает
// личный чат, `#имя комнаты` заводит комнату.
// Новый чат — docs/ui.md, «Новый чат». Это выбор одного действия:
// личный чат по нику или новая приватная комната (ADR-078).
import * as sync from "../sync.js";
import { el, message, setError, setNote } from "./dom.js";
@@ -8,99 +8,164 @@ import { el, message, setError, setNote } from "./dom.js";
// единицы UTF-16, а сервер — руны: за предел это не выпустит.
const ROOM_NAME_MAX = 64;
const MODES = [
["dm", "личный чат"],
["room", "новая комната"],
];
export function renderNew(root, ctx) {
root.append(head(ctx));
const body = el("div", "body");
// Место под ошибку одно на оба поля: строка состояния у экрана одна
// (ADR-028).
const body = el("div", "body new");
const dm = direct(ctx);
const room = roomForm(ctx);
const panels = { dm, room };
let mode = "dm";
const tabs = el("div", "tabs new__tabs");
tabs.setAttribute("role", "group");
tabs.setAttribute("aria-label", "что открыть");
const buttons = new Map();
const select = (next, focus = false) => {
mode = next;
for (const [name, panel] of Object.entries(panels)) {
const on = name === mode;
panel.root.hidden = !on;
const tab = buttons.get(name);
tab.classList.toggle("is-on", on);
tab.setAttribute("aria-pressed", String(on));
}
if (focus) {
panels[mode].field.focus();
}
};
MODES.forEach(([name, text], index) => {
if (index > 0) {
const sep = el("span", "tabs__sep", "/");
sep.setAttribute("aria-hidden", "true");
tabs.append(sep);
}
const tab = el("button", "tab", text);
tab.type = "button";
tab.addEventListener("click", () => select(name, true));
buttons.set(name, tab);
tabs.append(tab);
});
body.append(tabs, dm.root, room.root);
root.append(body);
select(mode);
}
function direct(ctx) {
const panel = section("напишите человеку по нику.");
const input = field("ник человека", "@ник");
const go = el("button", "button", "написать");
go.type = "submit";
const note = message();
panel.form.append(input.wrap, go, note);
const dm = row("@ник");
const room = row("#имя комнаты");
room.field.maxLength = ROOM_NAME_MAX;
dm.form.addEventListener("submit", async (event) => {
panel.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (dm.go.disabled) {
if (go.disabled) {
return;
}
setNote(note, "");
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
// ники строчные (ADR-019).
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
const nick = input.field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
dm.field.focus();
input.field.focus();
return;
}
dm.field.value = nick;
dm.go.disabled = true;
input.field.value = nick;
go.disabled = true;
try {
await sync.openDm(nick);
ctx.go(`#/dm/${nick}`);
} catch (err) {
setError(note, ctx.errorText(err));
dm.field.focus();
input.field.focus();
} finally {
dm.go.disabled = false;
go.disabled = false;
}
});
room.form.addEventListener("submit", async (event) => {
return { ...panel, field: input.field };
}
function roomForm(ctx) {
const panel = section(
"создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.",
);
panel.copy.append(el(
"span",
"new__aside",
"если вас добавят в чужую комнату, она появится в списке сама.",
));
const input = field("название новой комнаты", "#название");
input.field.maxLength = ROOM_NAME_MAX;
const go = el("button", "button", "создать комнату");
go.type = "submit";
const note = message();
panel.form.append(input.wrap, go, note);
panel.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (room.go.disabled) {
if (go.disabled) {
return;
}
setNote(note, "");
// Имя вводят как в списке: с «#» или без. Регистр имени комнаты
// сохраняется — это её название, а не идентификатор.
const name = room.field.value.trim().replace(/^#/, "").trim();
const name = input.field.value.trim().replace(/^#/, "").trim();
if (name === "") {
room.field.focus();
input.field.focus();
return;
}
room.go.disabled = true;
go.disabled = true;
try {
const chatId = await sync.createRoom(name);
if (chatId === null) {
room.field.focus();
input.field.focus();
return;
}
ctx.go(`#/room/${sync.roomIdOf(chatId)}`);
// Новая комната начинается с одного владельца. Первый полезный
// экран — её состав: там сразу видны владелец и добавление людей.
ctx.go(`#/room/${sync.roomIdOf(chatId)}/members`);
} catch (err) {
setError(note, ctx.errorText(err));
room.field.focus();
input.field.focus();
} finally {
room.go.disabled = false;
go.disabled = false;
}
});
body.append(dm.form, room.form, note);
root.append(body);
dm.field.focus();
return { ...panel, field: input.field };
}
// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark
// (docs/identity/brief.md, «Компоновка»).
function row(placeholder) {
const form = el("form", "form form--row");
function section(text) {
const root = el("section", "new__panel");
const copy = el("p", "new__copy", text);
const form = el("form", "form");
form.noValidate = true;
root.append(copy, form);
return { root, copy, form };
}
const line = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = placeholder;
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
line.append(prompt, field, go);
form.append(line);
return { form, field, go };
function field(label, placeholder) {
const wrap = el("label", "field");
wrap.append(el("span", null, label));
const input = el("input");
input.type = "text";
input.placeholder = placeholder;
input.autocapitalize = "off";
input.autocomplete = "off";
input.spellcheck = false;
wrap.append(input);
return { wrap, field: input };
}
function head(ctx) {
+1 -1
View File
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const VERSION = "v7";
const VERSION = "v8";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: