supabase-rb 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/supabase/auth/README.md +10 -4
  3. data/lib/supabase/auth/admin_api.rb +4 -0
  4. data/lib/supabase/auth/admin_mfa_api.rb +30 -0
  5. data/lib/supabase/auth/async/admin_api.rb +4 -1
  6. data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
  7. data/lib/supabase/auth/async/client.rb +2 -4
  8. data/lib/supabase/auth/async.rb +1 -0
  9. data/lib/supabase/auth/client.rb +71 -31
  10. data/lib/supabase/auth/helpers.rb +4 -0
  11. data/lib/supabase/auth/types.rb +14 -5
  12. data/lib/supabase/auth.rb +1 -0
  13. data/lib/supabase/client.rb +103 -22
  14. data/lib/supabase/client_options.rb +1 -1
  15. data/lib/supabase/functions/README.md +99 -12
  16. data/lib/supabase/functions/client.rb +72 -31
  17. data/lib/supabase/functions/types.rb +24 -3
  18. data/lib/supabase/postgrest/async/client.rb +2 -0
  19. data/lib/supabase/postgrest/client.rb +9 -2
  20. data/lib/supabase/postgrest/errors.rb +18 -6
  21. data/lib/supabase/postgrest/request_builder.rb +5 -11
  22. data/lib/supabase/realtime/README.md +111 -0
  23. data/lib/supabase/realtime/callback_safety.rb +41 -0
  24. data/lib/supabase/realtime/channel.rb +89 -23
  25. data/lib/supabase/realtime/client.rb +130 -44
  26. data/lib/supabase/realtime/errors.rb +0 -13
  27. data/lib/supabase/realtime/message.rb +13 -3
  28. data/lib/supabase/realtime/presence.rb +84 -32
  29. data/lib/supabase/realtime/push.rb +11 -2
  30. data/lib/supabase/realtime/timer.rb +72 -0
  31. data/lib/supabase/realtime.rb +2 -1
  32. data/lib/supabase/storage/README.md +117 -0
  33. data/lib/supabase/storage/async/client.rb +7 -4
  34. data/lib/supabase/storage/client.rb +16 -4
  35. data/lib/supabase/storage/file_api.rb +36 -9
  36. data/lib/supabase/storage/request.rb +3 -1
  37. data/lib/supabase/storage/utils.rb +15 -1
  38. data/lib/supabase/version.rb +1 -1
  39. data/lib/supabase.rb +0 -7
  40. metadata +33 -16
  41. data/lib/supabase/realtime/test_socket.rb +0 -65
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7490edd0417af9cdc03db6da250e89db6117ff7e8b2e0e57981c993a77e11ad8
4
- data.tar.gz: dfca661ce05dac328aea1aaaec095c8ee1d82365185c6a6ef3e25383361872ba
3
+ metadata.gz: b6f3e676fb9f80387385b5339aff40fcdc8b584b9c8e66f92072f2ac9d7ba356
4
+ data.tar.gz: f9972703bfd0828183e1a600c5cad893c4102c728b913384919b41da9e4deb06
5
5
  SHA512:
6
- metadata.gz: e8156e656365854825396e52643c1055ca0b5c608b8365c260cdfbe89f4e71923771eb9b1cb0d73eefd8f9e7bf7b350948edfda829cff331ed5972fd467b4293
7
- data.tar.gz: 042a0c5e2132027ffbb2dab37fda4e93aae88aca605fba711bade4ca4765d5a269366bfc947ab1ad674462b25562920662fe1c053aebc6b55377688e769df9c6
6
+ metadata.gz: 39871694f25f18c296f358f6f85325f18f31a1752e2bb79dc96c6ce0551f5d0c10a72c5994a5302761e6e4df170eadae058dc69fa04dac5764db2568198d1b1a
7
+ data.tar.gz: aa76e506b180682651570e8b8a3c1a091901655a5d95ac5d642b962dc33dfd09f2c77d131117eb70c42a1163c63cd341d8941d1db3064ce7504943e75e771f35
@@ -148,7 +148,8 @@ dedicated class gives callers a precise `rescue` target.
148
148
 
