authproto 0.3.0.beta1

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 (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. metadata +200 -0
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ require_relative '../configuration'
6
+ require_relative 'interface'
7
+
8
+ module AuthProto
9
+ module Storage
10
+
11
+ #
12
+ # Thread-safe in-memory storage implementation
13
+ #
14
+
15
+ class Memory < Interface
16
+
17
+ # Creates an empty in-memory store and starts a background expiry-cleanup thread.
18
+ #
19
+ # Data and locks are local to this instance and are lost on process exit.
20
+ #
21
+ def initialize
22
+ super
23
+ @data = {}
24
+ @locks = {}
25
+ @expirations = {}
26
+ @monitor = Monitor.new
27
+ @cleanup_interval = 60 # 1 minute
28
+ start_cleanup_thread
29
+ end
30
+
31
+ # Stores a value in Memory storage.
32
+ #
33
+ # @param key [String] nonempty key beginning with `atproto:`
34
+ # @param value [Object] value retained by reference
35
+ # @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
36
+ # @return [true] if the write succeeds
37
+ # @raise [StorageError] if key or TTL validation fails
38
+ #
39
+ def set(key, value, ttl: nil)
40
+ validate_key(key)
41
+ validate_ttl(ttl)
42
+
43
+ @monitor.synchronize do
44
+ @data[key] = value
45
+ set_expiration(key, ttl) if ttl
46
+ true
47
+ end
48
+ end
49
+
50
+ # Retrieves an unexpired value from Memory storage.
51
+ #
52
+ # @param key [String] nonempty key beginning with `atproto:`
53
+ # @return [Object, nil] stored value, or `nil` if missing/expired
54
+ # @raise [StorageError] if the key is invalid
55
+ #
56
+ def get(key)
57
+ validate_key(key)
58
+
59
+ @monitor.synchronize do
60
+ return nil if expired?(key)
61
+
62
+ @data[key]
63
+ end
64
+ end
65
+
66
+ # Deletes a key from Memory storage.
67
+ #
68
+ # @param key [String] nonempty key beginning with `atproto:`
69
+ # @return [true] `true`, including when the key was absent
70
+ # @raise [StorageError] if the key is invalid
71
+ #
72
+ def delete(key)
73
+ validate_key(key)
74
+
75
+ @monitor.synchronize do
76
+ @data.delete(key)
77
+ @expirations.delete(key)
78
+ true
79
+ end
80
+ end
81
+
82
+ # Checks for an unexpired key in Memory storage.
83
+ #
84
+ # @param key [String] nonempty key beginning with `atproto:`
85
+ # @return [Boolean] whether the key exists and has not expired
86
+ # @raise [StorageError] if the key is invalid
87
+ #
88
+ def exists?(key)
89
+ validate_key(key)
90
+
91
+ @monitor.synchronize do
92
+ return false unless @data.key?(key)
93
+ return false if expired?(key)
94
+
95
+ true
96
+ end
97
+ end
98
+
99
+ # Acquires a Memory lock, yields, and releases the lock when the block exits.
100
+ #
101
+ # Locks expire after `ttl` seconds and are not renewed while the block runs.
102
+ # Exceptions from the block propagate to the caller.
103
+ #
104
+ # @param key [String] nonempty lock key beginning with `atproto:`
105
+ # @param ttl [Integer] positive lock lifetime in seconds
106
+ #
107
+ # @yield work to protect with the lock
108
+ # @yieldreturn [Object] value returned to the caller
109
+ # @return [Object] block result
110
+ #
111
+ # @raise [LockError] if another owner holds the lock
112
+ # @raise [StorageError] if key or TTL validation fails
113
+ # @raise [ArgumentError] if no block is supplied
114
+ #
115
+ def with_lock(key, ttl: 30)
116
+ raise ArgumentError, "Block required" unless block_given?
117
+
118
+ acquired = acquire_lock(key, ttl: ttl)
119
+ raise LockError, "Failed to acquire lock" unless acquired
120
+
121
+ begin
122
+ yield
123
+ ensure
124
+ release_lock(key)
125
+ end
126
+ end
127
+
128
+ # Clears all values, expiration records and locks from this store.
129
+ # @return [void]
130
+ def clear
131
+ @monitor.synchronize do
132
+ @data.clear
133
+ @expirations.clear
134
+ @locks.clear
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def acquire_lock(key, ttl:)
141
+ validate_key(key)
142
+ validate_ttl(ttl)
143
+ lock_key = "lock:#{key}"
144
+
145
+ @monitor.synchronize do
146
+ return false if @locks[lock_key] && !lock_expired?(lock_key)
147
+
148
+ @locks[lock_key] = Time.now.to_i + ttl
149
+ true
150
+ end
151
+ end
152
+
153
+ def release_lock(key)
154
+ validate_key(key)
155
+ lock_key = "lock:#{key}"
156
+
157
+ @monitor.synchronize do
158
+ @locks.delete(lock_key)
159
+ true
160
+ end
161
+ end
162
+
163
+ def expired?(key)
164
+ return false unless @expirations.key?(key)
165
+
166
+ expiry = @expirations[key]
167
+ if Time.now.to_i >= expiry
168
+ @data.delete(key)
169
+ @expirations.delete(key)
170
+ true
171
+ else
172
+ false
173
+ end
174
+ end
175
+
176
+ def lock_expired?(lock_key)
177
+ expiry = @locks[lock_key]
178
+ return false unless expiry
179
+
180
+ if Time.now.to_i >= expiry
181
+ @locks.delete(lock_key)
182
+ true
183
+ else
184
+ false
185
+ end
186
+ end
187
+
188
+ def set_expiration(key, ttl)
189
+ @expirations[key] = Time.now.to_i + ttl
190
+ end
191
+
192
+ def cleanup_expired
193
+ @monitor.synchronize do
194
+ now = Time.now.to_i
195
+
196
+ # Cleanup expired data
197
+ @expirations.each do |key, expiry|
198
+ if now >= expiry
199
+ @data.delete(key)
200
+ @expirations.delete(key)
201
+ end
202
+ end
203
+
204
+ # Cleanup expired locks
205
+ @locks.delete_if { |key, expiry| now >= expiry }
206
+ end
207
+ end
208
+
209
+ def start_cleanup_thread
210
+ Thread.new do
211
+ loop do
212
+ sleep @cleanup_interval
213
+ cleanup_expired
214
+ rescue StandardError => e
215
+ # Log error but keep thread running
216
+ AuthProto.configuration.logger.error "Storage cleanup error: #{e.message}"
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'interface'
4
+
5
+ begin
6
+ require 'redis'
7
+ rescue LoadError
8
+ warn "Error: missing Redis adapter. Add this to your Gemfile:"
9
+ warn " gem 'redis', '~> 5.4'"
10
+ raise
11
+ end
12
+
13
+ module AuthProto
14
+ module Storage
15
+
16
+ #
17
+ # Redis storage implementation
18
+ #
19
+
20
+ class Redis < Interface
21
+
22
+ # Error raised for Redis-specific issues
23
+ class RedisError < StorageError; end
24
+
25
+ # Creates a Redis adapter; connections are opened lazily by the Redis client.
26
+ #
27
+ # Requires the optional `redis` gem. Values are stored using Redis string semantics.
28
+ #
29
+ # @param redis_client [::Redis, nil] existing Redis connection, or `nil` for `Redis.new`
30
+ #
31
+ def initialize(redis_client: nil)
32
+ super()
33
+ @redis_client = redis_client || ::Redis.new
34
+ end
35
+
36
+ # Stores a value in Redis storage.
37
+ #
38
+ # @param key [String] nonempty key beginning with `atproto:`
39
+ # @param value [#to_s, nil] value converted to Redis string data; `nil` is stored as an empty string
40
+ # @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
41
+ #
42
+ # @return [true] if the write succeeds
43
+ # @raise [StorageError] if key or TTL validation fails
44
+ # @raise [RedisError] if the backend operation fails
45
+ #
46
+ def set(key, value, ttl: nil)
47
+ validate_key(key)
48
+ validate_ttl(ttl)
49
+
50
+ @redis_client.set(key, value, ex: ttl)
51
+ true
52
+ rescue ::Redis::BaseError => e
53
+ raise RedisError, "Failed to set value: #{e.message}"
54
+ end
55
+
56
+ # Retrieves an unexpired value from Redis storage.
57
+ #
58
+ # @param key [String] nonempty key beginning with `atproto:`
59
+ # @return [String, nil] stored value, or `nil` if missing/expired; empty Redis strings also return `nil`
60
+ # @raise [StorageError] if the key is invalid
61
+ # @raise [RedisError] if the backend operation fails
62
+ #
63
+ def get(key)
64
+ validate_key(key)
65
+
66
+ value = @redis_client.get(key)
67
+ value.nil? || value == "" ? nil : value
68
+ rescue ::Redis::BaseError => e
69
+ raise RedisError, "Failed to get value: #{e.message}"
70
+ end
71
+
72
+ # Deletes a key from Redis storage.
73
+ #
74
+ # @param key [String] nonempty key beginning with `atproto:`
75
+ # @return [Boolean] whether a stored row/key was deleted
76
+ # @raise [StorageError] if the key is invalid
77
+ # @raise [RedisError] if the backend operation fails
78
+ #
79
+ def delete(key)
80
+ validate_key(key)
81
+
82
+ @redis_client.del(key).positive?
83
+ rescue ::Redis::BaseError => e
84
+ raise RedisError, "Failed to delete value: #{e.message}"
85
+ end
86
+
87
+ # Checks for an unexpired key in Redis storage.
88
+ #
89
+ # @param key [String] nonempty key beginning with `atproto:`
90
+ # @return [Boolean] whether the key exists and has not expired
91
+ # @raise [StorageError] if the key is invalid
92
+ # @raise [RedisError] if the backend operation fails
93
+ #
94
+ def exists?(key)
95
+ validate_key(key)
96
+
97
+ @redis_client.exists?(key)
98
+ rescue ::Redis::BaseError => e
99
+ raise RedisError, "Failed to check existence: #{e.message}"
100
+ end
101
+
102
+ # Acquires a Redis lock, yields, and releases the lock when the block exits.
103
+ #
104
+ # Locks expire after `ttl` seconds and are not renewed while the block runs.
105
+ # Exceptions from the block propagate to the caller.
106
+ #
107
+ # @param key [String] nonempty lock key beginning with `atproto:`
108
+ # @param ttl [Integer] positive lock lifetime in seconds
109
+ #
110
+ # @yield work to protect with the lock
111
+ # @yieldreturn [Object] value returned to the caller
112
+ # @return [Object] block result
113
+ #
114
+ # @raise [LockError] if another owner holds the lock
115
+ # @raise [StorageError] if key or TTL validation fails
116
+ # @raise [ArgumentError] if no block is supplied
117
+ # @raise [RedisError] if the backend operation fails
118
+ #
119
+ def with_lock(key, ttl: 30)
120
+ raise ArgumentError, "Block required" unless block_given?
121
+
122
+ acquired = acquire_lock(key, ttl: ttl)
123
+ raise LockError, "Failed to acquire lock" unless acquired
124
+
125
+ begin
126
+ yield
127
+ ensure
128
+ release_lock(key)
129
+ end
130
+ rescue ::Redis::BaseError => e
131
+ raise RedisError, "Lock operation failed: #{e.message}"
132
+ end
133
+
134
+ private
135
+
136
+ def acquire_lock(key, ttl:)
137
+ validate_key(key)
138
+ validate_ttl(ttl)
139
+
140
+ lock_key = "atproto:locks:#{key}"
141
+ @redis_client.set(lock_key, Time.now.to_i, nx: true, ex: ttl)
142
+ rescue ::Redis::BaseError => e
143
+ raise RedisError, "Failed to acquire lock: #{e.message}"
144
+ end
145
+
146
+ def release_lock(key)
147
+ validate_key(key)
148
+
149
+ lock_key = "atproto:locks:#{key}"
150
+ @redis_client.del(lock_key).positive?
151
+ rescue ::Redis::BaseError => e
152
+ raise RedisError, "Failed to release lock: #{e.message}"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+
6
+ require_relative 'interface'
7
+
8
+ begin
9
+ require 'sqlite3'
10
+ rescue LoadError
11
+ warn "Error: missing SQLite adapter. Add this to your Gemfile:"
12
+ warn " gem 'sqlite3', '~> 2.9'"
13
+ raise
14
+ end
15
+
16
+ module AuthProto
17
+ module Storage
18
+
19
+ #
20
+ # SQLite storage implementation
21
+ #
22
+
23
+ class SQLite < Interface
24
+
25
+ # Error raised for SQLite-specific issues
26
+ class SQLiteError < StorageError; end
27
+
28
+ # Default SQLite lock-wait timeout in milliseconds.
29
+ # @return [Integer]
30
+ DEFAULT_BUSY_TIMEOUT = 5_000
31
+
32
+ # Opens a SQLite store, creating parent directories and tables as needed.
33
+ #
34
+ # Enables WAL mode and full synchronous writes. Requires the optional `sqlite3` gem.
35
+ #
36
+ # @param path [String] database file path
37
+ # @param busy_timeout [Integer] nonnegative SQLite lock-wait timeout in milliseconds
38
+ # @raise [ArgumentError] if `busy_timeout` is not a nonnegative integer
39
+ # @raise [SQLiteError] if opening or configuring the database fails
40
+ # @raise [SystemCallError] if creating a parent directory fails
41
+ #
42
+ def initialize(path:, busy_timeout: DEFAULT_BUSY_TIMEOUT)
43
+ super()
44
+
45
+ unless busy_timeout.is_a?(Integer) && busy_timeout >= 0
46
+ raise ArgumentError, "Busy timeout must be a non-negative integer"
47
+ end
48
+
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+
51
+ @database = SQLite3::Database.new(path)
52
+ @database.busy_timeout = busy_timeout
53
+
54
+ configure_database
55
+ create_schema
56
+ rescue SQLite3::Exception => e
57
+ raise SQLiteError, "Failed to initialize database: #{e.message}"
58
+ end
59
+
60
+ # Stores a value in SQLite storage.
61
+ #
62
+ # @param key [String] nonempty key beginning with `atproto:`
63
+ # @param value [String, Numeric, nil] SQLite-compatible scalar value; strings are used for serialized OAuth data
64
+ # @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
65
+ #
66
+ # @return [true] if the write succeeds
67
+ # @raise [StorageError] if key or TTL validation fails
68
+ # @raise [SQLiteError] if the backend operation fails
69
+ #
70
+ def set(key, value, ttl: nil)
71
+ validate_key(key)
72
+ validate_ttl(ttl)
73
+
74
+ expires_at = ttl && (current_time + ttl)
75
+
76
+ @database.execute(
77
+ <<~SQL,
78
+ INSERT INTO atproto_storage (key, value, expires_at)
79
+ VALUES (?, ?, ?)
80
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at
81
+ SQL
82
+ [key, value, expires_at]
83
+ )
84
+
85
+ true
86
+ rescue SQLite3::Exception => e
87
+ raise SQLiteError, "Failed to set value: #{e.message}"
88
+ end
89
+
90
+ # Retrieves an unexpired value from SQLite storage.
91
+ #
92
+ # @param key [String] nonempty key beginning with `atproto:`
93
+ # @return [String, Numeric, nil] stored value, or `nil` if missing/expired
94
+ # @raise [StorageError] if the key is invalid
95
+ # @raise [SQLiteError] if the backend operation fails
96
+ #
97
+ def get(key)
98
+ validate_key(key)
99
+
100
+ now = current_time
101
+ row = @database.get_first_row(
102
+ <<~SQL,
103
+ SELECT value
104
+ FROM atproto_storage
105
+ WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
106
+ SQL
107
+ [key, now]
108
+ )
109
+
110
+ if row
111
+ row.first
112
+ else
113
+ delete_expired_value(key, now)
114
+ nil
115
+ end
116
+ rescue SQLite3::Exception => e
117
+ raise SQLiteError, "Failed to get value: #{e.message}"
118
+ end
119
+
120
+ # Deletes a key from SQLite storage.
121
+ #
122
+ # @param key [String] nonempty key beginning with `atproto:`
123
+ # @return [Boolean] whether a stored row/key was deleted
124
+ # @raise [StorageError] if the key is invalid
125
+ # @raise [SQLiteError] if the backend operation fails
126
+ #
127
+ def delete(key)
128
+ validate_key(key)
129
+
130
+ result = @database.get_first_value(
131
+ "DELETE FROM atproto_storage WHERE key = ? RETURNING 1",
132
+ [key]
133
+ )
134
+
135
+ !result.nil?
136
+ rescue SQLite3::Exception => e
137
+ raise SQLiteError, "Failed to delete value: #{e.message}"
138
+ end
139
+
140
+ # Checks for an unexpired key in SQLite storage.
141
+ #
142
+ # @param key [String] nonempty key beginning with `atproto:`
143
+ # @return [Boolean] whether the key exists and has not expired
144
+ # @raise [StorageError] if the key is invalid
145
+ # @raise [SQLiteError] if the backend operation fails
146
+ #
147
+ def exists?(key)
148
+ validate_key(key)
149
+
150
+ now = current_time
151
+
152
+ exists = @database.get_first_value(
153
+ <<~SQL,
154
+ SELECT 1
155
+ FROM atproto_storage
156
+ WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
157
+ SQL
158
+ [key, now]
159
+ )
160
+
161
+ if exists
162
+ true
163
+ else
164
+ delete_expired_value(key, now)
165
+ false
166
+ end
167
+ rescue SQLite3::Exception => e
168
+ raise SQLiteError, "Failed to check existence: #{e.message}"
169
+ end
170
+
171
+ # Acquires a SQLite lock, yields, and releases the lock when the block exits.
172
+ #
173
+ # Locks expire after `ttl` seconds and are not renewed while the block runs.
174
+ # Exceptions from the block propagate to the caller.
175
+ #
176
+ # @param key [String] nonempty lock key beginning with `atproto:`
177
+ # @param ttl [Integer] positive lock lifetime in seconds
178
+ #
179
+ # @yield work to protect with the lock
180
+ # @yieldreturn [Object] value returned to the caller
181
+ # @return [Object] block result
182
+ #
183
+ # @raise [LockError] if another owner holds the lock
184
+ # @raise [StorageError] if key or TTL validation fails
185
+ # @raise [ArgumentError] if no block is supplied
186
+ # @raise [SQLiteError] if the backend operation fails
187
+ #
188
+ def with_lock(key, ttl: 30)
189
+ raise ArgumentError, "Block required" unless block_given?
190
+
191
+ owner = acquire_lock(key, ttl: ttl)
192
+ raise LockError, "Failed to acquire lock" unless owner
193
+
194
+ begin
195
+ yield
196
+ ensure
197
+ release_lock(key, owner)
198
+ end
199
+ end
200
+
201
+ # Closes the underlying SQLite connection.
202
+ #
203
+ # @return [void]
204
+ # @raise [SQLiteError] if closing the database fails
205
+ #
206
+ def close
207
+ @database.close
208
+ rescue SQLite3::Exception => e
209
+ raise SQLiteError, "Failed to close database: #{e.message}"
210
+ end
211
+
212
+ private
213
+
214
+ def configure_database
215
+ @database.execute("PRAGMA journal_mode = WAL")
216
+ @database.execute("PRAGMA synchronous = FULL")
217
+ end
218
+
219
+ def create_schema
220
+ @database.execute_batch(<<~SQL)
221
+ CREATE TABLE IF NOT EXISTS atproto_storage (
222
+ key TEXT PRIMARY KEY NOT NULL,
223
+ value,
224
+ expires_at REAL
225
+ );
226
+
227
+ CREATE TABLE IF NOT EXISTS atproto_locks (
228
+ key TEXT PRIMARY KEY NOT NULL,
229
+ owner TEXT NOT NULL,
230
+ expires_at REAL NOT NULL
231
+ );
232
+ SQL
233
+ end
234
+
235
+ def acquire_lock(key, ttl:)
236
+ validate_key(key)
237
+ validate_ttl(ttl)
238
+
239
+ now = current_time
240
+ owner = SecureRandom.uuid
241
+
242
+ acquired_owner = @database.get_first_value(
243
+ <<~SQL,
244
+ INSERT INTO atproto_locks (key, owner, expires_at)
245
+ VALUES (?, ?, ?)
246
+ ON CONFLICT(key) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
247
+ WHERE atproto_locks.expires_at <= ?
248
+ RETURNING owner
249
+ SQL
250
+ [key, owner, now + ttl, now]
251
+ )
252
+
253
+ acquired_owner ? owner : nil
254
+ rescue SQLite3::Exception => e
255
+ raise SQLiteError, "Failed to acquire lock: #{e.message}"
256
+ end
257
+
258
+ def release_lock(key, owner)
259
+ result = @database.get_first_value(
260
+ "DELETE FROM atproto_locks WHERE key = ? AND owner = ? RETURNING 1",
261
+ [key, owner]
262
+ )
263
+
264
+ !result.nil?
265
+ rescue SQLite3::Exception => e
266
+ raise SQLiteError, "Failed to release lock: #{e.message}"
267
+ end
268
+
269
+ def delete_expired_value(key, now)
270
+ @database.execute(
271
+ "DELETE FROM atproto_storage WHERE key = ? AND expires_at <= ?",
272
+ [key, now]
273
+ )
274
+ end
275
+
276
+ def current_time
277
+ Time.now.to_f
278
+ end
279
+ end
280
+ end
281
+ end