async-matrix-bridge 2.0.0 → 3.0.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.
@@ -3,242 +3,161 @@
3
3
  # Released under the Apache License, Version 2.0.
4
4
  # Copyright, 2026, by General Intelligence Systems.
5
5
 
6
- require "async/matrix"
7
- require "json_schemer"
8
- require "pathname"
9
6
  require "yaml"
10
7
 
8
+ require_relative "puppet"
9
+
11
10
  module Async
12
11
  module Matrix
13
12
  module Bridge
13
+ # What the homeserver was told about this service, which is also the whole of its authority.
14
+ #
15
+ # A service cannot register itself: one which could would claim `.*` and read everything, so an
16
+ # administrator puts this file beside the homeserver's own configuration by hand.
17
+ #
18
+ # The namespaces are regexes the homeserver holds too, which is why a user outside them is
19
+ # refused here rather than asked about: the answer is already known.
14
20
  class Registration
15
- SCHEMA_DIR = Pathname.new(__dir__).join("registration", "schema").freeze
16
-
17
- Homeserver = Struct.new(:address, :domain)
18
-
19
- Appservice = Struct.new(:as_token, :hs_token, :sender_localpart)
20
-
21
- NAMESPACES = %w[users rooms aliases].freeze
22
-
23
- attr_reader :raw, :homeserver, :appservice
24
-
25
- def self.schema
26
- @schema ||= JSONSchemer.schema(SCHEMA_DIR.join("registration.json"))
27
- end
21
+ KINDS = ["users", "aliases", "rooms"].freeze
28
22
 
29
- def self.load(path, homeserver:, domain:)
30
- Pathname.new(path).then do |pathname|
31
- unless pathname.exist?
32
- raise Async::Matrix::NotFoundError.new(
33
- "M_NOT_FOUND",
34
- "Registration file not found: #{pathname}",
35
- )
36
- end
37
-
38
- new(YAML.safe_load_file(pathname), homeserver: homeserver, domain: domain)
39
- end
23
+ # @parameter path [String] the registration file.
24
+ # @parameter homeserver [String] where the homeserver is.
25
+ # @returns [Registration] the registration it describes.
26
+ def self.load(path, homeserver:)
27
+ self.new(::YAML.safe_load_file(path), homeserver: homeserver)
40
28
  end
41
29
 
42
- def self.validate!(data)
43
- schema.validate(data).to_a.then do |errors|
44
- unless errors.empty?
45
- messages = errors.map { |error| error["error"] }.join("; ")
46
- raise Async::Matrix::BadJsonError.new(
47
- "M_BAD_JSON",
48
- "Registration validation failed: #{messages}",
49
- )
30
+ # @parameter value [Hash] the registration, as the file spells it.
31
+ # @parameter homeserver [String] where the homeserver is.
32
+ def initialize(value, homeserver:)
33
+ ["id", "as_token", "hs_token", "sender_localpart"].each do |field|
34
+ unless value[field]
35
+ raise ArgumentError, "A registration must declare #{field}!"
50
36
  end
51
37
  end
52
- end
53
38
 
54
- def initialize(data, homeserver:, domain:)
55
- self.class.validate!(data)
56
-
57
- @raw = data
58
- @homeserver = Homeserver.new(homeserver, domain)
59
- @appservice = Appservice.new(
60
- data["as_token"],
61
- data["hs_token"],
62
- data["sender_localpart"],
63
- )
39
+ @value = value
40
+ @homeserver = homeserver
41
+ @domain = ::URI.parse(homeserver).host
64
42
  end
65
43
 
66
- def id = @raw["id"]
44
+ # @attribute [Hash] the registration, as the file spells it.
45
+ attr :value
67
46
 
68
- def url = @raw["url"]
47
+ # @attribute [String] where the homeserver is.
48
+ attr :homeserver
69
49
 
70
- def as_token = @appservice.as_token
50
+ # @attribute [String] the server name the users of this service belong to.
51
+ attr :domain
71
52
 
72
- def hs_token = @appservice.hs_token
53
+ # @returns [String] what the homeserver knows this service as.
54
+ def id = @value["id"]
73
55
 
