fix: dev-mock/prod parity + fail-closed config gates — 402 verification, cnon:sca binding validation, refund reconcile parity, SNAPSHOT_ENC_KEY fail-closed, webhook config gates, access-token startup validation, ClientIP validation

- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time
- main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation
- mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 62dca184df
commit e9b34d0ad6
8 changed files with 854 additions and 119 deletions
+36 -2
View File
@@ -94,7 +94,14 @@ func SquareLocationID() string {
func newHTTPClient() *httpClient {
env := SquareEnvironment()
baseURL := squareSandboxURL
if env == "production" {
// Empty/unknown SQUARE_ENVIRONMENT is production-ENFORCED, mirroring
// payments.IsExplicitDevOrMockEnv()'s fail-closed interpretation (an
// unset/mistyped env var must never downgrade to a less safe default), so
// the production base URL is used for both "production" and "". Only an
// explicit sandbox or dev/mock value resolves to the sandbox base URL (the
// dev/mock values never construct a real HTTP client anyway).
switch env {
case "production", "":
baseURL = squareProductionURL
}
return &httpClient{
@@ -106,6 +113,24 @@ func newHTTPClient() *httpClient {
}
}
// ValidateCredentials is the fail-closed startup validation for the REAL
// Square API credentials. It is called from main.go's startup gate ONLY for
// non-dev/mock deployments (main.go checks payments.IsExplicitDevOrMockEnv
// first — the env interpretation stays in the payments package to avoid a
// drifting duplicate read). With a real deployment selected, a missing
// SQUARE_ACCESS_TOKEN or SQUARE_LOCATION_ID would make every Square API call
// fail at runtime (401 auth, or request bodies without location_id), breaking
// payments/refunds — so the process refuses to start, mirroring the
// SQUARE_WEBHOOK_SIGNATURE_KEY gate. Never includes the secret values.
func ValidateCredentials() {
if strings.TrimSpace(os.Getenv("SQUARE_ACCESS_TOKEN")) == "" {
log.Fatalf("FATAL: SQUARE_ACCESS_TOKEN environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — every Square API call would be rejected (401) and online payment/refund processing is broken. Set the access token from the Square Developer Dashboard.", SquareEnvironment())
}
if strings.TrimSpace(SquareLocationID()) == "" {
log.Fatalf("FATAL: SQUARE_LOCATION_ID environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — Square payment requests would carry no location_id and fail at runtime. Set the location ID from the Square Developer Dashboard.", SquareEnvironment())
}
}
func (c *httpClient) doJSON(ctx context.Context, method, path string, body, target any) error {
if c.token == "" {
return fmt.Errorf("square: SQUARE_ACCESS_TOKEN is not set")
@@ -1008,7 +1033,16 @@ func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time)
}
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
// begin_time is optional at Square; a ZERO time must OMIT the param
// entirely. Formatting the zero time would send Square a year-1 timestamp
// (0001-01-01T00:00:00Z) that Square may reject. The reconciliation callers
// (PaymentWasRefunded / paymentRefundedExactlyWithClient) pass time.Time{}
// deliberately — they want ALL refunds, so the param must be absent, not
// "before year 1".
base := "/v2/refunds?limit=100"
if !beginTime.IsZero() {
base = "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
}
path := base
results := []RefundResult{}
for page := 0; page < 20; page++ {