Skip to content
44 changes: 44 additions & 0 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: golangci-lint

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
schedule:
- cron: "23 6 * * 1"

permissions:
contents: read
pull-requests: read

jobs:
lint:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
path: api
# Sibling checkouts (proto/common) for repos with replace directives.
# No-op for repos that do not need them.
- uses: actions/checkout@v4
if: ${{ hashFiles('api/go.mod') != '' }}
with:
repository: InstaNode-dev/common
path: common
continue-on-error: true
- uses: actions/checkout@v4
with:
repository: InstaNode-dev/proto
path: proto
continue-on-error: true
- uses: actions/setup-go@v5
with:
go-version-file: api/go.mod
- uses: golangci/golangci-lint-action@v8
with:
version: latest
working-directory: api
args: --timeout=5m
57 changes: 57 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# golangci-lint v2 config — start conservative, expand once baseline is clean
version: "2"

run:
timeout: 5m
tests: true

linters:
# Default linter set is govet+errcheck+ineffassign+staticcheck+unused.
# We explicitly add misspell + gocyclo on top. gosimple folded into staticcheck in v2.
enable:
- errcheck # checks unchecked errors
- govet # standard vet
- ineffassign # ineffective assignments
- staticcheck # bug detection (subsumes gosimple in v2)
- unused # unused code
- misspell # spelling
- gocyclo # cyclomatic complexity
settings:
gocyclo:
# Baseline threshold for an existing mature codebase. The largest pre-existing
# offender at the time golangci-lint was introduced (StackHandler.New) measured
# cyclomatic complexity 68; this is set just above it so the linter does not force
# 33 risky refactors of production request handlers as a precondition for adoption.
# This is a ratchet baseline: lower it incrementally as functions are decomposed in
# follow-up work, never raise it.
min-complexity: 69
exclusions:
rules:
- path: _test\.go
linters:
- errcheck
- gocyclo
# SA1019 (deprecated madmin.New / SetPolicy) in the MinIO provider. MinIO is a
# local-dev-only object-store backend; the suggested replacements
# (NewWithOptions / AttachPolicy) have different call shapes and semantics, so a
# mechanical swap would risk a behavior change on the credential-issuance path.
# Excluded as a targeted, file-scoped suppression rather than disabling SA1019
# globally; revisit when the madmin dependency is next upgraded.
- path: internal/providers/storage/minio/minio\.go
linters:
- staticcheck
text: "SA1019"
# QF1001 (De Morgan's law) fires on two SQL-injection identifier validators in
# resource.go (the unsafe-identifier and rotatePostgresPassword username guards).
# Mechanically inverting the boolean logic of a security guard is exactly the
# class of change that introduces subtle off-by-one acceptance bugs; the current
# !(...) form is intentional and readable in context. Suppressed by check, not by
# disabling staticcheck.
- path: internal/handlers/resource\.go
linters:
- staticcheck
text: "QF1001"

