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
}

Files