vortex-ruby-sdk 1.1.0 → 1.3.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.
- checksums.yaml +4 -4
- data/.claude/implementation-guide.md +533 -0
- data/CHANGELOG.md +32 -0
- data/PUBLISHING.md +6 -6
- data/README.md +2 -0
- data/lib/vortex/client.rb +164 -4
- data/lib/vortex/types.rb +14 -1
- data/lib/vortex/version.rb +1 -1
- data/lib/vortex.rb +1 -0
- data/vortex-ruby-sdk.gemspec +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef46095579ff2ce432423b52947fe7b09bcbac70a9aeaaa72c28245f79aee801
|
|
4
|
+
data.tar.gz: b71a185023f2593be2fd5adfa28771eb281de595b8539b023a60448119b29a7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9fee1a62aa03ee7184bf596b13644f48bb3df7a6164eec8bae3be10ebe9f66e21c848410831a09ff99136d01a023aa7bebdbbe9cdf95c7333d7a55a94bd58ed
|
|
7
|
+
data.tar.gz: 315cdc79b63d0f79563d25e9568df67043245cd8c3d230ef79e322b02c39c7559eb3f5613f0c575b946b01f651397052e1ca8aa76ec51683bec5988a7b2445d2
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Vortex Ruby SDK Implementation Guide
|
|
2
|
+
|
|
3
|
+
**Gem:** `vortex-ruby-sdk`
|
|
4
|
+
**Type:** Base SDK (Core library for Ruby applications)
|
|
5
|
+
**Requires:** Ruby 3.0+
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
From integration contract you need: API endpoint prefix, scope entity, authentication pattern
|
|
9
|
+
From discovery data you need: Ruby framework (Rails, Sinatra), database ORM, auth pattern
|
|
10
|
+
|
|
11
|
+
## Key Facts
|
|
12
|
+
- Framework-agnostic Ruby SDK
|
|
13
|
+
- Client-based: instantiate `Vortex::Client` class and call methods
|
|
14
|
+
- Built-in Rails and Sinatra helpers
|
|
15
|
+
- Faraday-based HTTP client
|
|
16
|
+
- Accept invitations requires custom database logic (must implement)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Step 1: Install
|
|
21
|
+
|
|
22
|
+
Add to `Gemfile`:
|
|
23
|
+
```ruby
|
|
24
|
+
gem 'vortex-ruby-sdk'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then install:
|
|
28
|
+
```bash
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Step 2: Set Environment Variable
|
|
35
|
+
|
|
36
|
+
Add to `.env`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
VORTEX_API_KEY=VRTX.your-api-key-here.secret
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Rails:** Use `config/credentials.yml.enc` or `dotenv-rails` gem
|
|
43
|
+
**Sinatra:** Use `dotenv` gem
|
|
44
|
+
|
|
45
|
+
**Never commit API key to version control.**
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Step 3: Create Vortex Client
|
|
50
|
+
|
|
51
|
+
### Rails Initializer (`config/initializers/vortex.rb`):
|
|
52
|
+
```ruby
|
|
53
|
+
Rails.application.config.vortex = Vortex::Client.new(
|
|
54
|
+
Rails.application.credentials.vortex_api_key || ENV['VORTEX_API_KEY']
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Rails Concern (`app/controllers/concerns/vortex_helper.rb`):
|
|
59
|
+
```ruby
|
|
60
|
+
module VortexHelper
|
|
61
|
+
extend ActiveSupport::Concern
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def vortex_client
|
|
66
|
+
@vortex_client ||= Vortex::Client.new(
|
|
67
|
+
Rails.application.credentials.vortex_api_key || ENV['VORTEX_API_KEY']
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Sinatra Configuration:
|
|
74
|
+
```ruby
|
|
75
|
+
# app.rb or config.ru
|
|
76
|
+
require 'sinatra/base'
|
|
77
|
+
require 'vortex'
|
|
78
|
+
|
|
79
|
+
class MyApp < Sinatra::Base
|
|
80
|
+
configure do
|
|
81
|
+
set :vortex_client, Vortex::Client.new(ENV['VORTEX_API_KEY'])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
helpers do
|
|
85
|
+
def vortex
|
|
86
|
+
settings.vortex_client
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Step 4: Extract Authenticated User
|
|
95
|
+
|
|
96
|
+
### Rails with Devise:
|
|
97
|
+
```ruby
|
|
98
|
+
# app/controllers/application_controller.rb
|
|
99
|
+
class ApplicationController < ActionController::API
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def current_vortex_user
|
|
103
|
+
return nil unless current_user
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
id: current_user.id.to_s,
|
|
107
|
+
email: current_user.email,
|
|
108
|
+
admin_scopes: current_user.admin? ? ['autojoin'] : []
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def require_authentication!
|
|
113
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized unless current_user
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Rails with JWT:
|
|
119
|
+
```ruby
|
|
120
|
+
class ApplicationController < ActionController::API
|
|
121
|
+
before_action :authenticate_user_from_token!
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def authenticate_user_from_token!
|
|
126
|
+
token = request.headers['Authorization']&.split(' ')&.last
|
|
127
|
+
return render json: { error: 'Unauthorized' }, status: :unauthorized unless token
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
payload = JWT.decode(token, Rails.application.credentials.secret_key_base, true, algorithm: 'HS256')[0]
|
|
131
|
+
@current_user = User.find(payload['user_id'])
|
|
132
|
+
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
|
|
133
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def current_vortex_user
|
|
138
|
+
return nil unless @current_user
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
id: @current_user.id.to_s,
|
|
142
|
+
email: @current_user.email,
|
|
143
|
+
admin_scopes: @current_user.admin? ? ['autojoin'] : []
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Sinatra:
|
|
150
|
+
```ruby
|
|
151
|
+
# app/helpers/auth_helper.rb
|
|
152
|
+
module AuthHelper
|
|
153
|
+
def current_user
|
|
154
|
+
return @current_user if defined?(@current_user)
|
|
155
|
+
|
|
156
|
+
# Session-based
|
|
157
|
+
if session[:user_id]
|
|
158
|
+
@current_user = User.find(session[:user_id])
|
|
159
|
+
# JWT-based
|
|
160
|
+
elsif request.env['HTTP_AUTHORIZATION']
|
|
161
|
+
token = request.env['HTTP_AUTHORIZATION'].split(' ').last
|
|
162
|
+
payload = JWT.decode(token, ENV['SECRET_KEY_BASE'], true, algorithm: 'HS256')[0]
|
|
163
|
+
@current_user = User.find(payload['user_id'])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@current_user
|
|
167
|
+
rescue
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def current_vortex_user
|
|
172
|
+
return nil unless current_user
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
id: current_user.id.to_s,
|
|
176
|
+
email: current_user.email,
|
|
177
|
+
admin_scopes: current_user.admin? ? ['autojoin'] : []
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def require_authentication!
|
|
182
|
+
halt 401, { error: 'Unauthorized' }.to_json unless current_user
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Adapt to their patterns:**
|
|
188
|
+
- Match their auth mechanism (Devise, JWT, sessions)
|
|
189
|
+
- Match their user structure
|
|
190
|
+
- Match their admin detection logic
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Step 5: Implement JWT Generation Endpoint
|
|
195
|
+
|
|
196
|
+
### Rails (`app/controllers/vortex_controller.rb`):
|
|
197
|
+
```ruby
|
|
198
|
+
class VortexController < ApplicationController
|
|
199
|
+
before_action :require_authentication!
|
|
200
|
+
include VortexHelper
|
|
201
|
+
|
|
202
|
+
def generate_jwt
|
|
203
|
+
user = current_vortex_user
|
|
204
|
+
extra = params.permit(:componentId, :scope, :scopeType).to_h.compact
|
|
205
|
+
|
|
206
|
+
jwt = vortex_client.generate_jwt(
|
|
207
|
+
user: user,
|
|
208
|
+
attributes: extra.empty? ? nil : extra
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
render json: { jwt: jwt }
|
|
212
|
+
rescue Vortex::VortexError => e
|
|
213
|
+
Rails.logger.error("JWT generation error: #{e.message}")
|
|
214
|
+
render json: { error: 'Internal server error' }, status: :internal_server_error
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Sinatra:
|
|
220
|
+
```ruby
|
|
221
|
+
require 'sinatra/base'
|
|
222
|
+
require 'json'
|
|
223
|
+
|
|
224
|
+
class MyApp < Sinatra::Base
|
|
225
|
+
helpers AuthHelper
|
|
226
|
+
|
|
227
|
+
post '/api/vortex/jwt' do
|
|
228
|
+
require_authentication!
|
|
229
|
+
|
|
230
|
+
content_type :json
|
|
231
|
+
|
|
232
|
+
begin
|
|
233
|
+
user = current_vortex_user
|
|
234
|
+
request_body = JSON.parse(request.body.read) rescue {}
|
|
235
|
+
|
|
236
|
+
extra = request_body.slice('componentId', 'scope', 'scopeType').compact
|
|
237
|
+
extra = nil if extra.empty?
|
|
238
|
+
|
|
239
|
+
jwt = vortex.generate_jwt(
|
|
240
|
+
user: user,
|
|
241
|
+
attributes: extra
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
{ jwt: jwt }.to_json
|
|
245
|
+
rescue Vortex::VortexError => e
|
|
246
|
+
logger.error("JWT generation error: #{e.message}")
|
|
247
|
+
status 500
|
|
248
|
+
{ error: 'Internal server error' }.to_json
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Step 6: Implement Accept Invitations Endpoint (CRITICAL)
|
|
257
|
+
|
|
258
|
+
### Rails Routes (`config/routes.rb`):
|
|
259
|
+
```ruby
|
|
260
|
+
Rails.application.routes.draw do
|
|
261
|
+
namespace :api do
|
|
262
|
+
namespace :vortex do
|
|
263
|
+
post 'jwt', to: 'vortex#generate_jwt'
|
|
264
|
+
get 'invitations', to: 'vortex#get_invitations_by_target'
|
|
265
|
+
get 'invitations/:invitation_id', to: 'vortex#get_invitation'
|
|
266
|
+
post 'invitations/accept', to: 'vortex#accept_invitations'
|
|
267
|
+
delete 'invitations/:invitation_id', to: 'vortex#revoke_invitation'
|
|
268
|
+
post 'invitations/:invitation_id/reinvite', to: 'vortex#reinvite'
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Rails with ActiveRecord:
|
|
275
|
+
```ruby
|
|
276
|
+
class Api::VortexController < ApplicationController
|
|
277
|
+
before_action :require_authentication!
|
|
278
|
+
include VortexHelper
|
|
279
|
+
|
|
280
|
+
def accept_invitations
|
|
281
|
+
invitation_ids = params[:invitationIds] || []
|
|
282
|
+
user = params[:user]
|
|
283
|
+
|
|
284
|
+
return render json: { error: 'Missing invitationIds or user' }, status: :bad_request if invitation_ids.empty? || !user
|
|
285
|
+
|
|
286
|
+
begin
|
|
287
|
+
# 1. Mark as accepted in Vortex
|
|
288
|
+
vortex_client.accept_invitations(invitation_ids, user)
|
|
289
|
+
|
|
290
|
+
# 2. CRITICAL - Add to database
|
|
291
|
+
ActiveRecord::Base.transaction do
|
|
292
|
+
invitation_ids.each do |invitation_id|
|
|
293
|
+
invitation = vortex_client.get_invitation(invitation_id)
|
|
294
|
+
|
|
295
|
+
(invitation['groups'] || []).each do |group|
|
|
296
|
+
GroupMembership.find_or_create_by!(
|
|
297
|
+
user_id: current_user.id,
|
|
298
|
+
group_type: group['type'],
|
|
299
|
+
group_id: group['groupId']
|
|
300
|
+
) do |membership|
|
|
301
|
+
membership.role = invitation['role'] || 'member'
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
render json: {
|
|
308
|
+
success: true,
|
|
309
|
+
acceptedCount: invitation_ids.length
|
|
310
|
+
}
|
|
311
|
+
rescue Vortex::VortexError => e
|
|
312
|
+
Rails.logger.error("Accept invitations error: #{e.message}")
|
|
313
|
+
render json: { error: 'Internal server error' }, status: :internal_server_error
|
|
314
|
+
rescue => e
|
|
315
|
+
Rails.logger.error("Database error: #{e.message}")
|
|
316
|
+
render json: { error: 'Internal server error' }, status: :internal_server_error
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Sinatra with Sequel:
|
|
323
|
+
```ruby
|
|
324
|
+
post '/api/vortex/invitations/accept' do
|
|
325
|
+
require_authentication!
|
|
326
|
+
content_type :json
|
|
327
|
+
|
|
328
|
+
request_body = JSON.parse(request.body.read)
|
|
329
|
+
invitation_ids = request_body['invitationIds'] || []
|
|
330
|
+
user = request_body['user']
|
|
331
|
+
|
|
332
|
+
halt 400, { error: 'Missing invitationIds or user' }.to_json if invitation_ids.empty? || !user
|
|
333
|
+
|
|
334
|
+
begin
|
|
335
|
+
# 1. Mark as accepted in Vortex
|
|
336
|
+
vortex.accept_invitations(invitation_ids, user)
|
|
337
|
+
|
|
338
|
+
# 2. CRITICAL - Add to database
|
|
339
|
+
DB.transaction do
|
|
340
|
+
invitation_ids.each do |invitation_id|
|
|
341
|
+
invitation = vortex.get_invitation(invitation_id)
|
|
342
|
+
|
|
343
|
+
(invitation['groups'] || []).each do |group|
|
|
344
|
+
GroupMembership.insert_conflict(
|
|
345
|
+
target: [:user_id, :group_type, :group_id],
|
|
346
|
+
update: { role: invitation['role'] || 'member' }
|
|
347
|
+
).insert(
|
|
348
|
+
user_id: current_user.id,
|
|
349
|
+
group_type: group['type'],
|
|
350
|
+
group_id: group['groupId'],
|
|
351
|
+
role: invitation['role'] || 'member',
|
|
352
|
+
joined_at: Time.now
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
{
|
|
359
|
+
success: true,
|
|
360
|
+
acceptedCount: invitation_ids.length
|
|
361
|
+
}.to_json
|
|
362
|
+
rescue Vortex::VortexError => e
|
|
363
|
+
logger.error("Accept invitations error: #{e.message}")
|
|
364
|
+
status 500
|
|
365
|
+
{ error: 'Internal server error' }.to_json
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Critical - Adapt database logic:**
|
|
371
|
+
- Use their actual table/model names (from discovery)
|
|
372
|
+
- Use their actual field names
|
|
373
|
+
- Use their ORM pattern (ActiveRecord, Sequel)
|
|
374
|
+
- Handle duplicate memberships if needed
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Step 7: Database Models
|
|
379
|
+
|
|
380
|
+
### Rails Migration:
|
|
381
|
+
```ruby
|
|
382
|
+
# db/migrate/YYYYMMDDHHMMSS_create_group_memberships.rb
|
|
383
|
+
class CreateGroupMemberships < ActiveRecord::Migration[7.0]
|
|
384
|
+
def change
|
|
385
|
+
create_table :group_memberships do |t|
|
|
386
|
+
t.string :user_id, null: false
|
|
387
|
+
t.string :group_type, null: false, limit: 100
|
|
388
|
+
t.string :group_id, null: false
|
|
389
|
+
t.string :role, default: 'member', limit: 50
|
|
390
|
+
t.timestamp :joined_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
|
|
391
|
+
|
|
392
|
+
t.index [:user_id, :group_type, :group_id], unique: true, name: 'unique_membership'
|
|
393
|
+
t.index [:group_type, :group_id], name: 'idx_group'
|
|
394
|
+
t.index [:user_id], name: 'idx_user'
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Rails Model:
|
|
401
|
+
```ruby
|
|
402
|
+
# app/models/group_membership.rb
|
|
403
|
+
class GroupMembership < ApplicationRecord
|
|
404
|
+
validates :user_id, presence: true
|
|
405
|
+
validates :group_type, presence: true
|
|
406
|
+
validates :group_id, presence: true
|
|
407
|
+
validates :role, presence: true
|
|
408
|
+
|
|
409
|
+
validates :user_id, uniqueness: { scope: [:group_type, :group_id] }
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Sequel Migration:
|
|
414
|
+
```ruby
|
|
415
|
+
# db/migrations/001_create_group_memberships.rb
|
|
416
|
+
Sequel.migration do
|
|
417
|
+
change do
|
|
418
|
+
create_table(:group_memberships) do
|
|
419
|
+
primary_key :id
|
|
420
|
+
String :user_id, null: false
|
|
421
|
+
String :group_type, size: 100, null: false
|
|
422
|
+
String :group_id, null: false
|
|
423
|
+
String :role, size: 50, default: 'member'
|
|
424
|
+
DateTime :joined_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
425
|
+
|
|
426
|
+
index [:user_id, :group_type, :group_id], unique: true, name: :unique_membership
|
|
427
|
+
index [:group_type, :group_id], name: :idx_group
|
|
428
|
+
index [:user_id], name: :idx_user
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Step 8: Build and Test
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
# Run migrations
|
|
440
|
+
rails db:migrate # Rails
|
|
441
|
+
sequel -m db/migrations $DATABASE_URL # Sequel
|
|
442
|
+
|
|
443
|
+
# Start server
|
|
444
|
+
rails server # Rails
|
|
445
|
+
bundle exec rackup # Sinatra
|
|
446
|
+
|
|
447
|
+
# Test JWT endpoint
|
|
448
|
+
curl -X POST http://localhost:3000/api/vortex/jwt \
|
|
449
|
+
-H "Authorization: Bearer your-auth-token"
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Expected response:
|
|
453
|
+
```json
|
|
454
|
+
{
|
|
455
|
+
"jwt": "eyJhbGciOiJIUzI1NiIs..."
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Common Errors
|
|
462
|
+
|
|
463
|
+
**"LoadError: cannot load such file -- vortex"** → Run `bundle install`
|
|
464
|
+
|
|
465
|
+
**"VORTEX_API_KEY not set"** → Check `.env` file, credentials, or environment variables
|
|
466
|
+
|
|
467
|
+
**User not added to database** → Must implement database logic in accept handler (see Step 6)
|
|
468
|
+
|
|
469
|
+
**"NoMethodError: undefined method `admin?'"** → Implement admin check in User model
|
|
470
|
+
|
|
471
|
+
**CORS errors** → Add CORS middleware:
|
|
472
|
+
|
|
473
|
+
**Rails:**
|
|
474
|
+
```ruby
|
|
475
|
+
# Gemfile
|
|
476
|
+
gem 'rack-cors'
|
|
477
|
+
|
|
478
|
+
# config/initializers/cors.rb
|
|
479
|
+
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
|
480
|
+
allow do
|
|
481
|
+
origins 'http://localhost:3000'
|
|
482
|
+
resource '*', headers: :any, methods: [:get, :post, :delete, :options]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
**Sinatra:**
|
|
488
|
+
```ruby
|
|
489
|
+
require 'sinatra/cross_origin'
|
|
490
|
+
|
|
491
|
+
class MyApp < Sinatra::Base
|
|
492
|
+
register Sinatra::CrossOrigin
|
|
493
|
+
|
|
494
|
+
configure do
|
|
495
|
+
enable :cross_origin
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
options '*' do
|
|
499
|
+
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, DELETE, OPTIONS'
|
|
500
|
+
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
|
|
501
|
+
200
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## After Implementation Report
|
|
509
|
+
|
|
510
|
+
List files created/modified:
|
|
511
|
+
- Dependency: Gemfile
|
|
512
|
+
- Client: config/initializers/vortex.rb (or concern)
|
|
513
|
+
- Controller: app/controllers/vortex_controller.rb (or Sinatra routes)
|
|
514
|
+
- Model: app/models/group_membership.rb
|
|
515
|
+
- Migration: db/migrate/XXX_create_group_memberships.rb
|
|
516
|
+
|
|
517
|
+
Confirm:
|
|
518
|
+
- Vortex gem installed
|
|
519
|
+
- VortexClient instance created
|
|
520
|
+
- JWT endpoint returns valid JWT
|
|
521
|
+
- Accept invitations includes database logic
|
|
522
|
+
- Routes registered at correct prefix
|
|
523
|
+
- Migrations run
|
|
524
|
+
|
|
525
|
+
## Endpoints Registered
|
|
526
|
+
|
|
527
|
+
All endpoints at `/api/vortex`:
|
|
528
|
+
- `POST /jwt` - Generate JWT for authenticated user
|
|
529
|
+
- `GET /invitations` - Get invitations by target
|
|
530
|
+
- `GET /invitations/:id` - Get invitation by ID
|
|
531
|
+
- `POST /invitations/accept` - Accept invitations (custom DB logic)
|
|
532
|
+
- `DELETE /invitations/:id` - Revoke invitation
|
|
533
|
+
- `POST /invitations/:id/reinvite` - Resend invitation
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Vortex Ruby SDK will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.2.0] - 2026-01-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Internal Invitations**: New `'internal'` delivery type for customer-managed invitations
|
|
12
|
+
- Support for `deliveryTypes: ['internal']`
|
|
13
|
+
- No email/SMS communication triggered by Vortex
|
|
14
|
+
- Target value can be any customer-defined identifier
|
|
15
|
+
- Useful for in-app invitation flows managed by customer's application
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Updated `deliveryTypes` field documentation to include `'internal'` as a valid value
|
|
19
|
+
|
|
20
|
+
## [1.1.3] - 2025-01-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **ACCEPT_USER Type**: New preferred format for accepting invitations with `email`, `phone`, and `name` fields
|
|
24
|
+
- Enhanced `accept_invitations` method to support both new User hash format and legacy target format
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **DEPRECATED**: Legacy target hash format for `accept_invitations` - use User hash instead
|
|
28
|
+
- Internal API calls now always use User format for consistency
|
|
29
|
+
- Added warning messages when legacy target format is used
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Maintained 100% backward compatibility - existing code using legacy target format continues to work
|
data/PUBLISHING.md
CHANGED
|
@@ -76,7 +76,7 @@ Create or update `CHANGELOG.md` with release notes:
|
|
|
76
76
|
### Step 4: Install Dependencies and Test
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
cd
|
|
79
|
+
cd sdks/vortex-ruby-sdk
|
|
80
80
|
|
|
81
81
|
# Install dependencies
|
|
82
82
|
bundle install
|
|
@@ -182,29 +182,29 @@ jobs:
|
|
|
182
182
|
|
|
183
183
|
- name: Install dependencies
|
|
184
184
|
run: |
|
|
185
|
-
cd
|
|
185
|
+
cd sdks/vortex-ruby-sdk
|
|
186
186
|
bundle install
|
|
187
187
|
|
|
188
188
|
- name: Run tests
|
|
189
189
|
run: |
|
|
190
|
-
cd
|
|
190
|
+
cd sdks/vortex-ruby-sdk
|
|
191
191
|
bundle exec rspec
|
|
192
192
|
|
|
193
193
|
- name: Run RuboCop
|
|
194
194
|
run: |
|
|
195
|
-
cd
|
|
195
|
+
cd sdks/vortex-ruby-sdk
|
|
196
196
|
bundle exec rubocop
|
|
197
197
|
|
|
198
198
|
- name: Build gem
|
|
199
199
|
run: |
|
|
200
|
-
cd
|
|
200
|
+
cd sdks/vortex-ruby-sdk
|
|
201
201
|
gem build vortex-ruby-sdk.gemspec
|
|
202
202
|
|
|
203
203
|
- name: Publish to RubyGems
|
|
204
204
|
env:
|
|
205
205
|
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
206
206
|
run: |
|
|
207
|
-
cd
|
|
207
|
+
cd sdks/vortex-ruby-sdk
|
|
208
208
|
gem push *.gem
|
|
209
209
|
```
|
|
210
210
|
|
data/README.md
CHANGED
|
@@ -12,6 +12,8 @@ A Ruby SDK for the Vortex invitation system, providing seamless integration with
|
|
|
12
12
|
- **Same Route Structure**: Ensures React provider compatibility
|
|
13
13
|
- **Comprehensive Testing**: Full test coverage with RSpec
|
|
14
14
|
- **Type Safety**: Clear method signatures and documentation
|
|
15
|
+
- **Multiple Delivery Types**: Support for `email`, `phone`, `share`, and `internal` invitation delivery
|
|
16
|
+
- `internal` invitations allow for customer-managed, in-app invitation flows with no external communication
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
data/lib/vortex/client.rb
CHANGED
|
@@ -80,6 +80,16 @@ module Vortex
|
|
|
80
80
|
expires: expires
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
# Add name if present (convert snake_case to camelCase for JWT)
|
|
84
|
+
if user[:name]
|
|
85
|
+
payload[:name] = user[:name]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Add avatarUrl if present (convert snake_case to camelCase for JWT)
|
|
89
|
+
if user[:avatar_url]
|
|
90
|
+
payload[:avatarUrl] = user[:avatar_url]
|
|
91
|
+
end
|
|
92
|
+
|
|
83
93
|
# Add adminScopes if present
|
|
84
94
|
if user[:admin_scopes]
|
|
85
95
|
payload[:adminScopes] = user[:admin_scopes]
|
|
@@ -146,16 +156,84 @@ module Vortex
|
|
|
146
156
|
raise VortexError, "Failed to revoke invitation: #{e.message}"
|
|
147
157
|
end
|
|
148
158
|
|
|
149
|
-
# Accept invitations
|
|
159
|
+
# Accept invitations using the new User format (preferred)
|
|
160
|
+
#
|
|
161
|
+
# Supports three formats:
|
|
162
|
+
# 1. User hash (preferred): { email: '...', phone: '...', name: '...' }
|
|
163
|
+
# 2. Target hash (deprecated): { type: 'email', value: '...' }
|
|
164
|
+
# 3. Array of targets (deprecated): [{ type: 'email', value: '...' }, ...]
|
|
150
165
|
#
|
|
151
166
|
# @param invitation_ids [Array<String>] List of invitation IDs to accept
|
|
152
|
-
# @param
|
|
167
|
+
# @param user_or_target [Hash, Array] User hash with :email/:phone/:name keys, OR legacy target(s)
|
|
153
168
|
# @return [Hash] The accepted invitation result
|
|
154
169
|
# @raise [VortexError] If the request fails
|
|
155
|
-
|
|
170
|
+
#
|
|
171
|
+
# @example New format (preferred)
|
|
172
|
+
# user = { email: 'user@example.com', name: 'John Doe' }
|
|
173
|
+
# result = client.accept_invitations(['inv-123'], user)
|
|
174
|
+
#
|
|
175
|
+
# @example Legacy format (deprecated)
|
|
176
|
+
# target = { type: 'email', value: 'user@example.com' }
|
|
177
|
+
# result = client.accept_invitations(['inv-123'], target)
|
|
178
|
+
def accept_invitations(invitation_ids, user_or_target)
|
|
179
|
+
# Check if it's an array of targets (legacy format with multiple targets)
|
|
180
|
+
if user_or_target.is_a?(Array)
|
|
181
|
+
warn '[Vortex SDK] DEPRECATED: Passing an array of targets is deprecated. ' \
|
|
182
|
+
'Use the User format instead: accept_invitations(invitation_ids, { email: "user@example.com" })'
|
|
183
|
+
|
|
184
|
+
raise VortexError, 'No targets provided' if user_or_target.empty?
|
|
185
|
+
|
|
186
|
+
last_result = nil
|
|
187
|
+
last_exception = nil
|
|
188
|
+
|
|
189
|
+
user_or_target.each do |target|
|
|
190
|
+
begin
|
|
191
|
+
last_result = accept_invitations(invitation_ids, target)
|
|
192
|
+
rescue => e
|
|
193
|
+
last_exception = e
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
raise last_exception if last_exception
|
|
198
|
+
|
|
199
|
+
return last_result || {}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check if it's a legacy target format (has :type and :value keys)
|
|
203
|
+
is_legacy_target = user_or_target.key?(:type) && user_or_target.key?(:value)
|
|
204
|
+
|
|
205
|
+
if is_legacy_target
|
|
206
|
+
warn '[Vortex SDK] DEPRECATED: Passing a target hash is deprecated. ' \
|
|
207
|
+
'Use the User format instead: accept_invitations(invitation_ids, { email: "user@example.com" })'
|
|
208
|
+
|
|
209
|
+
# Convert target to User format
|
|
210
|
+
target_type = user_or_target[:type]
|
|
211
|
+
target_value = user_or_target[:value]
|
|
212
|
+
|
|
213
|
+
user = {}
|
|
214
|
+
case target_type
|
|
215
|
+
when 'email'
|
|
216
|
+
user[:email] = target_value
|
|
217
|
+
when 'phone', 'phoneNumber'
|
|
218
|
+
user[:phone] = target_value
|
|
219
|
+
else
|
|
220
|
+
# For other types, try to use as email
|
|
221
|
+
user[:email] = target_value
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Recursively call with User format
|
|
225
|
+
return accept_invitations(invitation_ids, user)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# New User format
|
|
229
|
+
user = user_or_target
|
|
230
|
+
|
|
231
|
+
# Validate that either email or phone is provided
|
|
232
|
+
raise VortexError, 'User must have either email or phone' if user[:email].nil? && user[:phone].nil?
|
|
233
|
+
|
|
156
234
|
body = {
|
|
157
235
|
invitationIds: invitation_ids,
|
|
158
|
-
|
|
236
|
+
user: user.compact # Remove nil values
|
|
159
237
|
}
|
|
160
238
|
|
|
161
239
|
response = @connection.post('/api/v1/invitations/accept') do |req|
|
|
@@ -164,6 +242,8 @@ module Vortex
|
|
|
164
242
|
end
|
|
165
243
|
|
|
166
244
|
handle_response(response)
|
|
245
|
+
rescue VortexError
|
|
246
|
+
raise
|
|
167
247
|
rescue => e
|
|
168
248
|
raise VortexError, "Failed to accept invitations: #{e.message}"
|
|
169
249
|
end
|
|
@@ -207,6 +287,86 @@ module Vortex
|
|
|
207
287
|
raise VortexError, "Failed to reinvite: #{e.message}"
|
|
208
288
|
end
|
|
209
289
|
|
|
290
|
+
# Create an invitation from your backend
|
|
291
|
+
#
|
|
292
|
+
# This method allows you to create invitations programmatically using your API key,
|
|
293
|
+
# without requiring a user JWT token. Useful for server-side invitation creation,
|
|
294
|
+
# such as "People You May Know" flows or admin-initiated invitations.
|
|
295
|
+
#
|
|
296
|
+
# Target types:
|
|
297
|
+
# - 'email': Send an email invitation
|
|
298
|
+
# - 'phone': Create a phone invitation (short link returned for you to send)
|
|
299
|
+
# - 'internal': Create an internal invitation for PYMK flows (no email sent)
|
|
300
|
+
#
|
|
301
|
+
# @param widget_configuration_id [String] The widget configuration ID to use
|
|
302
|
+
# @param target [Hash] The invitation target: { type: 'email|sms|internal', value: '...' }
|
|
303
|
+
# @param inviter [Hash] The inviter info: { user_id: '...', user_email: '...', name: '...' }
|
|
304
|
+
# @param groups [Array<Hash>, nil] Optional groups: [{ type: '...', group_id: '...', name: '...' }]
|
|
305
|
+
# @param source [String, nil] Optional source for analytics (defaults to 'api')
|
|
306
|
+
# @param template_variables [Hash, nil] Optional template variables for email customization
|
|
307
|
+
# @param metadata [Hash, nil] Optional metadata passed through to webhooks
|
|
308
|
+
# @return [Hash] Created invitation with :id, :short_link, :status, :created_at
|
|
309
|
+
# @raise [VortexError] If the request fails
|
|
310
|
+
#
|
|
311
|
+
# @example Create an email invitation
|
|
312
|
+
# result = client.create_invitation(
|
|
313
|
+
# 'widget-config-123',
|
|
314
|
+
# { type: 'email', value: 'invitee@example.com' },
|
|
315
|
+
# { user_id: 'user-456', user_email: 'inviter@example.com', name: 'John Doe' },
|
|
316
|
+
# [{ type: 'team', group_id: 'team-789', name: 'Engineering' }]
|
|
317
|
+
# )
|
|
318
|
+
#
|
|
319
|
+
# @example Create an internal invitation (PYMK flow - no email sent)
|
|
320
|
+
# result = client.create_invitation(
|
|
321
|
+
# 'widget-config-123',
|
|
322
|
+
# { type: 'internal', value: 'internal-user-abc' },
|
|
323
|
+
# { user_id: 'user-456' },
|
|
324
|
+
# nil,
|
|
325
|
+
# 'pymk'
|
|
326
|
+
# )
|
|
327
|
+
def create_invitation(widget_configuration_id, target, inviter, groups = nil, source = nil, template_variables = nil, metadata = nil)
|
|
328
|
+
raise VortexError, 'widget_configuration_id is required' if widget_configuration_id.nil? || widget_configuration_id.empty?
|
|
329
|
+
raise VortexError, 'target must have type and value' if target[:type].nil? || target[:value].nil?
|
|
330
|
+
raise VortexError, 'inviter must have user_id' if inviter[:user_id].nil?
|
|
331
|
+
|
|
332
|
+
# Build request body with camelCase keys for the API
|
|
333
|
+
body = {
|
|
334
|
+
widgetConfigurationId: widget_configuration_id,
|
|
335
|
+
target: target,
|
|
336
|
+
inviter: {
|
|
337
|
+
userId: inviter[:user_id],
|
|
338
|
+
userEmail: inviter[:user_email],
|
|
339
|
+
name: inviter[:name],
|
|
340
|
+
avatarUrl: inviter[:avatar_url]
|
|
341
|
+
}.compact
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if groups && !groups.empty?
|
|
345
|
+
body[:groups] = groups.map do |g|
|
|
346
|
+
{
|
|
347
|
+
type: g[:type],
|
|
348
|
+
groupId: g[:group_id],
|
|
349
|
+
name: g[:name]
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
body[:source] = source if source
|
|
355
|
+
body[:templateVariables] = template_variables if template_variables
|
|
356
|
+
body[:metadata] = metadata if metadata
|
|
357
|
+
|
|
358
|
+
response = @connection.post('/api/v1/invitations') do |req|
|
|
359
|
+
req.headers['Content-Type'] = 'application/json'
|
|
360
|
+
req.body = JSON.generate(body)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
handle_response(response)
|
|
364
|
+
rescue VortexError
|
|
365
|
+
raise
|
|
366
|
+
rescue => e
|
|
367
|
+
raise VortexError, "Failed to create invitation: #{e.message}"
|
|
368
|
+
end
|
|
369
|
+
|
|
210
370
|
private
|
|
211
371
|
|
|
212
372
|
def build_connection
|
data/lib/vortex/types.rb
CHANGED
|
@@ -39,6 +39,19 @@ module Vortex
|
|
|
39
39
|
createdAt: String # ISO 8601 timestamp when the group was created
|
|
40
40
|
}.freeze
|
|
41
41
|
|
|
42
|
+
# AcceptUser structure for accepting invitations (new format - preferred)
|
|
43
|
+
# @example
|
|
44
|
+
# {
|
|
45
|
+
# email: 'user@example.com',
|
|
46
|
+
# phone: '+1234567890', # Optional
|
|
47
|
+
# name: 'John Doe' # Optional
|
|
48
|
+
# }
|
|
49
|
+
ACCEPT_USER = {
|
|
50
|
+
email: String, # Optional but either email or phone must be provided
|
|
51
|
+
phone: String, # Optional but either email or phone must be provided
|
|
52
|
+
name: String # Optional
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
42
55
|
# Invitation structure from API responses
|
|
43
56
|
# @example
|
|
44
57
|
# {
|
|
@@ -56,7 +69,7 @@ module Vortex
|
|
|
56
69
|
createdAt: String,
|
|
57
70
|
deactivated: :boolean,
|
|
58
71
|
deliveryCount: Integer,
|
|
59
|
-
deliveryTypes: Array, # of String
|
|
72
|
+
deliveryTypes: Array, # of String - valid values: "email", "phone", "share", "internal"
|
|
60
73
|
foreignCreatorId: String,
|
|
61
74
|
invitationType: String,
|
|
62
75
|
modifiedAt: String,
|
data/lib/vortex/version.rb
CHANGED
data/lib/vortex.rb
CHANGED
data/vortex-ruby-sdk.gemspec
CHANGED
|
@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
|
|
|
6
6
|
spec.name = 'vortex-ruby-sdk'
|
|
7
7
|
spec.version = Vortex::VERSION
|
|
8
8
|
spec.authors = ['Vortex Software']
|
|
9
|
-
spec.email = ['support@vortexsoftware.
|
|
9
|
+
spec.email = ['support@vortexsoftware.com']
|
|
10
10
|
|
|
11
11
|
spec.summary = 'Ruby SDK for Vortex invitation system'
|
|
12
12
|
spec.description = 'A Ruby SDK that provides seamless integration with the Vortex invitation system, including JWT generation and invitation management.'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vortex-ruby-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vortex Software
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -139,11 +139,13 @@ dependencies:
|
|
|
139
139
|
description: A Ruby SDK that provides seamless integration with the Vortex invitation
|
|
140
140
|
system, including JWT generation and invitation management.
|
|
141
141
|
email:
|
|
142
|
-
- support@vortexsoftware.
|
|
142
|
+
- support@vortexsoftware.com
|
|
143
143
|
executables: []
|
|
144
144
|
extensions: []
|
|
145
145
|
extra_rdoc_files: []
|
|
146
146
|
files:
|
|
147
|
+
- ".claude/implementation-guide.md"
|
|
148
|
+
- CHANGELOG.md
|
|
147
149
|
- LICENSE
|
|
148
150
|
- PUBLISHING.md
|
|
149
151
|
- README.md
|