# Ubichill > ゆるく集まる、じぶんの部屋。描いたり、観たり、いっしょに過ごす、中央のない 2D メタバース。 --- # ドキュメント URL: https://hub.ubichill.com/docs/ import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; Ubichill は、ワールドも mod も自分で作って、自分で持てる 2D メタバース。 ここには使う人・作る人・サーバーを建てる人のための説明をまとめている。 アカウントを作って、ホームワールドに入る。ランチパッドで部屋を探し、フレンドを招く。 [はじめかた](/docs/getting-started/) · [しくみと用語](/docs/concepts/) ブラウザのエディタで置くか、YAML で書く。中身はおなじ。 [エディタで作る](/docs/create/editor/) · [World as Code](/docs/create/world-as-code/) · [AI と一緒に](/docs/create/with-ai/) TypeScript で書いて、Worker のサンドボックスで動かす。権限は使った API から自動で決まる。 [はじめての mod](/docs/mods/) · [API](/docs/mods/api/) · [CLI](/docs/mods/cli/) 署名して、好きな場所に置く。Web でも CLI でも CI でも。 [署名して公開する](/docs/publish/) 中央のない連合。自分のサーバーを Helm で建てる。 [連合のしくみ](/docs/servers/federation/) · [公開範囲と招待](/docs/servers/access/) · [セルフホスト](/docs/servers/self-hosting/) アーキテクチャ・プロトコル・コントリビュート。 [アーキテクチャ](/docs/internals/architecture/) · [コントリビュート](/docs/internals/contributing/) ## AI に渡す ワールドの YAML や mod のコードを AI に書いてもらうときは、[`/llms.txt`](/llms.txt)(このドキュメントの目次)か [`/llms-full.txt`](/llms-full.txt)(全文)を渡す。書き方は [AI と一緒に作る](/docs/create/with-ai/)。 ## 本体リポジトリとの関係 リファレンス(API・フォーマット・CLI・権限・アーキテクチャ)は [ubichill 本体リポジトリ](https://github.com/ubichill/ubichill)のドキュメントをそのまま取り込んでいる。 ページ冒頭に「本体から同期」とあるものは、直すときは本体リポジトリへ。 --- # しくみと用語 URL: https://hub.ubichill.com/docs/concepts/ Ubichill に出てくる言葉と、それらの関係をまとめます。詳しい仕様は各リンク先にあります。 ## 全体の関係 ``` 作者(ID@ドメイン) ├─ ワールド(YAML・URL で配られる) │ └─ 初期 Entity の木 │ └─ Component(mod の部品。例 pen:pen) └─ mod(TypeScript・URL で配られる) └─ Component(Worker で動く) サーバー ├─ 人(アカウント・フレンド・通知) ← その人のホームサーバーが持つ ├─ 部屋 = インスタンス(ワールドを開いた実体) ← 部屋のサーバーが持つ └─ ほかのサーバーと連合(人・部屋・作品がサーバーをまたぐ) ``` - ワールドは「設計図」、インスタンス(部屋)はそれを開いた「実体」です。 - ワールドに置かれた Component の中身(コード)は mod が持ちます。ワールドは mod を URL から読み込みます。 - 人・部屋・作品は、それぞれ別のサーバー・別の場所にあってかまいません。これが「中央を持たない」という意味です。 ## 用語 ### ワールド(World) 1 つの YAML(または JSON)で書かれた、部屋の設計図。表示名・定員・背景・初期 Entity の木・使う mod を宣言します。 **ワールド=URL** で、その定義を返す URL が識別子です。置き場所は Ubichill のサーバーでも、GitHub Pages でも、自分のサーバーでもかまいません。 → [World as Code](/docs/create/world-as-code/)、[ワールド定義フォーマット](/docs/create/world-format/) ### インスタンス(部屋) ワールドを開いて、人が入れる状態にしたもの。同じワールドから何個でも作れます。 状態はインスタンスのメモリにだけあり、だれもいなくなると片付きます。初期状態は毎回ワールドの `initialEntities` から作られます。 住所は `部屋ID@ドメイン` で、ほかのサーバーの部屋にもこの住所で入れます。 ### ホームワールド ログインしたときに入る、自分用のワールド。ログインのたびに新しい部屋が作られます。設定で好きなワールドをホームにできます。 → [はじめかた](/docs/getting-started/) ### Entity ワールドの中の「箱」。`id`・位置や大きさ(`transform`)・`components[]` を持ち、`children[]` で入れ子にできます。 ゲームエンジンの GameObject に当たります。ペンの置き場、スクリーン、付箋の 1 枚、などがそれぞれ Entity です。 ### Component Entity に付ける振る舞いの部品。`:`(例 `pen:pen`・`video-player:screen`)という型で指定し、`data` に初期値を書きます。 1 つの Component は 1 つの Worker(後述)で動きます。 `core:collider`・`core:media`・`core:ui` の 3 つは本体に組み込みで、mod を読み込まずに使えます。 ### mod Component の中身を提供する配布物。TypeScript で書き、`@ubichill/cli` でビルドし、URL(GitHub Pages など)で配ります。 ペンも動画プレイヤーも付箋も、ぜんぶ mod です。本体は「mod を安全に動かす土台」だけを持ちます。 → [はじめての mod](/docs/mods/)、[mod パッケージ](/docs/mods/package/) ### Worker(サンドボックス) mod のコードが動く場所。利用者のブラウザの Web Worker の中で、`fetch` や `eval` などを封じた状態で動きます。 Worker から本体に届くのは、SDK(`Ubi.*`)を通った命令だけです。 → [アーキテクチャ](/docs/internals/architecture/) ### 権限(capability) mod が本体に頼めることの種類。`safe`(確認なし)・`sensitive`(既定で許可)・`dangerous`(承認が要る)の 3 段階があります。 開発者は宣言せず、使った API からビルド時に自動で決まります。許可するかどうかは利用者が決め、「作者+mod の ID」の組ごとに記憶されます。 → [権限リファレンス](/docs/mods/capabilities/) ### lock ワールドが使う mod の中身(バイト列のハッシュ)と権限の上限を固定するファイル(`.lock.json`)。 配布元が乗っ取られて中身が差し替わっても、ハッシュが合わなければ実行しません。lock に無い mod は実行されません。 ### 作者署名 ワールドや mod に付く、作者(`ID@ドメイン`)の Ed25519 署名。「その作者が作り、改ざんされていない」ことを示します。 置き場所(URL)は署名に含まれないので、どこに置いても同じ署名で確かめられます。 サーバーは秘密鍵を預かりません。署名はブラウザ・CLI・CI の手元で行います。 署名が確認できたワールドだけが一覧に出ます。安全性の保証ではなく、「だれが出したか」の保証です。 → [署名して公開する](/docs/publish/) ### 公開環境 署名して公開できる環境のこと。ブラウザ・CLI・CI の 3 種類があり、それぞれ鍵を 1 本持ちます。 1 つのアカウントに複数の公開環境を登録でき、取り消すとその鍵の署名はすべて無効になります。 → [署名して公開する](/docs/publish/) ### 公開範囲 部屋に「だれが入れるか・だれに見えるか」。パブリック・フレンド+・フレンドのみ・招待のみの 4 種類です。部屋のパスワードはありません。 → [公開範囲・フレンド・招待](/docs/servers/access/) ### サーバー Ubichill を動かしている 1 つのホスト(例 `ubichill.com`)。人のアカウント、部屋、ワールドの保管場所を持ちます。 Helm チャートで自分でも建てられます。 → [自分のサーバーを建てる](/docs/servers/self-hosting/) ### ホームサーバー その人のアカウントがあるサーバー。フレンド・通知・公開環境の一覧など「人のこと」は、ホームサーバーが決めます。 ### 連合(federation) サーバー同士がつながり、どのサーバーの人ともフレンドになり、招待し合い、相手のサーバーの部屋に入れるしくみ。 ActivityPub の形を使いますが、相手は Ubichill のサーバーだけです。中央のサーバーはありません。 → [連合のしくみ](/docs/servers/federation/) ### 人・部屋・作品の三層 連合では、権威が 3 つに分かれています。 | もの | だれが決めるか | 住所の例 | |---|---|---| | 人 | その人のホームサーバー | `@yokan@yokan.example` | | 部屋 | 部屋のあるサーバー | `chill-room@yokan.example` | | 作品(ワールド・mod) | 作者が置いた URL と作者の署名 | `https://yokan.github.io/worlds/chill-room.yaml` | 部屋の在室や公開範囲は部屋のサーバーしか知りません。作品の置き場所はサーバーと無関係に選べます。 ## 関連ページ - [アーキテクチャ](/docs/internals/architecture/) — 本体の内部構成(Go のインスタンスサーバー・SNS バックエンド・ブラウザ) - [プロトコルと互換の規則](/docs/internals/protocols/) — 約束ごとの種類と互換の扱い --- # エディタでワールドを作る URL: https://hub.ubichill.com/docs/create/editor/ Ubichill のワールドは、ブラウザのエディタだけで作れます。できあがるのは [World as Code](/docs/create/world-as-code/) と同じ YAML なので、 あとからコードで続きを書くこともできます。 ## 新しいワールドを作る 1. 部屋の中でランチパッドを開き、「ワールド」の見出しの「+」を押します。確認のあと、作成画面に移ります(いまの部屋からは退出します)。 2. 次の項目を入れます。 - **表示名**(例: ぼくのワールド) - **名前(URL)**: 小文字英数と `-` `_`。共有 URL `https://<サーバー>/@<あなたの ID>/<名前>` になります - 説明・サムネイル URL(任意) 3. 「下書きとして作成」か「作成して公開」を選びます。まずは下書きで作り、形になってから公開するのが簡単です。 ## 画面の見かた | 場所 | 役割 | |---|---| | 左: ヒエラルキー | Entity の木。「ルートに Entity を追加」「子 Entity を追加」、並べ替え(上へ・下へ)、削除、ID の編集(クリックして ID を編集)、タグ | | 中央: キャンバス | ワールドの見た目。Entity をドラッグして動かす。「スナップ」を ON にするとグリッドに吸い付く | | 右: インスペクター | 選んだ Entity の Transform(親基準の位置・サイズ)と、各 Component の data | | 下: アセット | 使える Component の一覧。ここから Entity に Component を付ける | | 上: ヘッダー | 公開の状態(下書き(未公開)/公開中/公開中・未公開の変更あり)、「下書き保存」(Cmd/Ctrl+S)、「公開する」、「コントロールパネル」 | ## mod を使えるようにする Component は mod が提供します。まず、ワールドで使う mod を登録します。 1. ヘッダーの「コントロールパネル」を開き、「mod管理」タブを選びます。 2. 使う mod を追加します。本体に同梱の mod(ペン・動画プレイヤーなど)はそのまま選べます。外部の mod は、作者が公開している URL を指定します。 3. バージョンを選びます。「latest(自動追従)」にすると新しい版に追従します。作者の署名がない版は本番では使えないので、別の版を選ぶか一覧から外します。 登録した mod の Component が、下のアセットに並びます。 ## Entity を置き、Component を付ける 1. ヒエラルキーで「ルートに Entity を追加」。ID はあとから変えられます(木の中で一意)。 2. アセットから Component を選び、Entity に付けます。1 つの Entity に複数の Component を付けられます(最大 32)。 3. インスペクターで位置・サイズ(Transform)と、Component ごとの data(色・太さ・URL など、mod が宣言したフィールド)を調整します。 4. 入れ子にしたいものは「子 Entity を追加」で、親の下に置きます。子の位置は親からの相対です。 ### Component 同士をつなぐ(参照) 動画プレイヤーの「コントロール」と「スクリーン」のように、Component が別の Component を相手にする場合は、インスペクターに参照の枠が出ます。 ヒエラルキーから相手の Entity をドラッグ&ドロップして指定します。必須の参照が未設定だったり、型が合わなかったりすると、ヒエラルキーとインスペクターに印が出ます。 ### 本体に組み込みの Component mod を登録しなくても使える Component が 3 つあります。 | 型 | 用途 | |---|---| | `core:ui` | 看板・説明・リンク。HTML 風の文字列を書く(`script` や `on*` 属性は捨てられる。リンクと画像は https だけ) | | `core:media` | 動画を描く場所。何を再生するかは、この Entity を参照した mod(動画プレイヤーなど)が決める | | `core:collider` | 当たり判定の形(矩形か円) | ## YAML を見る コントロールパネルの「YAML」タブで、いま作っているワールドの YAML がそのまま見えます。ここで直接書き換えることもでき、正しい YAML の間だけ反映されます(構文エラーの間は表示だけ)。 ファイルの読み込みもできます。YAML の書き方は [ワールド定義フォーマット](/docs/create/world-format/) を見てください。 :::tip[コードと行き来する] エディタで作ったワールドを YAML として取り出し、AI に続きを頼んだり、Git で管理したりできます。[AI と一緒に作る](/docs/create/with-ai/) も見てください。 ::: ## ワールドの情報を変える コントロールパネルの「ワールド情報」タブで、表示名・説明・サムネイル・定員・背景色・ワールドの幅と高さを変えられます。 ## 保存と公開 - **下書き保存**(Cmd/Ctrl+S): 鍵や署名の準備は要りません。公開中のワールドを下書き保存しても、公開中の版はそのままです。 - **公開する**: あなたの作者アカウントで署名して公開します。初めて公開するブラウザでは、自動で鍵が作られ、公開環境として登録されます(確認は出ません)。 ID をまだ決めていなければ、その場で決めます。 公開できないときは、理由が表示されます。よくあるのは次の 2 つです。 - 署名のない mod(または版)を使っている → 別の版を選ぶか、一覧から外す - mod を固定(lock)できない → 「再確認してインストール」で取り直す 公開すると、ワールドは一覧とプロフィールに出て、共有 URL `https://<サーバー>/@/<名前>` からだれでも入れるようになります。 署名のしくみや、CLI・CI から公開する方法は [署名して公開する](/docs/publish/) を見てください。 ## 部屋を作って試す コントロールパネルの上部にある「このワールドで新しいインスタンスを作って参加する」を押すと、いまの内容で部屋を作って入れます。 部屋を作るときに公開範囲(パブリック・フレンド+・フレンドのみ・招待のみ)を選びます。 エディタに戻るには、部屋の中のランチパッド → 現在地 → ワールドの編集画面へ。 ## 削除 コントロールパネルの「Danger Zone」を開くと、ワールドを削除できます。取り消せません。公開中のワールドを消すと、共有 URL からも入れなくなります。 --- # AI と一緒に作る URL: https://hub.ubichill.com/docs/create/with-ai/ ワールドは YAML、mod は TypeScript の、ふつうのファイルです。だから AI エージェント(Claude Code・Codex・Cursor など)に 「こういう部屋を作って」と頼めば、そのまま動くコードが返ってきます。このページは、AI に何を渡し、どう確かめるかをまとめます。 ## AI に渡すもの 1. **このサイトの `llms.txt`**: [https://hub.ubichill.com/llms.txt](/llms.txt) に、ドキュメントの目次と本文へのリンクがあります。AI に「まずこれを読んで」と渡してください。 2. **仕様のページ**: ワールドなら [ワールド定義フォーマット](/docs/create/world-format/)、mod なら [SDK API](/docs/mods/api/) と [mod パッケージ](/docs/mods/package/)。URL を貼るだけで十分です。 3. **動いている例**: 本体リポジトリの [`worlds/`](https://github.com/ubichill/ubichill/tree/main/worlds) と [`mods/`](https://github.com/ubichill/ubichill/tree/main/mods) にある実物。 4. **やりたいこと**: 「ペンと動画プレイヤーがある作業部屋」「クリックすると数が増えるボタン」のように、画面の言葉で。 ## ワールドを書いてもらう 最小のワールドはこれだけです。 ```yaml apiVersion: ubichill.com/v1alpha1 kind: World metadata: name: chill-room # 小文字英数と -(URL になる) version: 1.0.0 spec: displayName: チルな作業部屋 capacity: { default: 10, max: 20 } environment: backgroundColor: "#F0F8FF" worldSize: { width: 2000, height: 1500 } dependencies: - name: pen - name: video-player initialEntities: - id: pentray transform: { x: 9, y: 7 } components: - type: pen:tray ``` AI に頼むときの文の例: > `worlds/chill-room.yaml` を作って。ubichill.com/v1alpha1 の World。ペンのトレイを左上に、`core:media` のスクリーンを中央に置いて、 > 動画プレイヤーのコントロールをスクリーンの下に置いて、コントロールの `screen` 参照にスクリーンの Entity id を入れて。仕様は https://hub.ubichill.com/create/world-format/ を読んで。 ポイント: - Component の型は `:`。使う mod は `dependencies` に書きます。`core:*` は書きません。 - Component 同士をつなぐ参照は、`data` に相手の Entity の `id` を書きます。 - 上限: Entity 500・1 Entity に Component 32・YAML 100KB。 ## mod を書いてもらう mod は `src/*.worker.tsx` に、`export const config` と UI の `export default` を書きます。 ```tsx // src/like.worker.tsx import type { ComponentConfig } from 'ubichill'; export const config: ComponentConfig = { defaultTransform: { w: 120, h: 40 }, }; const like = Ubi.state.define({ count: Ubi.state.sync(0), // 全員で共有し、残る }); export default function LikeButton() { return ; } ``` AI に頼むときの文の例: > ubichill の mod を作って。npm の `ubichill` が SDK、`@ubichill/cli` がビルドと開発サーバー。`src/like.worker.tsx` に、押すと全員で共有される数が増える > 「いいね」ボタンを書いて。`Ubi.state.sync` で状態を持ち、UI は `export default` の関数で返す。API は https://hub.ubichill.com/mods/api/ を読んで。 > `tsconfig.json` の `jsxImportSource` は `ubichill`、`src/env.d.ts` に `declare const Ubi: import('ubichill').Ubi;` を置いて。 ポイント: - `Ubi` はグローバルに注入されます。`import` せず、`env.d.ts` の型宣言で補います。 - `export default` の関数が読んだ `Ubi.state` の値は自動で追いかけられ、変わったときだけ描き直されます。 - 権限は宣言しなくても、使った API からビルド時に決まります([権限リファレンス](/docs/mods/capabilities/))。 - `fetch` は使えません。外部と通信するなら `Ubi.fetch` を使います(利用者がドメインごとに許可します)。 ## 手元で動かして確かめる ```bash npm install ubichill npm install -D @ubichill/cli typescript npx ubichill dev # mod だけなら world.yaml なしでよい(合成した部屋で動く) npx ubichill dev worlds/chill-room.yaml ``` `http://localhost:3333/` を開くと、本番と同じ画面・同じ判定(lock・権限)で動きます。ファイルを保存すると、その mod だけビルドし直して画面が更新されます。 ビルドに失敗したら画面とターミナルに診断が出るので、それをそのまま AI に貼って直してもらってください。 :::tip[AI にターミナルも見せる] `ubichill dev` の出力(`[build] ✗ ...` の行と esbuild のエラー)と、`npx tsc --noEmit` の結果を AI に渡すと、直しが速くなります。 ::: ## 公開する できあがったら、作者アカウントで署名して公開します。 ```bash npx ubichill login # 1 回だけ。ブラウザで承認する npx ubichill publish worlds/chill-room.yaml # ワールドを Ubichill のサーバーへ npx ubichill publish worlds/chill-room.yaml --out=dist # GitHub Pages などに置くファイルを書き出す npx ubichill build && npx ubichill publish dist # mod の配布物に署名する ``` 流れの全体は [署名して公開する](/docs/publish/) を見てください。CI(GitHub Actions)から公開する方法もあります。 ## 関連ページ - [ワールド定義フォーマット](/docs/create/world-format/) — フィールドの正確な定義 - [はじめての mod](/docs/mods/) — 15 分で動く mod のチュートリアル - [SDK API](/docs/mods/api/) — `Ubi.*` のリファレンス - [CLI](/docs/mods/cli/) — `ubichill` コマンドの一覧 --- # World as Code URL: https://hub.ubichill.com/docs/create/world-as-code/ :::note[ubichill 本体から同期] このページは [`docs/worlds/WORLD_AS_CODE.md`](https://github.com/ubichill/ubichill/blob/main/docs/worlds/WORLD_AS_CODE.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。 ::: このドキュメントは **思想/全体像**。正確なフィールド定義は [FORMAT.md](/docs/create/world-format/)(正準リファレンス)を参照。 ワールドは YAML で定義する。**YAML が唯一の真実(Source of Truth)**であり、ワールドは URL で識別される (=「ワールド=URL」)。engine は URL からワールド定義を取得・検証してインスタンスを立てる。 ``` world.yaml (人間が書く / どこかにホスト) │ URL で取得(自ホスト bundle / GitHub / 他インスタンス=連合) ▼ worldRegistry(メモリ解決)→ instance ``` - **official ワールド**(本体にバンドルした `worlds/*.yaml`)や外部ワールドは **DB に保存しない**(メモリ解決+URL 配信)。 - **ユーザーが本体で作成したワールド**だけが DB に保存される(所有権付き)。 - ワールドは**ステートレス**。インスタンスが生きている間だけ状態を保持し、終了すると消える。 `initialEntities` が毎回の初期状態になる。 --- ## CRD 仕様 Kubernetes CRD と同じ構造を採用。`apiVersion` / `kind` / `metadata` / `spec` で統一。 ### World(ワールド定義) ```yaml apiVersion: ubichill.com/v1alpha1 kind: World metadata: name: my-world # kebab-case(ワールド id) version: "1.0.0" author: name: yourname spec: displayName: "マイワールド" description: "説明文" capacity: default: 10 max: 20 environment: backgroundColor: "#F0F8FF" worldSize: width: 2000 height: 1500 # 依存する mod の入手元ヒント(任意)。実際にどの Component を載せるかは # initialEntities の component type で決まる。 # version は 'latest'(常に最新を追う。省略時もこれになる)か、完全一致 (x.y.z) の pin のどちらか。 dependencies: - name: pen source: { url: "https://yourname.github.io/pen-mod", version: latest } # ワールド起動時の初期エンティティ(Entity=箱、component が振る舞いを配布) initialEntities: - id: pen-1 transform: { x: 200, y: 300, z: 0, w: 100, h: 60, rotation: 0 } components: - type: "pen:stroke" # : data: { strokeWidth: 3 } # 初期値(旧 spec.mods[].config 相当) permissions: allowGuestCreate: false allowGuestDelete: false ``` > **mod の定義(`mod.json`)は [../MOD.md](/docs/mods/package/) を参照。** ワールドは複数の mod を URL から > 読み込んで構成する。ワールド YAML は初期エンティティに載せる Component(`:`)と > 入手元 `dependencies` を宣言し、各 mod の中身(Component・権限・配布形式)は mod 側の関心事として分離している。 > フィールドの完全な定義は [FORMAT.md](/docs/create/world-format/)。 ### Avatar(カーソル定義) ```yaml apiVersion: ubichill.com/v1alpha1 kind: Avatar metadata: name: my-cursor version: "1.0.0" spec: displayName: "カスタムカーソル" hideSystemCursor: true states: default: url: ./assets/normal.png hotspot: { x: 0, y: 0 } pointer: url: ./assets/pointer.png hotspot: { x: 6, y: 0 } busy: url: ./assets/busy.webp # アニメーションは WebP / APNG hotspot: { x: 10, y: 10 } ``` #### 標準 Avatar State | state | 用途 | |---|---| | `default` | 通常時 | | `pointer` | クリック可能要素 | | `busy` | 処理中(アニメーション推奨) | | `text` | テキスト入力 | | `not-allowed` | 操作不可 | | `move` | 移動可能 | | `grabbing` | ドラッグ中 | --- ## 認証・所有権 - YAML を `ubi mod deploy` したユーザーが **owner**(PostgreSQL レコードに `ownerId` を保存) - `config` など作者専用フィールドの更新は `ownerId` 一致チェックで保護 - 将来的に OAuth(GitHub 等)と連携予定 --- ## mod の読み込み ワールドは初期エンティティの Component(`:`)と `spec.dependencies[].source` の URL から mod を読み込む。配布方法(GitHub Pages / CDN・versioned manifest)は mod 側の関心事なので [MOD.md](/docs/mods/package/#配布) を参照。 依存する mod は **ロックファイルで内容ハッシュ(integrity)ごと固定**する(world owner が所有)。 URL のドメイン乗っ取りや CDN 改竄で「同じバージョンの別物」が配信されても、ハッシュ不一致で 実行を拒否できる。詳細は [MOD.md(バージョンと lock)](/docs/mods/package/#バージョンと-lock)。 --- ## バリデーション ワールド YAML を `ubi mod deploy` した際、サーバー側で以下を検証する。 | チェック | 目的 | |---|---| | YAML サイズ制限(100KB) | YAML Bomb 防止 | | Alias 上限(100) | 再帰爆発防止 | | URL ホワイトリスト | XSS / フィッシング防止 | | 依存深度制限(3階層) | 無限ループ防止 | | SemVer 形式 | バージョン解決の一貫性 | --- # ワールド定義フォーマット URL: https://hub.ubichill.com/docs/create/world-format/ :::note[ubichill 本体から同期] このページは [`docs/worlds/FORMAT.md`](https://github.com/ubichill/ubichill/blob/main/docs/worlds/FORMAT.md) を `pnpm sync-docs` で取り込んだもの。直すときは本体リポジトリへ。 ::: これは**正確なフィールド定義(正準リファレンス)**。思想・全体像は [WORLD_AS_CODE.md](/docs/create/world-as-code/)。 ワールドは 1 つの YAML(または JSON)ドキュメントで自己記述する。**この仕様に従っていれば、誰がどこにホストしても、任意の ubichill インスタンスが URL からそのワールドを解決してインスタンスを立てられる**(連合)。 - **ワールド=URL**:ワールドの一意キーは、その定義を返す URL。 - 取得は素の `GET `(`Accept: application/yaml` で YAML、既定 JSON のホストもある)。 - インスタンス側は取得した定義を検証し、`initialEntities` を配置してインスタンスを生成する。 ## 最小例 ```yaml apiVersion: ubichill.com/v1alpha1 kind: World metadata: name: my-world # kebab-case([a-z0-9-]、1–50 文字)。ワールドの名前で、本体での URL は /@/my-world version: 1.0.0 # SemVer author: # 任意 name: Alice url: https://example.com # 任意 spec: displayName: マイワールド # 表示名(必須) description: 説明 # 任意 thumbnail: https://.../thumb.png # 任意(URL) capacity: default: 10 # 既定人数 max: 20 # 上限人数 environment: # 任意 backgroundColor: "#F0F8FF" # HEX worldSize: { width: 2000, height: 1500 } initialEntities: [] # 後述(既定 []) ``` ### `environment.worldSize` と `Ubi.ride` 既定では自分の位置はマウス追従(`pointermove` + ネイティブスクロール)で、`worldSize` はスクロールコンテナの初期サイズのヒントに過ぎず強制力は無い。 乗り物 Entity を持つ mod が `Ubi.ride.exclusive()` を宣言すると、ユーザーがそれに乗っている間だけアバターが OS カーソルから切り離され、矢印キーでの移動+カメラ追従(このときだけ `worldSize` がパン可能範囲としてクランプされる)に切り替わる。降りると通常のマウス追従に戻る。マウス操作とキーボード操作はワールド単位の排他設定ではなく、乗車状態によって動的に切り替わる。 ## `spec.initialEntities`(ECS 配置) ワールドの中身は Entity(=GameObject)の木で表す。 - **Entity**:`id`+`transform`+`components[]` を持つ「箱」。`children[]` で入れ子。id はツリー全体で一意。 - **Component**:`type: ":"`(例 `pen:tray`)で振る舞いを配布。1 Entity に複数可。`data` は Component ごとの初期値。 ```yaml initialEntities: - id: tv transform: { x: 0, y: 0, z: 0, scale: 1, rotation: 0 } # w/h は任意 tags: [surface] # 任意(kebab 系) components: - type: example:screen children: - id: remote transform: { x: 0, y: 320 } components: - type: example:remote data: screen: tv # 参照フィールド: example:screen を持つ Entity の id ``` ### 参照フィールド(Component 同士の配線) Component が別の Component と連携するときは、mod が `dataFields` に参照フィールド(`entityRef` / `entityRefArray`)を 宣言し、作者が値に **Entity の id** を書く(World Editor では Hierarchy からドロップする)。 - 参照が `component: <型>` を宣言していれば、指す Entity はその型の Component を持っていなければならない。 - `required` の参照が未設定・指す Entity が無い・型が合わないときは、World Editor(Hierarchy の印と Inspector)と `ubichill dev`(ターミナル)が知らせる。ワールドは作れるが、その参照に送るイベントは届かない。 - 相手を置く場所は自由(同じ Entity・子・別の木のどこでもよい)。親子や兄弟の形で連携を決める mod では、 その mod の説明に書かれた形に従う。 上限(`LIMITS`): initialEntities 最大 500、Component/Entity 最大 32、tags 最大 10、YAML 100KB。 ### 組み込み Component(`core:*`) Host が持つ Component。mod を読み込まずに使え、`dependencies` にも書かない。`core:` は予約名で、ここに無い型は検証で落ちる。 クライアントからは作成・変更できない(ワールド定義でだけ置ける)。 | 型 | 役割 | `data` | |---|---|---| | `core:collider` | 当たり判定の形 | `shape: rect`(`size: { w, h }` か `entity`)または `shape: circle`(`radius`)。`offset`・`isTrigger`・`layer`・`mask` | | `core:media` | 動画を描く場所(Unity の MeshRenderer に当たる)。何を再生するかは、参照フィールドでこの Entity を指した mod が決める。1 つの面を指せる出力は 1 つだけ(2 つ以上が指す・1 Entity に 2 つ置くと何も描かない)。描けるのはワールド定義に置いた Component の出力だけ(部屋の中で作られた Component は数えない) | `fit: contain`(既定・全体を見せる)か `cover`(枠を埋める) | | `core:ui` | 作者が書く UI(看板・説明・リンクなど)。mod を作らずに置ける | `markup`: HTML 風の文字列(2 万文字まで) | ```yaml - id: tv transform: { x: 100, y: 100, w: 640, h: 360 } components: - type: core:media data: { fit: contain } - id: player transform: { x: 100, y: 480 } components: - type: video-player:controls data: screen: tv # core:media を持つ Entity ``` `core:ui` の `markup` は、mod の UI と同じ描画(Shadow DOM・許可リスト)で描く。 - 使えるタグ・属性は mod の UI と同じ(`div`・`p`・`h1`〜`h6`・`a`・`img`・`ul`・`table`・`svg` など)。SVG の `viewBox`・`linearGradient` など大文字を含む名前もそのまま書ける。 - 使えないタグ(`b`・`i`・`center`・`font` など)は、タグを外して中身の文字だけを描く(太字にしたいときは `strong`)。 - `script`・`style`・`template`・`iframe` などは中身ごと捨てる。`on*` 属性も捨てる(押しても何も動かない)。 - 要素名でない `<`(`x

