pocketbase 0.1.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/pocketbase/auth_store.rb +224 -0
- data/lib/pocketbase/client.rb +390 -0
- data/lib/pocketbase/client_response_error.rb +96 -0
- data/lib/pocketbase/multipart.rb +139 -0
- data/lib/pocketbase/services/backup_service.rb +54 -0
- data/lib/pocketbase/services/base_service.rb +49 -0
- data/lib/pocketbase/services/batch_service.rb +143 -0
- data/lib/pocketbase/services/collection_service.rb +52 -0
- data/lib/pocketbase/services/cron_service.rb +21 -0
- data/lib/pocketbase/services/crud_service.rb +113 -0
- data/lib/pocketbase/services/file_service.rb +39 -0
- data/lib/pocketbase/services/health_service.rb +14 -0
- data/lib/pocketbase/services/log_service.rb +42 -0
- data/lib/pocketbase/services/realtime_service.rb +379 -0
- data/lib/pocketbase/services/record_service.rb +367 -0
- data/lib/pocketbase/services/settings_service.rb +50 -0
- data/lib/pocketbase/services/sql_service.rb +14 -0
- data/lib/pocketbase/utils.rb +146 -0
- data/lib/pocketbase/version.rb +5 -0
- data/lib/pocketbase.rb +35 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a51b5e2d68dec39bf14a8b65a1dec80e58ad2f4cd326ce0bcaf1137c9b05ddc5
|
|
4
|
+
data.tar.gz: 2f9ac3ae8bed190a05489dbe74944ee56b2da2f4f7376783ba8d8b5be57301aa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dd15eda4c54cc09950085ff926ff8554a3ddef11693c8bcba35188f8291e219ff63273c8c1cf6c6f4949dbfd12e6687ef54f44244feedb8caf731c4ec7216976
|
|
7
|
+
data.tar.gz: 7502f62ac5d8feb3d7f24a40564d27a7a639d3c6ee72ad52316f283378bce16fedc001ebcd9f92fd028c68f008f96b8be84c4fb1f7e0884ae1bdb2be406860f9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
- Prevent forwarding authorization and cookies across redirect origins; preserve HTTP redirect method/body semantics.
|
|
6
|
+
- Honor string-keyed hook results and preserve request options when `before_send` returns nil.
|
|
7
|
+
- Normalize realtime subscription query options; roll back failed listeners and retry failed subscription updates.
|
|
8
|
+
- Terminate idle SSE readers on disconnect and notify `on_disconnect` on stream interruption before reconnecting.
|
|
9
|
+
- Deliver auth changes reliably when callbacks unsubscribe themselves.
|
|
10
|
+
- Expand home-relative auth file paths and raise persistence errors instead of silently dropping writes.
|
|
11
|
+
- Add offline HTTP/SSE regression checks using local TCP servers.
|
|
12
|
+
|
|
13
|
+
## 0.1.0 (2026-10-12)
|
|
14
|
+
|
|
15
|
+
Initial release — full Ruby port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk):
|
|
16
|
+
|
|
17
|
+
- `PocketBase::Client` with `send_request`, `before_send`/`after_send` hooks, `filter` builder, `build_url`
|
|
18
|
+
- `PocketBase::AuthStore` (in-memory) and `PocketBase::FileAuthStore` (persisted), cookie import/export, `valid?`/`superuser?`/`auth_record?`, `on_change`
|
|
19
|
+
- `PocketBase::ClientResponseError` (`status`, `url`, `response`/`data`, `original_error`)
|
|
20
|
+
- Services: records (CRUD + password/OAuth2/OTP auth, password reset, verification, email change, impersonate, superuser auto-refresh), collections, files, logs, settings, backups, health, crons, SQL, batch
|
|
21
|
+
- Realtime SSE subscriptions with background thread + auto-reconnect
|
|
22
|
+
- Zero runtime dependencies (stdlib only)
|
|
23
|
+
- 65 integration tests against a live PocketBase server
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azmi Muwahid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# pocketbase (Ruby)
|
|
2
|
+
|
|
3
|
+
A Ruby port of the official [PocketBase JavaScript SDK](https://github.com/pocketbase/js-sdk) — the full client API for [PocketBase](https://pocketbase.io), written in idiomatic Ruby with **zero runtime dependencies** (stdlib only: `net/http`, `json`, `openssl`…).
|
|
4
|
+
|
|
5
|
+
> Credit: this gem is a port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk), created and maintained by the [PocketBase](https://pocketbase.io) team. All API design credit goes to them; this port just brings it to Ruby.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "pocketbase"
|
|
9
|
+
|
|
10
|
+
pb = PocketBase.new("http://127.0.0.1:8090")
|
|
11
|
+
|
|
12
|
+
# auth
|
|
13
|
+
pb.collection("users").auth_with_password("test@example.com", "1234567890")
|
|
14
|
+
puts pb.auth_store.token
|
|
15
|
+
|
|
16
|
+
# crud
|
|
17
|
+
post = pb.collection("posts").create("title" => "Hello", "views" => 1)
|
|
18
|
+
list = pb.collection("posts").get_list(1, 30, filter: pb.filter("title ~ {:t}", t: "Hello"), sort: "-created")
|
|
19
|
+
pb.collection("posts").update(post["id"], "views" => 2)
|
|
20
|
+
pb.collection("posts").delete(post["id"])
|
|
21
|
+
|
|
22
|
+
# realtime
|
|
23
|
+
unsub = pb.collection("posts").subscribe("*") { |e| puts "#{e["action"]}: #{e["record"]["id"]}" }
|
|
24
|
+
sleep 60
|
|
25
|
+
unsub.call
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
gem "pocketbase", "~> 0.1"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install directly:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
gem install pocketbase
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Ruby >= 2.7.
|
|
42
|
+
|
|
43
|
+
## Client basics
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
pb = PocketBase.new("http://127.0.0.1:8090")
|
|
47
|
+
# or PocketBase::Client.new(url, auth_store: ..., lang: "en-US")
|
|
48
|
+
|
|
49
|
+
pb.build_url("/api/health") # full URL builder
|
|
50
|
+
pb.filter("title ~ {:t} && views > {:v}", t: "hi", v: 5)
|
|
51
|
+
pb.collection("posts") # cached RecordService per collection
|
|
52
|
+
pb.create_batch # batch request builder
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Low-level requests (named `send_request` because `send` is taken by `Object#send`):
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
pb.send_request("/api/health", method: "GET", timeout: 10)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Options accepted by every service method (all optional):
|
|
62
|
+
|
|
63
|
+
| Option | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `query:` / `params:` | extra query params (merged) |
|
|
66
|
+
| `headers:` | extra headers |
|
|
67
|
+
| `timeout:` | seconds |
|
|
68
|
+
| `filter:`, `sort:`, `expand:`, `fields:` … | any other top-level key becomes a query param (like the js-sdk) |
|
|
69
|
+
|
|
70
|
+
Hooks:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
pb.before_send = ->(url, options) { { url: url, options: options } }
|
|
74
|
+
pb.after_send = ->(response, data, options) { data } # response.status / .headers / .url
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`before_send` accepts symbol- or string-keyed `url`/`options` results. Returning nil keeps current request options.
|
|
78
|
+
Redirects preserve methods and bodies except POST on 301/302 and non-GET/HEAD on 303. Credentials are removed when redirect origin changes.
|
|
79
|
+
|
|
80
|
+
Errors raise `PocketBase::ClientResponseError` with `#status`, `#url`, `#response` (aliased `#data`), `#original_error`, `#is_abort`.
|
|
81
|
+
|
|
82
|
+
## Auth store
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
pb.auth_store.token # raw JWT
|
|
86
|
+
pb.auth_store.record # authed record hash
|
|
87
|
+
pb.auth_store.valid? # token present and unexpired
|
|
88
|
+
pb.auth_store.superuser? # _superusers collection
|
|
89
|
+
pb.auth_store.auth_record? # regular auth record
|
|
90
|
+
pb.auth_store.save(token, record)
|
|
91
|
+
pb.auth_store.clear
|
|
92
|
+
pb.auth_store.on_change { |token, record| ... } # returns unsubscribe lambda
|
|
93
|
+
|
|
94
|
+
# cookie helpers (e.g. for Rails controllers)
|
|
95
|
+
cookie = pb.auth_store.export_to_cookie
|
|
96
|
+
pb.auth_store.load_from_cookie(request.headers["Cookie"])
|
|
97
|
+
|
|
98
|
+
# file-persisted store (like the js-sdk LocalAuthStore)
|
|
99
|
+
pb2 = PocketBase.new(url, auth_store: PocketBase::FileAuthStore.new("~/.pb_auth.json"))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`FileAuthStore` expands `~` in paths. Write failures raise their original filesystem error; do not treat failed saves or clears as persisted.
|
|
103
|
+
|
|
104
|
+
## Records & auth
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
posts = pb.collection("posts")
|
|
108
|
+
posts.get_list(1, 30, filter: "...", sort: "-created", expand: "author")
|
|
109
|
+
posts.get_full_list(batch: 200, filter: "...") # or get_full_list(200, filter: "...")
|
|
110
|
+
posts.get_first_list_item("title = 'Hello'") # 404 when missing
|
|
111
|
+
posts.get_one("RECORD_ID")
|
|
112
|
+
posts.create("title" => "Hi", "image" => File.open("a.png", "rb")) # file upload -> multipart
|
|
113
|
+
posts.update("RECORD_ID", "title" => "Hi again")
|
|
114
|
+
posts.delete("RECORD_ID")
|
|
115
|
+
|
|
116
|
+
users = pb.collection("users")
|
|
117
|
+
auth = users.auth_with_password("test@example.com", "secret")
|
|
118
|
+
users.auth_refresh
|
|
119
|
+
users.list_auth_methods
|
|
120
|
+
users.request_password_reset("test@example.com")
|
|
121
|
+
users.confirm_password_reset("TOKEN", "new-pass", "new-pass")
|
|
122
|
+
users.request_verification("test@example.com")
|
|
123
|
+
users.confirm_verification("TOKEN")
|
|
124
|
+
users.request_email_change("new@example.com")
|
|
125
|
+
users.confirm_email_change("TOKEN", "password")
|
|
126
|
+
users.request_otp("test@example.com") # => { "otpId" => ... }
|
|
127
|
+
users.auth_with_otp("OTP_ID", "password")
|
|
128
|
+
|
|
129
|
+
# superuser impersonation -> brand new client with the impersonated session
|
|
130
|
+
other = pb.collection("users").impersonate("RECORD_ID")
|
|
131
|
+
|
|
132
|
+
# superuser token auto-refresh (mirrors js-sdk autoRefreshThreshold, seconds)
|
|
133
|
+
pb.collection("_superusers").auth_with_password("admin@example.com", "secret",
|
|
134
|
+
auto_refresh_threshold: 1800)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### OAuth2
|
|
138
|
+
|
|
139
|
+
Assisted flow (opens the provider URL via your block, waits on the realtime `@oauth2` channel, exchanges the code):
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
auth = pb.collection("users").auth_with_oauth2(provider: "google") do |url|
|
|
143
|
+
puts "Open: #{url}" # or `system("open", url)`
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Manual flow:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
info = pb.collection("users").oauth2_login_url("google", scopes: ["email"])
|
|
151
|
+
# redirect the user to info["url"], then:
|
|
152
|
+
pb.collection("users").auth_with_oauth2_code("google", code, info["code_verifier"], redirect_url)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Files
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
pb.files.get_url(record, "avatar.png") # absolute URL
|
|
159
|
+
pb.files.get_url(record, "avatar.png", thumb: "100x100") # thumbnail
|
|
160
|
+
pb.files.get_url(record, "avatar.png", download: true) # force download
|
|
161
|
+
pb.files.get_url(record, "avatar.png", token: pb.files.get_token) # protected file
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Realtime
|
|
165
|
+
|
|
166
|
+
The SSE stream runs in a background thread with automatic reconnect (same backoff as the js-sdk).
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
unsub = pb.realtime.subscribe("posts/*") { |e| p e["action"] }
|
|
170
|
+
# or via the collection helper:
|
|
171
|
+
unsub = pb.collection("posts").subscribe("*") { |e| p e }
|
|
172
|
+
|
|
173
|
+
unsub.call # remove this listener
|
|
174
|
+
pb.collection("posts").unsubscribe("*")
|
|
175
|
+
pb.realtime.unsubscribe_by_prefix("posts")
|
|
176
|
+
pb.realtime.disconnect # close the stream (auto-closes when no subs remain)
|
|
177
|
+
pb.realtime.on_disconnect = ->(subs) { puts "disconnected, active: #{subs}" }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Batch
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
batch = pb.create_batch
|
|
184
|
+
batch.collection("posts").create("title" => "a")
|
|
185
|
+
batch.collection("posts").update("RECORD_ID", "title" => "b")
|
|
186
|
+
batch.collection("posts").upsert("id" => "MAYBE_ID", "title" => "c")
|
|
187
|
+
batch.collection("posts").delete("OTHER_ID")
|
|
188
|
+
results = batch.send # => [{ "status" => 200, "body" => {...} }, ...]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Note: `/api/batch` is **disabled by default** on the server — enable it in *Settings → Batch* (or via `pb.settings.update`).
|
|
192
|
+
|
|
193
|
+
## Admin services
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
pb.settings.get_all
|
|
197
|
+
pb.settings.update(full_settings_hash) # nested structs are replaced wholesale: round-trip get_all
|
|
198
|
+
pb.settings.test_s3("storage")
|
|
199
|
+
pb.settings.test_email("users", "to@example.com", "verification")
|
|
200
|
+
|
|
201
|
+
pb.backups.get_full_list
|
|
202
|
+
pb.backups.create("backup.zip") # name must end in .zip
|
|
203
|
+
pb.backups.upload("file" => File.open("backup.zip", "rb"))
|
|
204
|
+
pb.backups.delete("backup.zip")
|
|
205
|
+
pb.backups.restore("backup.zip")
|
|
206
|
+
pb.backups.get_download_url(pb.files.get_token, "backup.zip")
|
|
207
|
+
|
|
208
|
+
pb.logs.get_list(1, 30, filter: "...")
|
|
209
|
+
pb.logs.get_one("LOG_ID")
|
|
210
|
+
pb.logs.get_stats
|
|
211
|
+
pb.logs.truncate
|
|
212
|
+
|
|
213
|
+
pb.crons.get_full_list
|
|
214
|
+
pb.crons.run("job-id")
|
|
215
|
+
|
|
216
|
+
pb.sql.run("SELECT id FROM posts") # superusers only
|
|
217
|
+
|
|
218
|
+
pb.collections.get_one("COLLECTION_ID")
|
|
219
|
+
pb.collections.create(base_hash)
|
|
220
|
+
pb.collections.update("COLLECTION_ID", "listRule" => "")
|
|
221
|
+
pb.collections.delete("COLLECTION_ID")
|
|
222
|
+
pb.collections.import([col1, col2], delete_missing: false)
|
|
223
|
+
pb.collections.truncate("posts")
|
|
224
|
+
pb.collections.get_scaffolds
|
|
225
|
+
pb.collections.get_all_oauth2_providers
|
|
226
|
+
pb.collections.dry_run_view_query("SELECT id FROM posts")
|
|
227
|
+
|
|
228
|
+
pb.health.check
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Notes & differences from the js-sdk
|
|
232
|
+
|
|
233
|
+
- **Synchronous.** No promises; no auto-cancellation of in-flight requests (`auto_cancellation`/`cancel_request` exist as no-ops for API compatibility).
|
|
234
|
+
- **`send_request`** instead of `send` (`Object#send` collision).
|
|
235
|
+
- **Options are plain hashes**, e.g. `get_list(1, 30, filter: "...", timeout: 5)`. Pass request bodies as the first positional hash: `create("title" => "Hi")`.
|
|
236
|
+
- **Responses are hashes** with string keys (parsed JSON), not model classes.
|
|
237
|
+
- **Realtime** uses a background thread + `Queue`-friendly blocks instead of `EventSource`.
|
|
238
|
+
- **OAuth2 popup flow** is replaced by a block-based assisted flow (`auth_with_oauth2 { |url| ... }`) plus a manual `oauth2_login_url` helper.
|
|
239
|
+
- `settings.update` needs the **full settings object** (PocketBase replaces nested structs wholesale — partial nested hashes don't merge).
|
|
240
|
+
- `backups.create` names must end with `.zip`; `/api/batch` must be enabled server-side.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
# start a local PocketBase for the test suite
|
|
246
|
+
./pocketbase serve --http 127.0.0.1:8090
|
|
247
|
+
./pocketbase superuser upsert admin@test.com password123
|
|
248
|
+
|
|
249
|
+
ruby -Ilib -Itest -e 'Dir.glob("test/test_*.rb").each { |f| require_relative f }'
|
|
250
|
+
# or: POCKETBASE_URL=http://127.0.0.1:8090 rake test
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Offline checks need no PocketBase process:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
rake test_offline
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### RubyGems release
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
rake build # runs offline checks, then builds pocketbase-0.1.1.gem
|
|
263
|
+
gem push pocketbase-0.1.1.gem --host https://rubygems.org
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Publishing requires a RubyGems account with access to the `pocketbase` name and a push-enabled API key (or interactive login). Complete any required MFA challenge. Never commit credentials or API keys. RubyDoc links use the published gem version and its source comments.
|
|
267
|
+
|
|
268
|
+
## Credits
|
|
269
|
+
|
|
270
|
+
- [PocketBase](https://pocketbase.io) team — authors of the original [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk) this gem is ported from.
|
|
271
|
+
- [Azmi Muwahid](https://github.com/azmi2409) — Ruby port.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT. Port of [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk) (also MIT).
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module PocketBase
|
|
6
|
+
# In-memory auth store mirroring the js-sdk BaseAuthStore.
|
|
7
|
+
class AuthStore
|
|
8
|
+
DEFAULT_COOKIE_KEY = "pb_auth"
|
|
9
|
+
# crc32 checksum id of the _superusers collection (fallback check, as in js-sdk)
|
|
10
|
+
SUPERUSERS_COLLECTION_ID = "pbc_3142635823"
|
|
11
|
+
|
|
12
|
+
attr_reader :token, :record
|
|
13
|
+
|
|
14
|
+
def initialize(token = nil, record = nil)
|
|
15
|
+
@token = ""
|
|
16
|
+
@record = nil
|
|
17
|
+
@on_change_callbacks = []
|
|
18
|
+
save(token, record) if token || record
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Saves the token and record model, triggering on_change callbacks.
|
|
22
|
+
def save(token, record = nil)
|
|
23
|
+
@token = token.to_s
|
|
24
|
+
@record = record
|
|
25
|
+
trigger_change
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Clears the stored token and record, triggering on_change callbacks.
|
|
30
|
+
def clear
|
|
31
|
+
@token = ""
|
|
32
|
+
@record = nil
|
|
33
|
+
trigger_change
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Loosely checks whether the store holds a non-expired token.
|
|
38
|
+
def valid?
|
|
39
|
+
!Utils.token_expired?(@token)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Loosely checks whether the stored state belongs to a superuser.
|
|
43
|
+
def superuser?
|
|
44
|
+
payload = Utils.token_payload(@token)
|
|
45
|
+
return false unless payload["type"] == "auth"
|
|
46
|
+
|
|
47
|
+
collection_name = @record.is_a?(Hash) ? (@record["collectionName"] || @record[:collectionName]) : nil
|
|
48
|
+
return true if collection_name == "_superusers"
|
|
49
|
+
return true if collection_name.nil? && payload["collectionId"] == SUPERUSERS_COLLECTION_ID
|
|
50
|
+
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Loosely checks whether the stored state belongs to a regular auth record.
|
|
55
|
+
def auth_record?
|
|
56
|
+
Utils.token_payload(@token)["type"] == "auth" && !superuser?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Registers a callback invoked on every store change with (token, record).
|
|
60
|
+
# Returns an unsubscribe lambda. Pass fire_immediately: true to invoke
|
|
61
|
+
# the callback right away with the current state.
|
|
62
|
+
# Listeners present at dispatch start receive the change even when another
|
|
63
|
+
# listener unsubscribes itself. New listeners start with the next change.
|
|
64
|
+
#
|
|
65
|
+
# @param fire_immediately [Boolean] Deliver current state upon registration
|
|
66
|
+
# @yield [token, record] Called synchronously when authentication changes
|
|
67
|
+
# @yieldparam token [String] Current token
|
|
68
|
+
# @yieldparam record [Hash, nil] Current authenticated record
|
|
69
|
+
# @return [Proc] Unsubscribe callable
|
|
70
|
+
# @raise [ArgumentError] If no callback block is given
|
|
71
|
+
def on_change(fire_immediately: false, &callback)
|
|
72
|
+
raise ArgumentError, "callback block required" unless callback
|
|
73
|
+
|
|
74
|
+
@on_change_callbacks << callback
|
|
75
|
+
callback.call(@token, @record) if fire_immediately
|
|
76
|
+
-> { @on_change_callbacks.delete(callback) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Parses a cookie string and loads the token/record stored under `key`.
|
|
80
|
+
# (Does not validate the token, same as the js-sdk.)
|
|
81
|
+
def load_from_cookie(cookie, key = DEFAULT_COOKIE_KEY)
|
|
82
|
+
raw = parse_cookie(cookie)[key.to_s] || ""
|
|
83
|
+
data = {}
|
|
84
|
+
begin
|
|
85
|
+
parsed = JSON.parse(Utils.decode_uri_component(raw))
|
|
86
|
+
data = parsed if parsed.is_a?(Hash)
|
|
87
|
+
rescue StandardError
|
|
88
|
+
data = {}
|
|
89
|
+
end
|
|
90
|
+
save(data["token"] || data[:token] || "", data["record"] || data["model"] || data[:record] || data[:model])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Exports the current store state as a cookie string.
|
|
94
|
+
# Supported options: :secure, :http_only, :same_site, :path, :domain,
|
|
95
|
+
# :expires (Time), :max_age (seconds).
|
|
96
|
+
def export_to_cookie(options = {}, key = DEFAULT_COOKIE_KEY)
|
|
97
|
+
opts = {
|
|
98
|
+
secure: true,
|
|
99
|
+
http_only: true,
|
|
100
|
+
same_site: "Strict",
|
|
101
|
+
path: "/",
|
|
102
|
+
}.merge(options)
|
|
103
|
+
|
|
104
|
+
payload = Utils.token_payload(@token)
|
|
105
|
+
opts[:expires] ||= payload["exp"] ? Time.at(payload["exp"].to_i) : Time.at(0)
|
|
106
|
+
|
|
107
|
+
raw_data = {
|
|
108
|
+
"token" => @token,
|
|
109
|
+
"record" => @record ? JSON.parse(JSON.generate(@record)) : nil,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
result = serialize_cookie(key, JSON.generate(raw_data), opts)
|
|
113
|
+
|
|
114
|
+
# strip the model down to the bare minimum if the cookie is too large
|
|
115
|
+
if raw_data["record"] && result.bytesize > 4096
|
|
116
|
+
rec = raw_data["record"]
|
|
117
|
+
raw_data["record"] = {
|
|
118
|
+
"id" => rec["id"],
|
|
119
|
+
"email" => rec["email"],
|
|
120
|
+
"collectionId" => rec["collectionId"],
|
|
121
|
+
"collectionName" => rec["collectionName"],
|
|
122
|
+
"verified" => rec["verified"],
|
|
123
|
+
}.compact
|
|
124
|
+
result = serialize_cookie(key, JSON.generate(raw_data), opts)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def trigger_change
|
|
133
|
+
@on_change_callbacks.dup.each { |cb| cb&.call(@token, @record) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_cookie(cookie)
|
|
137
|
+
result = {}
|
|
138
|
+
cookie.to_s.split(";").each do |pair|
|
|
139
|
+
k, v = pair.strip.split("=", 2)
|
|
140
|
+
next if k.nil? || k.empty?
|
|
141
|
+
|
|
142
|
+
result[k] = v || ""
|
|
143
|
+
end
|
|
144
|
+
result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def serialize_cookie(key, value, options)
|
|
148
|
+
parts = ["#{key}=#{Utils.encode_uri_component(value)}"]
|
|
149
|
+
parts << "Path=#{options[:path]}" if options[:path]
|
|
150
|
+
parts << "Domain=#{options[:domain]}" if options[:domain]
|
|
151
|
+
parts << "Expires=#{options[:expires].utc.strftime("%a, %d %b %Y %H:%M:%S GMT")}" if options[:expires]
|
|
152
|
+
parts << "Max-Age=#{options[:max_age].to_i}" if options[:max_age]
|
|
153
|
+
parts << "Secure" if options[:secure]
|
|
154
|
+
parts << "HttpOnly" if options[:http_only]
|
|
155
|
+
same_site = options[:same_site]
|
|
156
|
+
parts << "SameSite=#{same_site == true ? "Strict" : same_site}" if same_site
|
|
157
|
+
parts.join("; ")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# File-persisted auth store (the Ruby equivalent of js-sdk's LocalAuthStore,
|
|
162
|
+
# which uses browser localStorage). State is stored as JSON at `path`.
|
|
163
|
+
class FileAuthStore < AuthStore
|
|
164
|
+
attr_reader :path
|
|
165
|
+
|
|
166
|
+
# Loads existing auth state, or saves supplied initial state.
|
|
167
|
+
# Parent directory must exist. Missing or unreadable state loads empty.
|
|
168
|
+
#
|
|
169
|
+
# @param path [String] File path; relative paths and ~ are expanded
|
|
170
|
+
# @param token [String, nil] Optional initial token
|
|
171
|
+
# @param record [Hash, nil] Optional initial authenticated record
|
|
172
|
+
# @raise [SystemCallError, IOError] If initial state cannot be persisted
|
|
173
|
+
def initialize(path, token = nil, record = nil)
|
|
174
|
+
@path = File.expand_path(path.to_s)
|
|
175
|
+
super()
|
|
176
|
+
if token || record
|
|
177
|
+
save(token, record)
|
|
178
|
+
else
|
|
179
|
+
load_from_file
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Updates memory, notifies listeners, then writes authentication to disk.
|
|
184
|
+
# Write failures propagate; memory may already contain the new state.
|
|
185
|
+
#
|
|
186
|
+
# @param token [String, nil] Token to save
|
|
187
|
+
# @param record [Hash, nil] Authenticated record
|
|
188
|
+
# @return [FileAuthStore] This store
|
|
189
|
+
# @raise [SystemCallError, IOError] If state cannot be written
|
|
190
|
+
def save(token, record = nil)
|
|
191
|
+
super
|
|
192
|
+
persist_to_file
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Clears authentication in memory and persists the cleared state.
|
|
197
|
+
# A write failure does not guarantee removal of the previous disk token.
|
|
198
|
+
#
|
|
199
|
+
# @return [FileAuthStore] This store
|
|
200
|
+
# @raise [SystemCallError, IOError] If cleared state cannot be written
|
|
201
|
+
def clear
|
|
202
|
+
super
|
|
203
|
+
persist_to_file
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def load_from_file
|
|
210
|
+
return unless File.exist?(@path)
|
|
211
|
+
|
|
212
|
+
data = JSON.parse(File.read(@path))
|
|
213
|
+
@token = data["token"].to_s
|
|
214
|
+
@record = data["record"]
|
|
215
|
+
rescue StandardError
|
|
216
|
+
@token = ""
|
|
217
|
+
@record = nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def persist_to_file
|
|
221
|
+
File.write(@path, JSON.generate({ "token" => @token, "record" => @record }))
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|