149
149
  ### Explicit JWT algorithm → digest mapping
150
150
 
151
- `Supabase::Auth::Client::ALG_TO_DIGEST` is a frozen lookup table:
151
+ `Supabase::Auth::Client::ALG_TO_DIGEST` is a frozen reference table of the
152
+ asymmetric algorithms and their digests:
152
153
 
153
154
  ```ruby
154
155
  ALG_TO_DIGEST = {
@@ -158,9 +159,14 @@ ALG_TO_DIGEST = {
158
159
  }.freeze
159
160
  ```
160
161
 
161
- Python resolves algorithms dynamically via `PyJWT.get_algorithm_by_name`.
162
- The Ruby table makes the supported set readable in one place and fails fast
163
- (`AuthInvalidJwtError`) on unsupported `alg` values.
162
+ The actual *acceptance* check in `get_claims` runs against the broader
163
+ `SUPPORTED_ALGORITHMS` constant, which mirrors PyJWT's default algorithm set
164
+ (`HS256/384/512`, `RS*`, `ES*` + `ES256K`, `PS*`, `EdDSA`/`Ed25519`). Symmetric
165
+ tokens (`HS256` or any token without a `kid` header) fall back to
166
+ `get_user(token)` for verification — same as supabase-py. Unknown `alg`
167
+ values raise `AuthInvalidJwtError("Algorithm not supported")`, matching the
168
+ PyJWT `NotImplementedError` message verbatim. EdDSA verification additionally
169
+ requires the optional `rbnacl` gem at runtime.
164
170
 
165
171
  ## Development
166
172
 
@@ -10,6 +10,9 @@ module Supabase
10
10
  # @return [AdminOAuthApi] OAuth 2.1 client administration accessor
11
11
  attr_reader :oauth
12
12
 
13
+ # @return [AdminMfaApi] MFA administration accessor
14
+ attr_reader :mfa
15
+
13
16
  # @param url [String] The GoTrue API base URL
14
17
  # @param headers [Hash] Headers including Authorization bearer token
15
18
  # @param http_client [Faraday::Connection, nil] Optional custom Faraday client
@@ -19,6 +22,7 @@ module Supabase
19
22
  def initialize(url:, headers: {}, http_client: nil, verify: true, proxy: nil, timeout: nil)
20
23
  super(url: url, headers: headers, http_client: http_client, verify: verify, proxy: proxy, timeout: timeout)
21
24
  @oauth = AdminOAuthApi.new(self)
25
+ @mfa = AdminMfaApi.new(self)
22
26
  end
23
27
 
24
28
  # Creates a new user via the admin API.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Auth
5
+ # Admin MFA namespace. Mirrors supabase-py's SyncGoTrueAdminMFAAPI.
6
+ # Accessed via {AdminApi#mfa}; delegates to the underscored implementations
7
+ # on AdminApi (same pattern as {AdminOAuthApi} / {AdminApi#oauth}).
8
+ class AdminMfaApi
9
+ # @param admin [AdminApi]
10
+ def initialize(admin)
11
+ @admin = admin
12
+ end
13
+
14
+ # Lists MFA factors for a user.
15
+ # @param user_id [String] user UUID
16
+ # @return [Types::AuthMFAAdminListFactorsResponse]
17
+ def list_factors(user_id:)
18
+ @admin._list_factors(user_id: user_id)
19
+ end
20
+
21
+ # Deletes an MFA factor for a user.
22
+ # @param user_id [String] user UUID
23
+ # @param id [String] factor UUID
24
+ # @return [Types::AuthMFAAdminDeleteFactorResponse]
25
+ def delete_factor(user_id:, id:)
26
+ @admin._delete_factor(user_id: user_id, id: id)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -3,6 +3,7 @@
3
3
  require "async/http/faraday"
4
4
 
5
5
  require_relative "admin_oauth_api"
6
+ require_relative "admin_mfa_api"
6
7
 
7
8
  module Supabase
8
9
  module Auth
@@ -11,11 +12,13 @@ module Supabase
11
12
  #
12
13
  # Inherits all admin methods (user CRUD, generate_link, invite, MFA admin,
