diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index e914e4e..87c3343 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -293,7 +293,7 @@ jobs: --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET" deploy omikujiGo OmikujiGo \ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ - --set-env-vars="OMIKUJI_COOLDOWN_SECONDS=60,KUDA_BASE_URL=https://kuda.kojiran.workers.dev" + --set-env-vars="OMIKUJI_COOLDOWN_SECONDS=60,KUDA_BASE_URL=https://kuda.kojiran.workers.dev,KUDA_API_KEY=${{ secrets.DEV_KUDA_API_KEY }}" deploy ogpRewriteGo OgpRewriteGo \ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ --set-env-vars="FUNC_BASE_URL=$OGP_FUNC_BASE_URL,OGP_PROJECT_ID=d-shrine-dev" diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 29cc6ae..a00ab07 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -241,7 +241,7 @@ jobs: --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET" deploy omikujiGo OmikujiGo \ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ - --set-env-vars="OMIKUJI_COOLDOWN_SECONDS=28800,KUDA_BASE_URL=https://kuda.kojiran.workers.dev" + --set-env-vars="OMIKUJI_COOLDOWN_SECONDS=28800,KUDA_BASE_URL=https://kuda.kojiran.workers.dev,KUDA_API_KEY=${{ secrets.PROD_KUDA_API_KEY }}" deploy ogpRewriteGo OgpRewriteGo \ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ --set-env-vars="FUNC_BASE_URL=$OGP_FUNC_BASE_URL,OGP_PROJECT_ID=d-shrine" diff --git a/app/functions-go/kuda.go b/app/functions-go/kuda.go index 343bcab..3d9611c 100644 --- a/app/functions-go/kuda.go +++ b/app/functions-go/kuda.go @@ -32,6 +32,16 @@ func resolveKudaBaseURL() string { return "https://kuda.kojiran.workers.dev" } +// kudaAPIKey は kuda のAPIキー(kuda_...)。環境変数 KUDA_API_KEY で注入し、 +// 設定されていれば Authorization: Bearer ヘッダで送る。サーバー間通信なので +// ヘッダレーンのみ使用(半公開キーの ?key= クエリレーンは使わない)。 +// 未設定なら無認証で呼ぶ(kudaの移行期間 REQUIRE_API_KEY=0 の互換動作。 +// キー必須化後は 401 になり、おみくじは no_entropy として振る舞う)。 +var kudaAPIKey = os.Getenv("KUDA_API_KEY") + +// kudaClientID は kuda 側の統計・監査ログで消費元を識別するためのラベル。 +const kudaClientID = "debug-shrine" + var kudaHTTPClient = &http.Client{} // kudaFetchTimeout は /drop 一式の取得に許す時間。おみくじは演出中に呼ばれる @@ -85,6 +95,10 @@ func fetchKudaDrop(ctx context.Context) (kudaDrop, error) { return kudaDrop{}, err } req.Header.Set("User-Agent", "debug-shrine-omikujiGo") + req.Header.Set("X-Client-Id", kudaClientID) + if kudaAPIKey != "" { + req.Header.Set("Authorization", "Bearer "+kudaAPIKey) + } resp, err := kudaHTTPClient.Do(req) if err != nil { @@ -96,7 +110,9 @@ func fetchKudaDrop(ctx context.Context) (kudaDrop, error) { return kudaDrop{}, err } if resp.StatusCode != http.StatusOK { - // 503 = プール枯渇。それ以外も「物理乱数が用意できない」として同じ扱い。 + // 503 = プール枯渇、429 = 日次クォータ超過、401 = キー無効/必須化後の未認証。 + // いずれも「物理乱数が用意できない」として同じ扱い(疑似乱数フォールバック + // はしない)。運用上の区別はこのエラーメッセージのログで行う。 return kudaDrop{}, fmt.Errorf("kuda /drop: status %d: %s", resp.StatusCode, string(body)) } var drop kudaDrop diff --git a/app/functions-go/kuda_test.go b/app/functions-go/kuda_test.go index 064de27..b484daf 100644 --- a/app/functions-go/kuda_test.go +++ b/app/functions-go/kuda_test.go @@ -16,7 +16,10 @@ import ( type kudaMock struct { Calls int64 // /drop が呼ばれた回数 Depleted bool // true にすると503(プール枯渇)を返す - server *httptest.Server + // LastAuth / LastClientID は直近の /drop リクエストのヘッダ(認証テスト用)。 + LastAuth atomic.Value + LastClientID atomic.Value + server *httptest.Server } func mockKuda(t *testing.T) *kudaMock { @@ -28,6 +31,8 @@ func mockKuda(t *testing.T) *kudaMock { return } n := atomic.AddInt64(&m.Calls, 1) + m.LastAuth.Store(r.Header.Get("Authorization")) + m.LastClientID.Store(r.Header.Get("X-Client-Id")) if m.Depleted { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprint(w, `{"error":"pool depleted"}`) @@ -115,3 +120,50 @@ func TestFetchKudaBytes_Unreachable(t *testing.T) { t.Error("unreachable kuda should return error") } } + +func TestFetchKudaDrop_APIKeyHeader(t *testing.T) { + m := mockKuda(t) + + // キー設定時: Authorization: Bearer で送る(ヘッダレーンのみ)。 + origKey := kudaAPIKey + kudaAPIKey = "kuda_testkey123" + t.Cleanup(func() { kudaAPIKey = origKey }) + + if _, err := fetchKudaDrop(context.Background()); err != nil { + t.Fatalf("fetchKudaDrop: %v", err) + } + if got := m.LastAuth.Load(); got != "Bearer kuda_testkey123" { + t.Errorf("Authorization = %q, want %q", got, "Bearer kuda_testkey123") + } + if got := m.LastClientID.Load(); got != kudaClientID { + t.Errorf("X-Client-Id = %q, want %q", got, kudaClientID) + } + + // キー未設定時: Authorization は送らない(移行期間の無認証互換)。 + kudaAPIKey = "" + if _, err := fetchKudaDrop(context.Background()); err != nil { + t.Fatalf("fetchKudaDrop (no key): %v", err) + } + if got := m.LastAuth.Load(); got != "" { + t.Errorf("Authorization should be empty without key, got %q", got) + } +} + +func TestFetchKudaDrop_AuthErrorIsNoEntropy(t *testing.T) { + // 401(キー必須化後の未認証)や429(クォータ超過)も503と同様に + // エラー=引けない、として扱う(疑似乱数フォールバックしない)。 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"invalid key"}`) + })) + orig := kudaBaseURL + kudaBaseURL = server.URL + t.Cleanup(func() { + kudaBaseURL = orig + server.Close() + }) + + if _, err := fetchKudaDrop(context.Background()); err == nil { + t.Fatal("expected error for 401 response, got nil") + } +} diff --git a/docs/backend.md b/docs/backend.md index 12ec70b..9c2fcc0 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -659,3 +659,21 @@ kuda は ANU の量子真空ゆらぎ+ガイガーカウンター(放射性崩 - **フロント**: `Ranking.vue` に「⚔️ せんとうりょく / 🪙 ぽいんと」のピル型 タブを追加。タブ切替は取得済みデータの表示切替のみで再フェッチしない。 「あなたの順位」カード・一覧・単位(bp/pt)が連動する。 + +## kuda APIキー対応 + +kudaがAPIキー認証を導入(移行期間中は `REQUIRE_API_KEY=0` でキー無しも可、 +フリップ後は401)。おみくじのkudaクライアントを先行対応した。 + +- **認証**: `KUDA_API_KEY` 環境変数(`kuda_...`)が設定されていれば + `Authorization: Bearer` ヘッダで送る。サーバー間通信なのでヘッダレーンのみ + 使用(半公開キー `kudaq_` の `?key=` クエリレーンは使わない)。 + 併せて `X-Client-Id: debug-shrine` を送り、kuda側の統計・監査で識別可能にする。 +- **キーの注入**: GitHub Secrets `DEV_KUDA_API_KEY` / `PROD_KUDA_API_KEY` → + デプロイworkflowの omikujiGo `--set-env-vars`。Secret未設定なら空=無認証の + 互換動作のまま。 +- **エラー方針は不変**: 401(キー無効)・429(日次クォータ超過)も503(枯渇)と + 同じ「引けない」(no_entropy)。疑似乱数フォールバックはしない。 +- **クォータ注意**: kudaの新規キー既定は30滴/日。おみくじは3バイト/回なので + 既定のままだと1日10回で頭打ちになる。キー発行時(ダッシュボード・Nostr認証)に + daily_quota を用途に合わせて引き上げること。