repo.proto
In apps/repo-worker/proto/mkit/repo/v1/
Preview
Binary · 16.3 KiB// SPDX-License-Identifier: MIT OR Apache-2.0
//
// mkit anonymous multiplayer repo — ConnectRPC wire protocol v1.
//
// A single shared mkit repository that anyone may push to anonymously, each
// write signed with an Ed25519 key (the key is the whole identity; no
// accounts, no allow-list).
//
// Storage model (framework-independent):
// - Immutable mkit objects (commits / trees / blobs) are content-addressed
// by their 32-byte BLAKE3 id and stored once (idempotent).
// - Refs are the only mutable state, advanced via compare-and-swap.
// - WatchRefs streams live ref advances for the multiplayer UI.
//
// Conventions mirror mkit-rpc/proto/mkit/rpc/v1/ssh/ssh.proto + common.proto:
// - edition 2023.
// - All object/ref ids are raw `bytes` = 32-byte BLAKE3 digests (never hex
// on the wire).
// - `RefExpectation` (the CAS enum) and `RefEntry` are shared with
// ssh.proto via mkit/common/v1/refs.proto — one proto definition, no
// hand-synced copies. See that file's header comment for the
// versioning/compat contract.
//
// AUTH (transport-level, not in this proto): every unary/stream call carries
// the signed-write envelope as request metadata (headers) — `X-Public-Key`,
// `X-Signature`, `X-Digest`, `X-Created-At`, `Idempotency-Key`. The canonical
// signed string is defined in src/lib/envelope.ts and the README. Verification
// is open-write/demo-mode: any valid Ed25519 key may write any ref. Carrying
// auth in metadata (rather than proto fields) keeps the message shapes clean
// and lets the same envelope guard every procedure uniformly.
//
// ADMIN AUTH (also transport-level, separate mechanism from the write
// envelope above): PurgeRoom requires a bearer `X-Admin-Token` header equal
// to the server's `ADMIN_TOKEN` secret (see apps/repo-worker/README.md
// "Retention & backup/restore"). This is a plain shared-secret check — NOT
// an Ed25519 envelope — because a purge is an operator action with no
// room-participant author to attribute. An unset `ADMIN_TOKEN` fails
// PurgeRoom closed (`unauthenticated`) for every caller, never open.
//
// TIMESTAMP CONVENTION (mkit#795): every bare `int64` timestamp field in
// this file names its own unit — `_unix` for epoch-SECONDS, `_unix_ms` for
// epoch-MILLISECONDS — because a plain `int64` gives a client no way to
// tell the two apart otherwise. `CommitEntry.created_at_unix` was always
// correct; `ChatMessage.created_at` and `PresenceMember.since` predate the
// convention and are actually epoch-ms despite looking unit-less. Both are
// now `[deprecated = true]` in favor of a same-named sibling field with the
// `_unix_ms` suffix (`created_at_unix_ms` / `since_unix_ms`), added rather
// than renamed in place: this module is checked by `buf breaking` with
// `breaking.use: [FILE]` (root buf.yaml), and a bare rename changes both
// the field's name AND its `json_name` — breaking Connect's JSON codec for
// any existing client, confirmed locally via `buf breaking . --against
// '.git#branch=main'` before this fix landed. A deprecated field is kept
// (not deleted) and the server keeps populating it, so old clients still
// decode correctly during the transition; new clients should read the
// `_unix_ms` field. Migrating instead to `google.protobuf.Timestamp`
// throughout would be a real wire-breaking change and would need a sibling
// `repo2.proto` per this proto tree's breaking-change convention (see
// ssh.proto/signer.proto's "sibling `ssh2.proto`/`signer2.proto`" header
// notes and common.proto's "bump to common2.proto") — out of scope here,
// since the ambiguity is fully fixed by the additive rename above.
edition = "2023";
package mkit.repo.v1;
import "mkit/common/v1/refs.proto";
// ----------------------------------------------------------------------------
// RepoService — the full anonymous-push + multiplayer surface.
service RepoService {
// Store a loose mkit object. The server MUST verify BLAKE3(bytes)==object_id
// and store idempotently (a re-PUT of the same id is a no-op).
rpc PutObject(PutObjectRequest) returns (PutObjectResponse);
// Fetch a loose object by id. `found=false` if absent.
rpc GetObject(GetObjectRequest) returns (GetObjectResponse);
// Read a ref's current value. `exists=false` if absent.
rpc GetRef(GetRefRequest) returns (GetRefResponse);
// Advance a ref under a CAS condition. `committed` on success, `conflict`
// on a precondition failure (the existing value is returned in
// `current_id` so the client can rebase/retry).
rpc UpdateRef(UpdateRefRequest) returns (UpdateRefResponse);
// List refs under an optional prefix.
rpc ListRefs(ListRefsRequest) returns (ListRefsResponse);
// Server-streaming live room activity — one RoomEvent per successful
// UpdateRef/PostMessage/React, plus presence roster changes, to every
// subscriber. RoomEvent is a oneof of the four kinds (commit/chat/
// reaction/presence) so the stream is one schema-validated feed instead of
// requiring a second, hand-parsed channel for chat/reactions/presence.
rpc WatchRefs(WatchRefsRequest) returns (stream RoomEvent);
// Post a signed chat message to the room. The write envelope (request
// metadata) authenticates the author exactly like UpdateRef — the verified
// Ed25519 pubkey IS the author, no accounts. The server content-addresses
// the canonical message bytes by BLAKE3 (stored once in the object store,
// like a commit), assigns a monotonic per-room sequence, and broadcasts a
// chat frame to every `/watch/<room>` subscriber. Rate-limited + length-
// capped server-side; `rate_limited=true` (with `accepted=false`) when the
// author posted too recently.
rpc PostMessage(PostMessageRequest) returns (PostMessageResponse);
// Recent messages for the room, oldest-first (chat reading order), capped by
// `limit`. Unauthenticated — reading the lobby is open to everyone.
rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse);
// Toggle a signed emoji reaction on a feed item (a chat message or a commit),
// keyed by its hex id. Signed like PostMessage — the verified Ed25519 pubkey
// is the reactor. Idempotent per (author, target, emoji): a second call from
// the same author removes it. Broadcasts a reaction frame to subscribers.
rpc React(ReactRequest) returns (ReactResponse);
// All reactions in the room (target_id → emoji → reactors); the client
// aggregates counts + "did I react". Unauthenticated read.
rpc ListReactions(ListReactionsRequest) returns (ListReactionsResponse);
// Walk the commit chain server-side from a ref head (or a `start_id` cursor)
// by first-parent, returning a bounded page of RAW commit/remix objects in ONE
// round-trip — instead of the client issuing O(depth) sequential GetObject
// calls that can't be pipelined (each parent id is only known after decoding
// the previous object). The client decodes the bytes with its existing
// decoder, so the server needs no field-by-field parity. Unauthenticated read.
rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse);
// Permanently delete a room's R2 objects (both the `objects/` and
// `messages/` key prefixes) and its Durable-Object-resident mutable state
// (refs, chat rows, reactions, and the denormalized commit index) — the
// ONLY way to reclaim storage the anonymous-multiplayer write path
// otherwise retains forever (immutable objects are never deleted by any
// other RPC). Admin-gated (see the ADMIN AUTH note above): the request
// MUST carry a valid `X-Admin-Token` header, or the server rejects with
// `unauthenticated` before this handler runs. There is no signed-write
// envelope requirement (a purge is an operator action, not a
// room-participant write) and no undo — the purge is immediate and
// irreversible. See the README "Retention & backup/restore" runbook
// before invoking against a production room.
rpc PurgeRoom(PurgeRoomRequest) returns (PurgeRoomResponse);
}
// ----------------------------------------------------------------------------
// Objects.
message PutObjectRequest {
string room = 1;
bytes object_id = 2; // 32-byte BLAKE3 of `bytes`
bytes bytes = 3; // raw mkit object bytes
}
message PutObjectResponse {
bool stored = 1; // true if this call wrote the object
bool duplicate = 2; // true if the object already existed (no-op write)
}
message GetObjectRequest {
string room = 1;
bytes object_id = 2;
}
message GetObjectResponse {
bool found = 1;
bytes bytes = 2; // empty when found=false
}
// Server-side commit-log walk (one round-trip per page).
message ListCommitsRequest {
string room = 1;
string ref = 2; // ref name to resolve as the head; empty = "main"
bytes start_id = 3; // optional cursor: walk from here instead of the ref head
uint32 page_size = 4; // max commits to return; the server caps this
}
// A denormalized commit-log entry served straight from the DO index — the
// fields the client renders, so it needs NO raw object bytes and NO decode.
// Hex strings throughout (matches the client's row shape 1:1).
message CommitEntry {
string hash = 1; // 64-hex commit/remix id
string parent = 2; // 64-hex first parent; empty if root
string author_pubkey = 3; // 64-hex signer
string message = 4;
int64 created_at_unix = 5; // unix seconds (client renders ISO); the naming model for this file's timestamp convention (mkit#795)
string kind = 6; // "commit" | "remix"
string sources_json = 7; // [[upstreamHex, commitHex], …]; "[]" for a commit
}
message ListCommitsResponse {
repeated CommitEntry commits = 1;
string next_cursor = 2; // 64-hex first-parent of the last entry; empty when the chain ended
}
// ----------------------------------------------------------------------------
// Refs.
message GetRefRequest {
string room = 1;
string name = 2;
}
message GetRefResponse {
bool exists = 1;
bytes object_id = 2; // empty when exists=false
}
message UpdateRefRequest {
string room = 1;
string name = 2;
bytes new_id = 3; // 32-byte target value
mkit.common.v1.RefExpectation expectation = 4; // REQUIRED; UNSPECIFIED is a protocol error
bytes expected_id = 5; // 32 bytes for MATCH; MUST be empty for ANY/MISSING
}
message UpdateRefResponse {
bool committed = 1;
bool conflict = 2; // true on CAS precondition failure
bytes current_id = 3; // the server's current value (for retry); empty if ref absent
}
message ListRefsRequest {
string room = 1;
string prefix = 2; // empty = all refs
string start_after = 3; // keyset cursor: return refs with name > start_after; empty = from the start
uint32 page_size = 4; // max refs to return; 0 = ALL (legacy unpaginated behavior); server caps at 1000
}
message ListRefsResponse {
repeated mkit.common.v1.RefEntry refs = 1;
string next_cursor = 2; // pass as start_after for the next page; empty when the listing ended
uint32 total = 3; // total refs matching the prefix (COUNT(*) on the PK)
}
// ----------------------------------------------------------------------------
// Multiplayer event stream.
message WatchRefsRequest {
string room = 1;
}
message RefEvent {
string name = 1;
bytes object_id = 2;
bytes author_pubkey = 3; // 32-byte Ed25519 pubkey of the writer
}
// A discriminated union of everything the room's live subscribers can
// receive over WatchRefs (and, during migration, the raw `/watch/<room>`
// WebSocket fallback described in repo-worker's README — both carry the SAME
// RoomEvent proto3-JSON encoding so there is one source of truth for the
// wire shape): a ref advance, a chat post, a reaction toggle, or a presence
// roster update. Replaces the old ad hoc, hand-parsed `WatchFrame` JSON enum
// + separate untyped `PresenceJson` shape — see mkit#705.
message RoomEvent {
oneof event {
RefEvent commit = 1;
ChatMessage chat = 2;
ReactionEvent reaction = 3;
PresenceEvent presence = 4;
}
}
// A live reaction TOGGLE — the new on/off state for one (target, emoji,
// author) tuple after a React call, broadcast to every room subscriber.
// Distinct from `Reaction` (the stored row shape ListReactionsResponse
// returns, which carries no `active`/`count`).
message ReactionEvent {
string target_id = 1; // hex id of the feed item (message_id or commit hash)
string emoji = 2;
bytes author_pubkey = 3; // 32-byte Ed25519 pubkey of the reactor
bool active = 4; // true if the reaction is now ON for this author
uint32 count = 5; // total reactors for (target, emoji) after the toggle
}
// Live room presence, broadcast to every subscriber on join/leave.
message PresenceEvent {
repeated PresenceMember members = 1; // distinct online keys, deduped by pubkey across tabs
uint32 viewers = 2; // connections with no identity yet ("N viewers")
}
message PresenceMember {
bytes author_pubkey = 1; // 32-byte Ed25519 pubkey
int64 since = 2 [deprecated = true]; // epoch-ms; ambiguous name — use since_unix_ms (mkit#795)
int64 since_unix_ms = 3; // epoch-ms the (earliest) connection joined
}
// ----------------------------------------------------------------------------
// Chat — signed messages that share the room (and the live event stream) with
// commits, so the front-page lobby renders one merged feed of a player's
// signed activity (chatter + pushes). The message body the author signs is
// just `{ room, text }`; the server stamps `created_at` + `seq` (ordering is
// server-authoritative, not client-clock).
message PostMessageRequest {
string room = 1;
string text = 2; // UTF-8; server caps the character count
}
message PostMessageResponse {
bytes message_id = 1; // 32-byte BLAKE3 of the canonical message bytes
bool accepted = 2; // stored + broadcast
bool rate_limited = 3; // refused: the author posted too recently
}
message ListMessagesRequest {
string room = 1;
uint32 limit = 2; // 0 = server default; clamped to a server max
}
// One stored chat message. `message_id` is the content address (BLAKE3 of the
// canonical bytes); `created_at_unix_ms` is server epoch-ms; `seq` is the
// monotonic per-room order used to merge against commits in the feed.
message ChatMessage {
bytes message_id = 1;
bytes author_pubkey = 2; // 32-byte Ed25519 pubkey of the signer
string text = 3;
int64 created_at = 4 [deprecated = true]; // epoch-ms; ambiguous name — use created_at_unix_ms (mkit#795)
uint64 seq = 5; // monotonic per-room sequence
int64 created_at_unix_ms = 6; // server-stamped epoch milliseconds
}
message ListMessagesResponse {
repeated ChatMessage messages = 1; // oldest-first
}
// ----------------------------------------------------------------------------
// Reactions — signed emoji on a feed item (chat message OR commit), keyed by
// the item's hex id. One row per (target, emoji, author); React toggles it.
message ReactRequest {
string room = 1;
string target_id = 2; // hex id of the feed item (message_id or commit hash)
string emoji = 3; // the reaction emoji (e.g. "👍")
}
message ReactResponse {
bool active = 1; // true if the reaction is now ON for this author
uint32 count = 2; // total reactors for (target, emoji) after the toggle
}
message ListReactionsRequest {
string room = 1;
}
message Reaction {
string target_id = 1;
string emoji = 2;
bytes author_pubkey = 3; // 32-byte Ed25519 pubkey of the reactor
}
message ListReactionsResponse {
repeated Reaction reactions = 1;
}
// ----------------------------------------------------------------------------
// Room retention / deletion — admin-only (see the ADMIN AUTH note + PurgeRoom
// above). One request purges everything content-addressed or mutable under a
// single room.
message PurgeRoomRequest {
string room = 1;
}
message PurgeRoomResponse {
bool purged = 1; // true unless the room already had nothing to purge
uint32 objects_deleted = 2; // R2 objects removed under {room}/objects/
uint32 message_bodies_deleted = 3; // R2 chat bodies removed under {room}/messages/
uint32 refs_deleted = 4; // DO `refs` rows removed
uint32 messages_deleted = 5; // DO `messages` rows removed
uint32 reactions_deleted = 6; // DO `reactions` rows removed
}