k9o 1.4.0 → 1.5.2

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.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module K9o
6
+ # Ed25519 signature verification (RFC 8032). Uses the openssl library when the Ruby in use can verify Ed25519, and
7
+ # an in-process implementation (plain integer arithmetic) otherwise, so the answer never depends on the build of
8
+ # OpenSSL underneath Ruby.
9
+ module Ed25519
10
+ P = (2**255) - 19
11
+ L = (2**252) + 27_742_317_777_372_353_535_851_937_790_883_648_493
12
+ D = (-121_665 * 121_666.pow(P - 2, P)) % P
13
+ SQRT_M1 = 2.pow((P - 1) / 4, P)
14
+ BASE_Y = (4 * 5.pow(P - 2, P)) % P
15
+
16
+ module_function
17
+
18
+ # True when +signature+ (64 bytes) is a valid Ed25519 signature of +message+ under +public_key+ (32 bytes).
19
+ # Malformed input answers false.
20
+ def verify(public_key, message, signature)
21
+ public_key = public_key.b
22
+ message = message.b
23
+ signature = signature.b
24
+ return false unless public_key.bytesize == 32 && signature.bytesize == 64
25
+
26
+ if native?
27
+ verify_native(public_key, message, signature)
28
+ else
29
+ Pure.verify(public_key, message, signature)
30
+ end
31
+ end
32
+
33
+ # True when this Ruby's openssl can verify Ed25519 signatures.
34
+ def native?
35
+ return @native unless @native.nil?
36
+
37
+ @native = begin
38
+ OpenSSL::PKey.respond_to?(:new_raw_public_key) &&
39
+ OpenSSL::PKey.new_raw_public_key('ED25519', ("\x00".b * 31) + "\x01".b).respond_to?(:verify)
40
+ rescue StandardError
41
+ false
42
+ end
43
+ end
44
+
45
+ def verify_native(public_key, message, signature)
46
+ OpenSSL::PKey.new_raw_public_key('ED25519', public_key).verify(nil, signature, message)
47
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
48
+ false
49
+ end
50
+
51
+ # RFC 8032 section 5.1 in plain integers (extended coordinates).
52
+ module Pure
53
+ P = Ed25519::P
54
+ L = Ed25519::L
55
+ D = Ed25519::D
56
+ IDENTITY = [0, 1, 1, 0].freeze
57
+
58
+ module_function
59
+
60
+ def verify(public_key, message, signature)
61
+ a_point = decode_point(public_key)
62
+ return false if a_point.nil?
63
+
64
+ r_bytes = signature.byteslice(0, 32)
65
+ s = int_le(signature.byteslice(32, 32))
66
+ return false unless s < L
67
+ return false if decode_point(r_bytes).nil?
68
+
69
+ k = int_le(sha512(r_bytes + public_key + message)) % L
70
+ # [s]B - [k]A must equal R.
71
+ check = add(scalar_mult(base, s), scalar_mult(negate(a_point), k))
72
+ encode_point(check) == r_bytes
73
+ end
74
+
75
+ # The 32-byte public key for a 32-byte seed. Exposed so tests can sign with a fresh key.
76
+ def public_key(seed)
77
+ encode_point(scalar_mult(base, expand(seed).first))
78
+ end
79
+
80
+ # A 64-byte signature of +message+ by +seed+. Exposed so tests can sign; callers verify with Ed25519.verify.
81
+ def sign(seed, message)
82
+ a, prefix = expand(seed)
83
+ pub = encode_point(scalar_mult(base, a))
84
+ r = int_le(sha512(prefix + message)) % L
85
+ r_bytes = encode_point(scalar_mult(base, r))
86
+ k = int_le(sha512(r_bytes + pub + message)) % L
87
+ r_bytes + le32((r + (k * a)) % L)
88
+ end
89
+
90
+ def expand(seed)
91
+ h = sha512(seed)
92
+ a = int_le(h.byteslice(0, 32))
93
+ a &= (1 << 254) - 8
94
+ a |= 1 << 254
95
+ [a, h.byteslice(32, 32)]
96
+ end
97
+
98
+ def sha512(data)
99
+ OpenSSL::Digest::SHA512.digest(data)
100
+ end
101
+
102
+ def int_le(bytes)
103
+ bytes.unpack('C*').each_with_index.sum { |byte, i| byte << (8 * i) }
104
+ end
105
+
106
+ def le32(int)
107
+ Array.new(32) { |i| (int >> (8 * i)) & 0xff }.pack('C*')
108
+ end
109
+
110
+ def base
111
+ x = recover_x(Ed25519::BASE_Y, 0)
112
+ [x, Ed25519::BASE_Y, 1, (x * Ed25519::BASE_Y) % P]
113
+ end
114
+
115
+ def recover_x(y, sign)
116
+ return nil if y >= P
117
+
118
+ x2 = ((y * y) - 1) * ((D * y * y) + 1).pow(P - 2, P) % P
119
+ if x2.zero?
120
+ return sign.zero? ? 0 : nil
121
+ end
122
+
123
+ x = x2.pow((P + 3) / 8, P)
124
+ x = (x * Ed25519::SQRT_M1) % P unless ((x * x) - x2) % P == 0
125
+ return nil unless ((x * x) - x2) % P == 0
126
+
127
+ x = P - x if (x & 1) != sign
128
+ x
129
+ end
130
+
131
+ def decode_point(bytes)
132
+ return nil unless bytes.bytesize == 32
133
+
134
+ y = int_le(bytes)
135
+ sign = y >> 255
136
+ y &= (1 << 255) - 1
137
+ x = recover_x(y, sign)
138
+ return nil if x.nil?
139
+
140
+ [x, y, 1, (x * y) % P]
141
+ end
142
+
143
+ def encode_point(point)
144
+ x, y, z, = point
145
+ zinv = z.pow(P - 2, P)
146
+ xa = (x * zinv) % P
147
+ ya = (y * zinv) % P
148
+ le32(ya | ((xa & 1) << 255))
149
+ end
150
+
151
+ def add(p1, p2)
152
+ x1, y1, z1, t1 = p1
153
+ x2, y2, z2, t2 = p2
154
+ a = ((y1 - x1) * (y2 - x2)) % P
155
+ b = ((y1 + x1) * (y2 + x2)) % P
156
+ c = (2 * t1 * t2 * D) % P
157
+ d = (2 * z1 * z2) % P
158
+ e = b - a
159
+ f = d - c
160
+ g = d + c
161
+ h = b + a
162
+ [(e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P]
163
+ end
164
+
165
+ def negate(point)
166
+ x, y, z, t = point
167
+ [(-x) % P, y, z, (-t) % P]
168
+ end
169
+
170
+ def scalar_mult(point, scalar)
171
+ result = IDENTITY
172
+ addend = point
173
+ while scalar.positive?
174
+ result = add(result, addend) if scalar.odd?
175
+ addend = add(addend, addend)
176
+ scalar >>= 1
177
+ end
178
+ result
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # End users: k9o.end_users.*, the people who sign in to the project's own apps.
5
+ #
6
+ # list(status:, q:, limit:, cursor:) GET /@<slug>/end-users
7
+ # get(id) GET /@<slug>/end-users/<id>
8
+ # role_grants.list(end_user_id:) GET /@<slug>/end-user-role-grants
9
+ # role_grants.grant(end_user_id:, role:) POST /@<slug>/end-user-role-grants
10
+ # role_grants.revoke(grant_id) DELETE /@<slug>/end-user-role-grants/<grant_id>
11
+ #
12
+ # Listing and reading end users needs the project admin role. Role grants are created and revoked by project
13
+ # admins; reading them is open to a wider set of project roles.
14
+ class EndUsers
15
+ STATUSES = %w[active locked deleted all].freeze
16
+
17
+ attr_reader :role_grants
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ @role_grants = EndUserRoleGrants.new(config)
22
+ end
23
+
24
+ # End users, newest first, one page at a time. +status+ is active, locked, deleted or all (default: active and
25
+ # locked); +q+ is an email prefix; +limit+ is 1 to 200 (default 50).
26
+ def list(status: nil, q: nil, limit: nil, cursor: nil)
27
+ ApprovalChecks.check_limit(limit)
28
+ query = {}
29
+ query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
30
+ query['q'] = q unless q.nil?
31
+ query['limit'] = limit unless limit.nil?
32
+ query['cursor'] = cursor unless cursor.nil?
33
+ HttpTransport.request(config: @config, method: 'GET', path: path('end-users'), query: query)
34
+ end
35
+
36
+ # One end user. An unknown id is a 404 K9O_END_USER_NOT_FOUND.
37
+ def get(id)
38
+ HttpTransport.request(config: @config, method: 'GET',
39
+ path: path("end-users/#{ApprovalChecks.id_segment(id, 'end user')}"))
40
+ end
41
+
42
+ private
43
+
44
+ def path(tail)
45
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), tail)
46
+ end
47
+ end
48
+
49
+ # A role is a free string (1 to 128 characters), not a fixed list.
50
+ class EndUserRoleGrants
51
+ def initialize(config)
52
+ @config = config
53
+ end
54
+
55
+ # The active grants, oldest first. Not paginated. +data+ is the array of grants (the gateway wraps it in
56
+ # { 'grants' => [...] }). An unknown +end_user_id+ answers an empty list.
57
+ def list(end_user_id: nil)
58
+ query = {}
59
+ query['endUserId'] = end_user_id unless end_user_id.nil?
60
+ unwrap(HttpTransport.request(config: @config, method: 'GET', path: path, query: query), 'grants')
61
+ end
62
+
63
+ # Give an end user a role; +data+ is the grant. Granting a role the user already holds is a 400
64
+ # K9O_INVALID_FIELD_VALUE; an unknown end user is a 404 K9O_NOT_FOUND. A revoked role can be granted again.
65
+ def grant(end_user_id:, role:)
66
+ body = { 'endUserId' => text!(end_user_id, 'end_user_id', 64), 'role' => text!(role, 'role', 128) }
67
+ unwrap(HttpTransport.request(config: @config, method: 'POST', path: path, body: body), 'grant')
68
+ end
69
+
70
+ # Revoke a grant (it is kept, marked revoked). +data+ is { 'revoked' => true|false, 'revokedAt' => ... }; an
71
+ # unknown or already revoked id is not an error, it answers revoked false and revokedAt nil.
72
+ def revoke(grant_id)
73
+ HttpTransport.request(config: @config, method: 'DELETE',
74
+ path: path("/#{ApprovalChecks.id_segment(grant_id, 'grant')}"))
75
+ end
76
+
77
+ private
78
+
79
+ def unwrap(envelope, key)
80
+ Envelope.new(data: envelope.data.is_a?(Hash) ? envelope.data[key] : envelope.data, meta: envelope.meta)
81
+ end
82
+
83
+ def text!(value, what, max)
84
+ return value if value.is_a?(String) && !value.empty? && value.length <= max
85
+
86
+ raise ApprovalChecks.invalid("#{what} must be a string of 1 to #{max} characters")
87
+ end
88
+
89
+ def path(tail = '')
90
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), "end-user-role-grants#{tail}")
91
+ end
92
+ end
93
+ end
data/lib/k9o/envelope.rb CHANGED
@@ -20,9 +20,10 @@ module K9o
20
20
 