13
14
  # OAuth admin) and only swaps the Faraday adapter to async-http-faraday.
14
- # `admin.oauth` returns an {Async::AdminOAuthApi} for naming consistency.
15
+ # `admin.oauth` returns an {Async::AdminOAuthApi} and `admin.mfa` returns an
16
+ # {Async::AdminMfaApi} for naming consistency.
15
17
  class AdminApi < Supabase::Auth::AdminApi
16
18
  def initialize(url:, headers: {}, http_client: nil, verify: true, proxy: nil, timeout: nil)
17
19
  super
18
20
  @oauth = AdminOAuthApi.new(self)
21
+ @mfa = AdminMfaApi.new(self)
19
22
  end
20
23
 
21
24
  private
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Auth
5
+ module Async
6
+ # Async counterpart to {Supabase::Auth::AdminMfaApi}.
7
+ #
8
+ # Behavior is identical — it delegates to the wrapped {AdminApi}'s
9
+ # underscored MFA methods. The wrapped admin uses the async Faraday adapter,
10
+ # so calls inside `Async do ... end` yield to the reactor on I/O.
11
+ class AdminMfaApi < Supabase::Auth::AdminMfaApi
12
+ end
13
+ end
14
+ end
15
+ end
@@ -22,10 +22,8 @@ module Supabase
22
22
  class Client < Supabase::Auth::Client
23
23
  def initialize(url:, headers: {}, **options)
24
24
  super
25
- @api = Api.new(url: @url, headers: @headers, http_client: @http_client,
26
- verify: @verify, proxy: @proxy, timeout: @timeout)
27
- @admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client,
28
- verify: @verify, proxy: @proxy, timeout: @timeout)
25
+ @api = Api.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
26
+ @admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
29
27
  end
30
28
  end
31
29
  end
@@ -10,5 +10,6 @@
10
10
  require_relative "../auth"
11
11
  require_relative "async/api"
12
12
  require_relative "async/admin_oauth_api"
13
+ require_relative "async/admin_mfa_api"
13
14
  require_relative "async/admin_api"
14
15
  require_relative "async/client"
@@ -13,13 +13,25 @@ module Supabase
13
13
  STORAGE_KEY = "supabase.auth.token"
14
14
  EXPIRY_MARGIN = 10
15
15
  JWKS_TTL = 600 # 10 minutes
16
- # Explicit algorithm-to-digest mapping; Python uses PyJWT's dynamic get_algorithm_by_name (F-008).
16
+ # Explicit asymmetric algorithm-to-digest mapping (reference table).
17
17
  ALG_TO_DIGEST = {
18
18
  "RS256" => "SHA256", "RS384" => "SHA384", "RS512" => "SHA512",
19
19
  "ES256" => "SHA256", "ES384" => "SHA384", "ES512" => "SHA512",
20
20
  "PS256" => "SHA256", "PS384" => "SHA384", "PS512" => "SHA512"
21
21
  }.freeze
22
22
 
23
+ # Full set of algorithms accepted by `get_claims`, matching the PyJWT
24
+ # defaults that supabase-py relies on via `get_algorithm_by_name`.
25
+ # EdDSA / Ed25519 verification additionally requires the optional
26
+ # `rbnacl` gem at runtime; the algorithm name is still accepted here.
27
+ SUPPORTED_ALGORITHMS = %w[
28
+ HS256 HS384 HS512
29
+ RS256 RS384 RS512
30
+ ES256 ES256K ES384 ES512
31
+ PS256 PS384 PS512
32
+ EdDSA Ed25519
33
+ ].freeze
34
+
23
35
  DEFAULT_OPTIONS = {
24
36
  auto_refresh_token: true,
25
37
  persist_session: true,
@@ -27,7 +39,7 @@ module Supabase
27
39
  flow_type: "implicit"
28
40
  }.freeze
29
41
 
30
- attr_reader :url, :headers, :admin, :mfa
42
+ attr_reader :url, :headers, :admin, :mfa, :logger
31
43
 
32
44
  # @param url [String] GoTrue server URL
33
45
  # @param headers [Hash] HTTP headers to include with every request
