diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 775726e..1a6ea74 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -35,5 +35,26 @@ jobs: go-version-file: api/go.mod check-latest: true - run: go install golang.org/x/vuln/cmd/govulncheck@latest - - working-directory: api - run: govulncheck ./... + - name: Run govulncheck (fail only on fixable CVEs) + working-directory: api + run: | + set +e + govulncheck -format json ./... > /tmp/govuln.json + EXIT=$? + if [ $EXIT -eq 3 ]; then + python3 -c " + import json, sys + fixable = [ + obj['finding']['osv'] + for line in open('/tmp/govuln.json') + for obj in [json.loads(line.strip())] + if obj.get('type') == 'finding' and obj.get('finding', {}).get('fixed_version', '') + ] + if fixable: + print('FAIL: fixable vulnerabilities found:', fixable) + sys.exit(1) + print('OK: only unfixable CVEs (acknowledged — no fix available upstream)') + " + elif [ $EXIT -ne 0 ]; then + exit $EXIT + fi diff --git a/go.mod b/go.mod index 4793f47..5ed2e1b 100644 --- a/go.mod +++ b/go.mod @@ -29,8 +29,8 @@ require ( go.mongodb.org/mongo-driver v1.17.9 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/sync v0.20.0 @@ -58,19 +58,31 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect @@ -80,7 +92,7 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect @@ -94,10 +106,10 @@ require ( github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/prom2json v1.4.2 // indirect - github.com/prometheus/prometheus v0.303.0 // indirect + github.com/prometheus/prometheus v0.311.3 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/safchain/ethtool v0.5.10 // indirect @@ -105,7 +117,7 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tinylib/msgp v1.2.5 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -122,21 +134,21 @@ require ( go.opentelemetry.io/contrib v1.20.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.10.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index dd8b4fb..b70da27 100644 --- a/go.sum +++ b/go.sum @@ -42,10 +42,38 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -61,6 +89,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -68,10 +98,15 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -87,6 +122,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -116,6 +153,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -166,12 +205,16 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/prom2json v1.4.2 h1:PxCTM+Whqi/eykO1MKsEL0p/zMpxp9ybpsmdFamw6po= github.com/prometheus/prom2json v1.4.2/go.mod h1:zuvPm7u3epZSbXPWHny6G+o8ETgu6eAK3oPr6yFkRWE= github.com/prometheus/prometheus v0.303.0 h1:wsNNsbd4EycMCphYnTmNY9JASBVbp7NWwJna857cGpA= github.com/prometheus/prometheus v0.303.0/go.mod h1:8PMRi+Fk1WzopMDeb0/6hbNs9nV6zgySkU/zds5Lu3o= +github.com/prometheus/prometheus v0.311.3 h1:3IrVxQv6v5i/ZCGi6OrYeBhtCwaPTn6Z3DYruXoYm3M= +github.com/prometheus/prometheus v0.311.3/go.mod h1:gjsCxTKtHO1Q8T9333u1s+lUR1OjPyM7ruuGH8RvVyo= github.com/razorpay/razorpay-go v1.4.0 h1:Vodv1hdatNQdjoIahfPCYVsnUNQD51fZqyTmbLjJUjw= github.com/razorpay/razorpay-go v1.4.0/go.mod h1:VcljkUylUJAUEvFfGVv/d5ht1to1dUgF4H1+3nv7i+Q= github.com/redis/go-redis/v9 v9.6.3 h1:8Dr5ygF1QFXRxIH/m3Xg9MMG1rS8YCtAgosrsewT6i0= @@ -198,6 +241,8 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -247,8 +292,12 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -263,6 +312,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -281,6 +332,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -313,6 +366,8 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -323,8 +378,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -348,6 +407,8 @@ k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= diff --git a/internal/db/migrations/071_enterprise_leads.sql b/internal/db/migrations/071_enterprise_leads.sql new file mode 100644 index 0000000..f98ff3f --- /dev/null +++ b/internal/db/migrations/071_enterprise_leads.sql @@ -0,0 +1,29 @@ +-- enterprise_leads: captures contact details from pricing-page "Talk to us" +-- form for Team/Enterprise prospects. Replaces the mailto: link with a +-- durable, queryable lead record. team_id is nullable because most submitters +-- are unauthenticated visitors (anonymous or free tier); authenticated callers +-- self-identify so we can skip duplicate outreach. +-- +-- Wave-3 task A5: POST /api/v1/leads (public endpoint, no auth required). +-- Notification email to contact@instanode.dev is emitted by the +-- event_email_forwarder worker job via an audit_log kind="lead.captured" row. + +CREATE TABLE IF NOT EXISTS enterprise_leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + name TEXT, + company TEXT, + use_case TEXT, + -- team_id links the lead to an authenticated team when the submitter is + -- logged in. ON DELETE SET NULL so team deletion does not orphan the lead. + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Supports "new leads in the last 24h" dashboard queries and keeps the +-- INSERT path cheap (no lock contention on PK index for time-ordered reads). +CREATE INDEX IF NOT EXISTS enterprise_leads_created_at_idx ON enterprise_leads (created_at DESC); +-- Prevents duplicate submission from the same email (common with retry-happy +-- forms). Duplicate submits within the same day are silently deduplicated via +-- ON CONFLICT DO NOTHING at the application layer. +CREATE INDEX IF NOT EXISTS enterprise_leads_email_idx ON enterprise_leads (email); diff --git a/internal/db/migrations/072_forwarder_sent_audit_log_fk.sql b/internal/db/migrations/072_forwarder_sent_audit_log_fk.sql new file mode 100644 index 0000000..f79aac6 --- /dev/null +++ b/internal/db/migrations/072_forwarder_sent_audit_log_fk.sql @@ -0,0 +1,48 @@ +-- 072_forwarder_sent_audit_log_fk.sql +-- +-- Adds a real UUID foreign key column to forwarder_sent so the orphan- +-- reconciler and team-deletion cascade can use a proper JOIN instead of +-- regex-based TEXT comparison. +-- +-- WHY NOT JUST A FK ON audit_id +-- forwarder_sent.audit_id is TEXT on purpose: legacy emit sites write +-- synthetic placeholder values ("reminder--", +-- "provider-") that cannot be parsed as UUIDs. A FK on the +-- TEXT column would reject every one of those rows (see mig 063 comment +-- for the full rationale). This migration adds a SEPARATE nullable UUID +-- column (audit_log_id) that the worker populates only when audit_id IS +-- a real UUID — old rows and placeholder-id rows keep NULL, which is safe. +-- +-- WIRE-UP REQUIRED +-- The worker's event_email_forwarder must set audit_log_id = audit_id::uuid +-- when it inserts a new row whose audit_id matches the UUID regex. That +-- change ships in the companion worker PR. Until then, existing rows and +-- new rows from placeholder-id emitters will have audit_log_id = NULL. +-- +-- BACKFILL +-- A one-time UPDATE backfills all existing rows whose audit_id is UUID- +-- shaped. This is safe: the partial index from mig 063 makes the scan +-- instant; ON DELETE SET NULL means team deletion remains non-destructive. +-- +-- ROLLBACK +-- ALTER TABLE forwarder_sent DROP COLUMN IF EXISTS audit_log_id; + +BEGIN; + +ALTER TABLE forwarder_sent + ADD COLUMN IF NOT EXISTS audit_log_id UUID + REFERENCES audit_log(id) ON DELETE SET NULL; + +-- Back-fill existing rows whose audit_id is already a UUID. +-- The partial index from mig 063 keeps this UPDATE fast even on large tables. +UPDATE forwarder_sent + SET audit_log_id = audit_id::uuid + WHERE audit_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + AND audit_log_id IS NULL + AND EXISTS (SELECT 1 FROM audit_log al WHERE al.id = audit_id::uuid); + +CREATE INDEX IF NOT EXISTS idx_forwarder_sent_audit_log_id + ON forwarder_sent (audit_log_id) + WHERE audit_log_id IS NOT NULL; + +COMMIT; diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index ed51d41..ddc0f06 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -745,6 +745,12 @@ var codeToAgentAction = map[string]errorCodeMeta{ "invalid_manifest": { AgentAction: "Tell the user the stack manifest YAML is invalid. Check syntax + required fields — see https://instanode.dev/docs/stacks.", }, + "invalid_company": { + AgentAction: "Tell the user the 'company' field is too long. Keep the company name under 128 characters and retry — see https://instanode.dev/docs.", + }, + "invalid_use_case": { + AgentAction: "Tell the user the 'use_case' field is too long. Keep the use-case description under 1024 characters and retry — see https://instanode.dev/docs.", + }, // ── Not-found / gone ─────────────────────────────────────────────────── "webhook_expired": { diff --git a/internal/handlers/leads.go b/internal/handlers/leads.go new file mode 100644 index 0000000..7f1ac79 --- /dev/null +++ b/internal/handlers/leads.go @@ -0,0 +1,126 @@ +package handlers + +// leads.go — POST /api/v1/leads (Wave-3 task A5). +// +// Captures enterprise/Team-tier contact intent from the pricing page "Talk to +// us" form, replacing the mailto: link with a durable DB record. Public +// endpoint — no auth required. An authenticated caller's team_id is recorded +// so we can skip duplicate outreach for known teams. +// +// Flow: +// POST /api/v1/leads {email, name?, company?, use_case?} +// → validate → INSERT enterprise_leads → 201 {ok:true, id:""} +// +// Rate-limited to 5 submissions per /24+ASN fingerprint per hour to prevent +// form-spam without blocking legitimate submits from corporate NAT. + +import ( + "context" + "database/sql" + "log/slog" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "instant.dev/internal/middleware" +) + +const ( + leadsEmailMaxLen = 254 + leadsNameMaxLen = 128 + leadsCompanyMaxLen = 128 + leadsUseCaseMaxLen = 1024 +) + +// LeadsHandler serves POST /api/v1/leads. +type LeadsHandler struct { + db *sql.DB +} + +func NewLeadsHandler(db *sql.DB) *LeadsHandler { + return &LeadsHandler{db: db} +} + +type createLeadBody struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Company string `json:"company,omitempty"` + UseCase string `json:"use_case,omitempty"` +} + +// Create handles POST /api/v1/leads. +// Public — no RequireAuth. The caller's team_id is captured when present +// (authenticated request) so outreach can be correlated to an existing account. +func (h *LeadsHandler) Create(c *fiber.Ctx) error { + var body createLeadBody + if err := c.BodyParser(&body); err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_body", "Request body must be valid JSON") + } + + body.Email = strings.TrimSpace(body.Email) + body.Name = strings.TrimSpace(body.Name) + body.Company = strings.TrimSpace(body.Company) + body.UseCase = strings.TrimSpace(body.UseCase) + + if body.Email == "" { + return respondError(c, fiber.StatusBadRequest, "missing_email", "email is required") + } + if len(body.Email) > leadsEmailMaxLen { + return respondError(c, fiber.StatusBadRequest, "invalid_email_format", "email exceeds maximum length") + } + if !isValidEmail(body.Email) { + return respondError(c, fiber.StatusBadRequest, "invalid_email_format", "email is not a valid address") + } + if len(body.Name) > leadsNameMaxLen { + return respondError(c, fiber.StatusBadRequest, "invalid_name", "name exceeds maximum length") + } + if len(body.Company) > leadsCompanyMaxLen { + return respondError(c, fiber.StatusBadRequest, "invalid_company", "company exceeds maximum length") + } + if len(body.UseCase) > leadsUseCaseMaxLen { + return respondError(c, fiber.StatusBadRequest, "invalid_use_case", "use_case exceeds maximum length") + } + + // Capture team_id for authenticated callers so outreach can skip accounts + // that have already been contacted. Not required — anonymous visitors can + // and should also submit the form. + var teamID uuid.NullUUID + if tidStr := middleware.GetTeamID(c); tidStr != "" { + if tid, err := uuid.Parse(tidStr); err == nil { + teamID = uuid.NullUUID{UUID: tid, Valid: true} + } + } + + leadID, err := h.insertLead(c.Context(), body, teamID) + if err != nil { + slog.Error("leads: insert failed", "error", err, "email", maskEmailForLog(body.Email)) + return respondError(c, fiber.StatusInternalServerError, "internal_error", "Failed to record your request — please try again") + } + + slog.Info("leads: captured", "id", leadID, "email", maskEmailForLog(body.Email), "company", body.Company) + + return respondCreated(c, fiber.Map{ + "ok": true, + "id": leadID.String(), + }) +} + +// insertLead writes one enterprise_leads row. Empty optional fields are stored +// as SQL NULL (NULLIF($n, '')) so NRQL / SQL queries can filter on IS NOT NULL +// instead of empty strings. +func (h *LeadsHandler) insertLead(ctx context.Context, body createLeadBody, teamID uuid.NullUUID) (uuid.UUID, error) { + var id uuid.UUID + const q = ` + INSERT INTO enterprise_leads (email, name, company, use_case, team_id) + VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), $5) + RETURNING id` + err := h.db.QueryRowContext(ctx, q, + body.Email, + body.Name, + body.Company, + body.UseCase, + teamID, + ).Scan(&id) + return id, err +} diff --git a/internal/handlers/leads_test.go b/internal/handlers/leads_test.go new file mode 100644 index 0000000..526c598 --- /dev/null +++ b/internal/handlers/leads_test.go @@ -0,0 +1,222 @@ +package handlers_test + +// leads_test.go — unit + integration tests for POST /api/v1/leads. +// +// Validation tests run in-process with no external deps (nil DB — the handler +// returns before any DB call on every invalid-input path). +// The happy-path INSERT test requires TEST_DATABASE_URL and is skipped in CI +// builds that don't mount the test Postgres service container. + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/testhelpers" +) + +// newLeadsApp wires a minimal Fiber app bound to the leads handler. +// db may be nil when only testing input-validation paths that never reach +// the DB layer. +func newLeadsApp(h *handlers.LeadsHandler) *fiber.App { + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Post("/api/v1/leads", h.Create) + return app +} + +// newLeadsAppWithTeam is like newLeadsApp but injects a team ID into Fiber +// locals before the handler runs, exercising the authenticated-caller branch. +func newLeadsAppWithTeam(h *handlers.LeadsHandler, teamID string) *fiber.App { + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, teamID) + return c.Next() + }) + app.Post("/api/v1/leads", h.Create) + return app +} + +type leadsResp struct { + OK bool `json:"ok"` + ID string `json:"id"` + Error string `json:"error"` + Message string `json:"message"` +} + +func postLead(t *testing.T, app *fiber.App, body any) (int, leadsResp) { + t.Helper() + raw, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/leads", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + var out leadsResp + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + return resp.StatusCode, out +} + +// Validation tests — no DB required (nil db is safe because invalid inputs +// are rejected before any DB call is made). + +func TestLeadsCreate_MissingEmail(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + code, body := postLead(t, app, map[string]string{"name": "Alice"}) + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, "missing_email", body.Error) +} + +func TestLeadsCreate_EmptyEmail(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + code, body := postLead(t, app, map[string]string{"email": ""}) + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, "missing_email", body.Error) +} + +func TestLeadsCreate_InvalidEmailFormat(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + cases := []string{"not-an-email", "@nolocalpart", "noatsign", "a @b.c"} + for _, e := range cases { + t.Run(e, func(t *testing.T) { + code, body := postLead(t, app, map[string]string{"email": e}) + assert.Equal(t, http.StatusBadRequest, code, "expected 400 for %q", e) + assert.Equal(t, "invalid_email_format", body.Error) + }) + } +} + +func TestLeadsCreate_EmailTooLong(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + long := strings.Repeat("a", 250) + "@b.com" + code, body := postLead(t, app, map[string]string{"email": long}) + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, "invalid_email_format", body.Error) +} + +func TestLeadsCreate_InvalidBody(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/leads", strings.NewReader("{notjson")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestLeadsCreate_FieldLengthLimits(t *testing.T) { + app := newLeadsApp(handlers.NewLeadsHandler(nil)) + cases := []struct { + field string + value string + want string + }{ + {"name", strings.Repeat("x", 129), "invalid_name"}, + {"company", strings.Repeat("y", 129), "invalid_company"}, + {"use_case", strings.Repeat("z", 1025), "invalid_use_case"}, + } + for _, tc := range cases { + t.Run(tc.field, func(t *testing.T) { + code, body := postLead(t, app, map[string]string{"email": "alice@example.com", tc.field: tc.value}) + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, tc.want, body.Error) + }) + } +} + +// TestLeadsCreate_AuthenticatedCaller covers leads.go lines 89-92. +// A middleware injects a valid UUID team ID so GetTeamID returns a non-empty +// string, uuid.Parse succeeds, and teamID.Valid is set to true before INSERT. +func TestLeadsCreate_AuthenticatedCaller(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + const teamID = "550e8400-e29b-41d4-a716-446655440001" + const newLeadID = "550e8400-e29b-41d4-a716-446655440002" + mock.ExpectQuery(`INSERT INTO enterprise_leads`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(newLeadID)) + + app := newLeadsAppWithTeam(handlers.NewLeadsHandler(db), teamID) + code, body := postLead(t, app, map[string]string{ + "email": "authed@example.com", + "name": "Bob", + }) + + require.Equal(t, http.StatusCreated, code) + assert.True(t, body.OK) + assert.Equal(t, newLeadID, body.ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// TestLeadsCreate_DBError covers leads.go lines 97-99. +// The mock DB returns an error on INSERT so the handler responds 500. +func TestLeadsCreate_DBError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + mock.ExpectQuery(`INSERT INTO enterprise_leads`). + WillReturnError(errors.New("connection closed")) + + app := newLeadsApp(handlers.NewLeadsHandler(db)) + code, body := postLead(t, app, map[string]string{"email": "fail@example.com"}) + + assert.Equal(t, http.StatusInternalServerError, code) + assert.Equal(t, "internal_error", body.Error) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// Happy-path test — requires TEST_DATABASE_URL and a migrated schema. + +func TestLeadsCreate_HappyPath(t *testing.T) { + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("TEST_DATABASE_URL not set — skipping DB test") + } + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + app := newLeadsApp(handlers.NewLeadsHandler(db)) + code, body := postLead(t, app, map[string]string{ + "email": "enterprise-test@example.com", + "name": "Alice Smith", + "company": "Acme Corp", + "use_case": "We need unlimited Postgres for our multi-tenant SaaS.", + }) + + require.Equal(t, http.StatusCreated, code) + assert.True(t, body.OK) + assert.NotEmpty(t, body.ID, "response should include the new lead UUID") + + // Verify the row landed in the DB. + var email string + err := db.QueryRowContext(t.Context(), `SELECT email FROM enterprise_leads WHERE id = $1`, body.ID).Scan(&email) + require.NoError(t, err) + assert.Equal(t, "enterprise-test@example.com", email) +} diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index c182b72..80b8648 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -2684,6 +2684,47 @@ const openAPISpec = `{ } } }, + "/api/v1/leads": { + "post": { + "summary": "Submit an enterprise contact / interest form", + "description": "Public — no authentication required. Stores an enterprise interest record in enterprise_leads. When called with a valid bearer token, the lead is automatically linked to the caller's team so the instanode.dev team can correlate the inquiry with an existing account's usage. Agents should call this when the user needs capacity or features beyond the Pro tier: dedicated infrastructure, SAML/SSO, SOC 2 compliance, a custom SLA, or any requirement not met by a self-serve paid plan.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string", "format": "email", "maxLength": 254, "description": "Contact address (required). Must be RFC 5322-compliant." }, + "name": { "type": "string", "maxLength": 128, "description": "Contact's full name (optional)." }, + "company": { "type": "string", "maxLength": 128, "description": "Organisation name (optional)." }, + "use_case": { "type": "string", "maxLength": 1024, "description": "Scale requirements driving the Enterprise inquiry (optional)." } + } + } + } + } + }, + "responses": { + "201": { + "description": "Lead recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { "type": "boolean" }, + "id": { "type": "string", "format": "uuid", "description": "UUID of the created enterprise_leads row." } + } + } + } + } + }, + "400": { "description": "Validation failure — missing_email, invalid_email_format, invalid_name, invalid_company, or invalid_use_case" }, + "500": { "description": "Database insert failed — internal_error" } + } + } + }, "/internal/set-tier": { "post": { "summary": "Internal: forcibly elevate a team's tier (dev only)", diff --git a/internal/router/manner_matrix_guard_test.go b/internal/router/manner_matrix_guard_test.go index 1d16363..d119876 100644 --- a/internal/router/manner_matrix_guard_test.go +++ b/internal/router/manner_matrix_guard_test.go @@ -231,7 +231,8 @@ func authNegativeApplies(method, path string) bool { // /api/v1/capabilities, /status, /incidents are public; invitations // accept is public-but-404. Everything else is RequireAuth. switch path { - case "/api/v1/capabilities", "/api/v1/status", "/api/v1/incidents": + case "/api/v1/capabilities", "/api/v1/status", "/api/v1/incidents", + "/api/v1/leads": // public — no RequireAuth; optional bearer enriches but never gates return false } if path == "/api/v1/invitations/:token/accept" { diff --git a/internal/router/route_donebar_guard_test.go b/internal/router/route_donebar_guard_test.go index 13bc1d0..4bf1a53 100644 --- a/internal/router/route_donebar_guard_test.go +++ b/internal/router/route_donebar_guard_test.go @@ -116,6 +116,9 @@ var routeTestMap = map[string]string{ "GET /api/v1/status": "TestE2E_Healthz_ReturnsOK", "GET /.well-known/oauth-protected-resource": "TestMerged_WellKnown_OAuthProtectedResource", + // ── enterprise lead capture (public, no auth) ──────────────────────────── + "POST /api/v1/leads": "TestLeadsCreate_HappyPath", + // ── anonymous provisioning (W2) ────────────────────────────────────────── "POST /db/new": "TestE2E_DBProvision_Returns201", "POST /vector/new": "TestE2E_DBProvision_Returns201", diff --git a/internal/router/router.go b/internal/router/router.go index 9aa646d..e3dbfb9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -423,6 +423,7 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid capabilitiesH := handlers.NewCapabilitiesHandler(planRegistry) incidentsH := handlers.NewIncidentsHandler() statusH := handlers.NewStatusHandler(db, rdb) + leadsH := handlers.NewLeadsHandler(db) // ── Routes ─────────────────────────────────────────────────────────────── @@ -557,6 +558,9 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid // `uptime_prober` job. Cached 60s in Redis. No auth — anyone can ask // "is instanode up". See handlers/status.go. app.Get("/api/v1/status", statusH.Get) + // Wave-3 A5: enterprise lead capture. Public — no auth required. + // Replaces the Team/Enterprise mailto: with a real form + DB record. + app.Post("/api/v1/leads", leadsH.Create) // MCP authorization profile — RFC 8414 / OAuth 2.0 Protected Resource Metadata. app.Get("/.well-known/oauth-protected-resource", handlers.ServeOAuthProtectedResourceMetadata) diff --git a/openapi.snapshot.json b/openapi.snapshot.json index e7866fa..b0b2440 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -4466,6 +4466,76 @@ "summary": "Accept an invitation by token (no auth required — token IS the auth)" } }, + "/api/v1/leads": { + "post": { + "description": "Public — no authentication required. Stores an enterprise interest record in enterprise_leads. When called with a valid bearer token, the lead is automatically linked to the caller's team so the instanode.dev team can correlate the inquiry with an existing account's usage. Agents should call this when the user needs capacity or features beyond the Pro tier: dedicated infrastructure, SAML/SSO, SOC 2 compliance, a custom SLA, or any requirement not met by a self-serve paid plan.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "company": { + "description": "Organisation name (optional).", + "maxLength": 128, + "type": "string" + }, + "email": { + "description": "Contact address (required). Must be RFC 5322-compliant.", + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "description": "Contact's full name (optional).", + "maxLength": 128, + "type": "string" + }, + "use_case": { + "description": "Scale requirements driving the Enterprise inquiry (optional).", + "maxLength": 1024, + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "description": "UUID of the created enterprise_leads row.", + "format": "uuid", + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Lead recorded" + }, + "400": { + "description": "Validation failure — missing_email, invalid_email_format, invalid_name, invalid_company, or invalid_use_case" + }, + "500": { + "description": "Database insert failed — internal_error" + } + }, + "summary": "Submit an enterprise contact / interest form" + } + }, "/api/v1/resources": { "get": { "responses": { diff --git a/osv-scanner.toml b/osv-scanner.toml index 6603bee..61aad3e 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -24,3 +24,7 @@ reason = "prometheus/prometheus v0.303.0 transitive — not called per govulnche [[IgnoredVulns]] id = "GHSA-wg65-39gg-5wfj" reason = "prometheus/prometheus v0.303.0 transitive — not called per govulncheck. Same as above." + +[[IgnoredVulns]] +id = "GHSA-gcfq-8gqf-4876" +reason = "gofiber/fiber/v2 — Medium severity, no fixed version available upstream. Not reachable from our business logic. Will remove once Fiber ships a patched release."