21
21
  # Meta accessor sugar.
22
22
  class Meta
23
- attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context
23
+ attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context, :approval
24
24
 
25
- def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil)
25
+ def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil,
26
+ approval: nil)
26
27
  @cursor = cursor
27
28
  @subscription = subscription
28
29
  @idempotency = idempotency
@@ -30,6 +31,7 @@ module K9o
30
31
  @cost = cost
31
32
  @timing = timing
32
33
  @context = context
34
+ @approval = approval
33
35
  end
34
36
 
35
37
  def self.from_wire(wire)
@@ -38,11 +40,12 @@ module K9o
38
40
  idem = wire['idempotency'] ? MetaIdempotency.new(replayed: !!wire['idempotency']['replayed']) : nil
39
41
  warnings = (wire['warnings'] || []).map { |w| MetaWarning.new(code: w['code'] || '', message: w['message'] || '') }
40
42
  timing = wire['timing'] ? MetaTiming.new(total_ms: wire['timing']['totalMs'].to_i) : nil
41
- context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId']) : nil
43
+ context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId'], project_id: wire['context']['projectId']) : nil
42
44
  Meta.new(
43
45
  cursor: cursor, subscription: sub, idempotency: idem,
44
46
  warnings: warnings, cost: wire['cost'] || {},
45
- timing: timing, context: context
47
+ timing: timing, context: context,
48
+ approval: wire['approval'].is_a?(Hash) ? wire['approval'] : nil
46
49
  )