@@ -37,10 +49,12 @@ module Supabase
37
49
  # @option options [String] :flow_type ("implicit") OAuth flow type ("implicit" or "pkce")
38
50
  # @option options [SupportedStorage] :storage custom storage backend
39
51
  # @option options [Faraday::Connection] :http_client custom HTTP client
52
+ # @option options [#warn] :logger optional logger; if nil, auto-refresh
53
+ # failures fall back to Kernel#warn ($stderr).
40
54
  def initialize(url:, headers: {}, **options)
41
55
  opts = DEFAULT_OPTIONS.merge(options)
42
56
  @url = url
43
- @headers = headers
57
+ @headers = Constants::DEFAULT_HEADERS.merge(headers)
44
58
  @auto_refresh_token = opts[:auto_refresh_token]
45
59
  @persist_session = opts[:persist_session]
46
60
  @detect_session_in_url = opts[:detect_session_in_url]
@@ -51,6 +65,7 @@ module Supabase
51
65
  @verify = opts.fetch(:verify, true)
52
66
  @proxy = opts[:proxy]
53
67
  @timeout = opts[:timeout]
68
+ @logger = opts[:logger]
54
69
 
55
70
  @current_session = nil
56
71
  @jwks = { "keys" => [] }
@@ -59,10 +74,8 @@ module Supabase
59
74
  @refresh_token_timer = nil
60
75
  @network_retries = 0
61
76
 
62
- @api = Api.new(url: @url, headers: @headers, http_client: @http_client,
63
- verify: @verify, proxy: @proxy, timeout: @timeout)
64
- @admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client,
65
- verify: @verify, proxy: @proxy, timeout: @timeout)
77
+ @api = Api.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
78
+ @admin = AdminApi.new(url: @url, headers: @headers, http_client: @http_client, verify: @verify, proxy: @proxy, timeout: @timeout)
66
79
  @mfa = MFAApi.new(self)
67
80
  end
68
81
 
@@ -109,6 +122,11 @@ module Supabase
109
122
  channel = options[:channel] || "sms"
110
123
  captcha_token = options[:captcha_token]
111
124
 
125
+ if password.nil? && (email || phone)
126
+ raise Errors::AuthInvalidCredentialsError,
127
+ "Sign up requires a password; for passwordless sign-in use sign_in_with_otp"
128
+ end
129
+
112
130
  if email