ようこそ

ルールはこちら

``` ## その他の `spec` フィールド(任意) - `dependencies[]`:`{ name, source: { url?, version? } }`。`url` があれば外部 mod(GitHub Pages 等)をその URL から取得し、無ければローカル(public mods)から解決する。mod の入手元ヒント。 - `permissions`:`{ allowGuestCreate: bool, allowGuestDelete: bool }`。 ## 連合(他インスタンス/外部ホスト) - ワールドを配信できる URL の例: - 本体がホストするワールド:`https:///api/v1/authors//worlds/.yaml`(外部ホストと同じ形。共有 URL `https:///@/` も受け付ける) - GitHub の生 YAML:`https://raw.githubusercontent.com////worlds/.yaml`(`blob` URL も可、自動で raw 化) - ほかの ubichill サーバーのワールドを一覧(グローバル)に出すには、公式アカウントが設定の「連合」でそのサーバーをフォローする。 - 受け手のインスタンスは URL を渡すだけでインスタンスを作成できる(`worldId` に id ではなく URL を渡す)。取得した定義は取り込み元(provenance)を `source`(`local`/`github`/`registry`/`remote-instance`/`url`)として保持する。 ## 作者署名(`.sig.json`) - ワールドと同じ場所に兄弟ファイルとして置く(本体ホストも `.../worlds/.sig.json` で同じ)。`ubichill publish --out` で生成する。 - 形式は mod と共通の作者署名(`format: "ubichill-signature"`・`version: 2`・`kind: "world"`。docs/PROTOCOLS.md)。作者アカウント(`author`)は必須で、正規の表記(`handle@domain`、ドメインは小文字)だけ。`subject` は `metadata.name`。 - 内容のハッシュ(`contentHash`)は `{ definition: , lock: <兄弟 lock の生 JSON | null> }` を正規化 JSON(RFC 8785 相当)にした sha256。 - 版 1 の署名(2026-10 より前の形式)は読まない。`ubichill publish` で署名し直す。 - ワールドの同一性は `ed25519:<公開鍵>/`。URL が変わっても同じデータ・同じ鍵なら同じワールド。 - 署名に作者アカウント `author: handle@domain` を含めると、受け手は `https:///.well-known/webfinger?resource=acct:handle@domain` の links(rel `https://ubichill.com/ns/signing-keys`)から作者の鍵一覧 `{ account, issuedAt, keys: [{ publicKey, addedAt?, revokedAt? }] }` を取得し、署名鍵が取り消されていない鍵なら作者として表示し同一性を `acct:handle@domain/` にする(鍵が変わっても変わらない)。一覧に無い・取り消された鍵なら作者は表示しない。 - 署名があって検証に失敗したワールドは解決を拒否する。 - **署名を検証できないワールド(未署名)は公開しない**:ワールド一覧・連合(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; } ``` ### 動画・画像など、ヘッダーを付けられない取得 `