47
50
  end
48
51
  end
@@ -60,5 +63,17 @@ module K9o
60
63
  MetaIdempotency = Struct.new(:replayed, keyword_init: true)
61
64
  MetaWarning = Struct.new(:code, :message, keyword_init: true)
62
65
  MetaTiming = Struct.new(:total_ms, keyword_init: true)
63
- MetaContext = Struct.new(:request_id, keyword_init: true)
66
+ MetaContext = Struct.new(:request_id, :project_id, keyword_init: true)
67
+ end
68
+
69
+ module K9o
70
+ # A binary download answer: the bytes exactly as sent and the content type the gateway named (or nil).
71
+ class Download
72
+ attr_reader :body, :content_type
73
+
74
+ def initialize(body:, content_type: nil)
75
+ @body = body
76
+ @content_type = content_type
77
+ end
78
+ end
64
79
  end
data/lib/k9o/functions.rb CHANGED
@@ -9,11 +9,14 @@ module K9o
9
9
  class Functions
10
10
  ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
11
11
 
12
+ attr_reader :triggers
13
+
12
14
  def initialize(config)
13
15
  @config = config
16
+ @triggers = FunctionTriggers.new(config)
14
17
  end
15
18
 
16
- # POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (up to 16 MiB,
19
+ # POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (for the platform operator; a project's deploy to it is refused) (up to 16 MiB,
17
20
  # +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