113
131
  body = {
114
132
  email: email,
@@ -378,14 +396,12 @@ module Supabase
378
396
  # @option options [String] :scope ("global") sign-out scope: "global", "local", or "others"
379
397
  def sign_out(options = {})
380
398
  scope = options[:scope] || options["scope"] || "global"
381
- session = get_session
382
399
 
383
- if session
384
- begin
385
- @admin.sign_out(session.access_token, scope)
386
- rescue Errors::AuthApiError
387
- # Suppress API errors from admin sign_out
388
- end
400
+ begin
401
+ session = get_session
402
+ @admin.sign_out(session.access_token, scope) if session
403
+ rescue Errors::AuthApiError
404
+ # Suppress API errors from get_session/admin.sign_out so logout always clears local state.
389
405
  end
390
406
 
391
407
  unless scope == "others"
@@ -542,8 +558,8 @@ module Supabase
542
558
  session = get_session
543
559
  raise Errors::AuthSessionMissing unless session
544
560
 
545
- data = _request("GET", "reauthenticate", jwt: session.access_token)
546
- Helpers.parse_auth_response(data)
561
+ _request("GET", "reauthenticate", jwt: session.access_token)
562
+ Types::AuthResponse.new(user: nil, session: nil)
547
563
  end
548
564
 
549
565
  # Send a password reset email. Does not require an active session.
@@ -614,10 +630,7 @@ module Supabase
614
630
  session = get_session
615
631
  raise Errors::AuthSessionMissing unless session
616
632
 
617
- link_identity = _request("GET", url,
618
- params: query,
619
- jwt: session.access_token,
620
- xform: ->(data) { Helpers.parse_link_identity_response(data) })
633
+ link_identity = _request("GET", url, params: query, jwt: session.access_token, xform: ->(data) { Helpers.parse_link_identity_response(data) })
621
634
  Types::OAuthResponse.new(provider: provider, url: link_identity.url)
622
635
  end
623
636
 
@@ -695,12 +708,14 @@ module Supabase
695
708
  return Types::ClaimsResponse.new(claims: payload, headers: header, signature: signature)
696
709
  end
697
710
 
711
+ # Mirror PyJWT's `get_algorithm_by_name` — reject unknown algs with the
712
+ # same message py would raise.
713
+ raise Errors::AuthInvalidJwtError, "Algorithm not supported" unless SUPPORTED_ALGORITHMS.include?(header["alg"])
714
+
698
715
  # Asymmetric JWT - verify via JWKS using the jwt gem's decode
699
716
  jwk_data = _fetch_jwks(header["kid"], jwks || { "keys" => [] })
700
717
  jwk_set = JWT::JWK::Set.new({ "keys" => [jwk_data] })
701
718
 
702
- raise Errors::AuthInvalidJwtError, "Unsupported algorithm: #{header["alg"]}" unless ALG_TO_DIGEST[header["alg"]]
703
-
704
719
  begin
705
720
  JWT.decode(token, nil, true, { algorithms: [header["alg"]], jwks: jwk_set })
706
721
  rescue JWT::DecodeError => e
@@ -806,12 +821,14 @@ module Supabase
806
821
  _call_refresh_token(session.refresh_token)
807
822
  @network_retries = 0
808
823
  end
809
- rescue Errors::AuthRetryableError
824
+ rescue Errors::AuthRetryableError => e
825
+ _log_refresh_error("auto_refresh", e)
810
826
  if @network_retries < Constants::MAX_RETRIES
811
827
  _start_auto_refresh_token(200 * (Constants::RETRY_INTERVAL ** (@network_retries - 1)))
812
828
  end
813
- rescue StandardError
814
- # Swallow other errors
829
+ rescue StandardError => e
830
+ # Non-retryable failure: log once, do NOT reschedule (no infinite loop).
831
+ _log_refresh_error("auto_refresh", e)
815
832
  end
816
833
  end
817
834
  @refresh_token_timer.start
@@ -838,7 +855,8 @@ module Supabase
838
855
  begin
839
856
  _call_refresh_token(refresh_token)
840
857
  @network_retries = 0
841
- rescue Errors::AuthRetryableError
858
+ rescue Errors::AuthRetryableError => e
859
+ _log_refresh_error("recover_and_refresh", e)
842
860
  if @network_retries < Constants::MAX_RETRIES
843
861
  if @refresh_token_timer
844
862
  @refresh_token_timer.cancel
@@ -849,8 +867,9 @@ module Supabase
849
867
  @refresh_token_timer.start
850
868
  return
851
869
  end
852
- rescue StandardError
853
- # Swallow other errors
870
+ rescue StandardError => e
871
+ # Non-retryable failure: log once, do NOT reschedule (no infinite loop).
872
+ _log_refresh_error("recover_and_refresh", e)
854
873
  end
855
874
  end
856
875
  _remove_session
@@ -882,6 +901,21 @@ module Supabase
882
901
  Helpers.parse_auth_response(data)
883
902
  end
884
903
 
904
+ # Log an auto-refresh failure. Used by both `_start_auto_refresh_token`
905
+ # and `_recover_and_refresh` so the diverged-from-py contract
906
+ # ("any refresh error is logged with class + message") is enforced in
907
+ # one place. Falls back to Kernel#warn when no logger is injected.
908
+ def _log_refresh_error(context, error)
909
+ msg = "[Supabase::Auth] refresh failed in #{context}: #{error.class}: #{error.message}"
910
+ if @logger.respond_to?(:warn)
911
+ @logger.warn(msg)
912
+ else
913
+ Kernel.warn(msg)
914
+ end
915
+ rescue StandardError
916
+ # Never let logging itself break the refresh loop.
917
+ end
918
+
885
919
  def _list_factors
886
920
  mfa.list_factors
887
921
  end
@@ -949,17 +983,23 @@ module Supabase
949
983
 
950
984
  private
951
985
 
986
+ # US-005 / Q1: parity with supabase-py — `_get_valid_session` checks only
987
+ # `expires_at` (matches `gotrue_client.py:_get_valid_session`, which after
988
+ # pydantic-validation does a single explicit `expires_at is None` check).
989
+ # Defence against missing access_token/refresh_token/user is deferred to
990
+ # the use site: `_call_refresh_token` raises `AuthSessionMissing` for an
991
+ # empty refresh token, `get_user`/admin calls return early on a nil
992
+ # access token, and `Types::Session.from_hash` tolerates a nil `user`.
952
993
  def _get_valid_session(raw_session)
953
994
  return nil unless raw_session
954
995
 
955
996
  begin
956
997
  data = raw_session.is_a?(String) ? JSON.parse(raw_session) : raw_session
957
998
  return nil unless data
958
- return nil unless data["access_token"] || data[:access_token]
959
- return nil unless data["refresh_token"] || data[:refresh_token]
960
- return nil unless data["expires_at"] || data[:expires_at]
961
999
 
962
1000
  expires_at = data["expires_at"] || data[:expires_at]
1001
+ return nil if expires_at.nil?
1002
+
963
1003
  begin
964
1004
  expires_at = Integer(expires_at)
965
1005
  data["expires_at"] = expires_at
@@ -102,6 +102,10 @@ module Supabase
102
102
  end
103
103
 
104
104
  begin
105
+ if exception.is_a?(Faraday::TimeoutError) || exception.response.nil?
106
+ return Errors::AuthRetryableError.new(exception.message, status: 0)
107
+ end
108
+
105
109
  response = exception.response
106
110
  status = response[:status]
107
111
 
@@ -368,11 +368,20 @@ module Supabase
368
368
  :factors,
369
369
  keyword_init: true
370
370
  ) do