74
- def sender_localpart = @appservice.sender_localpart
56
+ # @returns [String] the token this service authenticates to the homeserver with.
57
+ def as_token = @value["as_token"]
75
58
 
76
- def protocols = @raw["protocols"] || []
59
+ # @returns [String] the token the homeserver authenticates to this service with.
60
+ def hs_token = @value["hs_token"]
77
61
 
78
- def rate_limited? = @raw.fetch("rate_limited", false)
62
+ # @returns [String] the user an unnamed request acts as.
63
+ def sender = "@#{@value["sender_localpart"]}:#{@domain}"
79
64
 
80
- def receive_ephemeral? = @raw.fetch("receive_ephemeral", false)
65
+ # @returns [Boolean] whether the homeserver pushes read receipts and typing as well as events.
66
+ def ephemeral? = @value.fetch("receive_ephemeral", false)
81
67
 
68
+ # @parameter kind [String] `users`, `aliases` or `rooms`.
69
+ # @returns [Array(Hash)] the namespaces of that kind.
82
70
  def namespaces(kind)
83
- unless NAMESPACES.include?(kind.to_s)
84
- raise ArgumentError, "unknown namespace #{kind.inspect}; expected one of #{NAMESPACES.join(", ")}"
71
+ unless KINDS.include?(kind)
72
+ raise ArgumentError, "A registration has no #{kind} namespace!"
85
73
  end
86
74
 
87
- @raw.fetch("namespaces", {}).fetch(kind.to_s, [])
75
+ @value.dig("namespaces", kind) || []
88
76
  end
89
77
 
78
+ # Whether this service is interested in something, which is the same answer the homeserver has.
79
+ #
80
+ # @parameter kind [String] `users`, `aliases` or `rooms`.
81
+ # @parameter value [String] the ID or alias to ask about.
82
+ # @returns [Boolean] whether a namespace covers it.
90
83
  def covers?(kind, value)
91
- namespaces(kind).any? { |entry| Regexp.new(entry["regex"]).match?(value) }
84
+ self.namespaces(kind).any?{|namespace| self.class.claims?(namespace, value)}
92
85
  end
93
86
 
94
- def bot_mxid = "@#{sender_localpart}:#{@homeserver.domain}"
95
-
87
+ # Whether nothing else may create this, which is what an exclusive namespace means.
88
+ #
89
+ # @parameter kind [String] `users`, `aliases` or `rooms`.
90
+ # @parameter value [String] the ID or alias to ask about.
91
+ # @returns [Boolean] whether this service owns it alone.
96
92
  def exclusive?(kind, value)
97
- namespaces(kind).any? do |entry|
98
- entry["exclusive"] && Regexp.new(entry["regex"]).match?(value)
93
+ self.namespaces(kind).any? do |namespace|
94
+ namespace["exclusive"] and self.class.claims?(namespace, value)
99
95
  end
100
96
  end