18
21
  # bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
19
22
  def upload_artifact(bytes, kind)
@@ -30,8 +33,9 @@ module K9o
30
33
  end
31
34
 
32
35
  # POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
33
- # runtime: bun python go java kotlin dotnet swift php ruby rust tinygo zig assemblyscript
34
- # tier: 'gateway' (WebAssembly) or 'sandbox' (OCI image archive)
36
+ # Functions run in the sandbox. tier: 'sandbox' (OCI image archive); runtime: bun python go java kotlin dotnet
37
+ # swift php ruby rust zig. 'gateway' (WebAssembly) is for the platform operator and is refused for a customer
38
+ # deploy; tinygo and assemblyscript run only there, so they cannot be deployed to a project.
35
39
  # artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
36
40
  def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
37
41
  slug = require_project_slug
@@ -87,4 +91,79 @@ module K9o
87
91
  ::URI.encode_www_form_component(s)
88
92
  end
89
93
  end
94
+
95
+ # Event triggers: k9o.functions.triggers.* run a function when a record is inserted, updated, deleted or upserted
96
+ # in a collection. They need the project admin role. For triggers on a timetable see +k9o.schedules+.
97
+ #
98
+ # list(name) GET /@<slug>/functions/<name>/triggers
99
+ # create(name, collection:, events:, ...) POST /@<slug>/functions/<name>/triggers
100
+ # update(name, trigger_id, **fields) PATCH /@<slug>/functions/<name>/triggers/<id>
101
+ # delete(name, trigger_id) DELETE /@<slug>/functions/<name>/triggers/<id> (204, no body)
102
+ class FunctionTriggers
103
+ EVENTS = %w[insert update delete upsert].freeze
104
+ COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,127}\z/.freeze
105
+ UPDATE_FIELDS = %i[events filter enabled log_ok_invocations].freeze
106
+
107
+ def initialize(config)
108
+ @config = config
109
+ end
110
+
111
+ # Every trigger of the function, newest first. Not paginated.
112
+ def list(name)
113
+ call('GET', path(name))
114
+ end
115
+
116
+ # Create an event trigger; answers 201 with the trigger. +events+ lists 1 to 4 of insert, update, delete, upsert.
117
+ # +filter+ is a predicate Hash over the record; leave it out to fire on every matching event.
118
+ def create(name, collection:, events:, filter: nil, enabled: nil, log_ok_invocations: nil)
119
+ unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
120
+ raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (1 to 128 characters)',
121
+ collection: collection)
122
+ end
123
+
124
+ body = { 'collection' => collection, 'events' => check_events(events) }
125
+ body['filter'] = filter unless filter.nil?
126
+ body['enabled'] = enabled unless enabled.nil?
127
+ body['logOkInvocations'] = log_ok_invocations unless log_ok_invocations.nil?
128
+ call('POST', path(name), body: body)
129
+ end
130
+
131
+ # Change a trigger; send at least one of +events+, +filter+, +enabled+, +log_ok_invocations+. A Hash +filter+
132
+ # replaces the filter and an explicit +filter: nil+ clears it. The collection cannot be changed.
133
+ def update(name, trigger_id, **fields)
134
+ unknown = fields.keys - UPDATE_FIELDS
135
+ raise ApprovalChecks.invalid("unknown field: #{unknown.join(', ')}") unless unknown.empty?
136
+ raise ApprovalChecks.invalid("patch needs at least one of: #{UPDATE_FIELDS.join(', ')}") if fields.empty?
137
+
138
+ body = {}
139
+ body['events'] = check_events(fields[:events]) if fields.key?(:events)
140
+ body['filter'] = fields[:filter] if fields.key?(:filter)
141
+ body['enabled'] = fields[:enabled] if fields.key?(:enabled)
142
+ body['logOkInvocations'] = fields[:log_ok_invocations] if fields.key?(:log_ok_invocations)
143
+ call('PATCH', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"), body: body)
144
+ end
145
+
146
+ # Delete a trigger. Answers 204, so +data+ is nil; deleting again is a 404.
147
+ def delete(name, trigger_id)
148
+ call('DELETE', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"))
149
+ end
150
+
151
+ private
152
+
153
+ def check_events(events)
154
+ list = events.is_a?(Array) ? events.map { |e| e.is_a?(Symbol) ? e.to_s : e } : nil
155
+ return list if list && list.size.between?(1, 4) && list.all? { |e| EVENTS.include?(e) }
156
+
157
+ raise ApprovalChecks.invalid('events must list 1 to 4 of insert, update, delete, upsert', events: events)
158
+ end
159
+
160
+ def call(method, route, body: nil)
161
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
162
+ end
163
+
164
+ def path(name, tail = '')
165
+ slug = Studio.require_project_slug(@config, 'functions')
166
+ UrlGrammar.project_path(slug, "functions/#{ApprovalChecks.id_segment(name, 'function')}/triggers#{tail}")
167
+ end
168
+ end
90
169
  end
data/lib/k9o/gate.rb CHANGED
@@ -15,17 +15,26 @@ module K9o
15
15
  end
16
16
 
17
17
  # POST /@<slug>/gate/signin { email, password }
18
+ #
19
+ # Asks the project which region holds the email first (POST /@<slug>/gate/locate, the email only, no credential),
20
+ # then sends the password to that region's host if it is a host of your own org and apex, else to the base URL.
21
+ # A 429 or 503 from that step raises as any gateway error does, nothing else is sent, and it is safe to retry.
18
22
  def sign_in(email:, password:)
19
- HttpTransport.request(
23
+ origin = Locate.origin_for(@config, email, gate_path('locate'))
24
+ out = HttpTransport.request(
20
25
  config: @config, method: 'POST', path: gate_path('signin'),
21
- body: { 'email' => email, 'password' => password }
26
+ body: { 'email' => email, 'password' => password }, origin: origin
22
27
  )
28
+ Locate.remember(session_refresh_token(out), origin)
29
+ out
23
30
  end
24
31
 
25
32
  # POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
26
33
  def sign_out(refresh_token:)
27
- HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
28
- body: { 'refreshToken' => refresh_token })
34
+ out = HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
35
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token))
36
+ Locate.forget(refresh_token)
37
+ out
29
38
  end
