diff --git a/internal/appui/setup_flow.go b/internal/appui/setup_flow.go index 7fca987..6155476 100644 --- a/internal/appui/setup_flow.go +++ b/internal/appui/setup_flow.go @@ -75,11 +75,18 @@ func RunSetup(c *cli.Context) error { return setupError(slog, err) } - fmt.Printf(" %s%sStep 1/2%s 🔑 Authenticate with Hexmos\n", clr(cBold), clr(cBlue), clr(cReset)) - fmt.Println() - slog.write("phase 1: starting hexmos login flow") - - result, err = runHexmosLoginFlow(slog, selectedAPIURL) + if setuptpl.IsCloudAPIURL(selectedAPIURL) { + fmt.Printf(" %s%sStep 1/2%s 🔑 Authenticate with Hexmos\n", clr(cBold), clr(cBlue), clr(cReset)) + fmt.Println() + slog.write("phase 1: starting hexmos login flow") + result, err = runHexmosLoginFlow(slog, selectedAPIURL) + } else { + fmt.Printf(" %s%sStep 1/2%s 🔑 Sign in to LiveReview\n", clr(cBold), clr(cBlue), clr(cReset)) + fmt.Printf(" %sUsing self-hosted authentication at %s%s\n", clr(cDim), selectedAPIURL, clr(cReset)) + fmt.Println() + slog.write("phase 1: starting self-hosted login flow for api_url=%s", selectedAPIURL) + result, err = promptSelfHostedLoginFlow(selectedAPIURL, slog) + } if err != nil { return setupError(slog, fmt.Errorf("authentication failed: %w", err)) } diff --git a/internal/appui/setup_selfhosted.go b/internal/appui/setup_selfhosted.go new file mode 100644 index 0000000..8f856c4 --- /dev/null +++ b/internal/appui/setup_selfhosted.go @@ -0,0 +1,136 @@ +package appui + +import ( + "bufio" + "fmt" + "net/mail" + "os" + "strings" + "syscall" + + setuptpl "github.com/HexmosTech/git-lrc/setup" + "golang.org/x/term" +) + +func promptSelfHostedLoginFlow(apiURL string, slog *setupLog) (*setupResult, error) { + setupRequired, err := setuptpl.CheckSelfHostedSetupRequired(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to inspect self-hosted setup status: %w", err) + } + + reader := bufio.NewReader(os.Stdin) + + + if setupRequired { + fmt.Printf(" %sNo LiveReview users found on this server.%s\n", clr(cYellow), clr(cReset)) + fmt.Printf(" %sCreate the first admin account to continue.%s\n", clr(cDim), clr(cReset)) + fmt.Println() + + admin, err := promptSelfHostedInitialAdmin(reader) + if err != nil { + return nil, err + } + slog.write("initial self-hosted admin setup for email=%s org=%s", admin.Email, admin.OrgName) + return setuptpl.ProvisionSelfHostedUser(apiURL, setuptpl.SelfHostedLoginRequest{}, admin, slog.write) + } + + creds, err := promptSelfHostedCredentials(reader) + if err != nil { + return nil, err + } + slog.write("self-hosted login for email=%s", creds.Email) + return setuptpl.ProvisionSelfHostedUser(apiURL, creds, nil, slog.write) +} + +func promptSelfHostedCredentials(reader *bufio.Reader) (setuptpl.SelfHostedLoginRequest, error) { + email, err := promptEmail(reader, "Email") + if err != nil { + return setuptpl.SelfHostedLoginRequest{}, err + } + password, err := promptSetupPassword(reader, "Password") + if err != nil { + return setuptpl.SelfHostedLoginRequest{}, err + } + return setuptpl.SelfHostedLoginRequest{ + Email: email, + Password: password, + }, nil +} + +func promptSelfHostedInitialAdmin(reader *bufio.Reader) (*setuptpl.SelfHostedSetupAdminRequest, error) { + email, err := promptEmail(reader, "Admin email") + if err != nil { + return nil, err + } + password, err := promptSetupPassword(reader, "Admin password") + if err != nil { + return nil, err + } + if len(password) < 8 { + return nil, fmt.Errorf("password must be at least 8 characters") + } + orgName, err := promptSetupLine(reader, "Organization name") + if err != nil { + return nil, err + } + return &setuptpl.SelfHostedSetupAdminRequest{ + Email: email, + Password: password, + OrgName: orgName, + }, nil +} + +func promptSetupLine(reader *bufio.Reader, label string) (string, error) { + fmt.Printf(" %s%s:%s ", clr(cBold), label, clr(cReset)) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", strings.ToLower(label), err) + } + value := strings.TrimSpace(line) + if value == "" { + return "", fmt.Errorf("%s cannot be empty", strings.ToLower(label)) + } + return value, nil +} + +func promptSetupPassword(reader *bufio.Reader, label string) (string, error) { + fmt.Printf(" %s%s:%s ", clr(cBold), label, clr(cReset)) + if !term.IsTerminal(int(syscall.Stdin)) { + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", strings.ToLower(label), err) + } + value := strings.TrimSpace(line) + if value == "" { + return "", fmt.Errorf("%s cannot be empty", strings.ToLower(label)) + } + return value, nil + } + + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", strings.ToLower(label), err) + } + value := strings.TrimSpace(string(password)) + if value == "" { + return "", fmt.Errorf("%s cannot be empty", strings.ToLower(label)) + } + return value, nil +} + + +func promptEmail(reader *bufio.Reader, label string) (string, error) { + email, err := promptSetupLine(reader, label) + if err != nil { + return "", err + } + + if _, err := mail.ParseAddress(email); err != nil { + return "", fmt.Errorf("email must be a valid email address") + } + + return email, nil +} + + diff --git a/internal/appui/ui_connectors_handlers.go b/internal/appui/ui_connectors_handlers.go index 3ef4e49..9a5d47c 100644 --- a/internal/appui/ui_connectors_handlers.go +++ b/internal/appui/ui_connectors_handlers.go @@ -13,6 +13,7 @@ import ( "github.com/HexmosTech/git-lrc/internal/reviewopts" "github.com/HexmosTech/git-lrc/internal/staticserve" "github.com/HexmosTech/git-lrc/network" + setuptpl "github.com/HexmosTech/git-lrc/setup" "github.com/HexmosTech/git-lrc/storage" uicfg "github.com/HexmosTech/git-lrc/ui" ) @@ -182,7 +183,13 @@ func (s *connectorManagerServer) handleReauthenticate(w http.ResponseWriter, r * } slog := newSetupLog() - result, err := runHexmosLoginFlow(slog, apiURL) + var result *setupResult + var err error + if setuptpl.IsCloudAPIURL(apiURL) { + result, err = runHexmosLoginFlow(slog, apiURL) + } else { + result, err = promptSelfHostedLoginFlow(apiURL, slog) + } if err != nil { writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("reauthentication failed: %v", err)) return diff --git a/network/endpoints.go b/network/endpoints.go index ae4a389..d08d8f9 100644 --- a/network/endpoints.go +++ b/network/endpoints.go @@ -14,6 +14,22 @@ func SetupEnsureCloudUserURL(baseURL string) string { return strings.TrimSuffix(baseURL, "/") + "/api/v1/auth/ensure-cloud-user" } +func SetupAuthLoginURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/api/v1/auth/login" +} + +func SetupUIConfigURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/api/v1/ui-config" +} + +func SetupAuthSetupStatusURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/api/v1/auth/setup-status" +} + +func SetupAuthSetupURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/api/v1/auth/setup" +} + func SetupAuthRefreshURL(baseURL string) string { return strings.TrimSuffix(baseURL, "/") + "/api/v1/auth/refresh" } diff --git a/network/network_status.md b/network/network_status.md index e38dacb..f4176be 100644 --- a/network/network_status.md +++ b/network/network_status.md @@ -10,9 +10,9 @@ This document tracks network-side operations in git-lrc as an auditable inventor - Network boundary: outbound HTTP API operations and response handling in network package. - Modes represented: api. -- Operation count tracked: 19 operations. +- Operation count tracked: 22 operations. - Severity distribution: High 10, Medium 7, Low 2. -- Current diff note: worktree hook-runtime and local hook-management fixes changed hook/appcore/git-path helper logic only; no network package operation surface change. +- Current diff note: self-hosted setup now uses LiveReview email/password auth endpoints (`/api/v1/auth/login`, `/api/v1/auth/setup-status`, `/api/v1/auth/setup`) in addition to existing cloud ensure-cloud-user setup path. - Current diff note: internal reviewapi helper evidence links were revalidated after git path helper additions; network inventory scope is unchanged. - Primary sensitive data in scope: API keys, bearer tokens, org-context headers, diff content, connector validation payloads, update manifest metadata, binary download stream. - Highest-risk operation classes: review submission/polling, setup credential operations, proxy forwarding paths, binary download operations. @@ -44,11 +44,15 @@ This document tracks network-side operations in git-lrc as an auditable inventor | Operation | Mode | Data Handled | Purpose | Severity | Risk Acknowledgement | Compensation Status | Evidence | | --- | --- | --- | --- | --- | --- | --- | --- | | SetupEnsureCloudUser | api | Access-token-authenticated identity payload | Ensure cloud user record exists during setup | High | Authentication-context misuse risk | Compensated by bearer token boundary and scoped setup endpoint; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L6) | -| SetupRefreshTokens | api | Refresh token request payload and returned token set | Refresh auth session tokens during setup and re-auth flows | High | Token handling and session integrity risk | Compensated by scoped auth-refresh endpoint and typed request path in setup operations; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L11) | -| SetupCreateAPIKey | api | API key label and returned plaintext API key | Create connector/review API key | High | High sensitivity secret material exposure risk | Compensated by explicit auth flow and setup caller no-log/no-echo handling for create-key error paths; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L16) | -| SetupValidateConnectorKey | api | Provider key and validation request body | Validate AI connector key before persistence | High | Third-party key exposure/handling risk | Compensated by authenticated request path plus connector key redaction in setup error surfaces; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L21) | -| SetupCreateConnector | api | Connector configuration payload | Persist connector configuration via LiveReview API | High | Misconfiguration and sensitive metadata transmission risk | Compensated by bearer auth plus org context boundary; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L26) | -| SetupListConnectors | api | Existing connector inventory for current org | Inspect minimum AI readiness during setup and re-auth preflight | High | Sensitive connector metadata and auth-context exposure risk | Compensated by bearer auth plus org context boundary; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L31) | +| SetupAuthLogin | api | Email/password credentials and returned session tokens | Authenticate against self-hosted LiveReview during setup | High | Credential and token handling risk | Compensated by HTTPS/TLS deployment assumptions and setup caller no-log handling for secrets; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L11) | +| SetupUIConfig | api | Base URL | Check if the target server is cloud hosted | Low | Low sensitivity configuration info | Compensated by public endpoint without secrets; acceptable risk | [network/setup_operations.go](setup_operations.go#L16) | +| SetupAuthSetupStatus | api | Setup readiness metadata | Detect whether initial self-hosted admin setup is required | Medium | Low sensitivity operational metadata exposure risk | Compensated by public setup-status endpoint contract; acceptable risk | [network/setup_operations.go](setup_operations.go#L21) | +| SetupInitialAdmin | api | Initial admin credentials and returned session tokens | Create first self-hosted admin user during setup | High | Credential and token handling risk | Compensated by one-time setup gate on backend and setup caller secret-handling policy; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L26) | +| SetupRefreshTokens | api | Refresh token request payload and returned token set | Refresh auth session tokens during setup and re-auth flows | High | Token handling and session integrity risk | Compensated by scoped auth-refresh endpoint and typed request path in setup operations; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L31) | +| SetupCreateAPIKey | api | API key label and returned plaintext API key | Create connector/review API key | High | High sensitivity secret material exposure risk | Compensated by explicit auth flow and setup caller no-log/no-echo handling for create-key error paths; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L36) | +| SetupValidateConnectorKey | api | Provider key and validation request body | Validate AI connector key before persistence | High | Third-party key exposure/handling risk | Compensated by authenticated request path plus connector key redaction in setup error surfaces; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L41) | +| SetupCreateConnector | api | Connector configuration payload | Persist connector configuration via LiveReview API | High | Misconfiguration and sensitive metadata transmission risk | Compensated by bearer auth plus org context boundary; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L46) | +| SetupListConnectors | api | Existing connector inventory for current org | Inspect minimum AI readiness during setup and re-auth preflight | High | Sensitive connector metadata and auth-context exposure risk | Compensated by bearer auth plus org context boundary; residual risk acceptable | [network/setup_operations.go](setup_operations.go#L51) | ## Inventory: Proxy And Forwarding APIs @@ -72,6 +76,10 @@ This document tracks network-side operations in git-lrc as an auditable inventor | Client.DoJSON | api | Request/response JSON payload bytes | Standard JSON HTTP call wrapper | Medium | Medium risk from broad transport usage and status-handling variance | Compensated by centralized transport wrapper with timeout controls; acceptable risk | [network/http_client.go](http_client.go#L43) | | Client.Do | api | Raw HTTP request/response bytes | Generic HTTP call wrapper for non-JSON/raw workflows | Medium | Medium risk from raw payload handling flexibility | Partially compensated by shared client boundary; Suggestion: document callsite expectations for raw bodies | [network/http_client.go](http_client.go#L89) | | SetupEnsureCloudUserURL | api | Base URL plus endpoint normalization inputs | Normalize endpoint composition and reduce path ambiguity | Medium | Medium risk if normalization logic diverges from endpoint assumptions | Compensated by centralized URL builder utility; acceptable risk | [network/endpoints.go](endpoints.go#L13) | +| SetupAuthLoginURL | api | Base URL plus endpoint normalization inputs | Build self-hosted login endpoint URL | Medium | Medium risk if normalization logic diverges from endpoint assumptions | Compensated by centralized URL builder utility; acceptable risk | [network/endpoints.go](endpoints.go#L17) | +| SetupUIConfigURL | api | Base URL | Build UI config URL | Low | Low risk | Compensated by public endpoint utility; acceptable risk | [network/endpoints.go](endpoints.go#L21) | +| SetupAuthSetupStatusURL | api | Base URL plus endpoint normalization inputs | Build self-hosted setup-status endpoint URL | Medium | Medium risk if normalization logic diverges from endpoint assumptions | Compensated by centralized URL builder utility; acceptable risk | [network/endpoints.go](endpoints.go#L25) | +| SetupAuthSetupURL | api | Base URL plus endpoint normalization inputs | Build self-hosted initial-admin setup endpoint URL | Medium | Medium risk if normalization logic diverges from endpoint assumptions | Compensated by centralized URL builder utility; acceptable risk | [network/endpoints.go](endpoints.go#L29) | | PollReview | api | Review IDs, status payloads, timeout state | Timeout-bounded polling orchestration in review runtime | High | High availability/latency risk if review service is degraded | Compensated by bounded timeout and interval controls; residual risk acceptable | [internal/reviewapi/helpers.go](../internal/reviewapi/helpers.go#L201) | | formatJSONParseError | api | Response body text for parse diagnostics | Improve operator diagnostics when endpoint/port mismatches occur | Low | Low risk diagnostic utility behavior | Compensated by safer error interpretation path; acceptable risk | [internal/reviewapi/helpers.go](../internal/reviewapi/helpers.go#L129) | diff --git a/network/setup_operations.go b/network/setup_operations.go index a8c58ba..fec020a 100644 --- a/network/setup_operations.go +++ b/network/setup_operations.go @@ -7,6 +7,26 @@ func SetupEnsureCloudUser(client *Client, cloudBase string, payload any, jwt str return client.DoJSON(http.MethodPost, SetupEnsureCloudUserURL(cloudBase), payload, jwt, "", nil) } +// SetupAuthLogin submits the self-hosted email/password login request. +func SetupAuthLogin(client *Client, baseURL string, payload any) (*Response, error) { + return client.DoJSON(http.MethodPost, SetupAuthLoginURL(baseURL), payload, "", "", nil) +} + +// SetupUIConfig retrieves the deployment configuration to check the server mode. +func SetupUIConfig(client *Client, baseURL string) (*Response, error) { + return client.DoJSON(http.MethodGet, SetupUIConfigURL(baseURL), nil, "", "", nil) +} + +// SetupAuthSetupStatus checks whether initial self-hosted admin setup is required. +func SetupAuthSetupStatus(client *Client, baseURL string) (*Response, error) { + return client.DoJSON(http.MethodGet, SetupAuthSetupStatusURL(baseURL), nil, "", "", nil) +} + +// SetupInitialAdmin submits the first-time self-hosted admin setup request. +func SetupInitialAdmin(client *Client, baseURL string, payload any) (*Response, error) { + return client.DoJSON(http.MethodPost, SetupAuthSetupURL(baseURL), payload, "", "", nil) +} + // SetupRefreshTokens submits the auth refresh request. func SetupRefreshTokens(client *Client, cloudBase string, payload any) (*Response, error) { return client.DoJSON(http.MethodPost, SetupAuthRefreshURL(cloudBase), payload, "", "", nil) diff --git a/setup/deployment.go b/setup/deployment.go new file mode 100644 index 0000000..9019aa2 --- /dev/null +++ b/setup/deployment.go @@ -0,0 +1,30 @@ +package setup + +import ( + "encoding/json" + "strings" + "time" + + "github.com/HexmosTech/git-lrc/network" +) + +// IsCloudAPIURL reports whether apiURL targets the hosted LiveReview cloud backend. +// Empty apiURL is treated as cloud because setup defaults to CloudAPIURL. +func IsCloudAPIURL(apiURL string) bool { + normalized := strings.TrimRight(strings.TrimSpace(apiURL), "/") + if normalized == "" { + return true + } + + client := network.NewSetupClient(5 * time.Second) + if resp, err := network.SetupUIConfig(client, normalized); err == nil && resp.StatusCode == 200 { + var uiConfig struct { + IsCloud bool `json:"isCloud"` + } + if err := json.Unmarshal(resp.Body, &uiConfig); err == nil { + return uiConfig.IsCloud + } + } + + return strings.EqualFold(normalized, CloudAPIURL) || strings.Contains(normalized, "hexmos.com") +} diff --git a/setup/deployment_test.go b/setup/deployment_test.go new file mode 100644 index 0000000..faea23a --- /dev/null +++ b/setup/deployment_test.go @@ -0,0 +1,22 @@ +package setup + +import "testing" + +func TestIsCloudAPIURL(t *testing.T) { + tests := []struct { + apiURL string + want bool + }{ + {apiURL: "", want: true}, + {apiURL: CloudAPIURL, want: true}, + {apiURL: CloudAPIURL + "/", want: true}, + {apiURL: "http://localhost:8888", want: false}, + {apiURL: "https://review.acme.corp", want: false}, + } + + for _, tc := range tests { + if got := IsCloudAPIURL(tc.apiURL); got != tc.want { + t.Fatalf("IsCloudAPIURL(%q) = %v, want %v", tc.apiURL, got, tc.want) + } + } +} diff --git a/setup/provision.go b/setup/provision.go index 96011e9..d442ab0 100644 --- a/setup/provision.go +++ b/setup/provision.go @@ -67,26 +67,11 @@ func ProvisionLiveReviewUser(cbData *HexmosCallbackData, apiURL string, logf fun } } - apiKeyReq := CreateAPIKeyRequest{Label: "LRC CLI Key"} - apiKeyURL := network.SetupCreateAPIKeyURL(apiURL, result.OrgID) - log("creating API key: POST %s", apiKeyURL) - resp2, err := network.SetupCreateAPIKey(client, apiURL, result.OrgID, apiKeyReq, result.AccessToken) + plainKey, err := createSetupAPIKey(client, apiURL, result.OrgID, result.AccessToken, log) if err != nil { - return nil, fmt.Errorf("failed to create API key: %w", err) - } - if resp2.StatusCode != http.StatusCreated && resp2.StatusCode != http.StatusOK { - // Do not log/echo response bodies here because create-key responses can contain plaintext secrets. - log("create API key failed: status=%d", resp2.StatusCode) - return nil, fmt.Errorf("create API key returned %d", resp2.StatusCode) - } - - log("API key created: status=%d", resp2.StatusCode) - - var apiKeyResp CreateAPIKeyResponse - if err := json.Unmarshal(resp2.Body, &apiKeyResp); err != nil { - return nil, fmt.Errorf("failed to parse API key response: %w", err) + return nil, err } - result.PlainAPIKey = apiKeyResp.PlainKey + result.PlainAPIKey = plainKey return result, nil } diff --git a/setup/selfhosted_provision.go b/setup/selfhosted_provision.go new file mode 100644 index 0000000..509b2aa --- /dev/null +++ b/setup/selfhosted_provision.go @@ -0,0 +1,145 @@ +package setup + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/HexmosTech/git-lrc/network" +) + +// ProvisionSelfHostedUser authenticates against a self-hosted LiveReview API using +// email/password login (or initial admin setup) and creates an LRC CLI API key. +func ProvisionSelfHostedUser(apiURL string, creds SelfHostedLoginRequest, initialAdmin *SelfHostedSetupAdminRequest, logf func(format string, args ...interface{})) (*SetupResult, error) { + log := func(format string, args ...interface{}) { + if logf != nil { + logf(format, args...) + } + } + + apiURL = strings.TrimSpace(apiURL) + if apiURL == "" { + return nil, fmt.Errorf("self-hosted api_url is required") + } + + client := network.NewSetupClient(30 * time.Second) + + var authResp SelfHostedAuthResponse + if initialAdmin != nil { + log("creating initial self-hosted admin user") + resp, err := network.SetupInitialAdmin(client, apiURL, *initialAdmin) + if err != nil { + return nil, fmt.Errorf("failed to contact LiveReview API: %w", err) + } + if resp.StatusCode != http.StatusOK { + log("initial admin setup failed: status=%d body=%s", resp.StatusCode, string(resp.Body)) + return nil, fmt.Errorf("initial admin setup returned %d: %s", resp.StatusCode, strings.TrimSpace(string(resp.Body))) + } + if err := json.Unmarshal(resp.Body, &authResp); err != nil { + return nil, fmt.Errorf("failed to parse initial admin setup response: %w", err) + } + } else { + log("logging in to self-hosted LiveReview API") + resp, err := network.SetupAuthLogin(client, apiURL, creds) + if err != nil { + return nil, fmt.Errorf("failed to contact LiveReview API: %w", err) + } + if resp.StatusCode != http.StatusOK { + log("login failed: status=%d body=%s", resp.StatusCode, string(resp.Body)) + return nil, fmt.Errorf("login returned %d: %s", resp.StatusCode, strings.TrimSpace(string(resp.Body))) + } + if err := json.Unmarshal(resp.Body, &authResp); err != nil { + return nil, fmt.Errorf("failed to parse login response: %w", err) + } + } + + result, err := setupResultFromSelfHostedAuth(&authResp) + if err != nil { + return nil, err + } + + plainKey, err := createSetupAPIKey(client, apiURL, result.OrgID, result.AccessToken, log) + if err != nil { + return nil, err + } + result.PlainAPIKey = plainKey + return result, nil +} + +// CheckSelfHostedSetupRequired reports whether the target API needs first-time admin setup. +func CheckSelfHostedSetupRequired(apiURL string) (bool, error) { + apiURL = strings.TrimSpace(apiURL) + if apiURL == "" { + return false, fmt.Errorf("self-hosted api_url is required") + } + + client := network.NewSetupClient(10 * time.Second) + resp, err := network.SetupAuthSetupStatus(client, apiURL) + if err != nil { + return false, fmt.Errorf("failed to contact LiveReview API: %w", err) + } + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("setup-status returned %d: %s", resp.StatusCode, strings.TrimSpace(string(resp.Body))) + } + + var status SelfHostedSetupStatusResponse + if err := json.Unmarshal(resp.Body, &status); err != nil { + return false, fmt.Errorf("failed to parse setup-status response: %w", err) + } + return status.SetupRequired, nil +} + +func setupResultFromSelfHostedAuth(authResp *SelfHostedAuthResponse) (*SetupResult, error) { + if authResp == nil { + return nil, fmt.Errorf("login response is nil") + } + if strings.TrimSpace(authResp.Tokens.AccessToken) == "" { + return nil, fmt.Errorf("login response missing access_token") + } + if len(authResp.Organizations) == 0 { + return nil, fmt.Errorf("login response missing organizations") + } + + org := authResp.Organizations[0] + orgID := strings.TrimSpace(org.ID.String()) + if orgID == "" || orgID == "0" { + return nil, fmt.Errorf("login response missing organization id") + } + + return &SetupResult{ + Email: strings.TrimSpace(authResp.User.Email), + UserID: strings.TrimSpace(authResp.User.ID.String()), + OrgID: orgID, + OrgName: strings.TrimSpace(org.Name), + AccessToken: strings.TrimSpace(authResp.Tokens.AccessToken), + RefreshToken: strings.TrimSpace(authResp.Tokens.RefreshToken), + }, nil +} + +func createSetupAPIKey(client *network.Client, apiURL, orgID, accessToken string, log func(format string, args ...interface{})) (string, error) { + apiKeyReq := CreateAPIKeyRequest{Label: "LRC CLI Key"} + apiKeyURL := network.SetupCreateAPIKeyURL(apiURL, orgID) + log("creating API key: POST %s", apiKeyURL) + + resp, err := network.SetupCreateAPIKey(client, apiURL, orgID, apiKeyReq, accessToken) + if err != nil { + return "", fmt.Errorf("failed to create API key: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + log("create API key failed: status=%d", resp.StatusCode) + return "", fmt.Errorf("create API key returned %d", resp.StatusCode) + } + + var apiKeyResp CreateAPIKeyResponse + if err := json.Unmarshal(resp.Body, &apiKeyResp); err != nil { + return "", fmt.Errorf("failed to parse API key response: %w", err) + } + if strings.TrimSpace(apiKeyResp.PlainKey) == "" { + return "", fmt.Errorf("create API key response missing plain_key") + } + + log("API key created: status=%d", resp.StatusCode) + return strings.TrimSpace(apiKeyResp.PlainKey), nil +} diff --git a/setup/selfhosted_provision_test.go b/setup/selfhosted_provision_test.go new file mode 100644 index 0000000..a779971 --- /dev/null +++ b/setup/selfhosted_provision_test.go @@ -0,0 +1,82 @@ +package setup + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProvisionSelfHostedUserLoginAndCreateAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/login": + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": map[string]any{"id": 7, "email": "dev@example.com"}, + "tokens": map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + }, + "organizations": []map[string]any{ + {"id": 42, "name": "Acme", "role": "owner"}, + }, + }) + case "/api/v1/orgs/42/api-keys": + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if got := r.Header.Get("Authorization"); got != "Bearer access-token" { + t.Fatalf("unexpected auth header: %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "api_key": map[string]any{"id": 1, "label": "LRC CLI Key"}, + "plain_key": "lr_test_key", + }) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + result, err := ProvisionSelfHostedUser(server.URL, SelfHostedLoginRequest{ + Email: "dev@example.com", + Password: "secret", + }, nil, nil) + if err != nil { + t.Fatalf("ProvisionSelfHostedUser: %v", err) + } + if result.Email != "dev@example.com" { + t.Fatalf("unexpected email: %q", result.Email) + } + if result.OrgID != "42" { + t.Fatalf("unexpected org id: %q", result.OrgID) + } + if result.PlainAPIKey != "lr_test_key" { + t.Fatalf("unexpected api key: %q", result.PlainAPIKey) + } +} + +func TestCheckSelfHostedSetupRequired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/auth/setup-status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "setup_required": true, + "user_count": 0, + }) + })) + defer server.Close() + + required, err := CheckSelfHostedSetupRequired(server.URL) + if err != nil { + t.Fatalf("CheckSelfHostedSetupRequired: %v", err) + } + if !required { + t.Fatal("expected setup_required=true") + } +} diff --git a/setup/types.go b/setup/types.go index d44fa7f..c5446cb 100644 --- a/setup/types.go +++ b/setup/types.go @@ -30,6 +30,45 @@ type HexmosCallbackData struct { } `json:"result"` } +// SelfHostedLoginRequest is the body for POST /api/v1/auth/login. +type SelfHostedLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// SelfHostedSetupAdminRequest is the body for POST /api/v1/auth/setup. +type SelfHostedSetupAdminRequest struct { + Email string `json:"email"` + Password string `json:"password"` + OrgName string `json:"org_name"` +} + +// SelfHostedAuthResponse models login and initial-admin setup responses. +type SelfHostedAuthResponse struct { + Message string `json:"message"` + User struct { + ID json.Number `json:"id"` + Email string `json:"email"` + } `json:"user"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt string `json:"expires_at"` + TokenType string `json:"token_type"` + } `json:"tokens"` + Organizations []struct { + ID json.Number `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + } `json:"organizations"` +} + +// SelfHostedSetupStatusResponse models GET /api/v1/auth/setup-status. +type SelfHostedSetupStatusResponse struct { + SetupRequired bool `json:"setup_required"` + UserCount int `json:"user_count"` +} + // EnsureCloudUserRequest is the body for POST /api/v1/auth/ensure-cloud-user. type EnsureCloudUserRequest struct { Email string `json:"email"`