# @upbond/sdk サンプル集

シナリオ別の最小サンプル。各コードブロックは `examples/*.ts` の実ファイルを
[embedme](https://github.com/zakhenry/embedme) で埋め込んでおり、SDK の `tsconfig`
（`moduleResolution: NodeNext` / `strict` / `verbatimModuleSyntax`）で型チェック済み。
再生成は `pnpm --filter @upbond/sdk exec embedme EXAMPLES.md`、検証は
`pnpm --filter @upbond/sdk exec embedme --verify EXAMPLES.md`。

すべてのサンプルは `@upbond/sdk` からのみ import する。プレースホルダ（`your-client-id`
等）は自分の値に差し替える。issuer は staging を例示。

## 1. 認証専用（ウォレットなし）

UPBOND ID でのログインだけが必要なとき。ウォレット系メソッドを呼ばなければ MPC/鍵には
一切触れない（redirect 用 MPC チャンクもロードされない。既定の embedded ウィジェット iframe は
`init()` で premount される — 不要なら無視してよい）。`init()` はページ読み込み時に 1 回だけ呼び、callback
消費かセッション復元を行う。`login()` は issuer へリダイレクトするだけでアプリコードには
戻らない（戻りは次回の `init()` が処理する）。エラーは `UpbondError { code }` に正規化される。

```ts
// examples/auth-only.ts

// examples/auth-only.ts
//
// Auth-only integration: UPBOND ID (OIDC) with no embedded wallet. Shows the
// full session lifecycle — init() on load, login()/logout() from buttons,
// subscribe() for reactive UI, getUser() for claims — with UpbondError handling.

import { createUpbond, UpbondError, type UpbondState } from '@upbond/sdk';

const upbond = createUpbond({
  clientId: 'your-client-id',
  environment: 'staging', // omit for production (the default)
  scope: 'openid profile email',
  // redirectUri defaults to window.location.origin (register it at the
  // issuer). Auth-only needs nothing else: the MPC stack is a lazy chunk that
  // never loads unless a wallet method is called.
});

function describeError(err: unknown): string {
  if (err instanceof UpbondError) return `UpbondError(${err.code}): ${err.message}`;
  if (err instanceof Error) return `${err.name}: ${err.message}`;
  return String(err);
}

function render(state: UpbondState): void {
  const user = upbond.getUser();
  const label = state.auth === 'signed-in' ? (user?.email ?? user?.sub ?? 'signed in') : state.auth;
  const el = document.getElementById('status');
  if (el !== null) el.textContent = label;
}

// React to every state transition (auth status, claims, progress phase).
upbond.subscribe(render);

async function main(): Promise<void> {
  // init() consumes the ?code callback (and cleans the URL) or restores a
  // persisted session. Call it once, before wiring the buttons.
  try {
    await upbond.init();
  } catch (err) {
    console.error(describeError(err));
  }
  render(upbond.state);

  const loginBtn = document.getElementById('login') as HTMLButtonElement;
  const logoutBtn = document.getElementById('logout') as HTMLButtonElement;

  // login() persists PKCE state then redirects to the issuer — it never
  // resolves back into app code. Pass hints to skip the provider chooser,
  // e.g. upbond.login({ connection: 'google' }).
  loginBtn.onclick = () => {
    void upbond.login().catch((err: unknown) => console.error(describeError(err)));
  };
  logoutBtn.onclick = () => {
    void upbond.logout().catch((err: unknown) => console.error(describeError(err)));
  };
}

void main();

```

## 2. リダイレクトモードのフルウォレットフロー

RP ページ自身が MPC 儀式を実行するモード。`connectWallet()` は Layer 1（生体プロンプト
なし）で、戻り値の `status` に応じて初回は `setupWallet()`、新端末は `recoverWallet()` へ
分岐する。**注意**: `setupWallet` / `recoverWallet` / `signTransaction` / `signMessage` は
パスキー・生体プロンプトを開くため、必ずクリックハンドラ（ユーザージェスチャー）内で呼ぶ。
MPC スタックは最初のウォレット呼び出し（`connectWallet()`）で初めて動的 import される。
`getRecoveryStatus()` は Layer 1 で読めるが、`updateRecoveryContact()` は Layer 2 のため
先に `unlockWallet()` が必要（未 unlock だと `wallet_locked`）。

```ts
// examples/wallet-redirect.ts

// examples/wallet-redirect.ts
//
// Full redirect-mode wallet flow: connect the Layer 1 session, branch on
// needs-setup vs needs-recovery, then sign transactions/messages and manage the
// recovery contact — every key operation invoked from inside a user gesture.

import { createUpbond, UpbondError, type SignRequest, type WalletStatus } from '@upbond/sdk';

const upbond = createUpbond({
  clientId: 'your-client-id',
  environment: 'staging', // omit for production (the default)
  scope: 'openid profile email wallet',
  wallet: {
    mode: 'redirect', // embedded (widget) is the default since D18
    // The environment preset fills everything (issuer, Web3Auth project id,
    // network, verifier, recovery wiring); supply just the OTP prompt UI
    // recovery needs.
    recovery: {
      promptOtp: async () => window.prompt('Enter the OTP sent to your recovery contact') ?? '',
    },
  },
});

function describeError(err: unknown): string {
  if (err instanceof UpbondError) return `UpbondError(${err.code}): ${err.message}`;
  return err instanceof Error ? err.message : String(err);
}

// Bring the wallet to a ready state. connectWallet() is Layer 1 (no biometric
// prompt); setup/recovery each open a passkey ceremony, so they must run from
// the click handler that called this.
async function ensureWalletReady(status: WalletStatus): Promise<void> {
  if (status === 'needs-setup') {
    await upbond.setupWallet(); // first unlock creates the wallet
  } else if (status === 'needs-recovery') {
    await upbond.recoverWallet(); // synced passkey first, recoverian OTP fallback
  }
}

async function main(): Promise<void> {
  await upbond.init();

  const connectBtn = document.getElementById('connect') as HTMLButtonElement;
  const signTxBtn = document.getElementById('sign-tx') as HTMLButtonElement;
  const signMsgBtn = document.getElementById('sign-msg') as HTMLButtonElement;
  const updateContactBtn = document.getElementById('update-contact') as HTMLButtonElement;

  connectBtn.onclick = () => {
    void (async () => {
      try {
        // The MPC stack is dynamically imported here, on the first wallet call.
        const { status, walletAddress } = await upbond.connectWallet();
        await ensureWalletReady(status);

        // getRecoveryStatus() is Layer 1 — readable without an unlock.
        const recovery = await upbond.getRecoveryStatus();
        console.log('wallet address', walletAddress);
        console.log('recovery enrolled', recovery.enrolled, recovery.contact);
      } catch (err) {
        console.error(describeError(err));
      }
    })();
  };

  signTxBtn.onclick = () => {
    void (async () => {
      // Called straight from the click: signing re-gates with a fresh biometric.
      const request: SignRequest = {
        chainId: 1,
        payload:
          '0x02e480847735940084773594008252089400000000000000000000000000000000000000dead80c0',
      };
      try {
        const signature = await upbond.signTransaction(request);
        console.log('tx signature', signature);
      } catch (err) {
        console.error(describeError(err));
      }
    })();
  };

  signMsgBtn.onclick = () => {
    void (async () => {
      const message = new TextEncoder().encode('Login to Example dApp');
      try {
        const signature = await upbond.signMessage(message);
        console.log('message signature', signature);
      } catch (err) {
        console.error(describeError(err));
      }
    })();
  };

  updateContactBtn.onclick = () => {
    void (async () => {
      try {
        // Recovery changes are Layer 2 — the wallet must be unlocked first.
        await upbond.unlockWallet();
        await upbond.updateRecoveryContact({ channel: 'email' });
      } catch (err) {
        if (err instanceof UpbondError && err.code === 'wallet_locked') {
          console.warn('unlock the wallet before changing the recovery contact');
          return;
        }
        console.error(describeError(err));
      }
    })();
  };
}

void main();

```

## 3. 埋め込みウィジェットモード

ウォレット UI・鍵・トークン・儀式をすべて wallet オリジンの iframe/popup に閉じ込め、RP
ページは EIP-1193 provider 越しにアドレスと署名だけを受け取るモード（`wallet.mode: 'embedded'`）。
**注意**: `connect()` は wallet オリジンの popup を開くため、`await` を挟まず**同期的に**
ユーザージェスチャー内で呼ぶ（Safari/iOS は await 後の popup をブロックする）。
`getEthereumProvider()` は初回呼び出しでウィジェットをマウントし、ethers/viem がそのまま
刺さる標準 provider を返す。`openWallet()` / `closeWallet()` はオーバーレイ制御で、ジェスチャー
不要。

```ts
// examples/embedded-widget.ts

// examples/embedded-widget.ts
//
// Embedded (widget) mode: the wallet UI, keys and ceremonies live in a
// wallet-origin iframe/popup; the RP page only ever sees addresses and
// signatures via an EIP-1193 provider. connect() must be called synchronously
// from a user gesture (Safari/iOS block popups opened after an await).

import { createUpbond, Eip1193Error } from '@upbond/sdk';

// Embedded is the DEFAULT mode: no `wallet` block needed at all.
const upbond = createUpbond({
  clientId: 'your-client-id',
  environment: 'staging', // omit for production (the default)
});

async function main(): Promise<void> {
  await upbond.init();

  const connectBtn = document.getElementById('connect') as HTMLButtonElement;
  const signBtn = document.getElementById('sign') as HTMLButtonElement;
  const openBtn = document.getElementById('open-wallet') as HTMLButtonElement;
  const closeBtn = document.getElementById('close-wallet') as HTMLButtonElement;

  // connect() opens the wallet-origin popup for OIDC login + passkey
  // onboarding, so it MUST run synchronously in the gesture — do NOT await
  // anything before calling it.
  connectBtn.onclick = () => {
    upbond
      .connect()
      .then((accounts) => console.log('connected accounts', accounts))
      .catch((err: unknown) => console.error(err));
  };

  signBtn.onclick = () => {
    void (async () => {
      try {
        // getEthereumProvider() returns a standard EIP-1193 surface — ethers or
        // viem plug in directly. It mounts the widget on first call.
        const provider = await upbond.getEthereumProvider();
        const [from] = await provider.request<string[]>({ method: 'eth_accounts' });
        if (from === undefined) {
          console.warn('call connect() first');
          return;
        }
        const signature = await provider.request<string>({
          method: 'personal_sign',
          params: ['0x48656c6c6f', from], // "Hello"
        });
        console.log('signature', signature);
      } catch (err) {
        if (err instanceof Eip1193Error) {
          console.error(`Eip1193Error(${err.code}): ${err.message}`);
          return;
        }
        console.error(err);
      }
    })();
  };

  // Overlay controls are RP-initiated: no gesture or popup required.
  openBtn.onclick = () => {
    void upbond.openWallet('home');
  };
  closeBtn.onclick = () => {
    upbond.closeWallet();
  };
}

void main();

```

## 4. 低レベル OIDC プリミティブ（advanced）

リダイレクト状態を自前で管理し、authorization-code + PKCE フローを直接駆動する上級者向け。
通常は `createUpbond()` を使うべきで、独自の永続化・非ブラウザホスト・マルチタブ協調などが
必要なときだけこちらを使う。`beginAuthorization` / `completeAuthorization` / `refreshGrant` /
`buildLogoutUrl` を組み合わせ、`AuthError` と `isDefinitiveAuthError` で「再試行可能な一過性
失敗（ネットワーク/5xx）」と「再ログインが必要な確定的失敗（4xx）」を切り分ける。`refreshGrant`
には `offline_access` スコープが要る。

```ts
// examples/low-level-auth.ts

// examples/low-level-auth.ts
//
// Advanced: drive the OIDC authorization-code + PKCE flow directly with the
// low-level primitives, managing the redirect state yourself. Most apps should
// use createUpbond() instead — reach for these only when you own the session
// store (custom persistence, non-browser host, multi-tab coordination).

import {
  beginAuthorization,
  completeAuthorization,
  refreshGrant,
  buildLogoutUrl,
  AuthError,
  isDefinitiveAuthError,
  type AuthConfig,
  type PendingAuthorization,
  type TokenSet,
} from '@upbond/sdk';

const config: AuthConfig = {
  issuer: 'https://auth.stg.upbond.io',
  clientId: 'your-client-id',
  redirectUri: `${window.location.origin}/callback`,
  scope: 'openid profile email offline_access',
};

const PENDING_KEY = 'demo:pending';

// 1. Start login: persist the PKCE/nonce/state material across the redirect,
//    then navigate to the issuer.
async function startLogin(): Promise<void> {
  const pending = await beginAuthorization(config, { connection: 'google' });
  sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
  window.location.assign(pending.authorizeUrl);
}

// 2. On the redirect back, exchange the code for tokens. Consume the pending
//    material first — it is single-use.
async function handleCallback(): Promise<TokenSet | null> {
  const raw = sessionStorage.getItem(PENDING_KEY);
  if (raw === null) return null;
  sessionStorage.removeItem(PENDING_KEY);
  const pending = JSON.parse(raw) as PendingAuthorization;
  try {
    return await completeAuthorization(config, pending, window.location.href);
  } catch (err) {
    if (err instanceof AuthError) {
      console.error(
        `AuthError(${err.code}) status=${err.status ?? 'n/a'}`,
        isDefinitiveAuthError(err) ? '(definitive — re-login)' : '(transient — retry)',
      );
    }
    throw err;
  }
}

// 3. Refresh without a redirect (requires the offline_access scope above).
//    isDefinitiveAuthError separates "retry later" from "must re-login".
async function refresh(tokens: TokenSet): Promise<TokenSet> {
  if (tokens.refreshToken === undefined) {
    throw new Error('no refresh token — request the offline_access scope');
  }
  try {
    return await refreshGrant(config, tokens.refreshToken);
  } catch (err) {
    if (isDefinitiveAuthError(err)) {
      console.warn('refresh rejected — full re-login required');
      await startLogin();
    }
    throw err;
  }
}

// 4. RP-initiated logout at the issuer's end_session endpoint.
function logout(tokens: TokenSet): void {
  window.location.assign(buildLogoutUrl(config, tokens.idToken, `${window.location.origin}/`));
}

async function main(): Promise<void> {
  const params = new URLSearchParams(window.location.search);
  const tokens = params.has('code') || params.has('error') ? await handleCallback() : null;

  (document.getElementById('login') as HTMLButtonElement).onclick = () => void startLogin();

  if (tokens !== null) {
    console.log('signed in as', tokens.claims.sub);
    (document.getElementById('refresh') as HTMLButtonElement).onclick = () => {
      void refresh(tokens).then((next) => console.log('refreshed for', next.claims.sub));
    };
    (document.getElementById('logout') as HTMLButtonElement).onclick = () => logout(tokens);
  }
}

void main();

```

## 実際に動くエンドツーエンド例

このリポジトリの UAT ハーネスが、上記フローを実サーバに対して動かすランナブルな例になっている。

- `packages/uat-auth0-sdk` — 認証専用モードの `@upbond/sdk` ドッグフードページ（`sdk.html`）。
  staging issuer に対して init/login/logout/subscribe を検証する。
- `packages/uat-embedded-wallet` — 埋め込みウォレットデモ（PnP エントリ `pnp.html` と
  プロダクト MPC エントリ `mpc.html`）。Firebase Hosting へデプロイされる。

## 設計の根拠

API 設計の決定記録（core/web 境界、A2 儀式整合、単回使用 id_token ライフサイクル、環境
プリセット、埋め込みモード統合など）は `docs/24-sdk-design.md` を参照。埋め込みウィジェットの
アーキテクチャは `docs/25-widget.md`、契約凍結ポリシーは `docs/constitution.md` §6。