30
39
 
31
40
  # GET /@<slug>/gate/whoami
@@ -33,23 +42,53 @@ module K9o
33
42
  HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
34
43
  end
35
44
 
45
+ # POST /@<slug>/auth/sessions/revoke-all
46
+ #
47
+ # Ends every live session of the signed-in end user, on every device, this one included. Call it with the end
48
+ # user's access token as the client's `api_key` (the token `whoami` uses), not a refresh token. The answer's `data`
49
+ # is `{ revokedCount }`. Each ended session's next refresh raises a KError with code `K9O_SESSION_EXPIRED` and a
50
+ # `reason` of `"revoked"` in its `context`; access tokens already issued keep working until they expire. An
51
+ # operator's token raises `K9O_TRUST_ZONE_VIOLATION` (403), and a token issued for another project
52
+ # `K9O_PROJECT_SCOPE_MISMATCH` (403).
53
+ def revoke_all_sessions
54
+ HttpTransport.request(config: @config, method: 'POST', path: project_path('auth/sessions/revoke-all'))
55
+ end
56
+
36
57
  # POST /@<slug>/gate/refresh { refreshToken }
58
+ #
59
+ # The answer's `data` is `{ accessToken, refreshToken, expiresAt }`. The refresh token you sent stops working: store
60
+ # the returned `refreshToken` before the next refresh, and refresh one at a time per session. A second call with a
61
+ # secret that was already used is a replay: the session is revoked and the call raises a KError with code
62
+ # `K9O_SESSION_EXPIRED` and a `reason` of `"replay"` in its `context`. The newer secret then gets `"revoked"`. The
63
+ # other reasons are `"inactive"` and `"max_length"`; sign in again.
37
64
  def refresh_token(refresh_token:)
