@uzuhq/code-cli 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/auth/browser.js +22 -0
  4. package/dist/auth/config.js +48 -0
  5. package/dist/auth/env.js +42 -0
  6. package/dist/auth/jwt.js +27 -0
  7. package/dist/auth/login-flow.js +44 -0
  8. package/dist/auth/loopback.js +94 -0
  9. package/dist/auth/pkce.js +11 -0
  10. package/dist/auth/publish-token.js +74 -0
  11. package/dist/auth/token-cache.js +70 -0
  12. package/dist/auth/uzu-auth.js +94 -0
  13. package/dist/build-server-logic.js +28 -0
  14. package/dist/cf-images-upload.js +52 -0
  15. package/dist/cli.js +303 -0
  16. package/dist/create-2d-game.js +56 -0
  17. package/dist/dev-server/game-room.js +436 -0
  18. package/dist/dev-server/game-types.js +11 -0
  19. package/dist/dev-server/json-patch.js +114 -0
  20. package/dist/dev-server/load-logic.js +70 -0
  21. package/dist/dev-server/random.js +35 -0
  22. package/dist/dev-server/relay-room.js +84 -0
  23. package/dist/dev-server/server.js +367 -0
  24. package/dist/dev-server/sync-room.js +268 -0
  25. package/dist/dev.js +235 -0
  26. package/dist/harness/admin-client.js +215 -0
  27. package/dist/harness/client-entry.js +90 -0
  28. package/dist/harness/dev-button.js +249 -0
  29. package/dist/harness/mount.js +664 -0
  30. package/dist/harness/page.js +46 -0
  31. package/dist/r2-upload.js +93 -0
  32. package/dist/rest-register.js +50 -0
  33. package/dist/upload-session.js +71 -0
  34. package/game-2d-template/index.html.tpl +18 -0
  35. package/game-2d-template/manifest.json.tpl +6 -0
  36. package/game-2d-template/package.json.tpl +23 -0
  37. package/game-2d-template/src/main.ts +49 -0
  38. package/game-2d-template/src/vite-env.d.ts +1 -0
  39. package/game-2d-template/tsconfig.json +12 -0
  40. package/game-2d-template/vite.config.ts +6 -0
  41. package/package.json +43 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sally, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @uzuhq/code-cli
