` にする(鍵が変わっても変わらない)。一覧に無い・取り消された鍵なら作者は表示しない。
- 署名があって検証に失敗したワールドは解決を拒否する。
- **署名を検証できないワールド(未署名)は公開しない**:ワールド一覧・連合(global)一覧・プロフィール・インスタンス一覧に出さない。URL を直接指定すれば入れるが、入室前に確認を求める。
- 連合ピアの一覧に書かれた署名状態は信用せず、受け手が各ワールドを取得して検証する。
- サーバーは署名しない(鍵を持たない)。本体で作ったワールドも外部と同じく、作者が手元(ブラウザ・CLI)で「定義・lock」に署名し、`PUT /api/v1/worlds` に `{ yaml, lock, signature }` を一緒に送る(サーバーは中身を書き換えない。署名が通らなければ何も保存しない)。同じ作者・同じ `metadata.name` なら同じワールドの更新になる。エディタで `metadata.name` を変えると名前の変更(URL が変わり、以前の URL からもたどれる)。署名なしで送ると、公開中のワールドなら公開中の版を残して下書きになる。
- 公式ワールド(`worlds/`)は main に入ったら CI が公式アカウントで署名し(`ubichill publish worlds/*.yaml --out=worlds`)、イメージに同梱する。署名はコミットしない。ほかのリポジトリも同じ方法で公開できる(`packages/sdk/README.md` の CI の例)。
- 本体は YAML・lock・署名をファイル(DB)の生の値のまま配信する(既定値で補うと署名と一致しなくなるため)。
## 正準スキーマ
Zod スキーマが唯一の真実源:`packages/shared/src/schemas/world.schema.ts`(`WorldDefinitionSchema`)。互換性はこの `apiVersion` で管理する。
---
# はじめかた
URL: https://hub.ubichill.com/docs/getting-started/
Ubichill は、URL を開くだけで入れる 2D メタバースです。インストールは要りません。
ここでは、公式サーバー [ubichill.com](https://ubichill.com/) を例に、最初の一歩を説明します。
自分のサーバーや、ほかの人のサーバーでも流れは同じです。
## 1. アカウントを作る
1. [ubichill.com](https://ubichill.com/) を開き、「新規登録」を選びます。
2. メールアドレスとパスワード(8 文字以上)、**表示名**と **ID** を入れます。
- 表示名: 画面に出る名前。日本語も使えます。あとから変えられます。
- ID: 英小文字・数字・`_` の 3〜30 文字。URL や署名に使われ、**あとから変えられません**。
3. メールに届いた 6 桁の認証コードを入れます(10 分間有効)。届かないときは迷惑メールフォルダを確認し、「認証コードを再送信」を押します。
4. 「ログイン」でログインします。
:::note[作者アカウント]
あなたのアカウントは `ID@ドメイン`(例 `@yokan@ubichill.com`)という形で、ほかのサーバーからも参照されます。
ワールドや mod を公開するときの「作者」もこのアカウントです。詳しくは [署名して公開する](/docs/publish/) を見てください。
:::
## 2. ホームワールドに入る
ログインすると、ロビーのような一覧画面は無く、そのまま**自分のホームワールドの部屋**に入ります。
部屋はログインのたびに新しく作られ、あなたがいなくなれば片付きます。
- 既定のホームは公式のワールドです。設定 → ホームで、好きなワールドをホームにできます。
- ホームの部屋の公開範囲は、既定で**招待のみ**です。ログインするたびに公開一覧へ部屋が出ないようにするためです。
フレンドに見つけてもらいたいときは、設定 → ホームで公開範囲を「フレンドのみ」などに変えるか、公開の部屋へ移ります。
- 部屋の中では、マウス(スマートフォンでは指)がそのままあなたのアバターになります。ワールドに置かれた mod(ペン・動画プレイヤー・付箋など)をさわって過ごします。
## 3. ランチパッドでできること
部屋の中の右上(スマートフォンでは右下)のボタン、または **Esc** で**ランチパッド**が開きます。
ページを移らずに、ここからすべての操作をします。下のドックで画面を切り替えます。
| ドック | できること |
|---|---|
| ランチパッド | いまの部屋(ワールド・公開範囲・人数)、「ホーム」「招待」、オンラインのフレンド、ワールドの一覧 |
| 通知 | 招待・フレンドリクエスト・その他の通知 |
| 現在地 | いまの部屋の詳細。入り直し・新しいインスタンスを作る・ワールドの編集画面へ・参加者 |
| 音声 | ワールド全体の音量とミュート |
| 設定 | プロフィール・ホーム・ワールド・公開・アカウント・連合・mod の権限 |
- 上に出ている自分のアイコンと名前を押すと、プロフィールが開きます。
- 「フレンド」「ワールド」の見出しから、それぞれソーシャル・ワールドの画面に進みます。ワールドの見出しの「+」で、新しいワールドを作れます([エディタでワールドを作る](/docs/create/editor/))。
- 入力中は Esc が効きません。日本語の変換中も同じです。まず入力欄から抜けてください。
### フレンドを探す
ソーシャルの画面の検索に、ID か表示名を入れます。ほかのサーバーの人は `@ID@ドメイン`(例 `@bob@b.example`)で探せます。
フレンドの申請・承認・解除の規則は [公開範囲・フレンド・招待](/docs/servers/access/) にまとめてあります。
フレンドの現在地は部屋ごとにまとまって出ます。見えない部屋にいるフレンドは「プライベート」とだけ表示され、どこにいるかは出ません。
## 4. ほかの部屋に入る
入り方は 3 つあります。
1. **フレンドのところへ行く**: ソーシャルの画面で、フレンドのいる部屋の「入る」を押します。招待が要る部屋には「入る」の代わりに「リクエスト」が出ます。
2. **ワールドから入る**: ワールドの一覧でワールドを選び、「参加可能なインスタンス」から入るか、「インスタンスを作成」で新しい部屋を作ります。公開範囲はこのときに選びます。
3. **URL から入る**: ワールドの共有 URL(`https://ubichill.com/@ID/ワールド名`)や、部屋の URL をそのまま開きます。ほかのサーバーのワールドの URL でも入れます。ランチパッドの「ワールドの共有 URL を貼って入る」からも開けます。
ほかのサーバーの部屋(住所は `部屋ID@ドメイン`)にも、画面は自分のサーバーのまま入れます。中央のサーバーが無いしくみは [連合のしくみ](/docs/servers/federation/) を見てください。
:::caution[署名のないワールド]
作者の署名が確認できないワールドは一覧に出ません。URL を直接開けば入れますが、入る前に確認の画面が出ます。
署名が保証するのは「その作者が作り、改ざんされていない」ことまでで、安全性の保証ではありません。
:::
## 5. 人を招く
ランチパッドの「招待」から、フレンドを選んでいまの部屋に招待します。ひとことを添えられます(80 文字まで・一度に 20 人まで)。
- 招待された人は、部屋がある間・最長 24 時間、公開範囲にかかわらず入れます。
- 招待のみでない部屋は、入れる人に渡すリンクもコピーできます。招待のみの部屋は URL だけでは入れません。
- 招待の通知からは、そのまま部屋に入れます。
招待を送れる条件(部屋の公開範囲と、送る人が作成者かどうか)は [公開範囲・フレンド・招待](/docs/servers/access/) を見てください。
## 次に読む
- [しくみと用語](/docs/concepts/) — ワールド・インスタンス・mod・サーバーの関係
- [エディタでワールドを作る](/docs/create/editor/) — ブラウザだけでワールドを作る
- [はじめての mod](/docs/mods/) — TypeScript で機能を足す
---
# アーキテクチャ
URL: https://hub.ubichill.com/docs/internals/architecture/
:::note[ubichill 本体から同期]
このページは [`docs/ARCHITECTURE.md`](https://github.com/ubichill/ubichill/blob/main/docs/ARCHITECTURE.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。
:::
## インスタンスサーバーとSNSの境界
`services/instance` のGoサーバーが入退室・カーソル・エンティティ・ロック・メディア同期を所有する。
TypeScript backendはSNSのDB、認証、フレンド、ワールド解決と参加チケット発行を担当する。
ブラウザはSNSから接続先と短期チケットを取得し、Goへ直接WebSocket接続する。
ゲストモードではGoと定義ファイルだけで起動し、SNSのセッションを必要としない。
HTTPは `protocol/instance/openapi.json` からGo/TypeScriptの型を生成する。
リアルタイム通信は `protocol/instance/asyncapi.json` と `protocol/instance/README.md` を参照。
状態は現在メモリのみで、再起動時は初期状態へ戻る。確定更新の応答はDB保存を意味しない。
## システム全体像
```
Browser (Main Thread)
└─ React Host
└─ ModHostManager packages/sandbox
│ postMessage
▼
Sandbox Worker (Guest) packages/sandbox
└─ UbiSDK (Ubi) packages/sdk
├─ Ubi.state entity.data と双方向同期する宣言的ストア
├─ Ubi.entity callable: self / of(id) + query / get / spawn
├─ Ubi.event sendToHost / broadcast / emit (クロス Worker)
├─ Ubi.ui / .media / .canvas / .player
└─ Ubi.fetch / .registerSystem / .log
```
ロジックはすべて **Sandbox Worker** 内で処理し、React はレンダリングのみを担う。
WebSocket によるGoインスタンスサーバーとのワールド同期はメインスレッドが担い、Worker は知らない。
### SDK 名前空間(mod開発者向け)
| 名前空間 | 特性 | 用途 |
|---|---|---|
| `Ubi.state.*` | 宣言的・自動同期 | `state.local.x = v` で entity.data と双方向同期 (`sync` の options で挙動切替) |
| `Ubi.entity()` / `Ubi.entity(id)` | callable | self/other へ `.update / .destroy / .spawn` のハンドルを返す |
| `Ubi.entity.query / get / spawn` | 静的メソッド | type 検索 / id 取得 / 自由 spawn |
| `Ubi.event.*` | トリガー | `sendToHost` / `broadcast` (他ユーザー揮発) / `emit` (同 tab クロス Worker) |
| `Ubi.ui.*` | Fire & Forget | UI 描画 (`render`) / トースト |
| `Ubi.media.*` | Fire & Forget | video / HLS 再生制御 |
| `Ubi.canvas.*` | Fire & Forget | canvas 描画 |
| `Ubi.player.*` | プレイヤー | `others()` / `all()` / `syncCursor()` |
| `Ubi.fetch` / `.registerSystem` / `.log` | shortcut | HTTP / System 登録 / ログ |
ECS 用語と命名規約の詳細は [`design/ecs-migration.md`](https://github.com/ubichill/ubichill/blob/main/docs/design/ecs-migration.md) を参照。
Entity == GameObject、Worker 1 つ = Component インスタンス 1 つ。
---
## パッケージ責務
| パッケージ | 責務 | 依存禁止 |
|---|---|---|
| `@ubichill/ecs` | 純粋 ECS(Entity / Component / System / Query) | React・DOM・Worker・Network |
| `@ubichill/sandbox` | Worker ライフサイクル・Tick ループ・入力収集 | React |
| `@ubichill/react` | React Hooks 群(useModWorker / useEntity 等) | — |
| `@ubichill/sdk` | mod開発者向け公開 API(Ubi) | `@ubichill/shared` などの内部パッケージ |
| `@ubichill/shared` | 型定義・プロトコル定義 | — |
| `mods/*` | 各mod実装 | `@ubichill/sdk` のみ |
---
## mod実行モデル(ECS)
Worker 内では `Ubi`(UbiSDK インスタンス)が自動注入される。
modは ECS System として実装し、毎フレーム `entities` / `deltaTime` / `events` を受け取る。
- **Entity**: 識別子を持つオブジェクト(ローカル)
- **Component**: Entity に付与するデータの断片(型付き)
- **System**: 毎フレーム全 Entity を走査して処理を行う関数
- **Query**: 特定 Component を持つ Entity を絞り込むキャッシュ付きフィルタ
ECS は `@ubichill/ecs` で完結し、ネットワーク・DOM を一切知らない。
---
## Host ↔ Guest 通信プロトコル
### Guest → Host(ModGuestCommand)
| type | 分類 | 説明 |
|---|---|---|
| `CMD_READY` | Fire & Forget | Worker 初期化完了通知 |
| `SCENE_GET_ENTITY` | RPC | エンティティ取得 (`Ubi.entity.get`) |
| `SCENE_QUERY_ENTITIES` | RPC | type 検索 (`Ubi.entity.query`) |
| `SCENE_CREATE_ENTITY` | RPC | 新規 spawn (`Ubi.entity().spawn` / `Ubi.entity.spawn`) |
| `SCENE_UPDATE_ENTITY` | RPC | エンティティ更新 (`Ubi.entity().update` / `Ubi.entity(id).update` / `state.sync` flush) |
| `SCENE_DESTROY_ENTITY` | RPC | エンティティ削除 (`Ubi.entity().destroy` / `Ubi.entity(id).destroy`) |
| `NETWORK_SEND_TO_HOST` | Fire & Forget | 自 Host にメッセージ (`Ubi.event.sendToHost`) |
| `NETWORK_BROADCAST` | Fire & Forget | 全ユーザーの同 entity Worker に揮発性配信 (`Ubi.event.broadcast` / `state.sync` の ephemeral) |
| `EVENT_EMIT` | Fire & Forget | 同 tab 内の他 Worker (scope + targetType) にイベント (`Ubi.event.emit`) |
| `UI_RENDER` | Fire & Forget | VNode を Host に送って DOM 化 (`Ubi.ui.render`) |
| `UI_SHOW_TOAST` | Fire & Forget | トースト (`Ubi.ui.showToast`) |
| `CANVAS_FRAME` / `CANVAS_COMMIT_STROKE` / `CANVAS_REPLACE_STROKES` | Fire & Forget | canvas 描画 (`Ubi.canvas.*`) |
| `MEDIA_*` | Fire & Forget | video 制御 (`Ubi.media.*`) |
| `NETWORK_FETCH` | RPC | HTTP リクエスト (`Ubi.fetch`) |
| `CMD_LOG` | Fire & Forget | ログ (`Ubi.log`) |
**RPC** は `id` フィールドを持ち、Host が `EVT_RPC_RESPONSE` で応答する(タイムアウト 10s)。
### Host → Guest(ModHostEvent)
| type | 説明 |
|---|---|
| `EVT_LIFECYCLE_INIT` | 初期化(code / worldId / myUserId) |
| `EVT_LIFECYCLE_TICK` | フレーム更新(deltaTime ms) |
| `EVT_INPUT` | 入力イベントバッチ(MOUSE_MOVE / MOUSE_DOWN / KEY_DOWN 等) |
| `EVT_PLAYER_JOINED` | ユーザー入室 |
| `EVT_PLAYER_LEFT` | ユーザー退室 |
| `EVT_PLAYER_CURSOR_MOVED` | 他ユーザーのカーソル移動 |
| `EVT_SCENE_ENTITY_UPDATED` | 購読中エンティティ更新 |
| `EVT_NETWORK_BROADCAST` | 他ユーザーからのブロードキャスト受信 |
| `EVT_RPC_RESPONSE` | RPC 応答 |
| `EVT_CUSTOM` | mod独自イベント(Host → Worker) |
### 初期化シーケンスの注意点
`EVT_LIFECYCLE_INIT` は **キューを通さず** Worker へ直接 postMessage する。
キュー経由にすると `CMD_READY` が返ってこないまま Tick がキューに積まれ deadlock になる。
`CMD_READY` 受信後にキューをフラッシュする。`CMD_READY` が届かない = 初期化失敗。
### Capability ゲート(権限)
Worker→Host の各コマンドは、Host 側の単一ゲート (`createCapabilityGate`) を通る。capability は
使用 API からビルド時に自動生成され(宣言不要・情報表示用)、実際の許可は**危険度ティア別に
実行時ユーザー承認**する(on-demand)。safe は常に許可、sensitive は既定許可、dangerous は承認必須。
fetch は接続先ドメインごとに承認する。詳細は [`API.md`](/docs/mods/api/) の「権限 (Capability)」節。
---
## リアルタイム同期(UEP)
同期には 2 つのチャネルがある。
| チャネル | 用途 | 保存 | 頻度 |
|---|---|---|---|
| **Reliable State** | 位置確定・色変更・ロック | インスタンスのメモリ | 低(1-10Hz / アクション終了時) |
| **Volatile Stream** | ドラッグ中・カーソル・描画軌跡 | なし(ブロードキャストのみ) | 高(30-60Hz) |
`stream` は 30ms(33Hz)を目安にスロットリングする。
### 楽観的ロック(Lock → Mutate → Release)
1. ユーザーがオブジェクトに触れる → フロントで即座に `lockedBy: me` と仮定(レイテンシ隠蔽)
2. 操作中は `stream` チャネルで座標・軌跡を配信
3. 操作終了時に確定データを `PATCH`・`lockedBy: null` でロック解除
切断時はサーバーが全 `lockedBy` を強制 `null` にリセット(デッドロック防止)。
---
## Sandbox セキュリティ
Worker の中で mod コードを隔離する。詳細と WASM の扱いは [MOD_RUNTIME.md](/docs/mods/runtime/)。
1. **グローバルの封鎖** — `fetch` / `XMLHttpRequest` / `WebSocket` / `WebTransport` / `importScripts` / `Worker` /
`BroadcastChannel` / `indexedDB` / `caches` / `navigator` などを、`self` だけでなく**プロトタイプチェーンの全段から**
取り除く(ブラウザでは多くが `WorkerGlobalScope.prototype` 側にある)。取り除けない入口が 1 つでもあれば mod を実行しない
2. **コード生成の禁止** — `eval` / `Function` / 関数の `constructor` / 文字列のタイマーは `EvalError`。`SafeFunction`
(封鎖前に退避した `Function`)だけが mod コードを評価する
3. **Worker 専用の CSP** — 本番は `default-src 'none'; script-src 'unsafe-eval' 'wasm-unsafe-eval'` を Worker スクリプトの
応答に付ける(BFF・Vite preview)。スクリプトの URL を 1 つも許可しないので、1 を抜けても通信できず、同一オリジンを含めて
他の JS を読み込めない(lock で固定したコードだけが動く)。dev は Worker の依存パッケージのパスだけを許可する
4. **プロトタイプ凍結** — `Object.prototype` 等をフリーズしてプロトタイプチェーン汚染をブロック
5. **postMessage の一本化** — `self.postMessage` は警告のみ。Host へ届くのは SDK の経路だけで、Host 側の capability ゲートを必ず通る
WASM は禁止しない。WASM は `imports` で渡した関数しか呼べないため、JS 層の権限を越えない。
動的なコードが必要な mod は、QuickJS などのインタプリタを WASM として同梱して動かす。
**既知の限界**: 3 の CSP は BFF と Vite が付ける。それ以外の方法でフロントを配信すると 1・2 だけになり、
`import()` でのスクリプト読み込みは JS 側では止められない。
---
## パフォーマンス設計
詳細は `CLAUDE.md` のパフォーマンス設計セクションを参照。
| 最適化 | 場所 | 効果 |
|---|---|---|
| InputCollector MOUSE_MOVE デデュプ | `packages/sandbox` | フレームあたり mousemove を 1 件に集約(O(1)) |
| rAF バインドレス arrow field | `packages/sandbox` | 毎フレームの `bind()` アロケーション排除 |
| ECS エンティティ配列 dirty flag キャッシュ | `packages/ecs` | エンティティ変化なし時の tick を O(1) に |
| useCursorPosition DOM 直接書き込み | `packages/react` | カーソル 60fps 更新で React re-render ゼロ |
| useEntity shallow equality | `packages/react` | `JSON.stringify` 比較を O(k) に置き換え |
---
## UI レンダリングの内部フロー (図解)
### 大原則: Worker 内 JS はメインスレッドに触れない
modの UI ロジックは **Web Worker** の中で完結する。
Worker は DOM に直接アクセスできないため、Host への指示は `postMessage` 経由のシリアライズ可能なデータ(VNode)のみ。
flowchart TB
subgraph Worker["🔒 Sandbox Worker (隔離)"]
SYS["Mod Systems (ECS)"]
JSX["jsx-runtime\n関数 → '__h0' 変換"]
SDK["UbiSDK\npostMessage 送信"]
end
subgraph Host["Host (React / メインスレッド)"]
PHM["ModHostManager\npostMessage 受信"]
UPU["useModUI\nvnodes state 管理"]
PUM["ModUIMount"]
VNR["VNodeRenderer\ncreateElement / sanitize"]
end
SYS -->|"Ubi.ui.render(() => JSX)"| JSX
JSX -->|"VNode\n{onUbiClick: '__h0'}"| SDK
SDK -->|"postMessage\nUI_RENDER"| PHM
PHM -->|"onRender(targetId, vnode)"| UPU
UPU -->|"vnodes Map"| PUM
PUM --> VNR
VNR -->|"createElement()"| DOM["実 DOM"]
DOM -->|"click"| PUM
PUM -->|"sendEvent\nEVT_UI_ACTION {index:0}"| PHM
PHM -->|"postMessage"| SDK
SDK -->|"_callHandler(0)"| SYS
### TSX → VNode → postMessage → DOM の変換シーケンス
sequenceDiagram
participant W as Worker (Mod)
participant H as ModHostManager
participant R as useModUI
participant D as VNodeRenderer / DOM
W->>W: Ubi.ui.render(factory) — JSX で button + onUbiClick を返す
Note over W: jsx-runtime が関数をレジストリに登録<br/>VNode は onUbiClick:"__h0" 形式に変換(プリミティブのみ)
W->>H: postMessage type:UI_RENDER, vnode
H->>R: onRender("pen-palette", vnode)
R->>D: renderVNode(vnode, container)
Note over D: createElement — innerHTML 禁止<br/>href/src → sanitizeUrl()<br/>style → setProperty() で cssText 書き換え防止<br/>許可タグ外は無視
D->>H: click イベント発火
H->>W: postMessage type:EVT_UI_ACTION, handlerIndex:0
W->>W: _callHandler("pen-palette", 0, detail)
Note over W: クロージャが実行される<br/>penState.color = c
### セキュリティの多層防御
flowchart LR
code["modコード\n(文字列)"]
code -->|"封鎖に失敗したら実行しない"| sandbox
subgraph sandbox["Sandbox Worker 実行環境"]
freeze["グローバル封鎖(プロトタイプ全段)\nfetch / WebSocket / caches / importScripts\neval / Function は EvalError\n+ Worker 専用 CSP"]
cap["capability ゲート\n危険度ティア別に on-demand 承認\n未承認コマンドは拒否"]
serial["VNode シリアライズ\n関数は '__h0' に変換\npostMessage に関数は乗らない"]
vnode["VNodeRenderer サニタイズ\nタグ許可リスト / URL 検証\ninnerHTML 禁止"]
end
freeze & cap & serial & vnode --> safe["安全な実行"]
---
# コントリビュート
URL: https://hub.ubichill.com/docs/internals/contributing/
:::note[ubichill 本体から同期]
このページは [`CONTRIBUTING.md`](https://github.com/ubichill/ubichill/blob/main/CONTRIBUTING.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。
:::
Ubichill への貢献ありがとうございます。バグ報告・提案は Issue、変更は Pull Request で歓迎します。
コーディング規約の詳細は [CLAUDE.md](https://github.com/ubichill/ubichill/blob/main/CLAUDE.md) にまとまっているので、ここでは**設計思想**を共有します。
## 設計思想
### 基盤(コア)とmodを分ける
- **コアがやること**: modを安全に動かす基盤、ユーザーの同期処理、認証、ワールドの
ロード・編集の土台。つまり「みんなが共通で必要とする仕組み」。
- **modがやること**: 具体的な機能(ペン・動画・付箋…)。**機能はコアに足さず、
modで実装する**のが原則です。コアは薄く汎用に保ちます。
### ワールド作成は「誰でも」、mod開発は「エンジニア向け」
- **ワールド作成/編集画面**は非エンジニアでも直感的に使えることを目指します(迷わない UI)。
- **mod開発**はエンジニア向け。SDK は**内部をできるだけ隠蔽**し、少ないコードで
書けるようにしつつ、**自由度・拡張性**を損なわないバランスを狙います。
### 宣言的に、副作用と状態依存を避ける
- ロジックは**宣言的**に書く。命令的な手続きや、暗黙の**副作用・可変状態への依存**を避ける。
- modの状態は `Ubi.state` に集約し、純粋関数で組み立てる(`let` を避ける)。
### 過度な DRY / 複雑化を避ける
- 「共通化のための共通化」や早すぎる抽象化はしない。**まず素直で読みやすいこと**を優先。
- 重複が“痛み”になってから畳む。将来の仮定でレイヤーを増やさない。
## 開発の始め方
前提: [mise](https://mise.jdx.dev/) と Docker。Node / pnpm のバージョンは `package.json`(`devEngines.runtime` / `packageManager`)が真実源。
```bash
mise install # Node / pnpm を導入(mise.lock でチェックサム検証)
pnpm install
pnpm dev # 開発サーバー(Docker 含む)
pnpm lint # コミット前に必須(自動整形は pnpm lint:fix)
pnpm typecheck
pnpm build:workers # mod Worker のバンドル
```
規約(named export のみ / `any` 禁止 / PandaCSS / SVG アイコン 等)は [CLAUDE.md](https://github.com/ubichill/ubichill/blob/main/CLAUDE.md) を参照。
## ブランチ / コミット / PR
- `main` から作業ブランチを切る(`feat/...` / `fix/...` / `chore/...`)。
- コミット・PR は**日本語**、[Conventional Commits](https://www.conventionalcommits.org/)。
- CI(lint / typecheck)を通す。squash merge 推奨。
- **プレビュー環境**: メンテナが PR に `preview` ラベルを付けると、`pr-<番号>.ubichill.com` に
PR ごとのプレビューが払い出されます(未検証コードを勝手にデプロイしないためラベル制)。
## ライセンス / 行動規範
- 本プロジェクトは **AGPL-3.0**(+mod例外、[COPYING](https://github.com/ubichill/ubichill/blob/main/COPYING) / [LICENSE](https://github.com/ubichill/ubichill/blob/main/LICENSE))。
PR 提出をもって同ライセンスでの配布に同意したものとみなします。
- 参加者は [行動規範](https://github.com/ubichill/ubichill/blob/main/CODE_OF_CONDUCT.md) に従ってください。
---
# プロトコルと互換の規則
URL: https://hub.ubichill.com/docs/internals/protocols/
:::note[ubichill 本体から同期]
このページは [`docs/PROTOCOLS.md`](https://github.com/ubichill/ubichill/blob/main/docs/PROTOCOLS.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。
:::
Ubichill の約束ごと(プロトコル・データの形式・API)を、**だれが・どこに・どれだけ長く持つか**で 4 種類に分け、種類ごとに互換の扱いを決める。
互換を残すかどうかを場当たりで決めない。新しい約束ごとを作るときは、まずどの種類かを決めてここに足す。
| 種類 | 例 | 持つ人・場所 | 互換の扱い |
| --- | --- | --- | --- |
| A. 外に出るデータ | 署名・mod の lock・ワールドの YAML・サービストークン | 作者のリポジトリ・GitHub Pages・他のサーバー・外部サービス(こちらは消せない) | 形式に版を付ける。**作る側だけ新しくし、読む側は出したことのある版を読み続ける**。読む処理は 1 か所 |
| B. 公開している SDK とプロトコル | `ubichill`(npm)・Host ⇔ Worker の `PROTOCOL_VERSION` | 作者がビルドした mod の中 | semver。足すときは版を上げ、消すときは互換の最小の版も上げる(CI が確かめる) |
| C. サーバーの間の API | WebFinger・作者の鍵の一覧・ワールドの配信・連合の一覧(`scope=local`)・連合(AP 面・部屋 RPC 面) | 版の違う他のサーバー | 足すだけ。意味を変えるときは新しい道(パス・パラメーター)を足す |
| D. 内部の API | フロント ⇔ backend(`/api/v1/users/me/...` など)・フロント ⇔ Go のインスタンス(`protocol/instance`) | 同じリポジトリからデプロイ | **互換を残さない**。対応するフロントも一緒に配信し、古いタブはフロントの更新を検知して読み込み直す(`VersionSkewGuard`) |
非推奨(deprecated)の API は作らない。D は版のずれの検知で不要にし、A・B・C は最初から「足すだけ」で設計する。
## A. 外に出るデータ
| 形式 | 版 | 定義 | 置き場所 |
| --- | --- | --- | --- |
| ワールドの定義 | `apiVersion: ubichill.com/v1alpha1` | `shared/schemas/world.schema.ts` | `.yaml` |
| mod の lock | `lockVersion: 1` | `shared/schemas/modLock.schema.ts` | `.lock.json`・`v<版>/lock.json` |
| 作者署名(ワールド) | `version: 2`・`kind: 'world'` | `shared/signature/envelope.ts`(内容のハッシュは `shared/world/identity.ts`) | `.sig.json` |
| 作者署名(mod) | `version: 2`・`kind: 'mod'` | `shared/signature/envelope.ts`(内容のハッシュは `shared/mod/modSignature.ts`) | `v<版>/lock.sig.json` |
| サービストークン | JWT(EdDSA) | `shared/mod/serviceToken.ts` | 外部サービスへ渡す |
| CLI・CI の認証情報 | `version: 1` | `cli/src/credentials.ts` | `~/.config/ubichill`・CI の Secret |
### 作者署名の形式(版 2)
ワールド・mod など、作者が署名するものはすべてこの 1 つの形式(封筒)で署名する。種類を足すとき(購入の証明など)も `kind` を足すだけにする。
版 1(ワールドと mod で別々だった形式)は、ワールドを作っていたのが私たちだけだったので読み続けずに署名し直した(2026-10)。
```json
{
"format": "ubichill-signature",
"version": 2,
"kind": "world",
"alg": "ed25519",
"publicKey": "",
"author": "alice@example.com",
"subject": "my-world",
"subjectVersion": "1.0.0",
"contentHash": "sha256-...",
"signature": ""
}
```
- **対象**: `kind` だけが違う(`world` / `mod`、今後 `license` など)。`contentHash` の求め方は種類ごとに決める(ワールドは `{ definition, lock }`、mod は lock の実行内容)。
- **署名する値**: `signature` 以外の全項目の正規化した JSON(RFC 8785 相当)。`format` と `kind` が入るので、別の種類・別の用途の署名として使い回せない。
- **作者**: 必須。正規の表記(`handle@domain`、ドメインは小文字)だけ。作者の無い署名は作らない。
- **検証の順番(どの種類も同じ)**: 形式を読む → 対象と内容のハッシュを照らす → 元のバイト列で署名を照合する → 作者の鍵を確かめる(`authorKeyDirectory`)。
- **CI**: 形式の固定のために、決まった鍵で作った署名の例(`shared/signature/vectors.v2.json`)を置き、`loader/src/signatureVectors.test.ts` が本物の Ed25519 で、いつまでも検証できることと、同じ鍵から同じバイト列ができることを確かめる。署名する値の作り方を変えると落ちる。形式を変えるときは版を上げ、新しい版の例を足す(古い版の例は残す)。
## B. 公開している SDK と Host ⇔ Worker のプロトコル
- コマンド・イベントの一覧は `shared/mod/protocol.contract.ts` に版ごとに残す。`protocol.contract.test.ts` が今の定義と照らし合わせる。
- 足す: `PROTOCOL_VERSION` を +1 し、契約に新しい版を足す。消す・意味を変える: さらに `MIN_COMPATIBLE_PROTOCOL_VERSION` を上げる。どちらも忘れると CI が落ちる。
- **CI が見るのはコマンド・イベントの名前の増減だけ**。ペイロードの型や意味を変えたこと(項目を必須にした・単位を変えたなど)は検知しない。
`shared/mod/protocol.ts` と `shared/mod/types.ts` のペイロードの型を変える PR は、互換を壊していないかをレビューで確かめる。
- 名前が増えなくても、**古い Host が黙って捨てる意味をペイロードに足したとき**(例: v6 の `EVENT_EMIT` の `ref`)は足す変更として扱い、
`PROTOCOL_VERSION` を +1 して契約に同じ名前の版を足す。あわせて `Ubi.runtime` の機能(`RuntimeFeature` と `HOST_FEATURE_PROTOCOL`)を足し、
mod が古い Host を見分けて切り替えられるようにする。
- SDK のビルド結果が変わったのに changeset が無ければ落ちる(`scripts/check-npm-drift.mjs`)。
## C. サーバーの間の API
| API | 使う側 |
| --- | --- |
| `GET /.well-known/webfinger` | 作者を確かめる他のサーバー・ブラウザ |
| `GET /api/v1/authors/:handle/signing-keys` | 同上(`Access-Control-Allow-Origin: *`) |
| `GET /api/v1/authors/:handle/worlds/:name.yaml`(`.lock.json`・`.sig.json`) | ワールドを読む他のサーバー |
| `GET /api/v1/worlds?scope=local` | 連合のピア(このサーバーのワールドだけを返す) |
| `GET /api/v1/service-tokens/keys` | サービストークンを検証する外部サービス |
| `GET /.well-known/nodeinfo`・`/nodeinfo/2.1` | ubichill のサーバーかを確かめるほかのサーバー |
| `GET /ap/actor`・`/ap/users/:id`(`/outbox`) | 連合の actor の文書(鍵は FEP-521a の Multikey) |
| `POST /ap/inbox`・`/ap/users/:id/inbox` | 連合の知らせ(AS2 の activity。RFC 9421 の署名付き) |
| `GET /ap/instances/:id`・`POST /federation/v1/...` | 連合の部屋 RPC(入室・招待の権利・見えるか・現在地・フレンドか) |
### 連合(federation v1)
設計は [design/federation.md](https://github.com/ubichill/ubichill/blob/main/docs/design/federation.md)、形は `shared/src/federation/`。
- 版は `FEDERATION_VERSION`(NodeInfo の `metadata.ubichill.federation.version`)。機能は Application actor の `implements`(FEP-844e)で告知し、
使う前に相手が告知しているかを見る。機能を足すときは URI(`FEDERATION_FEATURES`)を足す。
- 受けるときは知らない activity の型・知らない項目を捨てる(202。`.strict()` にしない)。部屋 RPC の本文も同じ。
- 署名の形(RFC 9421 の ubichill プロファイル: Ed25519・`sig1`・含める項目・時刻の幅)を変えるときは、新しいラベル・`alg` を足して
両方を受ける期間を置く。今の形は RFC 9421 付録 B.2.6 と RFC 9530 の値でテストで固定している(`backend/src/services/federation/httpSignature.test.ts`)。
- id の形(`/ap/users/<ユーザー ID>`・`/ap/instances/<部屋 ID>`)はほかのサーバーが持つので変えない。
## D. 内部の API
内部 API を変えるときは、対応するフロントも一緒に配信する。変更の無いイメージはビルドし直さないため、フロントと backend のコミットは一致しないことがある。
開いたままの古いタブは、`VersionSkewGuard` がフロントのビルドに同梱した `/version.json` と比べ、更新が配信されていれば、入力中の画面を除くインスタンスの外ではすぐ、中では知らせてから読み込み直す。
API の `/api/version` は診断用で、再読み込みで画面が更新されるかの判定には使わない。`version.json` はキャッシュさせない。
そのため内部の API は、互換を残さずに変えてよい(Host の内部の実装は後方互換性に囚われない、という CLAUDE.md の方針の前提)。
インスタンスのプロトコル(`protocol/instance`)も D で、イベントを足す・消すときに互換の層を作らない。
`protocolVersion` を上げると古いタブは入室そのものを確定した失敗にするので、上げるのは接続・入室の手順やフレームの形を変えたときだけにする(イベントの増減では上げない)。
例: `server:notice`(その場だけの知らせ)はイベントを足しただけなので上げない。古いタブは知らないイベントを読み飛ばし、新しいタブも知らない `kind` を読み飛ばす。
ただし Go のインスタンスはフロント・backend と別のイメージで、別々にデプロイされる(新しいフロントが古い Go に繋ぐ時間も、その逆もある)。
`VersionSkewGuard` は Go の版を見ないので、**今あるイベントの配り方を変えるときは `world:join` の `features` で宣言させ、宣言した接続にだけ新しい形で送る**。
宣言しない接続には今の形を送り続ける(外部の実装・e2e の `apiSmoke` もこちら)。宣言を足すのはイベントの増減と同じ扱いで、`protocolVersion` は上げない。
例: `cursors:batch`(カーソルを `cursors:moved` でまとめて受ける。`protocol/instance/README.md` の「受け取れる形の宣言」)。
`ubichill dev` の SSE(`/__ubichill/events`)も D。dev サーバーと world.html は同じ CLI に入っていて版がずれない(形は `shared/world/devEvents.ts` だけで決める)。
通知の API(`/api/v1/notifications`・SSE)も D。通知の形(`shared/notification/activity.ts`)は ActivityStreams 2.0 に寄せてあるが、
ほかのサーバーと送り合うのは連合の activity(C)で、通知はそれを受けたサーバーが自分の利用者に見せる形(D)として分けている。
## 設計の規則を CI で守る
`scripts/architecture.mjs`(`pnpm check:architecture`)が、次の規則を全ソースに当てる。規則ごとに理由を持つ。
| 規則 | 守るもの |
| --- | --- |
| `host-runs-mods-without-backend` | Host は SNS backend の無い `world.html` でも mod を動かす(mod の経路から Host の API を呼ばない) |
| `world-app-closure` | `world.html` から辿れるコード(`src/world`・`src/worldApp`)は SNS(認証・router・backend API)を読み込まない。間接の import も含めて辿る |
| `shared-is-pure` | shared は Node・DOM・ネットワークに依存しない(ブラウザ・backend・CLI が同じ規則を使う) |
| `loader-main-is-browser-safe` | loader の既定の入口はブラウザで読める |
| `backend-runs-built-packages-only` | backend はソースのまま配るパッケージに依存しない |
| `mods-use-public-sdk-only` | mod は公開パッケージ `ubichill` だけに依存する |
| `signatures-in-one-place` | Ed25519 の照合は shared の署名・鍵の証明の部品(`signature/envelope.ts`・`world/identity.ts`・`user/cliAuth.ts`・連合の HTTP の署名 `federation/httpSignature.ts`)の中だけ |
規則を足すときは、何を守るのかを `why` に書く。
---
# サービストークン
URL: https://hub.ubichill.com/docs/internals/service-token/
:::note[ubichill 本体から同期]
このページは [`docs/SERVICE_TOKEN.md`](https://github.com/ubichill/ubichill/blob/main/docs/SERVICE_TOKEN.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。
:::
mod が自分の外部サービス(API サーバー)を持つとき、サービスは「この依頼は Ubichill にログインしている利用者から
来た」ことを確かめたい。確かめられないと、サービスは誰でも使える公開 API になり、回数制限もかけられない。
サービストークンは、そのための短命の署名。形式は標準の **JWT(EdDSA / Ed25519)** なので、サービスは既存の
JWT ライブラリと発行元の公開鍵(JWKS)だけで検証できる。共有の秘密も、Ubichill への事前登録も要らない。
関連: [MOD_RUNTIME.md](/docs/mods/runtime/)(通信の境界)/ [CAPABILITIES.md](/docs/mods/capabilities/)(権限 `identity:token`)
---
## 流れ
```
mod ── Ubi.identity.token('https://api.example.com') ──▶ Host(ドメイン承認と同じ規則で宛先を確認)
└─▶ Ubichill のサーバー(ログイン中のセッションで発行)
mod ── Ubi.fetch(url, { headers: { authorization: `Bearer ${token}` } }) ──▶ サービス
サービス ── 発行元の公開鍵(JWKS)で検証 ──▶ sub ごとに回数制限・利用記録
```
---
## mod 側
```ts
const API = 'https://api.example.com';
async function callApi(path: string) {
const { token } = await Ubi.identity.token(API); // 期限の少し前まで使い回されるので毎回呼んでよい
return Ubi.fetch(`${API}${path}`, { headers: { authorization: `Bearer ${token}` } });
}
```
- 宛先はサービスの**オリジン**(`https://api.example.com`。パスは付けない)。
- 宛先は `Ubi.fetch` と同じく、ユーザーが通信を許したドメインに限る(初回は承認画面が出る)。
- 権限 `identity:token`(dangerous)。mod の読み込み時にユーザーへ確認する。
- ゲスト・未ログイン・発行していないサーバーでは `IDENTITY_UNAVAILABLE` の UbiError。その場合の扱い
(ログインを促す、機能を絞る)は mod が決める。古い Host には `Ubi.identity` が無いので、
`Ubi.runtime.supports('identity')` で確かめる。
---
## トークンの中身
ヘッダー: `{ "alg": "EdDSA", "typ": "JWT", "kid": "<鍵の ID>" }`
| クレーム | 内容 |
| --- | --- |
| `iss` | 発行元の Ubichill の公開オリジン(例 `https://ubichill.example`) |
| `aud` | 宛先のサービスのオリジン。**ほかのサービスでは使えない** |
| `sub` | **サービスごとの仮名**。同じ人・同じサービスなら常に同じ。サービスが違えば別の値で、突き合わせられない |
| `mod` | 依頼した mod の ID(`video-player` など)。**自己申告**なので、だれの mod かは `mod_author` で確かめる |
| `mod_author` | 依頼した mod の作者アカウント(`ID@ドメイン`)。発行元が mod の作者署名と作者の公開環境を確かめたときだけ入る。開発用の Host で動く未署名の mod では入らない |
| `iat` / `exp` | 発行時刻と期限(5 分) |
| `jti` | トークンごとの一意な値 |
本当のユーザー ID・メールアドレス・表示名は入らない。
---
## サービス側の検証
1. `Authorization: Bearer ` を受け取る。
2. **`iss` が信頼する発行元の一覧にあるか**を先に確かめる(一覧に無い発行元の鍵は取りに行かない)。
3. 発行元の公開鍵を `/api/v1/service-tokens/keys`(JWKS)から取得してキャッシュする。
知らない `kid` が来たら取り直す(発行元が鍵を差し替えた場合)。
4. 署名(EdDSA のみ受け付ける)、`aud` が自分のオリジンか、`exp` / `iat` を確かめる。
**自分の mod からの依頼だけ受けるなら `mod_author` が自分の作者アカウントか**を確かめる(`mod` は別の作者も名乗れる)。
5. `sub` を利用者の識別子として、回数制限や利用記録に使う。
### Python(PyJWT)
```python
import jwt
from jwt import PyJWKClient
TRUSTED_ISSUERS = {"https://ubichill.example"}
AUDIENCE = "https://api.example.com"
_clients: dict[str, PyJWKClient] = {}
def verify(token: str) -> dict:
issuer = jwt.decode(token, options={"verify_signature": False})["iss"]
if issuer not in TRUSTED_ISSUERS:
raise PermissionError("untrusted issuer")
client = _clients.setdefault(issuer, PyJWKClient(f"{issuer}/api/v1/service-tokens/keys"))
claims = jwt.decode(
token,
client.get_signing_key_from_jwt(token).key,
algorithms=["EdDSA"],
audience=AUDIENCE,
issuer=issuer,
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
leeway=30,
)
# mod の ID は自己申告。自分の作者アカウントが署名した mod からの依頼だけ受ける
if claims.get("mod_author") != "alice@ubichill.example":
raise PermissionError("unexpected mod author")
return claims
```
### Node.js(jose)
```ts
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
const TRUSTED_ISSUERS = new Set(['https://ubichill.example']);
const jwks = new Map>();
export async function verify(token: string) {
const { iss } = decodeJwt(token);
if (!iss || !TRUSTED_ISSUERS.has(iss)) throw new Error('untrusted issuer');
const keys = jwks.get(iss) ?? createRemoteJWKSet(new URL(`${iss}/api/v1/service-tokens/keys`));
jwks.set(iss, keys);
const { payload } = await jwtVerify(token, keys, {
issuer: iss,
audience: 'https://api.example.com',
algorithms: ['EdDSA'],
clockTolerance: 30,
});
// mod の ID は自己申告。自分の作者アカウントが署名した mod からの依頼だけ受ける
if (payload.mod_author !== 'alice@ubichill.example') throw new Error('unexpected mod author');
return payload;
}
```
### 動画・画像など、ヘッダーを付けられない取得
`