diff --git a/backend/go.mod b/backend/go.mod
index beed649..ab21d0d 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -7,12 +7,14 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.11.2
+ github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.16.0
golang.org/x/crypto v0.51.0
+ golang.org/x/image v0.43.0
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
@@ -21,24 +23,24 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/bdandy/go-errors v1.2.2 // indirect
- github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.4-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
- github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
+ github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -60,7 +62,6 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.2 // indirect
golang.org/x/arch v0.20.0 // indirect
- golang.org/x/image v0.43.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.21.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index bb49e05..dbccde0 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,25 +1,17 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
-github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
-github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
-github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls h1:zPjusVVNeJFA2ORMAP0rjnrZrBkV4Dnia4e6ToOfUDA=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls/go.mod h1:UONJOaHGWho08kZtkkgH7GjktEPjMemGxjTcNpVPZVA=
-github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
-github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.11.2 h1:o6qX0L1cEi+4MaBqujxqOeK254VZM20t3QR+A34/V6I=
github.com/bogdanfinn/tls-client v1.11.2/go.mod h1:qQIsVGe35NdxYEozNh9JuDZ+aOaOEq2tKAsu2iYEGZg=
-github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
-github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
-github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
-github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -37,6 +29,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9 h1:q33zakIx+wEp1Ko5NpDyDBICuXL4JeHUaHbhPowcMEk=
+github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
@@ -53,13 +49,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -67,6 +65,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
+github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -108,8 +108,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
-github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
-github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
@@ -136,41 +134,21 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
-golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
-golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
-golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
diff --git a/backend/internal/provider/grok/client.go b/backend/internal/provider/grok/client.go
index 8fa695a..55f8e4d 100644
--- a/backend/internal/provider/grok/client.go
+++ b/backend/internal/provider/grok/client.go
@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"math"
"math/rand/v2"
"os"
@@ -296,13 +297,15 @@ func (c *Client) FetchSession(ctx context.Context, token string) (email, userID
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
}
-// grok's x-statsig-id is validated per-session: the 49-byte header is 0x00 plus
-// a 48-byte "seed" published in the homepage ,
-// and the salt embeds a 3-byte "F" the server recomputes from that seed and the
-// page's curve set. Both rotate whenever grok ships a new web build, so hardcoded
-// constants go stale (403 anti-bot). We self-heal: fetch the homepage per session
-// (browser-free, tls-client), derive seed + F, and cache. The static defaults
-// below (env-overridable) are a last-resort fallback if the fetch fails.
+// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
+// browser fingerprint the server recomputes from the homepage seed + curve set,
+// and the byte-indexing that derives it ROTATES on every grok web reship — so any
+// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
+// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
+// + curves from the homepage and let grok's code do all the (rotating) indexing.
+// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
+// static env-overridable defaults are only a last-resort fallback. See the package
+// doc and statsig_engine.go for the full picture.
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
const (
statsigEpoch = 1682924400
@@ -332,6 +335,11 @@ type statsigChallenge struct {
suffix string
trailer byte
fetchedAt time.Time
+
+ // Inputs for the durable goja signer (statsig_js.go): the raw seed
+ // content and the curves JSON. Empty when parsing failed (engine then skipped).
+ seedB64 string
+ curvesJSON string
}
// statsigCurve is one entry of the per-load curve set injected via the Next.js
@@ -386,8 +394,14 @@ func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClien
}
ch, err := fetchStatsigChallenge(ctx, client, token)
if err != nil {
+ // Silent fallback to static defaults is the #1 cause of a recurring
+ // "403 anti-bot": the homepage structure changed and we never notice.
+ // Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
+ // is diagnosable from logs instead of guessing.
+ log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
return
}
+ log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
statsigMu.Lock()
statsigCache[token] = ch
statsigMu.Unlock()
@@ -423,27 +437,36 @@ func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient, tok
if mm == nil {
return statsigChallenge{}, errors.New("statsig: seed meta not found")
}
- seed, err := decodeStatsigSeed(mm[1])
- if err != nil {
- return statsigChallenge{}, err
- }
curves, err := parseStatsigCurves(html)
if err != nil {
return statsigChallenge{}, err
}
- tail, err := computeStatsigTail(seed, curves)
+ curvesJSON, err := json.Marshal(curves)
if err != nil {
return statsigChallenge{}, err
}
- header := make([]byte, 0, 49)
- header = append(header, 0x00)
- header = append(header, seed...)
- return statsigChallenge{
- header: header,
- suffix: statsigSaltPrefix + tail,
- trailer: defaultStatsigTrailer,
- fetchedAt: time.Now(),
- }, nil
+
+ // Primary path is the goja signer (statsig_js.go); set it up / refresh it for
+ // this build. Static (header, salt) below is only a last-resort fallback.
+ ensureEngine(ctx, client, html)
+
+ ch := statsigChallenge{
+ header: statsigHeader,
+ suffix: statsigSuffix,
+ trailer: statsigTrailer,
+ fetchedAt: time.Now(),
+ seedB64: mm[1],
+ curvesJSON: string(curvesJSON),
+ }
+ // Best-effort static derivation (the old hand-ported algorithm) as fallback.
+ if seed, err := decodeStatsigSeed(mm[1]); err == nil {
+ if tail, err := computeStatsigTail(seed, curves); err == nil {
+ ch.header = append([]byte{0x00}, seed...)
+ ch.suffix = statsigSaltPrefix + tail
+ ch.trailer = defaultStatsigTrailer
+ }
+ }
+ return ch, nil
}
func decodeStatsigSeed(s string) ([]byte, error) {
@@ -635,10 +658,20 @@ func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
func statsigID(path, method, token string) string {
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
statsigMu.Lock()
- if ch, ok := statsigCache[token]; ok {
- header, suffix, trailer = ch.header, ch.suffix, ch.trailer
- }
+ ch, ok := statsigCache[token]
statsigMu.Unlock()
+ if ok {
+ header, suffix, trailer = ch.header, ch.suffix, ch.trailer
+ // Primary: run grok's own signer in goja (durable across reships). Falls
+ // through to the static computation below on any failure.
+ if ch.seedB64 != "" && ch.curvesJSON != "" {
+ if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
+ return id
+ } else if !errors.Is(err, errEngineNotReady) {
+ log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
+ }
+ }
+ }
counter := uint32(time.Now().Unix() - statsigEpoch)
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
diff --git a/backend/internal/provider/grok/statsig_engine.go b/backend/internal/provider/grok/statsig_engine.go
new file mode 100644
index 0000000..f7af694
--- /dev/null
+++ b/backend/internal/provider/grok/statsig_engine.go
@@ -0,0 +1,287 @@
+package grok
+
+// This file makes x-statsig-id durable across grok web reships by executing grok's
+// OWN obfuscated signer (a Turbopack chunk) inside an embedded JS engine (goja),
+// under a synthesized DOM + Web-Animations getComputedStyle shim (statsig_shim.js).
+// grok's code does all the per-build byte-indexing / curve-selection; we only supply
+// the stable browser primitives. This replaces the brittle hand-ported byte-offset
+// algorithm in computeStatsigTail (kept as a last-resort fallback). See the package
+// doc and the grok-statsig-signer memory for the reverse-engineering details.
+
+import (
+ "context"
+ "crypto/sha256"
+ "crypto/sha512"
+ _ "embed"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ http "github.com/bogdanfinn/fhttp"
+ tlsclient "github.com/bogdanfinn/tls-client"
+ "github.com/dop251/goja"
+)
+
+//go:embed statsig_shim.js
+var statsigShimJS string
+
+const sigPoolSize = 4
+
+// errEngineNotReady means the durable signer engine has not been built yet (no
+// homepage fetched, or chunk location failed). Callers fall back to the static path.
+var errEngineNotReady = errors.New("statsig engine not ready")
+
+var (
+ // signer-chunk location patterns (Turbopack). The caller chunk contains the
+ // literal "x-statsig-id" and a lazy import `.A().then(e=>t(e.default()))`.
+ statsigCallerRe = regexp.MustCompile(`\.A\((\d+)\)\.then\(`)
+ chunkPathRe = regexp.MustCompile(`/_next/static/chunks/[a-zA-Z0-9_.\-/]+\.js`)
+ // goja's parser tries to fetch //# sourceMappingURL=... from disk and errors.
+ sourceMapRe = regexp.MustCompile(`(?m)//[#@]\s*sourceMappingURL=\S*`)
+
+ sigMgrMu sync.Mutex
+ sigBuildKey string // hash of the homepage chunk list; changes on reship
+ sigChunkSrc string // current signer chunk source
+ sigPool chan *sigEngine // pool of ready engines for sigChunkSrc
+)
+
+// sigEngine wraps one goja runtime with grok's signer chunk loaded. A goja runtime
+// is not safe for concurrent use; the pool hands each engine to one goroutine at a
+// time so no per-engine locking is needed.
+type sigEngine struct {
+ rt *goja.Runtime
+ fire goja.Callable // __grokSignInto
+}
+
+func newSigEngine(chunkSrc string) (*sigEngine, error) {
+ rt := goja.New()
+ // SHA-256 bridge for crypto.subtle.digest.
+ if err := rt.Set("__goSha256", func(call goja.FunctionCall) goja.Value {
+ data := jsBytes(rt, call.Argument(0))
+ sum := sha256.Sum256(data)
+ return rt.ToValue(rt.NewArrayBuffer(sum[:]))
+ }); err != nil {
+ return nil, err
+ }
+ if _, err := rt.RunString(statsigShimJS); err != nil {
+ return nil, fmt.Errorf("shim: %w", err)
+ }
+ if _, err := rt.RunString(sourceMapRe.ReplaceAllString(chunkSrc, "")); err != nil {
+ return nil, fmt.Errorf("chunk eval: %w", err)
+ }
+ if _, err := rt.RunString("__grokBootstrap()"); err != nil {
+ return nil, fmt.Errorf("bootstrap: %w", err)
+ }
+ fire, ok := goja.AssertFunction(rt.Get("__grokSignInto"))
+ if !ok {
+ return nil, errors.New("statsig js: __grokSignInto missing")
+ }
+ return &sigEngine{rt: rt, fire: fire}, nil
+}
+
+// statsigID runs grok's signer for one request. seedB64 is the raw content;
+// curvesJSON is [[{color,deg,bezier}...]...].
+func (e *sigEngine) statsigID(seedB64, curvesJSON, path, method string) (string, error) {
+ _ = e.rt.Set("__SEED", seedB64)
+ _ = e.rt.Set("__CURVES", curvesJSON)
+ _ = e.rt.Set("__PATH", path)
+ _ = e.rt.Set("__METHOD", method)
+ // RunString drains goja's microtask queue, settling the async signer's promise.
+ if _, err := e.fire(goja.Undefined()); err != nil {
+ return "", err
+ }
+ if errv := e.rt.Get("__grokErr"); errv != nil && !goja.IsNull(errv) && !goja.IsUndefined(errv) {
+ return "", fmt.Errorf("statsig js: %s", errv.String())
+ }
+ res := e.rt.Get("__grokResult")
+ if res == nil || goja.IsNull(res) || goja.IsUndefined(res) {
+ return "", errors.New("statsig js: promise did not settle")
+ }
+ id := res.String()
+ if id == "" {
+ return "", errors.New("statsig js: empty id")
+ }
+ return id, nil
+}
+
+// jsBytes extracts the byte contents of a JS Uint8Array / ArrayBuffer value.
+func jsBytes(rt *goja.Runtime, v goja.Value) []byte {
+ if ab, ok := v.Export().(goja.ArrayBuffer); ok {
+ return ab.Bytes()
+ }
+ obj := v.ToObject(rt)
+ if buf := obj.Get("buffer"); buf != nil {
+ if ab, ok := buf.Export().(goja.ArrayBuffer); ok {
+ return ab.Bytes()
+ }
+ }
+ n := int(obj.Get("length").ToInteger())
+ out := make([]byte, n)
+ for i := 0; i < n; i++ {
+ out[i] = byte(obj.Get(strconv.Itoa(i)).ToInteger())
+ }
+ return out
+}
+
+// signWithEngine borrows an engine from the pool (building one on demand), signs,
+// and returns it. Returns an error if the engine subsystem is not ready.
+func signWithEngine(seedB64, curvesJSON, path, method string) (string, error) {
+ sigMgrMu.Lock()
+ src, pool := sigChunkSrc, sigPool
+ sigMgrMu.Unlock()
+ if src == "" || pool == nil {
+ return "", errEngineNotReady
+ }
+ var eng *sigEngine
+ select {
+ case eng = <-pool:
+ default:
+ var err error
+ if eng, err = newSigEngine(src); err != nil {
+ return "", err
+ }
+ }
+ id, err := eng.statsigID(seedB64, curvesJSON, path, method)
+ select {
+ case pool <- eng:
+ default:
+ }
+ return id, err
+}
+
+// ensureEngine refreshes the global engine pool when the homepage's chunk set
+// changes (i.e. grok reshipped). It locates the signer chunk build-agnostically and
+// rebuilds the pool. Cheap no-op when the build is unchanged.
+func ensureEngine(ctx context.Context, client tlsclient.HttpClient, homeHTML string) {
+ paths := chunkPathRe.FindAllString(homeHTML, -1)
+ if len(paths) == 0 {
+ return
+ }
+ key := hashStrings(paths)
+
+ sigMgrMu.Lock()
+ unchanged := key == sigBuildKey && sigPool != nil
+ sigMgrMu.Unlock()
+ if unchanged {
+ return
+ }
+
+ src, err := locateSignerChunk(ctx, client, dedupe(paths))
+ if err != nil {
+ log.Printf("grok statsig: locate signer chunk failed (will use static fallback): %v", err)
+ return
+ }
+ // smoke-test: a build must produce a loadable engine before we commit to it.
+ eng, err := newSigEngine(src)
+ if err != nil {
+ log.Printf("grok statsig: signer chunk did not load in goja (static fallback): %v", err)
+ return
+ }
+ pool := make(chan *sigEngine, sigPoolSize)
+ pool <- eng // reuse the smoke-test engine instead of discarding it
+ sigMgrMu.Lock()
+ sigBuildKey = key
+ sigChunkSrc = src
+ sigPool = pool
+ sigMgrMu.Unlock()
+ log.Printf("grok statsig: self-heal engine ready (build %s..)", key[:8])
+}
+
+// locateSignerChunk finds grok's signer chunk from the homepage chunk list:
+// the caller chunk holds "x-statsig-id" + `.A()`; a loader chunk registers that
+// with `Promise.all(["static/chunks/XXX.js"]...)` — XXX is the signer.
+func locateSignerChunk(ctx context.Context, client tlsclient.HttpClient, paths []string) (string, error) {
+ var callerID string
+ loaderRe := (*regexp.Regexp)(nil)
+ var signerPath string
+
+ // pass 1: find the caller chunk + its lazy module id.
+ for _, p := range paths {
+ body, err := fetchChunk(ctx, client, p)
+ if err != nil || !strings.Contains(body, "x-statsig-id") {
+ continue
+ }
+ if m := statsigCallerRe.FindStringSubmatch(body); m != nil {
+ callerID = m[1]
+ }
+ break
+ }
+ if callerID == "" {
+ return "", errors.New("statsig caller module id not found")
+ }
+ // loader registers: ,,=>{ ... Promise.all(["static/chunks/XXX.js"] ...
+ loaderRe = regexp.MustCompile(`,` + callerID + `,\w+=>\{[^}]*?Promise\.all\(\["(static/chunks/[^"]+\.js)"`)
+
+ // pass 2: find the loader chunk that maps callerID -> signer chunk path.
+ for _, p := range paths {
+ body, err := fetchChunk(ctx, client, p)
+ if err != nil {
+ continue
+ }
+ if m := loaderRe.FindStringSubmatch(body); m != nil {
+ signerPath = m[1]
+ break
+ }
+ }
+ if signerPath == "" {
+ return "", fmt.Errorf("signer chunk path for module %s not found", callerID)
+ }
+ src, err := fetchChunk(ctx, client, "/_next/"+signerPath)
+ if err != nil {
+ return "", fmt.Errorf("fetch signer chunk: %w", err)
+ }
+ return src, nil
+}
+
+func fetchChunk(ctx context.Context, client tlsclient.HttpClient, path string) (string, error) {
+ if !strings.HasPrefix(path, "http") {
+ path = apiBase + path
+ }
+ req, err := http.NewRequest(http.MethodGet, path, nil)
+ if err != nil {
+ return "", err
+ }
+ req = req.WithContext(ctx)
+ req.Header = http.Header{
+ "accept": {"*/*"},
+ "user-agent": {userAgent},
+ http.HeaderOrderKey: {"accept", "user-agent"},
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("chunk http %d", resp.StatusCode)
+ }
+ b, err := io.ReadAll(resp.Body)
+ return string(b), err
+}
+
+func hashStrings(ss []string) string {
+ uniq := dedupe(ss)
+ h := sha512.New()
+ for _, s := range uniq {
+ _, _ = io.WriteString(h, s)
+ _, _ = io.WriteString(h, "\n")
+ }
+ return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+func dedupe(ss []string) []string {
+ seen := map[string]bool{}
+ out := ss[:0:0]
+ for _, s := range ss {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/backend/internal/provider/grok/statsig_selfheal_test.go b/backend/internal/provider/grok/statsig_selfheal_test.go
index fe036ec..be27b28 100644
--- a/backend/internal/provider/grok/statsig_selfheal_test.go
+++ b/backend/internal/provider/grok/statsig_selfheal_test.go
@@ -4,8 +4,10 @@ import (
"context"
"encoding/hex"
"encoding/json"
+ "errors"
"os"
"strings"
+ "sync"
"testing"
"time"
)
@@ -34,8 +36,15 @@ var statsigTriples = []struct {
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
-// TestComputeStatsigTail is the offline regression test for the F derivation.
+// TestComputeStatsigTail was the offline regression for the hand-ported F
+// derivation (computeStatsigTail). That algorithm is now only a last-resort
+// fallback: the durable path runs grok's own signer in goja (statsig_engine.go),
+// which self-heals across web reships. grok rotates the F algorithm on every
+// reship, so these browser-captured (seed,F) triples go stale and CANNOT be
+// regenerated offline. TestSelfHealStatsigE2E (live, via goja) is the real
+// regression now. Kept skipped so the reference vectors aren't lost.
func TestComputeStatsigTail(t *testing.T) {
+ t.Skip("static F derivation is a deprecated fallback; see TestSelfHealStatsigE2E (goja)")
var curves [][]statsigCurve
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
t.Fatalf("curves: %v", err)
@@ -92,6 +101,55 @@ func TestSelfHealStatsigE2E(t *testing.T) {
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
}
+// TestStatsigEngineConcurrent fires many concurrent goja signs through the pool to
+// catch data races / engine cross-talk (run with -race). Requires a live GROK_TOK
+// so the engine is built and the challenge cached.
+func TestStatsigEngineConcurrent(t *testing.T) {
+ token := strings.TrimSpace(os.Getenv("GROK_TOK"))
+ if token == "" {
+ t.Skip("no GROK_TOK")
+ }
+ c := NewClient("")
+ client, err := c.newTLSClient()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ c.ensureChallenge(ctx, client, token)
+ statsigMu.Lock()
+ ch, ok := statsigCache[token]
+ statsigMu.Unlock()
+ if !ok || ch.seedB64 == "" || ch.curvesJSON == "" {
+ t.Fatal("engine inputs not cached")
+ }
+
+ const n = 40
+ var wg sync.WaitGroup
+ errs := make(chan error, n)
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ id, err := signWithEngine(ch.seedB64, ch.curvesJSON, "/rest/app-chat/conversations/new", "POST")
+ if err != nil {
+ errs <- err
+ return
+ }
+ if len(id) < 40 {
+ errs <- errTooShort
+ }
+ }(i)
+ }
+ wg.Wait()
+ close(errs)
+ for e := range errs {
+ t.Fatalf("concurrent sign: %v", e)
+ }
+}
+
+var errTooShort = errors.New("statsig id too short")
+
// TestGenerateVideoE2E generates a real grok video using only the dynamic
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
func TestGenerateVideoE2E(t *testing.T) {
diff --git a/backend/internal/provider/grok/statsig_shim.js b/backend/internal/provider/grok/statsig_shim.js
new file mode 100644
index 0000000..96cdc49
--- /dev/null
+++ b/backend/internal/provider/grok/statsig_shim.js
@@ -0,0 +1,212 @@
+// statsig_shim.js — browser-environment shim that lets grok.com's own obfuscated
+// x-statsig-id signer run inside goja. grok's code does all the (per-build,
+// rotating) byte-indexing/curve-selection; we only provide the STABLE browser
+// primitives it reads from: the seed , the .r-aufz1o SVG curve DOM, and a
+// standard Web-Animations getComputedStyle sampler. Inputs arrive via globals set
+// by Go before each sign: __SEED (base64 str), __CURVES (JSON [[{color,deg,bezier}]]),
+// __PATH, __METHOD. Go also injects __goSha256(Uint8Array)->ArrayBuffer.
+(function () {
+ 'use strict';
+ var g = globalThis;
+
+ // ---- base64 (goja has no atob/btoa) ----
+ var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ g.atob = function (s) {
+ s = String(s).replace(/=+$/, '');
+ var out = '', bits = 0, val = 0;
+ for (var i = 0; i < s.length; i++) {
+ var c = B64.indexOf(s.charAt(i));
+ if (c < 0) continue;
+ val = (val << 6) | c; bits += 6;
+ if (bits >= 8) { bits -= 8; out += String.fromCharCode((val >> bits) & 0xff); }
+ }
+ return out;
+ };
+ g.btoa = function (s) {
+ s = String(s); var out = '';
+ for (var i = 0; i < s.length; i += 3) {
+ var b0 = s.charCodeAt(i), b1 = s.charCodeAt(i + 1), b2 = s.charCodeAt(i + 2);
+ var h0 = b0 >> 2, h1 = ((b0 & 3) << 4) | (b1 >> 4);
+ var h2 = ((b1 & 15) << 2) | (b2 >> 6), h3 = b2 & 63;
+ out += B64[h0] + B64[h1];
+ out += isNaN(b1) ? '=' : B64[h2];
+ out += isNaN(b2) ? '=' : B64[h3];
+ }
+ return out;
+ };
+
+ // ---- TextEncoder (goja has no TextEncoder) ----
+ if (typeof g.TextEncoder === 'undefined') {
+ g.TextEncoder = function () {};
+ g.TextEncoder.prototype.encode = function (str) {
+ str = String(str);
+ var bytes = [];
+ for (var i = 0; i < str.length; i++) {
+ var c = str.charCodeAt(i);
+ if (c < 0x80) bytes.push(c);
+ else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
+ else if (c >= 0xd800 && c <= 0xdbff) { // surrogate pair
+ var c2 = str.charCodeAt(++i);
+ var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
+ bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
+ } else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
+ }
+ return Uint8Array.from(bytes);
+ };
+ }
+
+ // ---- crypto.subtle.digest, backed by Go SHA-256 ----
+ g.crypto = g.crypto || {};
+ g.crypto.subtle = g.crypto.subtle || {};
+ g.crypto.subtle.digest = function (algo, data) {
+ // grok only ever asks for sha-256; g.__goSha256 returns an ArrayBuffer
+ var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
+ return Promise.resolve(g.__goSha256(bytes));
+ };
+
+ // ---- Web Animations getComputedStyle sampler (the only real math we own) ----
+ var K = 4096;
+ function cubicBezier(x1, y1, x2, y2, p) {
+ if (p <= 0) return 0; if (p >= 1) return 1;
+ function bez(t, a, b) { var mt = 1 - t; return 3 * a * mt * mt * t + 3 * b * mt * t * t + t * t * t; }
+ var lo = 0, hi = 1;
+ for (var i = 0; i < 100; i++) { var m = (lo + hi) / 2; if (bez(m, x1, x2) < p) lo = m; else hi = m; }
+ return bez((lo + hi) / 2, y1, y2);
+ }
+ function hexToRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
+ function sample(anim) {
+ var kf = anim.keyframes, dur = anim.duration || K;
+ var frac = anim.currentTime / dur; if (frac < 0) frac = 0; if (frac > 1) frac = 1;
+ var bm = /cubic-bezier\(([^)]+)\)/.exec(kf.easing || '');
+ var eased = frac;
+ if (bm) { var p = bm[1].split(',').map(Number); eased = cubicBezier(p[0], p[1], p[2], p[3], frac); }
+ var c0 = hexToRgb(kf.color[0]), c1 = hexToRgb(kf.color[1]);
+ var col = [0, 1, 2].map(function (i) { return Math.round(c0[i] + (c1[i] - c0[i]) * eased); });
+ var d0 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[0])[1]);
+ var d1 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[1])[1]);
+ var ang = (d0 + (d1 - d0) * eased) * Math.PI / 180;
+ var cos = Math.cos(ang), sin = Math.sin(ang);
+ return { color: 'rgb(' + col[0] + ', ' + col[1] + ', ' + col[2] + ')',
+ transform: 'matrix(' + cos + ', ' + sin + ', ' + (-sin) + ', ' + cos + ', 0, 0)' };
+ }
+
+ // ---- minimal DOM ----
+ function makeEl(props) {
+ var e = {
+ nodeName: 'DIV', style: {}, childNodes: [], attrs: {}, _anim: null, _parent: null,
+ setAttribute: function (k, v) { e.attrs[k] = v; },
+ getAttribute: function (k) { return (k in e.attrs) ? e.attrs[k] : (props && props.attrs && k in props.attrs ? props.attrs[k] : null); },
+ appendChild: function (c) { e.childNodes.push(c); return c; },
+ append: function (c) { e.childNodes.push(c); return c; },
+ removeChild: function (c) { return c; },
+ remove: function () {},
+ animate: function (keyframes, opts) {
+ var anim = {
+ keyframes: keyframes,
+ duration: (opts && opts.duration) || (typeof opts === 'number' ? opts : K),
+ currentTime: 0, pause: function () {}, play: function () {}, cancel: function () {},
+ effect: { getKeyframes: function () { return Array.isArray(keyframes) ? keyframes : [keyframes]; } },
+ };
+ e._anim = anim; return anim;
+ },
+ getAnimations: function () { return e._anim ? [e._anim] : []; },
+ };
+ if (props) for (var k in props) if (k !== 'attrs') e[k] = props[k];
+ Object.defineProperty(e, 'parentElement', { get: function () { return e._parent; } });
+ Object.defineProperty(e, 'innerHTML', { set: function (v) { e._html = v; }, get: function () { return e._html; } });
+ Object.defineProperty(e, 'textContent', { set: function (v) { e._text = v; }, get: function () { return e._text; } });
+ return e;
+ }
+
+ // .r-aufz1o group: g.childNodes[0].childNodes[1].getAttribute('d') = svg path whose
+ // numbers (after substring(9), split('C')) decode back to each curve [c0..c5,deg,b0..b3].
+ function groupEl(flatCurves) {
+ var d = '_________' + flatCurves.map(function (c) { return c.join(' '); }).join('C');
+ var path = makeEl({ attrs: { d: d } });
+ var inner = makeEl(); inner.childNodes = [makeEl(), path];
+ var outer = makeEl(); outer.childNodes = [inner]; outer._parent = makeEl();
+ return outer;
+ }
+
+ var docBody = makeEl();
+ g.document = {
+ currentScript: null, body: docBody, head: makeEl(),
+ createElement: function (tag) { return makeEl({ nodeName: String(tag || 'div').toUpperCase() }); },
+ querySelectorAll: function (sel) {
+ sel = String(sel);
+ if (/aufz1o/.test(sel)) {
+ var curves = JSON.parse(g.__CURVES);
+ return curves.map(function (grp) {
+ return groupEl(grp.map(function (cv) { return cv.color.concat([cv.deg], cv.bezier); }));
+ });
+ }
+ if (/verification|name/i.test(sel)) {
+ var seed = g.__SEED;
+ return [{ nodeName: 'META', getAttribute: function (a) { return a === 'content' ? seed : null; },
+ get content() { return seed; } }];
+ }
+ return [];
+ },
+ querySelector: function (sel) { var r = this.querySelectorAll(sel); return r[0] || null; },
+ };
+ g.window = g;
+ g.self = g;
+ g.getComputedStyle = function (el) { return el && el._anim ? sample(el._anim) : { color: 'rgb(0, 0, 0)', transform: 'none' }; };
+ g.navigator = g.navigator || { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' };
+ g.location = g.location || { href: 'https://grok.com/', origin: 'https://grok.com', pathname: '/' };
+
+ // ---- Turbopack capture + bootstrap ----
+ var TP = []; TP.push = function (entry) { TP._entry = entry; return 0; };
+ g.TURBOPACK = TP;
+
+ // Called by Go AFTER the signer chunk is eval'd: run the module factory, grab default.
+ g.__grokBootstrap = function () {
+ var entry = TP._entry;
+ if (!entry) throw new Error('turbopack entry not registered');
+ var factory = entry[2];
+ var exports = {};
+ var ctx = {
+ s: function () {
+ var flat = Array.prototype.slice.call(arguments).flat(Infinity);
+ var name = null;
+ for (var i = 0; i < flat.length; i++) {
+ var x = flat[i];
+ if (typeof x === 'string') name = x;
+ else if (typeof x === 'function' && name != null) {
+ (function (nm, getter) { Object.defineProperty(exports, nm, { get: getter, configurable: true, enumerable: true }); })(name, x);
+ name = null;
+ }
+ }
+ },
+ };
+ factory(ctx);
+ // exports.default is a GETTER that invokes the module factory on every read,
+ // returning a FRESH async signer (path,method)=>Promise with a fresh internal
+ // curve cache. Keep the exports object; read .default fresh per sign so different
+ // sessions' curves never leak through the closure cache.
+ g.__grokExports = exports;
+ if (typeof exports.default !== 'function') throw new Error('no default export');
+ };
+
+ // Called by Go per sign. Fresh signer each time via the re-invoking getter.
+ g.__grokSign = function () {
+ var signer = g.__grokExports.default; // fresh async signer
+ return signer(g.__PATH, g.__METHOD); // returns Promise
+ };
+
+ // Synchronous bridge: Go calls this via RunString (which drains goja's job queue),
+ // then reads __grokResult / __grokErr. Works because crypto.subtle.digest resolves
+ // synchronously (Promise.resolve over a Go SHA-256), so the whole await chain settles
+ // within the microtask drain.
+ g.__grokResult = null;
+ g.__grokErr = null;
+ g.__grokSignInto = function () {
+ g.__grokResult = null; g.__grokErr = null;
+ try {
+ g.__grokSign().then(
+ function (r) { g.__grokResult = r; },
+ function (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
+ );
+ } catch (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
+ };
+})();