2
+
3
+ [UZU](https://uzu-app.com) 上で動くゲーム・シナリオを開発・配信するための CLI です。プロジェクト作成、ローカル開発サーバー、UZU へのパブリッシュまでを 1 つのツールで行えます。
4
+
5
+ ```bash
6
+ npm install -D @uzuhq/code-cli
7
+ ```
8
+
9
+ ## クイックスタート
10
+
11
+ ```bash
12
+ # 2D ゲームプロジェクトの雛形を作成
13
+ npx uzu create-2d-game my-game
14
+ cd my-game && npm install
15
+
16
+ # ローカル開発サーバーを起動(マルチプレイ動作確認用の harness 付き)
17
+ npx uzu dev
18
+
19
+ # UZU にログインしてパブリッシュ
20
+ npx uzu login
21
+ npx uzu publish
22
+ ```
23
+
24
+ ## コマンド
25
+
26
+ | コマンド | 説明 |
27
+ | ------------------------------ | ----------------------------------------------------------------------------- |
28
+ | `uzu create-2d-game <name>` | 2D エンジンを使ったゲームプロジェクトの雛形を作成 |
29
+ | `uzu dev` | 開発サーバーを起動し、複数プレイヤーの同時プレイを模擬する dev harness を提供 |
30
+ | `uzu publish` | ゲームをビルドして UZU にアップロード・登録 |
31
+ | `uzu login` / `uzu logout` | UZU アカウントでのログイン / ログアウト |
32
+ | `uzu token create/list/revoke` | CI 用 publish token の管理 |
33
+
34
+ ## publish の設定
35
+
36
+ プロジェクトルートの `manifest.json` でゲームのメタデータを定義します。
37
+
38
+ ```json
39
+ {
40
+ "id": "my-game",
41
+ "playerCount": 4,
42
+ "build": "npm run build",
43
+ "output": "dist"
44
+ }
45
+ ```
46
+
47
+ | フィールド | 説明 |
48
+ | ------------- | ----------------------------------------------------------------------- |
49
+ | `id` | ゲームの一意な ID |
50
+ | `playerCount` | プレイ人数(`characters` 指定時はその数が優先) |
51
+ | `build` | パブリッシュ前に実行するビルドコマンド(省略可) |
52
+ | `output` | アップロード対象のビルド出力ディレクトリ |
53
+ | `orientation` | `portrait`(既定)または `landscape` |
54
+ | `characters` | キャラクター定義(`id` / `name` / `description` / `icon` / `furigana`) |
55
+
56
+ CI からパブリッシュする場合は `uzu token create` で発行した token を環境変数 `UZU_PUBLISH_TOKEN` に設定してください。
57
+
58
+ ## 関連パッケージ
59
+
60
+ - [@uzuhq/code-sdk](https://www.npmjs.com/package/@uzuhq/code-sdk) — マルチプレイ通信・サウンド再生などのゲーム側 SDK
61
+
62
+ ## License
63
+
64
+ [MIT](./LICENSE)
@@ -0,0 +1,22 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * 既定ブラウザで URL を開く (best-effort)。
4
+ * 失敗してもユーザーが URL を手で踏めば完了するので catch して握りつぶす想定。
5
+ *
6
+ * sallyinc/forms apps/cli/src/auth/browser.ts からの移植。
7
+ */
8
+ export const openBrowser = (url) => {
9
+ const platform = process.platform;
10
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
11
+ const args = platform === 'win32' ? ['/c', 'start', '""', url] : [url];
12
+ try {
13
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
14
+ child.unref();
15
+ child.on('error', () => {
16
+ // 起動失敗は呼び出し側で URL を表示済なので握りつぶす
17
+ });
18
+ }
19
+ catch {
20
+ // 同上
21
+ }
22
+ };
@@ -0,0 +1,48 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * publish 用の認証情報を `~/.config/uzu-cli/credentials.json` に保存する。
6
+ *
7
+ * - refresh_token のみ永続化する (id_token は token-cache.ts でメモリ展開、ディスクには書かない)
8
+ * - env(dev/stg/prd) ごとに別アカウント扱いで保持する。backend は uzu-<env> Firebase で
9
+ * token を検証するため、env をまたいだ流用はできない
10
+ * - mode 0600 で書き込む (keychain は使わない)
11
+ */
12
+ const CONFIG_DIR = join(homedir(), '.config', 'uzu-cli');
13
+ const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
14
+ const readRaw = async () => {
15
+ try {
16
+ const parsed = JSON.parse(await readFile(CREDENTIALS_FILE, 'utf8'));
17
+ if (parsed !== null && typeof parsed === 'object' && 'envs' in parsed) {
18
+ return parsed;
19
+ }
20
+ }
21
+ catch {
22
+ // 未ログイン or 壊れたファイルは空扱い
23
+ }
24
+ return { envs: {} };
25
+ };
26
+ const write = async (creds) => {
27
+ await mkdir(CONFIG_DIR, { recursive: true });
28
+ await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
29
+ };
30
+ export const getCredentials = async (env) => {
31
+ const creds = await readRaw();
32
+ return creds.envs[env] ?? null;
33
+ };
34
+ export const saveCredentials = async (env, envCreds) => {
35
+ const creds = await readRaw();
36
+ creds.envs[env] = envCreds;
37
+ await write(creds);
38
+ };
39
+ /** 指定 env の認証情報を削除する。削除対象が無ければ false。 */
40
+ export const clearCredentials = async (env) => {
41
+ const creds = await readRaw();
42
+ if (creds.envs[env] === undefined)
43
+ return false;
44
+ delete creds.envs[env];
45
+ await write(creds);
46
+ return true;
47
+ };
48
+ export const credentialsPath = () => CREDENTIALS_FILE;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * uzu-cli の接続先環境設定。
3
+ *
4
+ * publish CLI は dev を既定とし、`--env` (hidden) で dev/stg/prd を切り替える。
5
+ * env ごとに auth サーバ(Firebase id_token 発行)・backend HTTP・UZU STUDIO のホストが対になる。
6
+ *
7
+ * env 切替の権限境界: backend は token を uzu-<env> Firebase で検証するため、dev/stg は対応する
8
+ * Firebase プロジェクトにアカウントがある社内メンバー(@sally-inc.jp)しかログインできず、
9
+ * 自然に弾かれる。CLI 側に admin 判定は持たせない。
10
+ */
11
+ export const UZU_ENVS = ['dev', 'stg', 'prd'];
12
+ export const DEFAULT_ENV = 'dev';
13
+ const ENV_HOSTS = {
14
+ prd: {
15
+ authHost: 'auth.uzu-app.com',
16
+ graphHost: 'prd.graph.backend.app.uzu.one',
17
+ studioHost: 'studio.uzu-app.com',
18
+ },
19
+ stg: {
20
+ authHost: 'stg.auth.uzu-app.com',
21
+ graphHost: 'stg.graph.backend.app.uzu.one',
22
+ studioHost: 'stg.studio.uzu-app.com',
23
+ },
24
+ dev: {
25
+ authHost: 'dev.auth.uzu-app.com',
26
+ graphHost: 'dev.graph.backend.app.uzu.one',
27
+ studioHost: 'dev.studio.uzu-app.com',
28
+ },
29
+ };
30
+ const isUzuEnv = (v) => UZU_ENVS.includes(v);
31
+ /** `--env` の生値を検証して UzuEnv に解決する。未指定は dev。 */
32
+ export const resolveEnv = (raw) => {
33
+ if (raw === undefined || raw === '')
34
+ return DEFAULT_ENV;
35
+ if (!isUzuEnv(raw)) {
36
+ throw new Error(`不正な env: ${raw} (dev | stg | prd のいずれかを指定してください)`);
37
+ }
38
+ return raw;
39
+ };
40
+ export const authBaseURL = (env) => `https://${ENV_HOSTS[env].authHost}`;
41
+ export const graphBaseURL = (env) => `https://${ENV_HOSTS[env].graphHost}`;
42
+ export const studioHost = (env) => ENV_HOSTS[env].studioHost;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Firebase ID token (JWT) の payload から uid / email を取り出す。
3
+ *
4
+ * 署名検証はしない: auth サーバから HTTPS で受領した自分宛トークンを表示用途で読むだけで、経路の
5
+ * 真正性は TLS が担保する (gcloud / uzuctl parseIDTokenClaims と同方針)。
6
+ *
7
+ * sallyinc/forms apps/cli/src/auth/jwt.ts からの移植。
8
+ */
9
+ export const parseIdTokenClaims = (idToken) => {
10
+ const parts = idToken.split('.');
11
+ const payloadSegment = parts[1];
12
+ if (parts.length !== 3 || payloadSegment === undefined) {
13
+ throw new Error('id token is not a well-formed JWT');
14
+ }
15
+ let claims;
16
+ try {
17
+ claims = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
18
+ }
19
+ catch {
20
+ throw new Error('id token payload is not valid JSON');
21
+ }
22
+ // uid は user_id 優先、空なら sub。email claim 不在は空文字 (旧 lookup の挙動維持)。
23
+ const uid = claims.user_id || claims.sub || '';
24
+ if (!uid)
25
+ throw new Error('id token has no user_id or sub claim');
26
+ return { uid, email: claims.email ?? '' };
27
+ };
@@ -0,0 +1,44 @@
1
+ import { openBrowser } from './browser.js';
2
+ import { authBaseURL } from './env.js';
3
+ import { parseIdTokenClaims } from './jwt.js';
4
+ import { startLoopbackServer } from './loopback.js';
5
+ import { computeCodeChallenge, generateCodeVerifier } from './pkce.js';
6
+ import { buildLoginURL, exchangeCodeForTokens } from './uzu-auth.js';
7
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; // uzuctl と同じ 5 分
8
+ /**
9
+ * uzu auth サーバの標準 OAuth (PKCE) flow を回してログインする。
10
+ *
11
+ * 1. PKCE verifier / challenge 生成
12
+ * 2. 127.0.0.1 で callback サーバ起動 (loopback redirect, RFC 8252)
13
+ * 3. ブラウザで {authHost}/login?redirect_uri=...&client_id=uzu-cli&code_challenge=... を開く
14
+ * 4. ブラウザで Google ログイン (uzu SSO セッションがあれば 1 click)
15
+ * 5. callback で code を受領
16
+ * 6. POST /api/oauth/token → access_token(= Firebase ID token) / refresh_token / expires_in
17
+ * 7. access_token (JWT) を decode して uid + email を取得
18
+ *
19
+ * sallyinc/forms apps/cli/src/auth/login-flow.ts からの移植 (env を引数化)。
20
+ */
21
+ export const runLoginFlow = async (env, onURL) => {
22
+ const verifier = generateCodeVerifier();
23
+ const challenge = computeCodeChallenge(verifier);
24
+ const server = await startLoopbackServer();
25
+ try {
26
+ const baseUrl = authBaseURL(env);
27
+ const loginUrl = buildLoginURL(baseUrl, server.redirectUri, challenge);
28
+ onURL?.(loginUrl);
29
+ openBrowser(loginUrl);
30
+ const code = await server.waitForCode(LOGIN_TIMEOUT_MS);
31
+ const tokens = await exchangeCodeForTokens(baseUrl, code, verifier, server.redirectUri);
32
+ const { uid, email } = parseIdTokenClaims(tokens.accessToken);
33
+ return {
34
+ email,
35
+ uid,
36
+ refreshToken: tokens.refreshToken,
37
+ idToken: tokens.accessToken,
38
+ expiresIn: tokens.expiresIn,
39
+ };
40
+ }
41
+ finally {
42
+ server.close();
43
+ }
44
+ };
@@ -0,0 +1,94 @@
1
+ import { createServer } from 'node:http';
2
+ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>uzu: Login successful</title>
3
+ <body style="font-family:system-ui;max-width:480px;margin:80px auto;text-align:center;">
4
+ <h2>Login successful</h2>
5
+ <p>このタブを閉じてターミナルに戻ってください。</p>
6
+ </body>`;
7
+ const failureHtml = (msg) => {
8
+ const escaped = msg.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
9
+ return `<!doctype html><meta charset="utf-8"><title>uzu: Login failed</title>
10
+ <body style="font-family:system-ui;max-width:480px;margin:80px auto;text-align:center;">
11
+ <h2>Login failed</h2>
12
+ <p>${escaped}</p>
13
+ </body>`;
14
+ };
15
+ export const startLoopbackServer = async () => {
16
+ let resolve = null;
17
+ let reject = null;
18
+ let delivered = false;
19
+ // waitForCode 呼び出し前に callback が着弾した場合の結果退避先。
20
+ // 呼び出し順序に依存せずコード / エラーを取りこぼさないためのバッファ。
21
+ let pending = null;
22
+ const server = createServer((req, res) => {
23
+ if (!req.url?.startsWith('/callback')) {
24
+ res.writeHead(404);
25
+ res.end();
26
+ return;
27
+ }
28
+ if (delivered) {
29
+ res.writeHead(410, { 'Content-Type': 'text/plain' });
30
+ res.end('already handled');
31
+ return;
32
+ }
33
+ delivered = true;
34
+ const url = new URL(req.url, 'http://127.0.0.1');
35
+ const err = url.searchParams.get('error');
36
+ const code = url.searchParams.get('code');
37
+ if (err) {
38
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
39
+ res.end(failureHtml(`auth server returned error: ${err}`));
40
+ const e = new Error(`auth server returned error: ${err}`);
41
+ if (reject)
42
+ reject(e);
43
+ else
44
+ pending = { err: e };
45
+ return;
46
+ }
47
+ if (!code) {
48
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
49
+ res.end(failureHtml('missing authorization code'));
50
+ const e = new Error('authorization code missing in callback');
51
+ if (reject)
52
+ reject(e);
53
+ else
54
+ pending = { err: e };
55
+ return;
56
+ }
57
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
58
+ res.end(SUCCESS_HTML);
59
+ if (resolve)
60
+ resolve(code);
61
+ else
62
+ pending = { code };
63
+ });
64
+ await new Promise((res, rej) => {
65
+ server.once('error', rej);
66
+ server.listen(0, '127.0.0.1', () => res());
67
+ });
68
+ const port = server.address().port;
69
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
70
+ return {
71
+ port,
72
+ redirectUri,
73
+ waitForCode: (timeoutMs) => new Promise((res, rej) => {
74
+ // callback が waitForCode より先に着弾していたら退避結果を即返す。
75
+ if (pending) {
76
+ if ('code' in pending)
77
+ res(pending.code);
78
+ else
79
+ rej(pending.err);
80
+ return;
81
+ }
82
+ const t = setTimeout(() => rej(new Error(`login timed out after ${timeoutMs}ms`)), timeoutMs);
83
+ resolve = (code) => {
84
+ clearTimeout(t);
85
+ res(code);
86
+ };
87
+ reject = (err) => {
88
+ clearTimeout(t);
89
+ rej(err);
90
+ };
91
+ }),
92
+ close: () => server.close(),
93
+ };
94
+ };
@@ -0,0 +1,11 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /**
3
+ * PKCE (RFC 7636) helpers。
4
+ * - verifier: 32 byte 乱数を base64url
5
+ * - challenge: SHA256(verifier) を base64url
6
+ *
7
+ * sallyinc/forms apps/cli/src/auth/pkce.ts からの移植 (uzuctl Go 実装の TS 版)。
8
+ */
9
+ const base64url = (buf) => buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
10
+ export const generateCodeVerifier = () => base64url(randomBytes(32));
11
+ export const computeCodeChallenge = (verifier) => base64url(createHash('sha256').update(verifier).digest());
@@ -0,0 +1,74 @@
1
+ /**
2
+ * publish token (CI から publish するための長寿命 credential) のクライアント側処理。
3
+ *
4
+ * - `UZU_PUBLISH_TOKEN` から token を読む。CI はこれだけ渡せば publish できる
5
+ * - 発行 / 一覧 / 失効は auth サーバの /api/publish-tokens を叩く。認証は `uzu login` 由来の
6
+ * ID token で、publish token 由来の ID token はサーバ側で 403 になる
7
+ *
8
+ * baseUrl はすべて引数で受ける(テストの httptest 差し替え・env 切替の両対応)。
9
+ *
10
+ * @docs
11
+ * - 設計: docs/architecture/auth-platform.md
12
+ * - 使い方: docs/play_screen_v3/sdk-guide/dev-tools.md
13
+ */
14
+ /** CI に渡す環境変数名。 */
15
+ export const PUBLISH_TOKEN_ENV = 'UZU_PUBLISH_TOKEN';
16
+ /** API がハングしないためのタイムアウト (ms)。 */
17
+ const REQUEST_TIMEOUT_MS = 15_000;
18
+ /** 環境変数の publish token。未設定・空文字なら null。 */
19
+ export const publishTokenFromEnv = () => {
20
+ const raw = process.env[PUBLISH_TOKEN_ENV];
21
+ if (raw === undefined)
22
+ return null;
23
+ const token = raw.trim();
24
+ return token === '' ? null : token;
25
+ };
26
+ /** 非 200 レスポンスを {error, error_description} としてパースする。 */
27
+ const parseApiError = (text, status) => {
28
+ try {
29
+ const j = JSON.parse(text);
30
+ if (j.error) {
31
+ return new Error(j.error_description
32
+ ? `uzu auth: ${j.error} (${j.error_description})`
33
+ : `uzu auth: ${j.error}`);
34
+ }
35
+ }
36
+ catch { }
37
+ return new Error(`uzu auth: status ${status}`);
38
+ };
39
+ const request = async (baseUrl, path, idToken, init) => {
40
+ const res = await fetch(`${baseUrl}${path}`, {
41
+ method: init.method,
42
+ headers: {
43
+ authorization: `Bearer ${idToken}`,
44
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
45
+ },
46
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
47
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
48
+ });
49
+ const text = await res.text();
50
+ if (!res.ok)
51
+ throw parseApiError(text, res.status);
52
+ try {
53
+ return JSON.parse(text);
54
+ }
55
+ catch {
56
+ throw new Error('uzu auth: invalid response');
57
+ }
58
+ };
59
+ /** publish token を発行する。平文はこのレスポンスにしか現れない。 */
60
+ export const createPublishToken = (baseUrl, idToken, name) => request(baseUrl, '/api/publish-tokens', idToken, {
61
+ method: 'POST',
62
+ body: { name },
63
+ });
64
+ /** 有効な publish token を一覧する。 */
65
+ export const listPublishTokens = async (baseUrl, idToken) => {
66
+ const { tokens } = await request(baseUrl, '/api/publish-tokens', idToken, { method: 'GET' });
67
+ return tokens;
68
+ };
69
+ /** publish token を失効させる。 */
70
+ export const revokePublishToken = async (baseUrl, idToken, id) => {
71
+ await request(baseUrl, `/api/publish-tokens/${encodeURIComponent(id)}`, idToken, {
72
+ method: 'DELETE',
73
+ });
74
+ };
@@ -0,0 +1,70 @@
1
+ import { getCredentials, saveCredentials } from './config.js';
2
+ import { authBaseURL } from './env.js';
3
+ import { PUBLISH_TOKEN_ENV, publishTokenFromEnv } from './publish-token.js';
4
+ import { exchangePublishToken, refreshTokens } from './uzu-auth.js';
5
+ /**
6
+ * id_token のメモリキャッシュ + 認証情報からの取得し直し。uzuctl GetValidIDToken 相当。
7
+ *
8
+ * 取得元の優先順位は cache → `UZU_PUBLISH_TOKEN` → credentials.json:
9
+ * - CI は publish token だけを渡す。env が立っていればログイン状態を見ない
10
+ * - ローカルは env を立てない限り従来どおり credentials.json が使われる
11
+ *
12
+ * - id_token はディスクに書かない (refresh_token のみ credentials.json に永続化)
13
+ * - 有効期限の残りが REFRESH_SKEW を切ったら取り直す (refresh_token grant / token exchange)
14
+ * - env ごとに in-memory cache を持ち、同一プロセスの連打では再取得しない
15
+ * - auth サーバが refresh_token を rotate して返した場合は credentials.json に追従する
16
+ */
17
+ const REFRESH_SKEW_MS = 5 * 60 * 1000;
18
+ const memoryCache = {};
19
+ /** credentials.json の refresh_token から id_token を取り直す。未ログインなら null。 */
20
+ const mintFromCredentials = async (env) => {
21
+ const creds = await getCredentials(env);
22
+ if (!creds)
23
+ return null;
24
+ const refreshed = await refreshTokens(authBaseURL(env), creds.refreshToken);
25
+ if (refreshed.refreshToken && refreshed.refreshToken !== creds.refreshToken) {
26
+ await saveCredentials(env, { ...creds, refreshToken: refreshed.refreshToken });
27
+ }
28
+ return { idToken: refreshed.accessToken, expiresIn: refreshed.expiresIn };
29
+ };
30
+ export const getValidIdToken = async (env) => {
31
+ const now = Date.now();
32
+ const cached = memoryCache[env];
33
+ if (cached && cached.expiresAt - now > REFRESH_SKEW_MS) {
34
+ return cached.idToken;
35
+ }
36
+ const publishToken = publishTokenFromEnv();
37
+ if (publishToken !== null) {
38
+ const tokens = await exchangePublishToken(authBaseURL(env), publishToken);
39
+ memoryCache[env] = {
40
+ idToken: tokens.accessToken,
41
+ expiresAt: now + tokens.expiresIn * 1000,
42
+ };
43
+ return tokens.accessToken;
44
+ }
45
+ const minted = await mintFromCredentials(env);
46
+ if (minted === null) {
47
+ throw new Error(`未ログインです。\`uzu login --env ${env}\` を実行するか、` +
48
+ `${PUBLISH_TOKEN_ENV} に publish token を設定してください`);
49
+ }
50
+ memoryCache[env] = { idToken: minted.idToken, expiresAt: now + minted.expiresIn * 1000 };
51
+ return minted.idToken;
52
+ };
53
+ /**
54
+ * `uzu login` 由来の id_token を取得する。publish token では代替しない。
55
+ *
56
+ * token 管理コマンド専用。publish token 由来の id_token はサーバ側でも 403 になる
57
+ * (漏れた CI トークンから新しい token を生やせないようにするため)。
58
+ */
59
+ export const getLoginIdToken = async (env) => {
60
+ const minted = await mintFromCredentials(env);
61
+ if (minted === null) {
62
+ throw new Error(`未ログインです。\`uzu login --env ${env}\` を先に実行してください`);
63
+ }
64
+ return minted.idToken;
65
+ };
66
+ /** test / logout で in-memory cache をリセットするため。 */
67
+ export const _resetTokenCache = () => {
68
+ for (const k of Object.keys(memoryCache))
69
+ delete memoryCache[k];
70
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * uzu auth サーバの標準 OAuth (2.1) endpoint helpers。
3
+ *
4
+ * - GET /login → ブラウザを誘導し redirect_uri に code を返す
5
+ * - POST /api/oauth/token → authorization_code / refresh_token grant で
6
+ * access_token(= Firebase ID token) / refresh_token を発行・更新
7
+ * token-exchange grant で publish token を ID token に交換
8
+ *
9
+ * sallyinc/forms apps/cli/src/auth/uzu-auth.ts からの移植。client_id を `uzu-cli` に差し替えた。
10
+ * `uzu-cli` は auth 側 lib/oauth.ts の FIRST_PARTY_OAUTH_CLIENT_IDS / lib/clients.ts に登録済み。
11
+ *
12
+ * baseUrl はすべて引数で受ける(テストの httptest 差し替え・env 切替の両対応)。
13
+ */
14
+ const CLIENT_ID = 'uzu-cli';
15
+ /**
16
+ * publish token の subject_token_type (RFC 8693)。
17
+ * auth 側 lib/publish-token.ts の PUBLISH_TOKEN_TYPE と一致していること
18
+ * (別パッケージなので共有できない。ズレると exchange が invalid_request になる)。
19
+ */
20
+ const PUBLISH_TOKEN_TYPE = 'urn:uzu:params:oauth:token-type:publish-token';
21
+ /** refresh が hang しないためのタイムアウト (ms)。 */
22
+ const TOKEN_TIMEOUT_MS = 15_000;
23
+ export const buildLoginURL = (authBaseUrl, redirectUri, codeChallenge) => {
24
+ const q = new URLSearchParams({
25
+ redirect_uri: redirectUri,
26
+ client_id: CLIENT_ID,
27
+ code_challenge: codeChallenge,
28
+ code_challenge_method: 'S256',
29
+ });
30
+ return `${authBaseUrl}/login?${q.toString()}`;
31
+ };
32
+ /** 非 200 レスポンスを OAuth 標準の {error, error_description} としてパースする。両 grant 共通。 */
33
+ const parseOAuthTokenError = (text, status) => {
34
+ try {
35
+ const j = JSON.parse(text);
36
+ if (j.error) {
37
+ return new Error(j.error_description
38
+ ? `uzu auth: ${j.error} (${j.error_description})`
39
+ : `uzu auth: ${j.error}`);
40
+ }
41
+ }
42
+ catch { }
43
+ return new Error(`uzu auth: status ${status}`);
44
+ };
45
+ /** /api/oauth/token に JSON body を POST する低レベル helper。baseUrl 引数は httptest 差し替え用。 */
46
+ const requestToken = async (baseUrl, body) => {
47
+ const res = await fetch(`${baseUrl}/api/oauth/token`, {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify(body),
51
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
52
+ });
53
+ const text = await res.text();
54
+ if (!res.ok)
55
+ throw parseOAuthTokenError(text, res.status);
56
+ let j;
57
+ try {
58
+ j = JSON.parse(text);
59
+ }
60
+ catch {
61
+ throw new Error('uzu auth: invalid token response');
62
+ }
63
+ if (!j.access_token)
64
+ throw new Error('uzu auth: missing access_token in response');
65
+ return {
66
+ accessToken: j.access_token,
67
+ refreshToken: j.refresh_token ?? '',
68
+ expiresIn: Number(j.expires_in) || 3600,
69
+ };
70
+ };
71
+ /** authorization_code grant。redirectUri は code に紐づく値と一致させる必要がある。 */
72
+ export const exchangeCodeForTokens = async (baseUrl, code, codeVerifier, redirectUri) => {
73
+ const tokens = await requestToken(baseUrl, {
74
+ grant_type: 'authorization_code',
75
+ code,
76
+ code_verifier: codeVerifier,
77
+ redirect_uri: redirectUri,
78
+ client_id: CLIENT_ID,
79
+ });
80
+ if (!tokens.refreshToken)
81
+ throw new Error('uzu auth: missing refresh_token in response');
82
+ return tokens;
83
+ };
84
+ /** refresh_token grant。サーバは securetoken を裏で叩く。 */
85
+ export const refreshTokens = (baseUrl, refreshToken) => requestToken(baseUrl, { grant_type: 'refresh_token', refresh_token: refreshToken });
86
+ /**
87
+ * publish token を短命の ID token に交換する (RFC 8693 token exchange)。
88
+ * レスポンスに refresh_token は含まれない (期限が切れたら publish token から取り直す)。
89
+ */
90
+ export const exchangePublishToken = (baseUrl, publishToken) => requestToken(baseUrl, {
91
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
92
+ subject_token: publishToken,
93
+ subject_token_type: PUBLISH_TOKEN_TYPE,
94
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/play_screen_v3/connection-method/arch3-authority.md
4
+ *
5
+ * ゲームの logic.ts を ESM 形式の単一 logic.js にバンドルする。
6
+ * 出力ファイルは R2 にアップロードされ、WfP デプロイ時にテンプレートとマージされる。
7
+ */
8
+ import { build } from 'esbuild';
9
+ /**
10
+ * logic.ts → logic.js にバンドルビルドする。
11
+ *
12
+ * @param logicPath - ゲームの logic.ts (or .js) への絶対パス
13
+ * @param outPath - 出力先の logic.js への絶対パス
14
+ */
15
+ export const buildServerLogic = async (logicPath, outPath) => {
16
+ await build({
17
+ entryPoints: [logicPath],
18
+ outfile: outPath,
19
+ bundle: true,
20
+ format: 'esm',
21
+ target: 'es2022',
22
+ platform: 'neutral',
23
+ // SDK の type-only import は tsc で消えるが安全のため external 指定。
24
+ // @uzupj/uzu-sdk は旧パッケージ名 (未移行 scenario 向けの両対応)
25
+ external: ['@uzuhq/code-sdk', '@uzupj/uzu-sdk'],
26
+ });
27
+ console.log(`Built server logic: ${logicPath} → ${outPath}`);
28
+ };