371
- def self.from_hash(hash)
372
- return nil if hash.nil?
373
-
374
- factors = (hash["factors"] || hash[:factors] || []).map { |f| Factor.from_hash(f) }
375
- new(factors: factors)
371
+ # Accepts both forms:
372
+ # - bare Array of factor hashes (supabase-py / current GoTrue response)
373
+ # - `{"factors" => [...]}` wrapped Hash (legacy)
374
+ def self.from_hash(data)
375
+ return nil if data.nil?
376
+
377
+ raw_factors = if data.is_a?(Array)
378
+ data
379
+ elsif data.is_a?(Hash)
380
+ data["factors"] || data[:factors] || []
381
+ else
382
+ []
383
+ end
384
+ new(factors: raw_factors.map { |f| Factor.from_hash(f) })
376
385
  end
377
386
  end
378
387
 
data/lib/supabase/auth.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "auth/errors"
7
7
  require_relative "auth/api"
8
8
  require_relative "auth/admin_api"
9
9
  require_relative "auth/admin_oauth_api"
10
+ require_relative "auth/admin_mfa_api"
10
11
  require_relative "auth/helpers"
11
12
  require_relative "auth/storage"
12
13
  require_relative "auth/memory_storage"
@@ -71,7 +71,31 @@ module Supabase
71
71
 
72
72
  @supabase_url = supabase_url.to_s.chomp("/")
73
73
  @supabase_key = supabase_key
74
- @options = options || {}
74
+ # Plain Hash → ClientOptions: paritet with supabase-py, where every
75
+ # option flows through a typed dataclass. The legacy nested
76
+ # `{ auth: {...}, postgrest: {...}, global: { headers: {...} } }` shape
77
+ # is kept as a raw Hash so existing callers don't break — anything
78
+ # else is canonicalized into a ClientOptions struct so the per-sub-
79
+ # client kwargs derivation has one code path.
80
+ legacy_hash_shape =
81
+ options.is_a?(Hash) &&
82
+ options.keys.any? { |k| %i[auth postgrest storage functions realtime global].include?(k.to_sym) }
83
+
84
+ @options =
85
+ if options.is_a?(Hash) && !legacy_hash_shape
86
+ ClientOptions.new(**options.transform_keys(&:to_sym))
87
+ elsif options.is_a?(Supabase::ClientOptions)
88
+ # Mirror supabase-py's `self.options = copy.copy(options)` followed by
89
+ # `self.options.headers = {**options.headers, ...}` — both the struct
90
+ # and its headers hash become unique to this client, so a downstream
91
+ # `client.options.headers["X"] = ...` mutation can't leak across
92
+ # clients constructed from the same `ClientOptions` instance (F-C?).
93
+ isolated = options.dup
94
+ isolated.headers = isolated.headers.dup
95
+ isolated
96
+ else
97
+ options
98
+ end
75
99
  @async = async