issues:
max-issues-per-linter: 0
max-same-issues: 0
2 changes: 1 addition & 1 deletion internal/crypto/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ func TestSignJWT_AutoStampsIssuedAt(t *testing.T) {
got, err := crypto.VerifyJWT([]byte(coverageJWTSecret), tok)
require.NoError(t, err)
require.NotNil(t, got.IssuedAt)
assert.True(t, !got.IssuedAt.Time.Before(before) && !got.IssuedAt.Time.After(after),
assert.True(t, !got.IssuedAt.Before(before) && !got.IssuedAt.After(after),
"IssuedAt must be auto-stamped to ~now")
}

Expand Down
2 changes: 1 addition & 1 deletion internal/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ func (p *brevoProvider) Send(ctx context.Context, to, subject, plainText, htmlBo
)
return fmt.Errorf("email.brevo.do: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

// Brevo: 201 Created on success. 400 surfaces sender-not-verified, 401
// is bad api-key, 4xx generally are payload problems. Surface the
Expand Down
18 changes: 9 additions & 9 deletions internal/handlers/admin_customers.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ func (h *AdminCustomersHandler) List(c *fiber.Ctx) error {
return respondError(c, fiber.StatusServiceUnavailable, "db_failed",
"Failed to list customers")
}
defer rows.Close()
defer func() { _ = rows.Close() }()

out := make([]CustomerListItem, 0, limit)
var total int
Expand Down Expand Up @@ -481,13 +481,13 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
var u CustomerDetailUser
var id uuid.UUID
if err := userRows.Scan(&id, &u.Email, &u.Role, &u.CreatedAt); err != nil {
userRows.Close()
_ = userRows.Close() // result set fully consumed; close error irrelevant
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan user row")
}
u.ID = id.String()
out.Users = append(out.Users, u)
}
userRows.Close()
_ = userRows.Close() // result set fully consumed; close error irrelevant

// Resource summary.
resRows, err := h.db.QueryContext(c.Context(), `
Expand All @@ -504,12 +504,12 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
for resRows.Next() {
var rs CustomerDetailResourceSummary
if err := resRows.Scan(&rs.ResourceType, &rs.Count, &rs.StorageBytes); err != nil {
resRows.Close()
_ = resRows.Close() // result set fully consumed; close error irrelevant
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan resource row")
}
out.Resources = append(out.Resources, rs)
}
resRows.Close()
_ = resRows.Close() // result set fully consumed; close error irrelevant

// Deployment count.
deployCount, err := models.CountActiveDeploymentsByTeam(c.Context(), h.db, teamID)
Expand All @@ -536,7 +536,7 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
var id uuid.UUID
var meta sql.NullString
if err := auditRows.Scan(&id, &ai.Actor, &ai.Kind, &ai.Summary, &meta, &ai.CreatedAt); err != nil {
auditRows.Close()
_ = auditRows.Close() // result set fully consumed; close error irrelevant
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan audit row")
}
ai.ID = id.String()
Expand All @@ -545,7 +545,7 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
}
out.RecentAudit = append(out.RecentAudit, ai)
}
auditRows.Close()
_ = auditRows.Close() // result set fully consumed; close error irrelevant

return c.JSON(fiber.Map{
"ok": true,
Expand Down Expand Up @@ -755,8 +755,8 @@ func (h *AdminCustomersHandler) cancelOnDemote(c *fiber.Ctx, teamID uuid.UUID, t
SubscriptionID: subID,
}

switch {
case subID == "":
switch subID {
case "":
// No subscription on file. Still emit an audit row so the BI/Loops
// consumer sees the demote transition uniformly — but with
// cancel_attempted=false so the email template knows nothing was
Expand Down
3 changes: 3 additions & 0 deletions internal/handlers/admin_promos_audit_residual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func notesAppWithDB(t *testing.T, db *sql.DB, callerEmail string) *fiber.App {
func TestAdminNotes_CreateFailed_Sqlmock(t *testing.T) {
t.Setenv("ADMIN_EMAILS", adminCallerEmail)
db, mock, err := sqlmockNewRegexp(t)
if err != nil {
t.Fatalf("sqlmockNewRegexp: %v", err)
}
defer db.Close()
tid := uuid.New()
mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby"))
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ func lookupMaskedEmails(ctx context.Context, db *sql.DB, events []*models.AuditE
slog.Warn("audit.email_lookup_failed", "error", err)
return out
}
defer rows.Close()
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, email string
if err := rows.Scan(&id, &email); err != nil {
Expand Down
21 changes: 11 additions & 10 deletions internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,11 @@ func emitAuthLoginAudit(db *sql.DB, teamID, userID uuid.UUID, email, provider, i
// c.Get("User-Agent") results, whose backing bytes live inside the
// fasthttp request Ctx. fiber recycles that Ctx into a pool the instant
// the handler returns, so the background goroutine below MUST read
// heap-owned copies, never aliases into the recycled Ctx. email/provider
// are already heap-owned (DB column / package const) but cloned for
// symmetry; teamID/userID are value types.
email = strings.Clone(email)
// heap-owned copies, never aliases into the recycled Ctx. provider is
// already heap-owned (DB column / package const) but cloned for symmetry;
// teamID/userID are value types. email is accepted for call-site symmetry
// but is not read in the background goroutine below, so it is intentionally
// not cloned (cloning it was an ineffectual assignment).
provider = strings.Clone(provider)
ip = strings.Clone(ip)
userAgent = strings.Clone(userAgent)
Expand Down Expand Up @@ -502,7 +503,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
if err != nil {
return nil, fmt.Errorf("github token exchange: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

var tokenResp struct {
AccessToken string `json:"access_token"`
Expand All @@ -524,7 +525,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
if err != nil {
return nil, fmt.Errorf("github user fetch: %w", err)
}
defer userResp.Body.Close()
defer func() { _ = userResp.Body.Close() }()

var profile struct {
ID int `json:"id"`
Expand All @@ -541,7 +542,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
emailResp, err := client.Do(emailReq)
if err == nil {
defer emailResp.Body.Close()
defer func() { _ = emailResp.Body.Close() }()
body, _ := io.ReadAll(emailResp.Body)
var emails []struct {
Email string `json:"email"`
Expand Down Expand Up @@ -668,7 +669,7 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google
if err != nil {
return nil, fmt.Errorf("google token verify: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
return nil, fmt.Errorf("google token invalid (status %d)", resp.StatusCode)
Expand Down Expand Up @@ -717,7 +718,7 @@ func exchangeGoogleAuthorizationCode(ctx context.Context, clientID, clientSecret
if err != nil {
return "", fmt.Errorf("google token exchange: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

var tokenResp struct {
AccessToken string `json:"access_token"`
Expand Down Expand Up @@ -748,7 +749,7 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog
if err != nil {
return nil, fmt.Errorf("google userinfo: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/auth_final2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func startEmptyNameGoogleOAuth(t *testing.T, sub, email string) {
mux := http.NewServeMux()
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email)))
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
Expand Down
8 changes: 4 additions & 4 deletions internal/handlers/auth_oauth_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@ func (f *fakeOAuthServer) handler() http.Handler {
if id == "" {
id = "424242"
}
_, _ = w.Write([]byte(fmt.Sprintf(`{"id":%s,"login":"octocat","email":%q}`, id, f.ghEmail)))
_, _ = fmt.Fprintf(w, `{"id":%s,"login":"octocat","email":%q}`, id, f.ghEmail)
})
mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail)))
_, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail)
})
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud)))
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud)
})
mux.HandleFunc("/g/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand All @@ -105,7 +105,7 @@ func (f *fakeOAuthServer) handler() http.Handler {
})
mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr())))
_, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr())
})
return mux
}
Expand Down
3 changes: 2 additions & 1 deletion internal/handlers/cli_auth_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,8 @@ func TestCLI_GenerateSessionID_HexShape(t *testing.T) {
assert.NotEqual(t, a, b, "two consecutive session ids must differ")
// Every char must be lower-case hex.
for i, r := range a {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
if !isHex {
t.Errorf("session id contains non-hex byte at idx %d: %q", i, r)
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ func (h *DeployHandler) New(c *fiber.Ctx) error {
return respondError(c, fiber.StatusBadRequest, "tarball_open_failed",
"Failed to read tarball")
}
defer f.Close()
defer func() { _ = f.Close() }()

// P0-3: io.ReadAll, not a single f.Read — a lone Read short-reads on
// disk-spilled multipart files (n is discarded), truncating large tarballs.
Expand Down Expand Up @@ -1255,7 +1255,7 @@ func (h *DeployHandler) Redeploy(c *fiber.Ctx) error {
return respondError(c, fiber.StatusBadRequest, "tarball_open_failed",
"Failed to read tarball")
}
defer f.Close()
defer func() { _ = f.Close() }()

// P0-3: io.ReadAll, not a single f.Read — a lone Read short-reads on
// disk-spilled multipart files (n is discarded), truncating large tarballs.
Expand Down
6 changes: 0 additions & 6 deletions internal/handlers/deploy_env_vars_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,3 @@ func TestDeployNew_EnvVarsInvalidJSON_Returns400(t *testing.T) {
assert.Equal(t, "invalid_env_vars", errBody.Error,
"error key must be invalid_env_vars so agents can branch on it; got: %s", errBody.Error)
}

func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
14 changes: 1 addition & 13 deletions internal/handlers/email_webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,19 +325,7 @@ func (h *EmailWebhookHandler) SES(c *fiber.Ctx) error {
// when the test path injects a nil verifier (handlers built via
// NewEmailWebhookHandler always have one).
if h.snsVerifier != nil {
if err := h.snsVerifier.verify(snsMessage{
Type: env.Type,
MessageID: env.MessageID,
Token: env.Token,
TopicArn: env.TopicArn,
Subject: env.Subject,
Message: env.Message,
Timestamp: env.Timestamp,
SignatureVersion: env.SignatureVersion,
Signature: env.Signature,
SigningCertURL: env.SigningCertURL,
SubscribeURL: env.SubscribeURL,
}); err != nil {
if err := h.snsVerifier.verify(snsMessage(env)); err != nil {
slog.Warn("email.webhook.ses.sns_signature_failed",
"error", err,
"signing_cert_url", env.SigningCertURL,
Expand Down
8 changes: 1 addition & 7 deletions internal/handlers/export_bvwave_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,7 @@ type BVRequestDeletionDeps struct {
}

func (d BVRequestDeletionDeps) toInternal() requestDeletionDeps {
return requestDeletionDeps{
DB: d.DB,
Email: d.Email,
APIPublicURL: d.APIPublicURL,
DashboardBaseURL: d.DashboardBaseURL,
TTLMinutes: d.TTLMinutes,
}
return requestDeletionDeps(d)
}

// BVRequestEmailConfirmedDeletion exposes requestEmailConfirmedDeletion.
Expand Down
Loading
Loading