101
- end
102
- end
103
- end
104
- end
105
-
106
- __END__
107
- require "tempfile"
108
-
109
- describe "Async::Matrix::Bridge::Registration" do
110
- Registration = Async::Matrix::Bridge::Registration
111
-
112
- def valid_data
113
- {
114
- "id" => "agent",
115
- "url" => "http://appservice:9000",
116
- "as_token" => "as_secret",
117
- "hs_token" => "hs_secret",
118
- "sender_localpart" => "agent",
119
- "namespaces" => {
120
- "users" => [{"regex" => "@_agent_.*:localhost", "exclusive" => true}],
121
- "aliases" => [],
122
- "rooms" => [],
123
- },
124
- }
125
- end
126
-
127
- def rejected?(data)
128
- Registration.new(data, homeserver: "http://hs", domain: "d")
129
- false
130
- rescue Async::Matrix::BadJsonError
131
- true
132
- end
133
-
134
- def build(overrides = {})
135
- Registration.new(valid_data.merge(overrides), homeserver: "http://synapse:8008", domain: "localhost")
136
- end
137
-
138
- it "reads the spec fields off the registration" do
139
- registration = build
140
- registration.id.should == "agent"
141
- registration.url.should == "http://appservice:9000"
142
- registration.sender_localpart.should == "agent"
143
- end
144
-
145
- it "presents the config surface a Client and an Endpoint need" do
146
- registration = build
147
- registration.homeserver.address.should == "http://synapse:8008"
148
- registration.appservice.as_token.should == "as_secret"
149
- registration.appservice.hs_token.should == "hs_secret"
150
- registration.bot_mxid.should == "@agent:localhost"
151
- end
152
-
153
- it "derives bot_mxid from sender_localpart and the supplied domain" do
154
- Registration.new(valid_data, homeserver: "http://hs", domain: "example.org")
155
- .bot_mxid.should == "@agent:example.org"
156
- end
157
-
158
- it "requires the spec's required fields" do
159
- ["id", "as_token", "hs_token", "sender_localpart", "namespaces"].map do |field|
160
- rejected?(valid_data.reject { |k, _| k == field })
161
- end.should == [true, true, true, true, true]
162
- end
163
-
164
- it "requires url to be present but allows an explicit null" do
165
- build("url" => nil).url.should == nil
166
-
167
- rejected?(valid_data.reject { |k, _| k == "url" }).should == true
168
- end
169
-
170
- it "rejects a namespace entry missing regex or exclusive" do
171
- [{"exclusive" => true}, {"regex" => "@a:b"}].map do |entry|
172
- rejected?(valid_data.merge("namespaces" => {"users" => [entry]}))
173
- end.should == [true, true]
174
- end
175
-
176
- it "rejects a non-string id" do
177
- rejected?(valid_data.merge("id" => 42)).should == true
178
- end
179
-
180
- it "reports whether a value falls in a namespace" do
181
- registration = build
182
- registration.covers?(:users, "@_agent_alice:localhost").should == true
183
- registration.covers?(:users, "@someone:localhost").should == false
184
- end
185
-
186
- it "reports exclusivity separately from coverage" do
187
- registration = build("namespaces" => {"users" => [{"regex" => "@a.*:localhost", "exclusive" => false}]})
188
- registration.covers?(:users, "@alice:localhost").should == true
189
- registration.exclusive?(:users, "@alice:localhost").should == false
190
- end
191
97
 
192
- it "returns an empty list for an absent namespace kind" do
193
- build("namespaces" => {"users" => []}).namespaces(:rooms).should == []
194
- end
195
-
196
- it "raises for an unknown namespace kind" do
197
- error =
198
- begin
199
- build.namespaces(:widgets)
200
- nil
201
- rescue ArgumentError => e
202
- e
98
+ # Whether one namespace names this, which is the whole of it rather than part.
99
+ #
100
+ # Anchored because a namespace names a user rather than appears inside one: unanchored,
101
+ # `@_bridge_.*` would claim `@notours_bridge_x:localhost` too, and a bridge asks this about
102
+ # senders to decide what is its own.
103
+ #
104
+ # @parameter namespace [Hash] one entry of one kind.
105
+ # @parameter value [String] the ID or alias to ask about.
106
+ # @returns [Boolean] whether the regex matches all of it.
107
+ def self.claims?(namespace, value)
108
+ ::Regexp.new("\\A(?:#{namespace["regex"]})\\z").match?(value)
203
109
  end
204
110
 
205
- error.class.should == ArgumentError
206
- end
207
-
208
- it "defaults the spec's optional booleans to false" do
209
- registration = build
210
- registration.receive_ephemeral?.should == false
211
- registration.rate_limited?.should == false
212
- registration.protocols.should == []
213
- end
214
-
215
- it "reads receive_ephemeral when set" do
216
- build("receive_ephemeral" => true).receive_ephemeral?.should == true
217
- end
111
+ # A client which acts as this service, or as one of the users it may act as.
112
+ #
113
+ # Identity assertion rather than a login: the token says which service, so the user is a
114
+ # parameter on every request. `Async::REST::Resource#with` merges it into the reference, which
115
+ # is why this is a derived resource rather than a middleware.
116
+ #
117
+ # `ts` is timestamp massaging, which only a service may ask for: it says when an event really
118
+ # happened, so a backlog carried over from another network is not stamped as now.
119
+ #
120
+ # @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
121
+ # @parameter user_id [String | nil] the user to act as, or nil for this service's own.
122
+ # @parameter device_id [String | nil] the device to act as, which is honoured from v1.17.
123
+ # @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
124
+ # @returns [Async::Matrix::ClientServer::Client] a client which acts as them.
125
+ def as(client, user_id = nil, device_id: nil, ts: nil)
126
+ service = client.authenticated(self.as_token)
127
+ parameters = {user_id: user_id, device_id: device_id, ts: ts}.compact
128
+
129
+ case user_id
130
+ in nil
131
+ service.with(parameters: parameters)
132
+ in String if self.covers?("users", user_id)
133
+ service.with(parameters: parameters)
134
+ in String
135
+ raise ArgumentError, "#{user_id} is outside this service's namespaces!"
136
+ end
137
+ end
218
138
 
