diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index a1b68f62..2e0ddb27 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -8,6 +8,37 @@ - 当前主目录 `artifacts/real-host-acceptance/` 已只保留最终证据;历史调试样本已迁到 `artifacts/real-host-acceptance-archive/` - access ready 语义已经收口为:`/v1/models` 命中 `smoke_test_model`,且最小 `POST /v1/chat/completions` smoke 成功;不会再出现 models-only 假 ready +- 2026-06-01 已继续收掉 `subscription_ready` 的最后一个真实闭环缺口: + - 根因不是 provider、不是前端,也不是宿主随机波动,而是 CRM 旧实现会在 subscription closure 里把目标用户替换成 synthetic managed user,再用 managed key 做 probe + - 这样会出现“closure 返回 `subscription_ready`,但目标用户自己的 `GET /api/v1/subscriptions/active` 仍为空,`/v1/models` 仍然 `403 INSUFFICIENT_BALANCE`”的假阳性 + - 最新本机真实验收已确认修复后的语义: + - 页面级 artifact:`artifacts/provider-admin-matrix/1780271169_provider_admin_actions/99-summary.json` + - batch 明细:`GET /api/import-batches/5` + - `access_closure.DetailsJSON` 已切成 `effective_probe_key_source=requested_probe_api_key` + - 目标用户 `GET /api/v1/subscriptions/active` 已返回 active subscription + - 目标用户 `GET /api/v1/groups/available` 已出现 `OpenAI 中转默认分组-subscription` + - 目标用户自己的 `local-user-api-key-20260531` 直探 `/v1/models` 与 `/v1/chat/completions` 均已回到 `HTTP 200` +- 2026-06-01 已把 `self_service` 的真实前置条件正式写死: + - `self_service` 不会自动为目标用户充值 + - 如果目标用户 key 有效但余额不足,宿主会直接返回 `INSUFFICIENT_BALANCE` + - 所以 `self_service_ready` 的真实验收前提仍然必须包含“用户余额已满足最小调用成本” +- 2026-05-31 已继续把 `providers.html` 的页面内显式动作收口成独立 acceptance 入口: + - 新增脚本:`scripts/acceptance/verify_provider_admin_actions.sh` + - 覆盖范围: + - `GET /api/packs` + - `GET /api/hosts` + - `GET /api/packs/{pack_id}/providers` + - `POST /api/providers/{provider_id}/preview-import` + - `POST /api/providers/{provider_id}/import` + - `POST /api/provider-drafts` + - `PUT /api/provider-drafts/{draft_id}` + - `DELETE /api/provider-drafts/{draft_id}` + - `POST /api/provider-drafts/{draft_id}/publish` + - 同轮已补本地伪远端回归: + - `scripts/test/test_real_host_scripts.sh` + - 当前结论: + - `providers.html` 不再只有动作级审计矩阵,页面内显式动作已经有可重复执行的 acceptance 入口 + - `rollback / reconcile / status / import-batches` 仍不属于这页当前显式 UI 能力 - `subscription` 主链路已通过 latest fresh-host 复验: - MiniMax 53hk:`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json` - DeepSeek 2166:`artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json` diff --git a/docs/PROVIDER_ONBOARDING_PLAYBOOK.md b/docs/PROVIDER_ONBOARDING_PLAYBOOK.md index 9e9a6923..d686c1a8 100644 --- a/docs/PROVIDER_ONBOARDING_PLAYBOOK.md +++ b/docs/PROVIDER_ONBOARDING_PLAYBOOK.md @@ -57,8 +57,9 @@ - 必须同时通过 `/v1/models` 和 `/v1/chat/completions` 4. `subscription` 和 `self_service` 的 probe key 语义不同 - - `subscription` 最终 probe key 是宿主 managed key - `self_service` 最终 probe key 是普通用户 gateway key + - `subscription` 在显式提供 `access_api_key` 时,最终 probe key 也必须是**目标用户自己的 gateway key** + - 只有在没有显式 `access_api_key` 的 fallback/运维场景,`subscription` 才允许走宿主 managed key 5. `self_service` 普通用户 key 的真实认证方式是 `Authorization: Bearer` - 不是 `x-api-key` @@ -170,6 +171,7 @@ 2. 普通用户 key 可用 3. key 已绑定目标标准 group 4. 用户有可用余额 +5. relay-manager 不会替用户自动充值;如果余额不足,宿主会直接返回 `INSUFFICIENT_BALANCE` `subscription`: @@ -178,6 +180,7 @@ 3. 目标 group 是 `subscription` 类型 4. 用户有 active subscription 5. key 已绑定该 subscription group +6. 如果本轮导入显式传了 `access_api_key`,closure 与 completion smoke 必须用这把**真实用户 key** 验证,而不是 synthetic managed key ### 3.2 导入时最容易漏掉的三件事 diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 93ed8f74..ff5e9522 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -160,12 +160,18 @@ - `billing_model_source=channel_mapped` 2. subscription 场景的 gateway probe 语义必须保持: - - 最终 probe key 是宿主 managed key - - 不是外部原始 `access_api_key` - - closure artifact 必须把“请求传入的 key”和“实际探测使用的 key”分开表达: - - `requested_probe_api_key` = 调用方传入原始 key - - `effective_probe_key_source=managed_subscription` = 实际 gateway probe 走宿主 managed key - - `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription` + - 当请求显式提供 `requested_probe_api_key` 时: + - 最终 probe key 必须是**目标用户自己的 gateway key** + - 不能再用宿主 synthetic managed key 把 closure 伪装成 `ready` + - closure artifact 必须落 `requested_probe_api_key` + - `effective_probe_key_source=requested_probe_api_key` + - 仅当请求**没有**显式提供 `requested_probe_api_key` 时,才允许 fallback 到宿主 managed subscription key: + - `effective_probe_key_source=managed_subscription` + - `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription` + - 对于真实用户 subscription ready,最终必须同时满足: + - `GET /api/v1/subscriptions/active` 非空 + - `GET /api/v1/groups/available` 可见目标 subscription group + - 目标用户自己的 key 直探 `/v1/models` 与 `/v1/chat/completions` 为 `HTTP 200` 3. 任何 live 结论都必须先确认: - 在线 CRM 进程启动时间 @@ -208,6 +214,6 @@ - ❌ 把历史 review/task board 当当前 gate - ❌ 把历史 PASS artifact 当当前 latest-head 真相 - ❌ 把 `/v1/models` 通过当成 completion 已通过 -- ❌ 把 subscription 场景原始 `access_api_key` 当成最终 probe key -- ❌ 把 `subscription` closure 里的 `requested_probe_api_key` 误读成实际 gateway probe key +- ❌ 在 `requested_probe_api_key` 已提供时,仍然用 managed synthetic key 把 `subscription_ready` 判真 +- ❌ 把旧 artifact 里的 `managed_subscription` 语义继续当成最新实现 - ❌ 把 harness 参数错误(`PACK_PATH`、容器目标、probe auth)当成产品源码失败 diff --git a/internal/access/closure_test.go b/internal/access/closure_test.go index 2d93899f..4a17810f 100644 --- a/internal/access/closure_test.go +++ b/internal/access/closure_test.go @@ -78,12 +78,12 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { } } -func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) { +func TestServiceCloseSubscriptionExplicitProbeAPIKeyUsesRequestedUserKey(t *testing.T) { host := &fakeClosureHost{ gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200}, managedAccess: map[string]sub2api.SubscriptionAccessRef{ - "user-1": {UserID: "host-user-1", APIKey: "managed-user-key"}, + "user-1": {UserID: "user-1", APIKey: "caller-supplied-key"}, }, } service := NewService(host) @@ -97,14 +97,20 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi if err != nil { t.Fatalf("Close() error = %v", err) } - if host.gatewayProbe.APIKey != "managed-user-key" { - t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey) + if len(host.assigned) != 1 || host.assigned[0].UserID != "user-1" { + t.Fatalf("assigned subscriptions = %+v, want requested user assignment", host.assigned) } - if result.EffectiveProbeAPIKey != "managed-user-key" { - t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey) + if host.gatewayProbe.APIKey != "caller-supplied-key" { + t.Fatalf("gateway probe api key = %q, want caller-supplied-key", host.gatewayProbe.APIKey) } - if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription { - t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription) + if len(host.ensureRequests) != 1 || host.ensureRequests[0].ProbeAPIKey != "caller-supplied-key" { + t.Fatalf("ensure requests = %+v, want probe api key forwarded", host.ensureRequests) + } + if result.EffectiveProbeAPIKey != "caller-supplied-key" { + t.Fatalf("effective probe api key = %q, want caller-supplied-key", result.EffectiveProbeAPIKey) + } + if result.EffectiveProbeKeySource != ProbeKeySourceRequestedProbeAPIKey { + t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceRequestedProbeAPIKey) } } @@ -197,6 +203,7 @@ func TestServiceCloseRepairsOpenAIResponsesCapabilityMismatch(t *testing.T) { type fakeClosureHost struct { assigned []sub2api.AssignSubscriptionRequest + ensureRequests []sub2api.EnsureSubscriptionAccessRequest managedAccess map[string]sub2api.SubscriptionAccessRef assignErr error gatewayProbe sub2api.GatewayAccessCheckRequest @@ -215,6 +222,7 @@ type fakeClosureHost struct { } func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) { + f.ensureRequests = append(f.ensureRequests, req) if ref, ok := f.managedAccess[req.UserSelector]; ok { return ref, nil } diff --git a/internal/access/subscription.go b/internal/access/subscription.go index e0245259..0401fde0 100644 --- a/internal/access/subscription.go +++ b/internal/access/subscription.go @@ -9,8 +9,31 @@ import ( ) func (s *Service) prepareSubscriptionPlan(ctx context.Context, req ClosureRequest, plan closurePlan) (closurePlan, error) { + requestedProbeAPIKey := strings.TrimSpace(req.ProbeAPIKey) for _, target := range req.Subscriptions { resolvedTarget := target.UserID + if requestedProbeAPIKey != "" { + if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{ + UserID: resolvedTarget, + GroupID: req.GroupID, + DurationDays: target.DurationDays, + }); err != nil { + return closurePlan{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err) + } + accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ + UserSelector: target.UserID, + GroupID: req.GroupID, + ProbeAPIKey: requestedProbeAPIKey, + }) + if err != nil { + return closurePlan{}, fmt.Errorf("ensure subscription access for %s: %w", target.UserID, err) + } + if strings.TrimSpace(accessRef.APIKey) != "" { + plan.effectiveProbeAPIKey = strings.TrimSpace(accessRef.APIKey) + } + continue + } + accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ UserSelector: target.UserID, GroupID: req.GroupID, diff --git a/internal/app/batch_runtime.go b/internal/app/batch_runtime.go index 53c4e779..7de1a4cd 100644 --- a/internal/app/batch_runtime.go +++ b/internal/app/batch_runtime.go @@ -216,6 +216,29 @@ func (r batchImportRuntimeRunner) resolveValidationAPIKey(ctx context.Context, i if err != nil { return "", err } + probeAPIKey := strings.TrimSpace(r.request.ProbeAPIKey) + if probeAPIKey != "" { + if _, err := r.hostClient.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{ + UserID: r.request.SubscriptionUsers[0], + GroupID: groupID, + DurationDays: r.request.SubscriptionDays, + }); err != nil { + return "", err + } + accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ + UserSelector: r.request.SubscriptionUsers[0], + GroupID: groupID, + ProbeAPIKey: probeAPIKey, + }) + if err != nil { + return "", err + } + if strings.TrimSpace(accessRef.APIKey) == "" { + return probeAPIKey, nil + } + return strings.TrimSpace(accessRef.APIKey), nil + } + accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ UserSelector: r.request.SubscriptionUsers[0], GroupID: groupID, diff --git a/internal/app/coverage_helpers_test.go b/internal/app/coverage_helpers_test.go index d6c3e8ec..e9a8fcb4 100644 --- a/internal/app/coverage_helpers_test.go +++ b/internal/app/coverage_helpers_test.go @@ -2239,6 +2239,9 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { case r.URL.Path == "/v1/models" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"): writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}}) return + case r.URL.Path == "/v1/models" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key": + writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}}) + return case r.URL.Path == "/v1/chat/completions" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"): writeJSON(w, http.StatusOK, map[string]any{ "id": "chatcmpl_subscription", @@ -2251,6 +2254,32 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { }}, }) return + case r.URL.Path == "/v1/chat/completions" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key": + writeJSON(w, http.StatusOK, map[string]any{ + "id": "chatcmpl_subscription", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "pong", + }, + }}, + }) + return + case r.URL.Path == "/api/v1/admin/users/1/api-keys": + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "items": []map[string]any{{ + "id": 501, + "key": "caller-probe-key", + "name": "caller-key", + }}, + }, + }) + return + case r.URL.Path == "/api/v1/admin/api-keys/501" && r.Method == http.MethodPut: + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"api_key": map[string]any{"id": 501}}}) + return } baseHandler.ServeHTTP(w, r) })) @@ -2304,7 +2333,7 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { PackPath: packPath, ProviderID: "deepseek", AccessAPIKey: "caller-probe-key", - SubscriptionUsers: []string{"crm-user-1"}, + SubscriptionUsers: []string{"1"}, SubscriptionDays: 30, }) if err != nil { @@ -2331,8 +2360,8 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) { if got, _ := payload["requested_probe_api_key"].(string); got != "caller-probe-key" { t.Fatalf("requested_probe_api_key = %q, want caller-probe-key", got) } - if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" { - t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got) + if got, _ := payload["effective_probe_key_source"].(string); got != "requested_probe_api_key" { + t.Fatalf("effective_probe_key_source = %q, want requested_probe_api_key", got) } batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID) if err != nil { diff --git a/internal/app/reconcile_background.go b/internal/app/reconcile_background.go index a8723936..be07c7a3 100644 --- a/internal/app/reconcile_background.go +++ b/internal/app/reconcile_background.go @@ -167,6 +167,7 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite. return "", fmt.Errorf("subscription access closure missing subscription_users") } subscriptionDays := parseJSONInt(details["subscription_days"]) + requestedProbeAPIKey, _ := details["requested_probe_api_key"].(string) groupID, err := resolveManagedResourceHostIDByBatch(ctx, store, batch.ID, "group") if err != nil { return "", err @@ -175,6 +176,31 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite. if err != nil { return "", err } + requestedProbeAPIKey = strings.TrimSpace(requestedProbeAPIKey) + if requestedProbeAPIKey != "" { + if subscriptionDays > 0 { + if _, err := client.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{ + UserID: subscriptionUsers[0], + GroupID: groupID, + DurationDays: subscriptionDays, + }); err != nil { + return "", err + } + } + accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ + UserSelector: subscriptionUsers[0], + GroupID: groupID, + ProbeAPIKey: requestedProbeAPIKey, + }) + if err != nil { + return "", err + } + if strings.TrimSpace(accessRef.APIKey) == "" { + return requestedProbeAPIKey, nil + } + return strings.TrimSpace(accessRef.APIKey), nil + } + accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ UserSelector: subscriptionUsers[0], GroupID: groupID, diff --git a/internal/host/sub2api/client.go b/internal/host/sub2api/client.go index f0d385da..50f55e7d 100644 --- a/internal/host/sub2api/client.go +++ b/internal/host/sub2api/client.go @@ -151,6 +151,7 @@ type AssignSubscriptionRequest struct { type EnsureSubscriptionAccessRequest struct { UserSelector string GroupID string + ProbeAPIKey string } type SubscriptionAccessRef struct { diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index 26705256..747a4c58 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -851,7 +851,7 @@ func TestAssignSubscriptionWithMock(t *testing.T) { } } -func TestEnsureSubscriptionAccessWithMock(t *testing.T) { +func TestEnsureSubscriptionAccessManagedProbeWithMock(t *testing.T) { var calls []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls = append(calls, r.Method+" "+r.URL.Path) @@ -911,6 +911,45 @@ func TestEnsureSubscriptionAccessWithMock(t *testing.T) { } } +func TestEnsureSubscriptionAccessRealUserProbeWithMock(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/admin/users/1/api-keys": + w.Write([]byte(`{"data":{"items":[{"id":501,"key":"caller-probe-key","name":"user-key"}]}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501": + var req struct { + GroupID int64 `json:"group_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode bind api key request: %v", err) + } + if req.GroupID != 101 { + t.Fatalf("group id = %d, want 101", req.GroupID) + } + w.Write([]byte(`{"data":{"api_key":{"id":501}}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := NewClient(srv.URL, WithBearerToken("admin-token")) + ref, err := client.EnsureSubscriptionAccess(context.Background(), EnsureSubscriptionAccessRequest{ + UserSelector: "1", + GroupID: "101", + ProbeAPIKey: "caller-probe-key", + }) + if err != nil { + t.Fatal(err) + } + if ref.UserID != "1" { + t.Fatalf("user id = %q, want 1", ref.UserID) + } + if ref.APIKey != "caller-probe-key" { + t.Fatalf("api key = %q, want caller-probe-key", ref.APIKey) + } +} + func TestCheckGatewayAccessWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer gk" { diff --git a/internal/host/sub2api/subscription_access.go b/internal/host/sub2api/subscription_access.go index fd570f2c..75556db3 100644 --- a/internal/host/sub2api/subscription_access.go +++ b/internal/host/sub2api/subscription_access.go @@ -52,6 +52,9 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri if err != nil { return SubscriptionAccessRef{}, fmt.Errorf("parse group id %q: %w", groupID, err) } + if probeAPIKey := strings.TrimSpace(req.ProbeAPIKey); probeAPIKey != "" { + return c.ensureRealSubscriptionAccess(ctx, selector, groupInt, probeAPIKey) + } identity := buildManagedSubscriptionIdentity(selector, groupID) user, err := c.findManagedSubscriptionUser(ctx, identity.Email) @@ -82,7 +85,7 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri if err != nil { return SubscriptionAccessRef{}, err } - if err := c.bindManagedSubscriptionAPIKey(ctx, keyRecord.ID, groupInt); err != nil { + if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupInt); err != nil { return SubscriptionAccessRef{}, err } return SubscriptionAccessRef{UserID: strconv.FormatInt(user.ID, 10), APIKey: identity.CustomKey}, nil @@ -297,11 +300,55 @@ func (c *Client) findManagedSubscriptionAPIKey(ctx context.Context, userID int64 return nil, fmt.Errorf("managed api key %q not found for user %d", identity.KeyName, userID) } -func (c *Client) bindManagedSubscriptionAPIKey(ctx context.Context, keyID, groupID int64) error { +func (c *Client) ensureRealSubscriptionAccess(ctx context.Context, selector string, groupID int64, probeAPIKey string) (SubscriptionAccessRef, error) { + userID, err := strconv.ParseInt(strings.TrimSpace(selector), 10, 64) + if err != nil { + return SubscriptionAccessRef{}, fmt.Errorf("parse real subscription user id %q: %w", selector, err) + } + keyRecord, err := c.findUserAPIKeyByRawKey(ctx, userID, probeAPIKey) + if err != nil { + return SubscriptionAccessRef{}, err + } + if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupID); err != nil { + return SubscriptionAccessRef{}, err + } + return SubscriptionAccessRef{ + UserID: strconv.FormatInt(userID, 10), + APIKey: probeAPIKey, + }, nil +} + +func (c *Client) findUserAPIKeyByRawKey(ctx context.Context, userID int64, rawKey string) (*adminAPIKeyRecord, error) { + statusCode, _, body, err := c.perform(ctx, http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys?page=1&page_size=1000&sort_by=created_at&sort_order=desc", userID), nil) + if err != nil { + return nil, fmt.Errorf("list user api keys: %w", err) + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return nil, newHTTPError(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys", userID), statusCode, body) + } + var envelope struct { + Data struct { + Items []adminAPIKeyRecord `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode user api keys response: %w", err) + } + trimmedRawKey := strings.TrimSpace(rawKey) + for _, item := range envelope.Data.Items { + if strings.TrimSpace(item.Key) == trimmedRawKey { + key := item + return &key, nil + } + } + return nil, fmt.Errorf("probe api key not found for user %d", userID) +} + +func (c *Client) bindAPIKeyGroup(ctx context.Context, keyID, groupID int64) error { payload := map[string]any{"group_id": groupID} statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), payload) if err != nil { - return fmt.Errorf("bind managed api key group: %w", err) + return fmt.Errorf("bind api key group: %w", err) } if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), statusCode, body)