76
100
 
77
101
  configured_headers =
@@ -103,8 +127,7 @@ module Supabase
103
127
  @auth.on_auth_state_change do |event, session|
104
128
  next unless %w[SIGNED_IN TOKEN_REFRESHED SIGNED_OUT].include?(event)
105
129
 
106
- token = session&.access_token || @supabase_key
107
- propagate_auth(token)
130
+ apply_auth(session&.access_token)
108
131
  end
109
132
  @auth
110
133
  end
@@ -139,10 +162,47 @@ module Supabase
139
162
  postgrest.from(table)
140
163
  end
141
164
 
165
+ # Alias for {#from}. Mirrors supabase-py's `Client.table(table_name)` so
166
+ # code ported from Python (`client.table("users").select("*")`) works
167
+ # unchanged.
168
+ # @see supabase-py supabase/_sync/client.py:128
169
+ alias table from
170
+
142
171
  def rpc(func, params = {}, **opts)
143
172
  postgrest.rpc(func, params, **opts)
144
173
  end
145
174
 
175
+ # Realtime shortcuts on the umbrella — mirror supabase-py so callers can do
176
+ # `client.channel("public:users")` instead of `client.realtime.channel(...)`.
177
+ # The `realtime:` topic prefix is still optional (handled inside the
178
+ # Realtime client).
179
+ def channel(topic, params: nil)
180
+ realtime.channel(topic, params: params)
181
+ end
182
+
183
+ def get_channels
184
+ realtime.get_channels
185
+ end
186
+
187
+ # Unsubscribe a channel and drop it from the realtime registry. Mirrors
188
+ # supabase-py: the sync client blocks until the phx_leave frame is written;
189
+ # the async client (`async def remove_channel`) lets callers await it.
190
+ # Under `async: true` we get the same shape via {#dispatch_realtime} — the
191
+ # call returns an `Async::Task` the caller may `.wait` on (US-050), so a
192
+ # slow `Socket#send` never stalls the calling fiber.
193
+ # @see supabase-py supabase/_async/client.py:231
194
+ def remove_channel(channel)
195
+ dispatch_realtime { realtime.remove_channel(channel) }
196
+ end
197
+
198
+ # Unsubscribe every realtime channel registered on this client. Mirrors
199
+ # supabase-py's `Client.remove_all_channels`; same sync/async contract as
200
+ # {#remove_channel}.
201
+ # @see supabase-py supabase/_sync/client.py:234
202
+ def remove_all_channels
203
+ dispatch_realtime { realtime.remove_all_channels }
204
+ end
205
+
146
206
  # Return a Postgrest client scoped to `name` without mutating self. Matches
147
207
  # supabase-py: `client.schema("foo").from_("x")` queries the foo schema but
148
208
  # leaves `client.from(...)` (and other call sites) on the default schema.
@@ -155,29 +215,47 @@ module Supabase
155
215
  # Update the Authorization header used by every sub-client. Useful after
156
216
  # auth.sign_in returns a fresh JWT — the apikey stays the same but the
157
217
  # bearer token becomes the user's access token.
218
+ #
219
+ # Breaking change vs <=3.1.1: `set_auth(nil)` no longer drops the memoized
220
+ # auth sub-client (and with it any persisted session). Call `auth.sign_out`
221
+ # to clear session state.
158
222
  def set_auth(token)
159
- @headers["Authorization"] = "Bearer #{token || @supabase_key}"
160
- # Reset memoized sub-clients so they pick up the new header on next access.
161
- # Realtime gets its own pathway (set_auth pushes access_token frames).
162
- @auth = nil
163
- @storage = nil
164
- @functions = nil
165
- @postgrest = nil
166
- @realtime&.set_auth(token)
223
+ apply_auth(token)
167
224
  self