219
- it "loads from a YAML file" do
220
- file = Tempfile.new(["registration", ".yaml"])
221
- begin
222
- file.write(YAML.dump(valid_data))
223
- file.close
224
-
225
- registration = Registration.load(file.path, homeserver: "http://synapse:8008", domain: "localhost")
226
- registration.id.should == "agent"
227
- registration.bot_mxid.should == "@agent:localhost"
228
- ensure
229
- file.unlink
230
- end
231
- end
139
+ # The user this service is, as something which can be given a device.
140
+ #
141
+ # An unnamed request acts as this user, but acting as a *device* of theirs needs one to exist:
142
+ # a service holds keys per device, and the sender has none until it makes one.
143
+ #
144
+ # @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
145
+ # @returns [Puppet] the user this service is.
146
+ def bot(client)
147
+ Puppet.new(self, client, @value["sender_localpart"])
148
+ end
232
149
 
233
- it "raises NotFoundError for a missing file" do
234
- error =
235
- begin
236
- Registration.load("/nonexistent/registration.yaml", homeserver: "http://hs", domain: "d")
237
- nil
238
- rescue Async::Matrix::NotFoundError => e
239
- e
150
+ # One of this service's users, which has to be made before it can be acted as.
151
+ #
152
+ # @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
153
+ # @parameter localpart [String] the part of the ID which names them, without the sigil.
154
+ # @returns [Puppet] the user, and what makes it exist.
155
+ def puppet(client, localpart)
156
+ Puppet.new(self, client, localpart)
240
157
  end
241
158
 
242
- error.class.should == Async::Matrix::NotFoundError
159
+ def inspect = "\#<#{self.class} #{self.id}>"
160
+ end
243
161
  end
244
162
  end
