noresponse 2.0.17
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/lib/crystalline/enum.rbi +17 -0
- data/lib/crystalline/metadata_fields.rb +263 -0
- data/lib/crystalline/module.rb +186 -0
- data/lib/crystalline/types.rb +168 -0
- data/lib/crystalline/utils.rb +67 -0
- data/lib/crystalline.rb +11 -0
- data/lib/no_response/assessments.rb +1252 -0
- data/lib/no_response/auth.rb +263 -0
- data/lib/no_response/issued_credential.rb +21 -0
- data/lib/no_response/keys.rb +318 -0
- data/lib/no_response/late_results.rb +301 -0
- data/lib/no_response/models/callbacks.rb +10 -0
- data/lib/no_response/models/errors/apistatuserror.rb +41 -0
- data/lib/no_response/models/errors/apistatuserror.rbi +30 -0
- data/lib/no_response/models/errors/authenticationerror.rb +108 -0
- data/lib/no_response/models/errors/authenticationerror.rbi +56 -0
- data/lib/no_response/models/errors/badrequesterror.rb +108 -0
- data/lib/no_response/models/errors/badrequesterror.rbi +56 -0
- data/lib/no_response/models/errors/conflicterror.rb +108 -0
- data/lib/no_response/models/errors/conflicterror.rbi +56 -0
- data/lib/no_response/models/errors/notfounderror.rb +108 -0
- data/lib/no_response/models/errors/notfounderror.rbi +56 -0
- data/lib/no_response/models/errors/permissiondeniederror.rb +108 -0
- data/lib/no_response/models/errors/permissiondeniederror.rbi +56 -0
- data/lib/no_response/models/errors/ratelimiterror.rb +108 -0
- data/lib/no_response/models/errors/ratelimiterror.rbi +56 -0
- data/lib/no_response/models/errors.rb +16 -0
- data/lib/no_response/models/operations/createassessment_request.rb +36 -0
- data/lib/no_response/models/operations/createassessment_request.rbi +20 -0
- data/lib/no_response/models/operations/createassessment_response.rb +32 -0
- data/lib/no_response/models/operations/createassessment_response.rbi +20 -0
- data/lib/no_response/models/operations/getassessment_request.rb +41 -0
- data/lib/no_response/models/operations/getassessment_request.rbi +20 -0
- data/lib/no_response/models/operations/getassessment_response.rb +32 -0
- data/lib/no_response/models/operations/getassessment_response.rbi +20 -0
- data/lib/no_response/models/operations/queryaccountassessments_response.rb +44 -0
- data/lib/no_response/models/operations/queryaccountassessments_response.rbi +20 -0
- data/lib/no_response/models/operations/recordassessmentoutcome_request.rb +52 -0
- data/lib/no_response/models/operations/recordassessmentoutcome_request.rbi +26 -0
- data/lib/no_response/models/operations/recordassessmentoutcome_response.rb +32 -0
- data/lib/no_response/models/operations/recordassessmentoutcome_response.rbi +20 -0
- data/lib/no_response/models/operations/rotateapikey_response.rb +32 -0
- data/lib/no_response/models/operations/rotateapikey_response.rbi +20 -0
- data/lib/no_response/models/operations.rb +17 -0
- data/lib/no_response/models/shared/alternate_name.rb +23 -0
- data/lib/no_response/models/shared/alternate_name.rbi +9 -0
- data/lib/no_response/models/shared/alternate_name_union.rb +23 -0
- data/lib/no_response/models/shared/alternate_name_union.rbi +9 -0
- data/lib/no_response/models/shared/assessment.rb +235 -0
- data/lib/no_response/models/shared/assessment.rbi +116 -0
- data/lib/no_response/models/shared/assessmentlist.rb +78 -0
- data/lib/no_response/models/shared/assessmentlist.rbi +38 -0
- data/lib/no_response/models/shared/assessmentoutcome.rb +61 -0
- data/lib/no_response/models/shared/assessmentoutcome.rbi +32 -0
- data/lib/no_response/models/shared/assessmentoutcomerequest.rb +71 -0
- data/lib/no_response/models/shared/assessmentoutcomerequest.rbi +32 -0
- data/lib/no_response/models/shared/assessmentquery.rb +70 -0
- data/lib/no_response/models/shared/assessmentquery.rbi +32 -0
- data/lib/no_response/models/shared/assessmentrequest.rb +125 -0
- data/lib/no_response/models/shared/assessmentrequest.rbi +62 -0
- data/lib/no_response/models/shared/assessmentrequestclientcontext.rb +125 -0
- data/lib/no_response/models/shared/assessmentrequestclientcontext.rbi +62 -0
- data/lib/no_response/models/shared/assessmentrequestidentifiers.rb +74 -0
- data/lib/no_response/models/shared/assessmentrequestidentifiers.rbi +38 -0
- data/lib/no_response/models/shared/assessmentrequestissuedcredential.rb +33 -0
- data/lib/no_response/models/shared/assessmentrequestissuedcredential.rbi +14 -0
- data/lib/no_response/models/shared/assessmentrequestpolicycontext.rb +46 -0
- data/lib/no_response/models/shared/assessmentrequestpolicycontext.rbi +20 -0
- data/lib/no_response/models/shared/assessmenttiming.rb +41 -0
- data/lib/no_response/models/shared/assessmenttiming.rbi +20 -0
- data/lib/no_response/models/shared/assurancelevel.rb +18 -0
- data/lib/no_response/models/shared/assurancelevel.rbi +9 -0
- data/lib/no_response/models/shared/authcontext.rb +285 -0
- data/lib/no_response/models/shared/authcontext.rbi +152 -0
- data/lib/no_response/models/shared/authcontextmfa.rb +55 -0
- data/lib/no_response/models/shared/authcontextmfa.rbi +26 -0
- data/lib/no_response/models/shared/authenticationerrorcode.rb +18 -0
- data/lib/no_response/models/shared/authenticationerrorcode.rbi +9 -0
- data/lib/no_response/models/shared/badrequesterrorcode.rb +20 -0
- data/lib/no_response/models/shared/badrequesterrorcode.rbi +9 -0
- data/lib/no_response/models/shared/band.rb +21 -0
- data/lib/no_response/models/shared/band.rbi +9 -0
- data/lib/no_response/models/shared/breach.rb +108 -0
- data/lib/no_response/models/shared/breach.rbi +56 -0
- data/lib/no_response/models/shared/breachexposed.rb +28 -0
- data/lib/no_response/models/shared/breachexposed.rbi +9 -0
- data/lib/no_response/models/shared/breachkind.rb +24 -0
- data/lib/no_response/models/shared/breachkind.rbi +9 -0
- data/lib/no_response/models/shared/breachselector.rb +20 -0
- data/lib/no_response/models/shared/breachselector.rbi +9 -0
- data/lib/no_response/models/shared/breachsummary.rb +109 -0
- data/lib/no_response/models/shared/breachsummary.rbi +56 -0
- data/lib/no_response/models/shared/confidence.rb +21 -0
- data/lib/no_response/models/shared/confidence.rbi +9 -0
- data/lib/no_response/models/shared/conflicterrorcode.rb +19 -0
- data/lib/no_response/models/shared/conflicterrorcode.rbi +9 -0
- data/lib/no_response/models/shared/consistency.rb +85 -0
- data/lib/no_response/models/shared/consistency.rbi +32 -0
- data/lib/no_response/models/shared/consistencyvalue.rb +21 -0
- data/lib/no_response/models/shared/consistencyvalue.rbi +9 -0
- data/lib/no_response/models/shared/country.rb +20 -0
- data/lib/no_response/models/shared/country.rbi +9 -0
- data/lib/no_response/models/shared/email.rb +23 -0
- data/lib/no_response/models/shared/email.rbi +9 -0
- data/lib/no_response/models/shared/email_union.rb +23 -0
- data/lib/no_response/models/shared/email_union.rbi +9 -0
- data/lib/no_response/models/shared/failurereason.rb +24 -0
- data/lib/no_response/models/shared/failurereason.rbi +9 -0
- data/lib/no_response/models/shared/flow.rb +18 -0
- data/lib/no_response/models/shared/flow.rbi +9 -0
- data/lib/no_response/models/shared/freshness.rb +20 -0
- data/lib/no_response/models/shared/freshness.rbi +9 -0
- data/lib/no_response/models/shared/hookpoint.rb +19 -0
- data/lib/no_response/models/shared/hookpoint.rbi +9 -0
- data/lib/no_response/models/shared/identifierkind.rb +21 -0
- data/lib/no_response/models/shared/identifierkind.rbi +9 -0
- data/lib/no_response/models/shared/incompletereason.rb +23 -0
- data/lib/no_response/models/shared/incompletereason.rbi +9 -0
- data/lib/no_response/models/shared/incompletesource.rb +53 -0
- data/lib/no_response/models/shared/incompletesource.rbi +20 -0
- data/lib/no_response/models/shared/ipsummary.rb +61 -0
- data/lib/no_response/models/shared/ipsummary.rbi +26 -0
- data/lib/no_response/models/shared/ipsummarytype.rb +24 -0
- data/lib/no_response/models/shared/ipsummarytype.rbi +9 -0
- data/lib/no_response/models/shared/keyrotation.rb +79 -0
- data/lib/no_response/models/shared/keyrotation.rbi +44 -0
- data/lib/no_response/models/shared/keyrotationrequest.rb +33 -0
- data/lib/no_response/models/shared/keyrotationrequest.rbi +14 -0
- data/lib/no_response/models/shared/lane.rb +21 -0
- data/lib/no_response/models/shared/lane.rbi +9 -0
- data/lib/no_response/models/shared/linetype.rb +22 -0
- data/lib/no_response/models/shared/linetype.rbi +9 -0
- data/lib/no_response/models/shared/location.rb +23 -0
- data/lib/no_response/models/shared/location.rbi +9 -0
- data/lib/no_response/models/shared/location_union.rb +23 -0
- data/lib/no_response/models/shared/location_union.rbi +9 -0
- data/lib/no_response/models/shared/name_candidate.rb +23 -0
- data/lib/no_response/models/shared/name_candidate.rbi +9 -0
- data/lib/no_response/models/shared/name_candidate_union.rb +23 -0
- data/lib/no_response/models/shared/name_candidate_union.rbi +9 -0
- data/lib/no_response/models/shared/notfounderrorcode.rb +18 -0
- data/lib/no_response/models/shared/notfounderrorcode.rbi +9 -0
- data/lib/no_response/models/shared/outcome.rb +23 -0
- data/lib/no_response/models/shared/outcome.rbi +9 -0
- data/lib/no_response/models/shared/permissiondeniederrorcode.rb +18 -0
- data/lib/no_response/models/shared/permissiondeniederrorcode.rbi +9 -0
- data/lib/no_response/models/shared/phoneenrichment.rb +32 -0
- data/lib/no_response/models/shared/phoneenrichment.rbi +14 -0
- data/lib/no_response/models/shared/phoneenrichmentfields.rb +451 -0
- data/lib/no_response/models/shared/phoneenrichmentfields.rbi +212 -0
- data/lib/no_response/models/shared/platform.rb +18 -0
- data/lib/no_response/models/shared/platform.rbi +9 -0
- data/lib/no_response/models/shared/probleminvalidparam.rb +48 -0
- data/lib/no_response/models/shared/probleminvalidparam.rbi +26 -0
- data/lib/no_response/models/shared/ratelimiterrorcode.rb +18 -0
- data/lib/no_response/models/shared/ratelimiterrorcode.rbi +9 -0
- data/lib/no_response/models/shared/reasoncode.rb +52 -0
- data/lib/no_response/models/shared/reasoncode.rbi +9 -0
- data/lib/no_response/models/shared/reasonidentifier.rb +23 -0
- data/lib/no_response/models/shared/reasonidentifier.rbi +9 -0
- data/lib/no_response/models/shared/recommendation.rb +20 -0
- data/lib/no_response/models/shared/recommendation.rbi +9 -0
- data/lib/no_response/models/shared/responsemode.rb +16 -0
- data/lib/no_response/models/shared/responsemode.rbi +9 -0
- data/lib/no_response/models/shared/risk.rb +171 -0
- data/lib/no_response/models/shared/risk.rbi +80 -0
- data/lib/no_response/models/shared/riskconfidence.rb +20 -0
- data/lib/no_response/models/shared/riskconfidence.rbi +9 -0
- data/lib/no_response/models/shared/riskcoverage.rb +49 -0
- data/lib/no_response/models/shared/riskcoverage.rbi +26 -0
- data/lib/no_response/models/shared/riskdetails.rb +55 -0
- data/lib/no_response/models/shared/riskdetails.rbi +26 -0
- data/lib/no_response/models/shared/riskdetailsbreachname.rb +40 -0
- data/lib/no_response/models/shared/riskdetailsbreachname.rbi +20 -0
- data/lib/no_response/models/shared/riskdetailsphonename.rb +40 -0
- data/lib/no_response/models/shared/riskdetailsphonename.rbi +20 -0
- data/lib/no_response/models/shared/riskreason.rb +67 -0
- data/lib/no_response/models/shared/riskreason.rbi +26 -0
- data/lib/no_response/models/shared/risksummary.rb +73 -0
- data/lib/no_response/models/shared/risksummary.rbi +38 -0
- data/lib/no_response/models/shared/risksummaryemail.rb +55 -0
- data/lib/no_response/models/shared/risksummaryemail.rbi +26 -0
- data/lib/no_response/models/shared/risksummaryphone.rb +69 -0
- data/lib/no_response/models/shared/risksummaryphone.rbi +32 -0
- data/lib/no_response/models/shared/risksummaryusername.rb +41 -0
- data/lib/no_response/models/shared/risksummaryusername.rbi +20 -0
- data/lib/no_response/models/shared/scope.rb +21 -0
- data/lib/no_response/models/shared/scope.rbi +9 -0
- data/lib/no_response/models/shared/security.rb +32 -0
- data/lib/no_response/models/shared/security.rbi +14 -0
- data/lib/no_response/models/shared/signal.rb +149 -0
- data/lib/no_response/models/shared/signal.rbi +68 -0
- data/lib/no_response/models/shared/signaloutcome.rb +22 -0
- data/lib/no_response/models/shared/signaloutcome.rbi +9 -0
- data/lib/no_response/models/shared/social_profile.rb +23 -0
- data/lib/no_response/models/shared/social_profile.rbi +9 -0
- data/lib/no_response/models/shared/social_profile_union.rb +23 -0
- data/lib/no_response/models/shared/social_profile_union.rbi +9 -0
- data/lib/no_response/models/shared/status.rb +20 -0
- data/lib/no_response/models/shared/status.rbi +9 -0
- data/lib/no_response/models/shared/timezone.rb +23 -0
- data/lib/no_response/models/shared/timezone.rbi +9 -0
- data/lib/no_response/models/shared/timezone_union.rb +23 -0
- data/lib/no_response/models/shared/timezone_union.rbi +9 -0
- data/lib/no_response/models/shared/transport.rb +17 -0
- data/lib/no_response/models/shared/transport.rbi +9 -0
- data/lib/no_response/models/shared/website.rb +23 -0
- data/lib/no_response/models/shared/website.rbi +9 -0
- data/lib/no_response/models/shared/website_union.rb +23 -0
- data/lib/no_response/models/shared/website_union.rbi +9 -0
- data/lib/no_response/models/shared.rb +92 -0
- data/lib/no_response/noresponseclient.rb +110 -0
- data/lib/no_response/sdk_hooks/hooks.rb +97 -0
- data/lib/no_response/sdk_hooks/idempotency_key_hook.rb +25 -0
- data/lib/no_response/sdk_hooks/registration.rb +43 -0
- data/lib/no_response/sdk_hooks/types.rb +178 -0
- data/lib/no_response/sdkconfiguration.rb +107 -0
- data/lib/no_response/sdkconfiguration.rbi +83 -0
- data/lib/no_response/utils/forms.rb +230 -0
- data/lib/no_response/utils/headers.rb +80 -0
- data/lib/no_response/utils/query_params.rb +165 -0
- data/lib/no_response/utils/request_bodies.rb +77 -0
- data/lib/no_response/utils/retries.rb +137 -0
- data/lib/no_response/utils/security.rb +156 -0
- data/lib/no_response/utils/url.rb +122 -0
- data/lib/no_response/utils/utils.rb +243 -0
- data/lib/no_response/wait.rb +66 -0
- data/lib/no_response/webhooks.rb +177 -0
- data/lib/noresponse.rb +25 -0
- metadata +501 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Late-result handlers. Hand-written beside the generated client.
|
|
5
|
+
#
|
|
6
|
+
# An assessment can be revised after the signup was allowed: a slow source
|
|
7
|
+
# settles and the recommendation rises. NoResponse sends the new version as a
|
|
8
|
+
# signed assessment.updated webhook. LateResults.handle verifies that delivery
|
|
9
|
+
# with Webhooks.construct_event, picks an action from your policy, and acts
|
|
10
|
+
# through the auth provider's admin API: revoke the user's sessions or ban the
|
|
11
|
+
# user.
|
|
12
|
+
#
|
|
13
|
+
# The handler verifies the delivery itself, so it never acts on an unverified
|
|
14
|
+
# event. The provider calls are thin: you pass the credentials, and each
|
|
15
|
+
# action is the provider's documented admin endpoint.
|
|
16
|
+
|
|
17
|
+
require 'erb'
|
|
18
|
+
require 'json'
|
|
19
|
+
require 'net/http'
|
|
20
|
+
require 'uri'
|
|
21
|
+
require_relative 'webhooks'
|
|
22
|
+
|
|
23
|
+
module NoResponse
|
|
24
|
+
module LateResults
|
|
25
|
+
# What to do to the user. BAN also ends every session.
|
|
26
|
+
NONE = 'none'
|
|
27
|
+
REVOKE_SESSIONS = 'revoke_sessions'
|
|
28
|
+
BAN = 'ban'
|
|
29
|
+
ACTIONS = [NONE, REVOKE_SESSIONS, BAN].freeze
|
|
30
|
+
|
|
31
|
+
# Revoke sessions on review; do nothing on allow or step_up. Banning is opt-in.
|
|
32
|
+
DEFAULT_POLICY = { 'allow' => NONE, 'step_up' => NONE, 'review' => REVOKE_SESSIONS }.freeze
|
|
33
|
+
|
|
34
|
+
RECOMMENDATIONS = %w[allow step_up review].freeze
|
|
35
|
+
|
|
36
|
+
# Raised when the provider's admin API refuses a call. Answer the webhook
|
|
37
|
+
# with a 5xx so NoResponse retries it. The message holds no response body
|
|
38
|
+
# and no credentials.
|
|
39
|
+
class ProviderActionError < StandardError
|
|
40
|
+
attr_reader :provider, :status
|
|
41
|
+
|
|
42
|
+
def initialize(provider, status, message)
|
|
43
|
+
super(message)
|
|
44
|
+
@provider = provider
|
|
45
|
+
@status = status
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# event is the verified Webhooks::Event. action is the action taken: NONE
|
|
50
|
+
# when the policy, the event or the user id called for nothing. user_id is
|
|
51
|
+
# the provider user id acted on, set only when an action was taken. Answer
|
|
52
|
+
# an endpoint.verification with Webhooks.verification_response(event).
|
|
53
|
+
Result = Struct.new(:event, :action, :user_id, keyword_init: true)
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# The action your policy names for a verified event. Only a served
|
|
57
|
+
# assessment with a recommendation can act. policy maps allow, step_up
|
|
58
|
+
# and review (as strings or symbols) to NONE, REVOKE_SESSIONS or BAN; a
|
|
59
|
+
# recommendation you leave out does nothing. An unknown action raises
|
|
60
|
+
# ArgumentError.
|
|
61
|
+
def action_for(event, policy = DEFAULT_POLICY)
|
|
62
|
+
return NONE if event.type != 'assessment.updated' || event.expired?
|
|
63
|
+
|
|
64
|
+
data = event.data
|
|
65
|
+
recommendation = data.recommendation || data.risk&.recommendation
|
|
66
|
+
recommendation = recommendation.to_s
|
|
67
|
+
return NONE unless RECOMMENDATIONS.include?(recommendation)
|
|
68
|
+
|
|
69
|
+
table = policy.to_h.transform_keys(&:to_s)
|
|
70
|
+
return NONE unless table.key?(recommendation)
|
|
71
|
+
|
|
72
|
+
action = table[recommendation]
|
|
73
|
+
return NONE if action.nil?
|
|
74
|
+
|
|
75
|
+
action = action.to_s
|
|
76
|
+
raise ArgumentError, "unknown late result action #{action}" unless ACTIONS.include?(action)
|
|
77
|
+
|
|
78
|
+
action
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Verify a webhook delivery and apply your policy to it.
|
|
82
|
+
#
|
|
83
|
+
# payload, headers and secrets, plus tolerance_seconds and now, are
|
|
84
|
+
# exactly what Webhooks.construct_event takes; a delivery that fails
|
|
85
|
+
# verification raises Webhooks::VerificationError before any provider
|
|
86
|
+
# call. Deduplicate on result.event.id and ignore versions older than one
|
|
87
|
+
# you have acted on.
|
|
88
|
+
#
|
|
89
|
+
# policy defaults to DEFAULT_POLICY. user_id is an optional callable
|
|
90
|
+
# (account_id, event) returning the provider user id for the assessment's
|
|
91
|
+
# account_id; it defaults to the account_id itself, as when you assess
|
|
92
|
+
# with the provider's user id. Return nil or "" to take no action.
|
|
93
|
+
def handle(payload, headers, secrets, provider, policy: DEFAULT_POLICY, user_id: nil, **verify)
|
|
94
|
+
event = Webhooks.construct_event(payload, headers, secrets, **verify)
|
|
95
|
+
action = action_for(event, policy)
|
|
96
|
+
return Result.new(event: event, action: NONE) if action == NONE || event.type != 'assessment.updated'
|
|
97
|
+
|
|
98
|
+
account_id = event.data.account_id
|
|
99
|
+
resolved = user_id ? user_id.call(account_id, event) : account_id
|
|
100
|
+
return Result.new(event: event, action: NONE) if resolved.nil? || resolved.to_s.empty?
|
|
101
|
+
|
|
102
|
+
resolved = resolved.to_s
|
|
103
|
+
if action == BAN
|
|
104
|
+
provider.ban(resolved)
|
|
105
|
+
else
|
|
106
|
+
provider.revoke_sessions(resolved)
|
|
107
|
+
end
|
|
108
|
+
Result.new(event: event, action: action, user_id: resolved)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Clerk Backend API. revoke_sessions lists the user's active sessions
|
|
112
|
+
# (GET /sessions?user_id&status=active) and revokes each
|
|
113
|
+
# (POST /sessions/{id}/revoke). ban is POST /users/{id}/ban, which
|
|
114
|
+
# revokes every session and blocks sign-in.
|
|
115
|
+
#
|
|
116
|
+
# base_url defaults to https://api.clerk.com/v1. http_client is an
|
|
117
|
+
# optional callable (method, uri, headers, body) returning
|
|
118
|
+
# [status, body]; the default uses Net::HTTP.
|
|
119
|
+
def clerk(secret_key:, base_url: 'https://api.clerk.com/v1', http_client: nil)
|
|
120
|
+
raise ArgumentError, 'clerk needs secret_key' if secret_key.to_s.empty?
|
|
121
|
+
|
|
122
|
+
Clerk.new(Caller.new('clerk', base_url, "Bearer #{secret_key}", http_client))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# WorkOS User Management. revoke_sessions pages through
|
|
126
|
+
# GET /user_management/users/{id}/sessions and revokes each active one
|
|
127
|
+
# with POST /user_management/sessions/revoke. WorkOS has no block or ban,
|
|
128
|
+
# so ban revokes the sessions and then deletes the user
|
|
129
|
+
# (DELETE /user_management/users/{id}); deletion is permanent.
|
|
130
|
+
#
|
|
131
|
+
# base_url defaults to https://api.workos.com.
|
|
132
|
+
def workos(api_key:, base_url: 'https://api.workos.com', http_client: nil)
|
|
133
|
+
raise ArgumentError, 'workos needs api_key' if api_key.to_s.empty?
|
|
134
|
+
|
|
135
|
+
WorkOS.new(Caller.new('workos', base_url, "Bearer #{api_key}", http_client))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Auth0 Management API. revoke_sessions deletes every session
|
|
139
|
+
# (DELETE /users/{id}/sessions) and refresh token
|
|
140
|
+
# (DELETE /users/{id}/refresh-tokens). ban first sets blocked: true
|
|
141
|
+
# (PATCH /users/{id}), then does the same. Pass domain (such as
|
|
142
|
+
# example.eu.auth0.com; the API is https://{domain}/api/v2) or base_url.
|
|
143
|
+
# token is a Management API access token with update:users,
|
|
144
|
+
# delete:sessions and delete:refresh_tokens.
|
|
145
|
+
def auth0(token:, domain: nil, base_url: nil, http_client: nil)
|
|
146
|
+
raise ArgumentError, 'auth0 needs token' if token.to_s.empty?
|
|
147
|
+
|
|
148
|
+
base = base_url || (domain.to_s.empty? ? nil : "https://#{domain}/api/v2")
|
|
149
|
+
raise ArgumentError, 'auth0 needs domain or base_url' if base.nil?
|
|
150
|
+
|
|
151
|
+
Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# One provider HTTP client: base URL, Authorization header, error mapping.
|
|
156
|
+
class Caller
|
|
157
|
+
def initialize(name, base_url, authorization, http_client)
|
|
158
|
+
@name = name
|
|
159
|
+
@base = base_url.to_s.sub(%r{/+\z}, '')
|
|
160
|
+
@authorization = authorization
|
|
161
|
+
@http_client = http_client
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
attr_reader :name
|
|
165
|
+
|
|
166
|
+
def call(method, path, query: nil, body: nil, allow_404: false)
|
|
167
|
+
uri = URI.parse(@base + path)
|
|
168
|
+
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
|
169
|
+
headers = { 'Authorization' => @authorization, 'Accept' => 'application/json' }
|
|
170
|
+
payload = nil
|
|
171
|
+
unless body.nil?
|
|
172
|
+
headers['Content-Type'] = 'application/json'
|
|
173
|
+
payload = JSON.generate(body)
|
|
174
|
+
end
|
|
175
|
+
status, text = perform(method, uri, headers, payload)
|
|
176
|
+
return nil if allow_404 && status == 404
|
|
177
|
+
raise ProviderActionError.new(@name, status, "#{@name} #{method} #{path} answered #{status}") unless status.between?(200, 299)
|
|
178
|
+
return nil if text.nil? || text.empty?
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
JSON.parse(text)
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def perform(method, uri, headers, payload)
|
|
190
|
+
return @http_client.call(method, uri, headers, payload) if @http_client
|
|
191
|
+
|
|
192
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
193
|
+
http.use_ssl = uri.scheme == 'https'
|
|
194
|
+
http.open_timeout = 10
|
|
195
|
+
http.read_timeout = 30
|
|
196
|
+
request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
|
|
197
|
+
request.body = payload unless payload.nil?
|
|
198
|
+
response = http.start { |connection| connection.request(request) }
|
|
199
|
+
[response.code.to_i, response.body.to_s]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
module Segment
|
|
204
|
+
def self.encode(id)
|
|
205
|
+
raise ArgumentError, 'user id must be a non-empty string' unless id.is_a?(String) && !id.empty?
|
|
206
|
+
|
|
207
|
+
ERB::Util.url_encode(id)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.sessions(body)
|
|
211
|
+
list = body.is_a?(Hash) ? body['data'] : body
|
|
212
|
+
list.is_a?(Array) ? list.grep(Hash) : []
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.active_ids(list)
|
|
216
|
+
list.select { |s| s['status'] == 'active' && s['id'].is_a?(String) && !s['id'].empty? }.map { |s| s['id'] }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class Clerk
|
|
221
|
+
PAGE = 100
|
|
222
|
+
|
|
223
|
+
def initialize(caller_)
|
|
224
|
+
@call = caller_
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def name = 'clerk'
|
|
228
|
+
|
|
229
|
+
def revoke_sessions(user_id)
|
|
230
|
+
Segment.encode(user_id)
|
|
231
|
+
active = []
|
|
232
|
+
(0...1000).each do |round|
|
|
233
|
+
page = Segment.sessions(@call.call('GET', '/sessions', query: [['user_id', user_id], %w[status active],
|
|
234
|
+
['limit', PAGE.to_s],
|
|
235
|
+
['offset', (round * PAGE).to_s]]))
|
|
236
|
+
active.concat(Segment.active_ids(page))
|
|
237
|
+
break if page.length < PAGE
|
|
238
|
+
end
|
|
239
|
+
active.each { |id| @call.call('POST', "/sessions/#{ERB::Util.url_encode(id)}/revoke") }
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ban(user_id)
|
|
244
|
+
@call.call('POST', "/users/#{Segment.encode(user_id)}/ban")
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class WorkOS
|
|
250
|
+
def initialize(caller_)
|
|
251
|
+
@call = caller_
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def name = 'workos'
|
|
255
|
+
|
|
256
|
+
def revoke_sessions(user_id)
|
|
257
|
+
user = Segment.encode(user_id)
|
|
258
|
+
active = []
|
|
259
|
+
after = T.let(nil, T.nilable(String))
|
|
260
|
+
1000.times do
|
|
261
|
+
query = T.let([%w[limit 100]], T::Array[T::Array[String]])
|
|
262
|
+
query << ['after', after] if after
|
|
263
|
+
body = @call.call('GET', "/user_management/users/#{user}/sessions", query: query)
|
|
264
|
+
active.concat(Segment.active_ids(Segment.sessions(body)))
|
|
265
|
+
nxt = body.is_a?(Hash) && body['list_metadata'].is_a?(Hash) ? body['list_metadata']['after'] : nil
|
|
266
|
+
break unless nxt.is_a?(String) && !nxt.empty?
|
|
267
|
+
|
|
268
|
+
after = nxt
|
|
269
|
+
end
|
|
270
|
+
active.each { |id| @call.call('POST', '/user_management/sessions/revoke', body: { 'session_id' => id }) }
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def ban(user_id)
|
|
275
|
+
revoke_sessions(user_id)
|
|
276
|
+
@call.call('DELETE', "/user_management/users/#{Segment.encode(user_id)}", allow_404: true)
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
class Auth0
|
|
282
|
+
def initialize(caller_)
|
|
283
|
+
@call = caller_
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def name = 'auth0'
|
|
287
|
+
|
|
288
|
+
def revoke_sessions(user_id)
|
|
289
|
+
user = Segment.encode(user_id)
|
|
290
|
+
@call.call('DELETE', "/users/#{user}/sessions")
|
|
291
|
+
@call.call('DELETE', "/users/#{user}/refresh-tokens")
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def ban(user_id)
|
|
296
|
+
@call.call('PATCH', "/users/#{Segment.encode(user_id)}", body: { 'blocked' => true })
|
|
297
|
+
revoke_sessions(user_id)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module NoResponse
|
|
5
|
+
module Models
|
|
6
|
+
module Errors
|
|
7
|
+
|
|
8
|
+
# Every error the SDK raises for an API response is a NoResponseError.
|
|
9
|
+
class NoResponseError < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class APIStatusError < NoResponseError
|
|
13
|
+
include Crystalline::MetadataFields
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
field :body, T.nilable(::String), {'format_json': {'letter_case': ::NoResponse::Utils.field_name("body")}}
|
|
17
|
+
field :raw_response, T.nilable(Faraday::Response), {}
|
|
18
|
+
field(
|
|
19
|
+
:status_code,
|
|
20
|
+
T.nilable(::Integer),
|
|
21
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("status_code")}}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
sig { params(status_code: ::Integer, body: ::String, raw_response: Faraday::Response).void }
|
|
25
|
+
def initialize(status_code:, body:, raw_response:)
|
|
26
|
+
@status_code = status_code
|
|
27
|
+
@body = body
|
|
28
|
+
@raw_response = raw_response
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
return false unless other.is_a?(self.class)
|
|
33
|
+
return false unless @status_code == other.status_code
|
|
34
|
+
return false unless @body == other.body
|
|
35
|
+
return false unless @raw_response == other.raw_response
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class NoResponse::Models::Errors::NoResponseError < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class NoResponse::Models::Errors::APIStatusError < NoResponse::Models::Errors::NoResponseError
|
|
8
|
+
extend ::Crystalline::MetadataFields::ClassMethods
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class NoResponse::Models::Errors::APIStatusError
|
|
12
|
+
|
|
13
|
+
def body
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def body=(body)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def raw_response
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def raw_response=(raw_response)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def status_code
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status_code=(status_code)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "apistatuserror"
|
|
5
|
+
|
|
6
|
+
module NoResponse
|
|
7
|
+
module Models
|
|
8
|
+
module Errors
|
|
9
|
+
# HTTP 401 problem document.
|
|
10
|
+
class AuthenticationError < NoResponse::Models::Errors::NoResponseError
|
|
11
|
+
extend T::Sig
|
|
12
|
+
include Crystalline::MetadataFields
|
|
13
|
+
|
|
14
|
+
field :type, ::String, {'format_json': {'letter_case': ::NoResponse::Utils.field_name("type"), required: true}}
|
|
15
|
+
|
|
16
|
+
field(
|
|
17
|
+
:title,
|
|
18
|
+
::String,
|
|
19
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("title"), required: true}}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
field(
|
|
23
|
+
:detail,
|
|
24
|
+
::String,
|
|
25
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("detail"), required: true}}
|
|
26
|
+
)
|
|
27
|
+
# Machine codes an HTTP 401 answer can carry.
|
|
28
|
+
field(
|
|
29
|
+
:code,
|
|
30
|
+
Models::Shared::AuthenticationErrorCode,
|
|
31
|
+
{
|
|
32
|
+
'format_json': {
|
|
33
|
+
'letter_case': ::NoResponse::Utils.field_name("code"),
|
|
34
|
+
required: true,
|
|
35
|
+
'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::AuthenticationErrorCode, false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
field(
|
|
41
|
+
:request_id,
|
|
42
|
+
::String,
|
|
43
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("request_id"), required: true}}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
field(
|
|
47
|
+
:status,
|
|
48
|
+
::Integer,
|
|
49
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("status"), required: true}}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
field(
|
|
53
|
+
:instance,
|
|
54
|
+
Crystalline::Nilable.new(::String),
|
|
55
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("instance")}}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
field(
|
|
59
|
+
:invalid_params,
|
|
60
|
+
Crystalline::Nilable.new(Crystalline::Array.new(Models::Shared::ProblemInvalidParam)),
|
|
61
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("invalid_params")}}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
sig {
|
|
65
|
+
params(
|
|
66
|
+
type: ::String,
|
|
67
|
+
title: ::String,
|
|
68
|
+
detail: ::String,
|
|
69
|
+
code: Models::Shared::AuthenticationErrorCode,
|
|
70
|
+
request_id: ::String,
|
|
71
|
+
status: ::Integer,
|
|
72
|
+
instance: T.nilable(::String),
|
|
73
|
+
invalid_params: T.nilable(T::Array[Models::Shared::ProblemInvalidParam])
|
|
74
|
+
)
|
|
75
|
+
.void
|
|
76
|
+
}
|
|
77
|
+
def initialize(type:, title:, detail:, code:, request_id:, status: 401, instance: nil, invalid_params: nil)
|
|
78
|
+
@type = type
|
|
79
|
+
@title = title
|
|
80
|
+
@detail = detail
|
|
81
|
+
@code = code
|
|
82
|
+
@request_id = request_id
|
|
83
|
+
unless status == 401
|
|
84
|
+
raise ArgumentError, "Invalid value for status"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@status = 401
|
|
88
|
+
@instance = instance
|
|
89
|
+
@invalid_params = invalid_params
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
93
|
+
def ==(other)
|
|
94
|
+
return false unless other.is_a?(self.class)
|
|
95
|
+
return false unless @type == other.type
|
|
96
|
+
return false unless @title == other.title
|
|
97
|
+
return false unless @detail == other.detail
|
|
98
|
+
return false unless @code == other.code
|
|
99
|
+
return false unless @request_id == other.request_id
|
|
100
|
+
return false unless @status == other.status
|
|
101
|
+
return false unless @instance == other.instance
|
|
102
|
+
return false unless @invalid_params == other.invalid_params
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class NoResponse::Models::Errors::AuthenticationError
|
|
5
|
+
extend ::Crystalline::MetadataFields::ClassMethods
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class NoResponse::Models::Errors::AuthenticationError
|
|
9
|
+
def type
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def type=(str_)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def title
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def title=(str_)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def detail
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def detail=(str_)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def code
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def code=(str_)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def request_id=(str_)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def status
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def status=(str_)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def instance
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def instance=(str_)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def invalid_params
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def invalid_params=(str_)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "apistatuserror"
|
|
5
|
+
|
|
6
|
+
module NoResponse
|
|
7
|
+
module Models
|
|
8
|
+
module Errors
|
|
9
|
+
# HTTP 400 problem document.
|
|
10
|
+
class BadRequestError < NoResponse::Models::Errors::NoResponseError
|
|
11
|
+
extend T::Sig
|
|
12
|
+
include Crystalline::MetadataFields
|
|
13
|
+
|
|
14
|
+
field :type, ::String, {'format_json': {'letter_case': ::NoResponse::Utils.field_name("type"), required: true}}
|
|
15
|
+
|
|
16
|
+
field(
|
|
17
|
+
:title,
|
|
18
|
+
::String,
|
|
19
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("title"), required: true}}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
field(
|
|
23
|
+
:detail,
|
|
24
|
+
::String,
|
|
25
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("detail"), required: true}}
|
|
26
|
+
)
|
|
27
|
+
# Machine codes an HTTP 400 answer can carry.
|
|
28
|
+
field(
|
|
29
|
+
:code,
|
|
30
|
+
Models::Shared::BadRequestErrorCode,
|
|
31
|
+
{
|
|
32
|
+
'format_json': {
|
|
33
|
+
'letter_case': ::NoResponse::Utils.field_name("code"),
|
|
34
|
+
required: true,
|
|
35
|
+
'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::BadRequestErrorCode, false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
field(
|
|
41
|
+
:request_id,
|
|
42
|
+
::String,
|
|
43
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("request_id"), required: true}}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
field(
|
|
47
|
+
:status,
|
|
48
|
+
::Integer,
|
|
49
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("status"), required: true}}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
field(
|
|
53
|
+
:instance,
|
|
54
|
+
Crystalline::Nilable.new(::String),
|
|
55
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("instance")}}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
field(
|
|
59
|
+
:invalid_params,
|
|
60
|
+
Crystalline::Nilable.new(Crystalline::Array.new(Models::Shared::ProblemInvalidParam)),
|
|
61
|
+
{'format_json': {'letter_case': ::NoResponse::Utils.field_name("invalid_params")}}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
sig {
|
|
65
|
+
params(
|
|
66
|
+
type: ::String,
|
|
67
|
+
title: ::String,
|
|
68
|
+
detail: ::String,
|
|
69
|
+
code: Models::Shared::BadRequestErrorCode,
|
|
70
|
+
request_id: ::String,
|
|
71
|
+
status: ::Integer,
|
|
72
|
+
instance: T.nilable(::String),
|
|
73
|
+
invalid_params: T.nilable(T::Array[Models::Shared::ProblemInvalidParam])
|
|
74
|
+
)
|
|
75
|
+
.void
|
|
76
|
+
}
|
|
77
|
+
def initialize(type:, title:, detail:, code:, request_id:, status: 400, instance: nil, invalid_params: nil)
|
|
78
|
+
@type = type
|
|
79
|
+
@title = title
|
|
80
|
+
@detail = detail
|
|
81
|
+
@code = code
|
|
82
|
+
@request_id = request_id
|
|
83
|
+
unless status == 400
|
|
84
|
+
raise ArgumentError, "Invalid value for status"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@status = 400
|
|
88
|
+
@instance = instance
|
|
89
|
+
@invalid_params = invalid_params
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
93
|
+
def ==(other)
|
|
94
|
+
return false unless other.is_a?(self.class)
|
|
95
|
+
return false unless @type == other.type
|
|
96
|
+
return false unless @title == other.title
|
|
97
|
+
return false unless @detail == other.detail
|
|
98
|
+
return false unless @code == other.code
|
|
99
|
+
return false unless @request_id == other.request_id
|
|
100
|
+
return false unless @status == other.status
|
|
101
|
+
return false unless @instance == other.instance
|
|
102
|
+
return false unless @invalid_params == other.invalid_params
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|