168
225
  end
169
226
 
170
227
  private
171
228
 
172
- # Refresh the Authorization header (used by every sub-client other than
173
- # auth itself, which manages its own headers) and reset the memoized
174
- # sub-clients so they pick up the new token on next access.
175
- def propagate_auth(token)
176
- @headers["Authorization"] = "Bearer #{token}"
177
- @storage = nil
178
- @functions = nil
179
- @postgrest = nil
180
- @realtime&.set_auth(token)
229
+ # Single internal path shared by the public `#set_auth` and the
230
+ # `on_auth_state_change` listener installed on `#auth`. Refreshes the
231
+ # Authorization header used by every non-auth sub-client and resets their
232
+ # memoized instances so they pick up the new token on next access.
233
+ # `@auth` is intentionally preserved — clearing it would also discard the
234
+ # in-memory persisted session held by its storage backend.
235
+ #
236
+ # Under `async: true` the realtime fan-out is dispatched as a child
237
+ # `Async` task so the calling fiber returns immediately instead of
238
+ # waiting for every joined channel's `Socket#send` to drain — see
239
+ # spec/async/apply_auth_non_blocking_spec.rb (US-047 / US-048).
240
+ def apply_auth(token)
241
+ @headers["Authorization"] = "Bearer #{token || @supabase_key}"
242
+ @storage = @functions = @postgrest = nil
243
+ dispatch_realtime { @realtime&.set_auth(token) }
244
+ end
245
+
246
+ # Shared dispatch for every umbrella → realtime call that may touch the
247
+ # socket (`set_auth` fan-out, `remove_channel`, `remove_all_channels`).
248
+ # The realtime client is thread-based, so its socket writes are plain
249
+ # blocking Ruby. Sync mode calls straight through. Under `async: true`
250
+ # the block runs in a child `Async` task: inside a reactor the calling
251
+ # fiber gets the task back immediately (`.wait` restores Python's `await`
252
+ # semantics); outside a reactor `Async { }` degrades to running inline,
253
+ # which matches the sync path.
254
+ def dispatch_realtime(&block)
255
+ return yield unless @async
256
+
257
+ require "async" unless defined?(Async)
258
+ Async(&block)
181
259
  end
182
260
 
183
261
  def auth_class
@@ -234,7 +312,7 @@ module Supabase
234
312
  case key
235
313
  when :auth
236
314
  { auto_refresh_token: o.auto_refresh_token, persist_session: o.persist_session,
237
- storage: o.storage, flow_type: o.flow_type }.compact
315
+ storage: o.storage, flow_type: o.flow_type, http_client: o.http_client }.compact
238
316
  when :postgrest
239
317
  { schema: o.schema, timeout: o.postgrest_client_timeout, http_client: o.http_client }.compact
240
318
  when :storage
@@ -250,7 +328,10 @@ module Supabase
250
328
  end
251
329
 
252
330
  # Factory that matches supabase-py's `supabase.create_client()` signature.
331
+ # Routes through `Client.create` so a persisted session in the auth client's
332
+ # storage is restored at construction time and its access_token becomes the
333
+ # initial Authorization bearer — instead of the anon key. See F-C6 / US-021.
253
334
  def self.create_client(supabase_url:, supabase_key:, options: {}, async: false)
254
- Client.new(supabase_url: supabase_url, supabase_key: supabase_key, options: options, async: async)
335
+ Client.create(supabase_url: supabase_url, supabase_key: supabase_key, options: options, async: async)
255
336
  end
256
337
  end
@@ -23,7 +23,7 @@ module Supabase
23
23
  class ClientOptions
24
24
  DEFAULT_POSTGREST_TIMEOUT = 120
25
25
  DEFAULT_STORAGE_TIMEOUT = 20
26
- DEFAULT_FUNCTIONS_TIMEOUT = 60
26
+ DEFAULT_FUNCTIONS_TIMEOUT = 5
27
27
 
28
28
  DEFAULT_HEADERS = { "X-Client-Info" => "supabase-rb/#{Supabase::VERSION}" }.freeze
29
29