38
- HttpTransport.request(
65
+ out = HttpTransport.request(
39
66
  config: @config, method: 'POST', path: gate_path('refresh'),
40
- body: { 'refreshToken' => refresh_token }
67
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token)
41
68
  )
69
+ Locate.rotate(refresh_token, out.respond_to?(:data) ? out.data : nil)
70
+ out
42
71
  end
43
72
 
44
73
  private
45
74
 
75
+ def session_refresh_token(envelope)
76
+ data = envelope.respond_to?(:data) ? envelope.data : nil
77
+ session = data.is_a?(Hash) ? data['session'] : nil
78
+ session.is_a?(Hash) ? session['refreshToken'] : nil
79
+ end
80
+
46
81
  def gate_path(action)
82
+ project_path("gate/#{action}")
83
+ end
84
+
85
+ def project_path(suffix)
47
86
  raise KError.new(
48
87
  code: 'K9O_MISSING_PROJECT_CONTEXT',
49
88
  message: 'k9o.gate operations require project_slug bound on the client',
50
89
  context: {}
51
90
  ) if @config.project_slug.nil? || @config.project_slug.empty?
52
- UrlGrammar.project_path(@config.project_slug, "gate/#{action}")
91
+ UrlGrammar.project_path(@config.project_slug, suffix)
53
92
  end
54
93
  end
55
94
  end