163
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require "json"
7
+ require "openssl"
8
+
9
+ require "protocol/http/middleware"
10
+ require "protocol/http/response"
11
+ require "protocol/url/reference"
12
+
13
+ require "console"
14
+
15
+ require_relative "transaction"
16
+ require_relative "transaction_store"
17
+
18
+ module Async
19
+ module Matrix
20
+ module Bridge
21
+ # The paths a homeserver calls, which this service answers.
22
+ #
23
+ # A middleware rather than a Rack application: it is the same primitive which sits below a
24
+ # resource on the way out, pointed the other way, so it composes with everything else in the
25
+ # stack and reads a streamed body without an adapter in between.
26
+ class Service < ::Protocol::HTTP::Middleware
27
+ # Where every path this service answers begins.
28
+ PREFIX = "/_matrix/app/v1"
29
+
30
+ JSON_HEADERS = {"content-type" => "application/json"}.freeze
31
+
32
+ # @parameter registration [Registration] what the homeserver was told about this service.
33
+ # @parameter version [String] the specification version to type the events as.
34
+ # @parameter store [TransactionStore] what remembers the transactions already handled.
35
+ # @yields {|transaction| ...}
36
+ # @parameter transaction [Transaction] the events the homeserver pushed.
37
+ def initialize(registration, version: Async::Matrix::LATEST, store: TransactionStore.new, &block)
38
+ super(::Protocol::HTTP::Middleware::NotFound)
39
+
40
+ @registration = registration
41
+ @version = version
42
+ @store = store
43
+ @block = block
44
+
45
+ @queries = {}
46
+ end
47
+
48
+ # @attribute [Registration] what the homeserver was told about this service.
49
+ attr :registration
50
+
51
+ # Answer whether a user is one of this service's own.
52
+ #
53
+ # The homeserver asks before it invents them, and nothing means no.
54
+ #
55
+ # @yields {|user_id| ...} whether the user exists, or should be made to.
56
+ def query_user(&block)
57
+ @queries[:user] = block
58
+ end
59
+
60
+ # Answer whether a room alias is one of this service's own, creating the room if it is.
61
+ #
62
+ # @yields {|alias_id| ...} whether the room exists, or should be made to.
63
+ def query_room(&block)
64
+ @queries[:room] = block
65
+ end
66
+
67
+ # Answer what this service bridges to, which a client asks a homeserver and it asks here.
68
+ #
69
+ # @yields {|kind, rest| ...} the protocols, locations or users it was asked for.
70
+ def query_thirdparty(&block)
71
+ @queries[:thirdparty] = block
72
+ end
73
+
74
+ # @parameter request [Protocol::HTTP::Request] what the homeserver asked.
75
+ # @returns [Protocol::HTTP::Response] what this service answers.
76
+ def call(request)
77
+ reference = ::Protocol::URL::Reference.parse(request.path)
78
+ segments = reference.path.to_s.delete_prefix("#{PREFIX}/").split("/")
79
+ parameters = self.parameters_of(reference)
80
+
81
+ case [request.method, segments]
82
+ # Nothing of ours, so nothing to say about it: a health check is not an unauthorised request.
83
+ in [_, [*]] if !reference.path.to_s.start_with?("#{PREFIX}/")
84
+ self.failure(404, "M_UNRECOGNIZED", "This service does not answer #{request.path}.")
85
+
86
+ # Every path of ours, the ping included: a wrong token is what the ping exists to find.
87
+ in [_, [*]] if !self.authentic?(request, parameters)
88
+ self.failure(403, "M_FORBIDDEN", "The token does not match the registration.")
89
+
90
+ in ["POST", ["ping"]]
91
+ self.rendered(200, {})
92
+
93
+ in ["PUT", ["transactions", txn_id]]
94
+ self.transaction(request, ::Protocol::URL::Encoding.unescape(txn_id))
95
+
96
+ in ["GET", ["users", user_id]]
97
+ self.answered(:user, ::Protocol::URL::Encoding.unescape(user_id), parameters)
98
+
99
+ in ["GET", ["rooms", alias_id]]
100
+ self.answered(:room, ::Protocol::URL::Encoding.unescape(alias_id), parameters)
101
+
102
+ in ["GET", ["thirdparty", *rest]]
103
+ self.answered(:thirdparty, rest, parameters)
104
+
105
+ # Which tells a homeserver that a feature is missing, rather than that this service is broken.
106
+ else
107
+ self.failure(404, "M_UNRECOGNIZED", "This service does not answer #{request.path}.")
108
+ end
109
+ end
110
+
111
+ def inspect = "\#<#{self.class} #{@registration.id}>"
112
+
113
+ private
114
+
115
+ # Hand the transaction over, and say whether to send it again.
116
+ #
117
+ # It is not recorded until it has been handled, so a handler which raises leaves the
118
+ # homeserver to retry rather than losing what it carried.
119
+ #
120
+ # @parameter request [Protocol::HTTP::Request] the request it arrived in.
121
+ # @parameter txn_id [String] the transaction, which the homeserver numbers.
122
+ # @returns [Protocol::HTTP::Response] the answer.
123
+ def transaction(request, txn_id)
124
+ case @store.seen?(txn_id)
125
+ in true
126
+ Console.debug(self) {"Transaction #{txn_id} was handled already."}
127
+
128
+ self.rendered(200, {})
129
+ in false
130
+ transaction = Transaction.for(request, txn_id, version: @version)
131
+
132
+ @block&.call(transaction)
133
+ @store.mark_seen(txn_id)
134
+
135
+ self.rendered(200, {})
136
+ end
137
+ rescue ::JSON::ParserError => error
138
+ # Sending it again would carry the same bytes, so this says so rather than asking for them.
139
+ Console.error(self, "A transaction was not JSON.", txn_id: txn_id, error: error)
140
+
141
+ self.failure(400, "M_BAD_JSON", error.message)
142
+ rescue => error
143
+ # Not recorded, so the homeserver sends it again: whatever failed may not fail twice.
144
+ Console.error(self, "A transaction was not handled.", txn_id: txn_id, error: error)
145
+
146
+ self.failure(500, "M_UNKNOWN", error.message)
147
+ end
148
+
149
+ # Ask whoever registered an answer for this kind of question.
150
+ #
151
+ # The answer is a block rather than a lambda, so one which does not want the query string
152
+ # may simply not take it.
153
+ #
154
+ # @parameter kind [Symbol] `:user`, `:room` or `:thirdparty`.
155
+ # @parameter subject [Object] what was asked about.
156
+ # @parameter parameters [Hash] the query string, which the third party lookups read.
157
+ # @returns [Protocol::HTTP::Response] the answer.
158
+ def answered(kind, subject, parameters)
159
+ query = @queries[kind]
160
+
161
+ case query&.call(subject, parameters)
162
+ # Nothing answered, so this service does not claim it.
163
+ in nil | false
164
+ self.failure(404, "M_NOT_FOUND", "This service does not know #{subject}.")
165
+
166
+ # A document to send back, which the third party lookups answer with.
167
+ in Hash | Array => document
168
+ self.rendered(200, document)
169
+
170
+ # Anything else is a yes, and a yes carries no document.
171
+ else
172
+ self.rendered(200, {})
173
+ end
174
+ end
175
+
176
+ # Whether the request carries the token the registration declares.
177
+ #
178
+ # From v1.4 it is a bearer token. Before that it was a query parameter, which a homeserver
179
+ # may still send, and which must agree when both are there.
180
+ #
181
+ # @parameter request [Protocol::HTTP::Request] the request to check.
182
+ # @parameter parameters [Hash] its query string, decoded.
183
+ # @returns [Boolean] whether it matches.
184
+ def authentic?(request, parameters)
185
+ offered = [self.bearer(request), parameters["access_token"]].compact
186
+
187
+ case offered
188
+ in []
189
+ false
190
+ in [*] if offered.all?{|token| self.matches?(token)}
191
+ true
192
+ else
193
+ false
194
+ end
195
+ end
196
+
197
+ # @parameter request [Protocol::HTTP::Request] the request to read.
198
+ # @returns [String | nil] the bearer token it carries.
199
+ def bearer(request)
200
+ case request.headers["authorization"]
201
+ in String => authorization if authorization.start_with?("Bearer ")
202
+ authorization.delete_prefix("Bearer ")
203
+ else
204
+ nil
205
+ end
206
+ end
207
+
208
+ # Compared in constant time, because the comparison is what an attacker measures.
209
+ #
210
+ # @parameter token [String] the token offered.
211
+ # @returns [Boolean] whether it is the one the registration declares.
212
+ def matches?(token)
213
+ ::OpenSSL.fixed_length_secure_compare(token, @registration.hs_token)
214
+ rescue ArgumentError
215
+ false
216
+ end
217
+
218
+ # The query string, which a reference carries as it arrived rather than decoded.
219
+ #
220
+ # @parameter reference [Protocol::URL::Reference] the path this request asked for.
221
+ # @returns [Hash] its parameters, which may be none.
222
+ def parameters_of(reference)
223
+ case reference.query
224
+ in String => query
225
+ ::Protocol::URL::Encoding.decode_www_form(query)
226
+ else
227
+ {}
228
+ end
229
+ end
230
+
231
+ # @parameter status [Integer] the status to answer with.
232
+ # @parameter payload [Hash | Array] the document to answer with.
233
+ # @returns [Protocol::HTTP::Response] the response.
234
+ def rendered(status, payload)
235
+ ::Protocol::HTTP::Response[status, JSON_HEADERS, [::JSON.generate(payload)]]
236
+ end
237
+
238
+ # The shape the client-server API uses, so a caller reads it the same way either side.
239
+ #
240
+ # @parameter status [Integer] the status to answer with.
241
+ # @parameter errcode [String] the Matrix error code.
242
+ # @parameter error [String] what went wrong, for a person to read.
243
+ # @returns [Protocol::HTTP::Response] the response.
244
+ def failure(status, errcode, error)
245
+ self.rendered(status, {errcode: errcode, error: error})
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end