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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +79 -142
- data/lib/async/matrix/bridge/puppet.rb +89 -0
- data/lib/async/matrix/bridge/registration.rb +116 -197
- data/lib/async/matrix/bridge/service.rb +250 -0
- data/lib/async/matrix/bridge/transaction.rb +90 -0
- data/lib/async/matrix/bridge/transaction_store.rb +0 -39
- data/lib/async/matrix/bridge/version.rb +1 -1
- metadata +16 -23
- data/lib/async/matrix/bridge/endpoint.rb +0 -482
- data/lib/async/matrix/bridge/registration/schema/namespace_list.json +0 -22
- data/lib/async/matrix/bridge/registration/schema/registration.json +0 -91
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69434c46fa54930571a05c7cedf4797cdc1e608494f0c785c6c8c87a3dfe7e7d
|
|
4
|
+
data.tar.gz: e708c9852ea6a8baf87ce6651f09edb5a314da5fe3fb2394968654d95aec8808
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d567fef4705acd06c3e50daad570ce07770236c864a9b606afd90c90cb594beee718c2d2fcc978bb16a1495e81461ad34beb053ccd18a5a8664679135196fa5
|
|
7
|
+
data.tar.gz: a0545367381d298e5f66a82cf3ffdb485fced43adc30bd66fd2580878d7364673cd9262558e4941389ab46ee007d5a3f0a0d8f7fc10b62635b62610a025dc1b4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## [3.0.0] - 2026-10-13
|
|
12
|
+
|
|
13
|
+
A second ground-up pass, this time with async-rest as the only reference. The
|
|
14
|
+
gem is five classes, the server side is middleware rather than a per-request
|
|
15
|
+
object, and the events are async-matrix's own event classes rather than Hashes.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `Bridge::Service` — a `Protocol::HTTP::Middleware` answering the application
|
|
20
|
+
service API, so it is served by an `Async::HTTP::Server` with no Rack
|
|
21
|
+
anywhere. One `case` on method and path, authentication by constant-time
|
|
22
|
+
compare of a Bearer token or the legacy `?access_token=`, and a `404` for
|
|
23
|
+
anything outside `/_matrix/app/v1` decided before authentication, so a
|
|
24
|
+
`/healthz` beside it is a `404` rather than a `403`. The user, room and
|
|
25
|
+
third-party queries are three blocks rather than three routes.
|
|
26
|
+
|
|
27
|
+
- `Bridge::Transaction` — one pushed transaction as an
|
|
28
|
+
`Async::Matrix::Representation`, parsed with async-rest's own JSON parser and
|
|
29
|
+
typed through async-matrix's event registry. `each_room` groups a
|
|
30
|
+
transaction's events by room, in the order they arrived.
|
|
31
|
+
|
|
32
|
+
- `Bridge::Puppet` — one user the service may act as, with `client`, `on` for
|
|
33
|
+
a device and `register!` / `device!` for provisioning it. A localpart outside
|
|
34
|
+
the registration's namespaces is refused locally.
|
|
35
|
+
|
|
36
|
+
- `Registration#as` — identity assertion, as a resource derived with `#with`:
|
|
37
|
+
`user_id`, `device_id` (v1.17) and `ts` (v1.3) are parameters on the request
|
|
38
|
+
rather than a login. `bot` and `puppet` name the two identities a service
|
|
39
|
+
has.
|
|
40
|
+
|
|
41
|
+
- `examples/bridge.rb` — a working two-way bridge between Matrix and a line
|
|
42
|
+
protocol, encrypted rooms included, and `examples/puppeting.rb` for identity
|
|
43
|
+
assertion on its own. The suite runs both against the homeserver in
|
|
44
|
+
`synapse/`, and asks the bridge whether each side heard the other.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- `Registration.new(value, homeserver:)` and `Registration.load(path,
|
|
49
|
+
homeserver:)` take the homeserver URL alone. `domain:` is gone, derived from
|
|
50
|
+
it with `URI.parse` so the two cannot disagree.
|
|
51
|
+
|
|
52
|
+
- `Registration#homeserver` is the URL string rather than a `Homeserver`
|
|
53
|
+
struct; `#domain` is the derived server name. The `Homeserver` and
|
|
54
|
+
`Appservice` structs are gone, so `registration.homeserver.address`,
|
|
55
|
+
`.homeserver.domain` and `registration.appservice.*` become
|
|
56
|
+
`registration.homeserver`, `.domain` and the readers on `Registration`
|
|
57
|
+
itself.
|
|
58
|
+
|
|
59
|
+
- `Registration#raw` → `#value`, `Registration#bot_mxid` → `#sender` returning
|
|
60
|
+
the full MXID, `Registration#receive_ephemeral?` → `#ephemeral?`.
|
|
61
|
+
|
|
62
|
+
- Namespace regexes match the whole of a name rather than part of one. A
|
|
63
|
+
service asks `covers?` to decide which senders are its own, and unanchored,
|
|
64
|
+
`@_bridge_.*` claimed `@notours_bridge_x:localhost` as well.
|
|
65
|
+
|
|
66
|
+
- `async-matrix` is now `~> 4.0`, and `protocol-http` is a declared dependency
|
|
67
|
+
since a `Service` is one of its middlewares.
|
|
68
|
+
|
|
69
|
+
### Removed
|
|
70
|
+
|
|
71
|
+
- `Bridge::Endpoint`, and with it Rack, the Rack env input, the `concurrency:`
|
|
72
|
+
semaphore and the symbol-keyed Hashes. Events are objects a pattern reads by
|
|
73
|
+
class and field together.
|
|
74
|
+
|
|
75
|
+
- `json_schemer` and the vendored registration schemas, and with them
|
|
76
|
+
`Registration.schema` and `Registration.validate!`. A registration is
|
|
77
|
+
validated by what it is asked for: the four required fields are checked at
|
|
78
|
+
construction, anything else is reached through `#value`.
|
|
79
|
+
|
|
80
|
+
- `Registration#url`, `Registration#protocols`, `Registration#rate_limited?`
|
|
81
|
+
and `Registration#sender_localpart`. Readers for fields this service does not
|
|
82
|
+
act on, which the file still carries and `#value` still exposes.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## [2.0.0] - 2026-10-09
|
|
86
|
+
|
|
87
|
+
A ground-up replacement of the application service API. Everything is now one
|
|
88
|
+
class, `Bridge::Endpoint`, driven by Ruby pattern matching, plus
|
|
89
|
+
`Bridge::Registration` for configuration. The Grape-and-handler-objects stack
|
|
90
|
+
is gone.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- `Bridge::Endpoint` — the whole AS wire protocol in one class. Built per
|
|
95
|
+
request from a Rack env *or* a `Protocol::HTTP::Request`, returning the
|
|
96
|
+
matching response type, so it serves a Rack stack and a bare
|
|
97
|
+
`Async::HTTP::Server` equally. It also defines `to_ary` so it can be
|
|
98
|
+
returned straight from a Rack router block, and `handled?` so it works as a
|
|
99
|
+
router's fall-through. Events reach the matcher block as symbol-keyed
|
|
100
|
+
Hashes, because Ruby hash patterns match Symbol keys only — which is what
|
|
101
|
+
lets `case/in` destructure nested `content:` with no `deconstruct_keys`
|
|
102
|
+
anywhere.
|
|
103
|
+
|
|
104
|
+
- Concurrent dispatch. The events of a transaction run in their own fibers
|
|
105
|
+
under a bounded `Async::Semaphore` (`concurrency:`, default 8) and are all
|
|
106
|
+
awaited before the response, instead of running sequentially inline. Outside
|
|
107
|
+
a reactor dispatch falls back to sequential so the endpoint still works
|
|
108
|
+
under `Rack::MockRequest`.
|
|
109
|
+
|
|
110
|
+
- `Bridge::Registration` — the registration file, validated against
|
|
111
|
+
`registration.json` and `namespace_list.json` vendored verbatim from
|
|
112
|
+
matrix-org/matrix-spec (`data/api/application-service/definitions/`). Those
|
|
113
|
+
are already JSON Schema draft 2020-12. Synapse ships no schema for this file
|
|
114
|
+
— it validates by hand in `synapse/config/appservice.py` — so the spec is
|
|
115
|
+
the only machine-readable source. Takes `homeserver:` and `domain:` as
|
|
116
|
+
required arguments and exposes `covers?`/`exclusive?` for namespaces.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- **Namespace flattened.** `Async::Matrix::Bridge::ApplicationService::*` →
|
|
121
|
+
`Async::Matrix::Bridge::*`. The three classes sit directly on `Bridge`:
|
|
122
|
+
`Bridge::Endpoint`, `Bridge::Registration`, `Bridge::TransactionStore`.
|
|
123
|
+
There is no sub-namespace because there is nothing to distinguish it from
|
|
124
|
+
— the gem is the appservice protocol and nothing else. Bridge *domain*
|
|
125
|
+
concepts (portals, ghosts, provisioning) would be new peers if they ever
|
|
126
|
+
arrive, and the remote-network half of a bridge is inherently
|
|
127
|
+
application-specific.
|
|
128
|
+
|
|
129
|
+
### Removed
|
|
130
|
+
|
|
131
|
+
- `Server`, `Server::Grape`, `Bot`, `Bot::Handler`, `Bot::Context`,
|
|
132
|
+
`Dispatcher`, `TransactionHandler` and `Transaction`. `Bridge::Endpoint`
|
|
133
|
+
replaces all of them. The `#event_types`/`#call` handler duck-type, the
|
|
134
|
+
`on` DSL with its `msgtype:`/`not_from:` filters, handler registration and
|
|
135
|
+
the `thirdparty` duck-type are all gone: filtering is now `case/in` with
|
|
136
|
+
guards, and the long-standing `Dispatcher`/`TransactionHandler`
|
|
137
|
+
double-naming is resolved by deleting both.
|
|
138
|
+
|
|
139
|
+
- `Config` and its 17-file mautrix bridgev2 JSON Schema suite.
|
|
140
|
+
`Bridge::Registration` replaces it, validating the application service
|
|
141
|
+
registration file against the Matrix spec's own schema. Configuration a
|
|
142
|
+
registration does not describe — `database`, `encryption`, `permissions`,
|
|
143
|
+
`backfill` — is no longer modelled here; `Config` remains available at the
|
|
144
|
+
`v1.0.0` tag.
|
|
145
|
+
|
|
146
|
+
- Grape is no longer a dependency. `Bridge::Endpoint` speaks Rack and
|
|
147
|
+
Protocol::HTTP directly.
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
|
|
151
|
+
- **Transactions were silently dropped.** `json_body` re-read `rack.input`
|
|
152
|
+
after Grape's formatter middleware had already consumed it, and that
|
|
153
|
+
middleware only rewinds when the input responds to `#rewind` — which rack 3
|
|
154
|
+
streaming input (protocol-rack, so falcon and `Async::HTTP`) does not. Every
|
|
155
|
+
transaction parsed as `{}` and dispatched nothing while answering `200`. It
|
|
156
|
+
went unnoticed because `Rack::MockRequest` supplies a rewindable `StringIO`.
|
|
157
|
+
|
|
158
|
+
- **A failing handler lost its events permanently.** The dispatcher logged
|
|
159
|
+
the exception, recorded the transaction id and returned `200`, so the
|
|
160
|
+
homeserver's retry was deduplicated away. `Bridge::Endpoint` answers `500`
|
|
161
|
+
and does not record the id, leaving the transaction retryable.
|
data/README.md
CHANGED
|
@@ -5,190 +5,127 @@
|
|
|
5
5
|
[](https://github.com/general-intelligence-systems/async-matrix-bridge/blob/main/LICENSE)
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The application service side of [Matrix](https://matrix.org), for Ruby: the half of a bridge the homeserver pushes to, built on [async-matrix](https://github.com/general-intelligence-systems/async-matrix) and so on [async-rest](https://github.com/socketry/async-rest).
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## Install
|
|
10
|
+
A homeserver `PUT`s a transaction of events at your service. This gem authenticates it, drops a repeat of one it has already answered, types every event with async-matrix's own event classes and hands the transaction to your block. Everything the service says back to the homeserver it says through an async-matrix `Client`, as the user it is pretending to be.
|
|
13
11
|
|
|
14
12
|
```ruby
|
|
15
13
|
gem "async-matrix-bridge"
|
|
16
14
|
```
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
want to run it in a Rack stack:
|
|
16
|
+
## The gem
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
gem "ratalada", "~> 4.0"
|
|
23
|
-
gem "protocol-rack"
|
|
24
|
-
```
|
|
18
|
+
Five classes, and nothing else:
|
|
25
19
|
|
|
26
|
-
|
|
20
|
+
| Class | What it is |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `Registration` | the registration file both ends read, and the identities it permits |
|
|
23
|
+
| `Puppet` | one user the service may act as, with its own device |
|
|
24
|
+
| `Service` | a `Protocol::HTTP::Middleware` answering the application service API |
|
|
25
|
+
| `Transaction` | one pushed transaction, as a representation of typed events |
|
|
26
|
+
| `TransactionStore` | which transaction IDs have been answered already |
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
on. There is no handler to register, no filter DSL and no base class.
|
|
28
|
+
## Serving
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
`Service` is middleware rather than a Rack app, so it is the thing an `Async::HTTP::Server` serves. The block is handed a `Transaction`, and the events in it are async-matrix event objects: `case/in` reads them by class and by field together.
|
|
31
|
+
|
|
32
|
+
``` ruby
|
|
32
33
|
require "async/matrix/bridge"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Bridge::Endpoint.new(request, config: config, store: store) do |message|
|
|
46
|
-
case message
|
|
47
|
-
in {type: "m.room.member", content: {membership: "invite"}, room_id:, state_key: ^bot}
|
|
48
|
-
client.join_room(room_id)
|
|
49
|
-
in {type: "m.room.message", content: {msgtype: "m.text", body:}, room_id:, sender:} if sender != bot
|
|
50
|
-
client.send_notice(room_id, "Echo: #{body}")
|
|
51
|
-
else
|
|
52
|
-
nil
|
|
34
|
+
|
|
35
|
+
registration = Async::Matrix::Bridge::Registration.load("registration.yaml", homeserver: "http://localhost:8008")
|
|
36
|
+
|
|
37
|
+
service = Async::Matrix::Bridge::Service.new(registration) do |transaction|
|
|
38
|
+
transaction.each_room do |room_id, events|
|
|
39
|
+
events.each do |event|
|
|
40
|
+
case event
|
|
41
|
+
in {sender:, content: Async::Matrix::Events::TextMessage(body:)}
|
|
42
|
+
puts "<#{sender}> #{body}"
|
|
43
|
+
else
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
53
46
|
end
|
|
54
|
-
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Async do
|
|
51
|
+
Async::HTTP::Server.new(service, Async::HTTP::Endpoint.parse("http://0.0.0.0:9000")).run
|
|
55
52
|
end
|
|
56
53
|
```
|
|
57
54
|
|
|
58
|
-
`
|
|
59
|
-
server — pass `request.env` there instead.
|
|
55
|
+
`each_room` groups a transaction's events by the room they belong to, in the order they arrived. `events` and `ephemeral` are the two lists the specification puts in a transaction; there is no third, which matters for encryption below.
|
|
60
56
|
|
|
61
|
-
|
|
62
|
-
[`examples/`](https://github.com/general-intelligence-systems/async-matrix-bridge/tree/main/examples).
|
|
57
|
+
A homeserver pushes over HTTP/1.1 and will not do h2c with prior knowledge, so a listener pinned to HTTP/2 never receives anything.
|
|
63
58
|
|
|
64
|
-
|
|
59
|
+
The queries are three blocks rather than three routes. Each answers whether the service has something, and `nil` is `M_NOT_FOUND`:
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`deconstruct_keys` on anything.
|
|
61
|
+
``` ruby
|
|
62
|
+
service.query_user{|user_id| registration.covers?("users", user_id)}
|
|
63
|
+
service.query_room{|room_alias| room_alias == "#_bridge_lobby:localhost"}
|
|
64
|
+
```
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
pin for "addressed to me", a guard for "not from me":
|
|
66
|
+
A path outside `/_matrix/app/v1` is a `404` before anything is authenticated, so the service composes as the fall-through of a router which has its own routes.
|
|
74
67
|
|
|
75
|
-
|
|
76
|
-
in {type: "m.room.member", content: {membership: "invite"}, state_key: ^bot}
|
|
77
|
-
in {type: "m.room.message", content: {msgtype: "m.text", body:}, sender:} if sender != bot
|
|
78
|
-
```
|
|
68
|
+
If your block raises, the response is `500` and the transaction ID is **not** recorded, so the homeserver retries it. Recording it would make one failed handler drop those events for good.
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
`:query` Hash, so one `case` covers the whole protocol. Whatever it returns is
|
|
82
|
-
the JSON body; `nil` is `M_NOT_FOUND`.
|
|
70
|
+
## Acting as somebody
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
in {query: :protocol, protocol:}
|
|
86
|
-
{instances: instances_for(protocol)}
|
|
87
|
-
```
|
|
72
|
+
A service does not log in. It asserts an identity: its own token says which service, and the user is a parameter on the request. `Registration#as` is that, and `Puppet` is one user it may act as.
|
|
88
73
|
|
|
89
|
-
|
|
74
|
+
``` ruby
|
|
75
|
+
puppet = registration.puppet(client, "_bridge_ripley")
|
|
90
76
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
bare `Async::HTTP::Server` with no Rack anywhere in the stack:
|
|
77
|
+
puppet.register!
|
|
78
|
+
puppet.device!("RIPLEY1", display_name: "ripley")
|
|
94
79
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Bridge::Endpoint.new(request, config: config, store: store) do |message|
|
|
98
|
-
case message
|
|
99
|
-
in {type: "m.room.message", content: {body:}, room_id:}
|
|
100
|
-
client.send_notice(room_id, "Echo: #{body}")
|
|
101
|
-
else
|
|
102
|
-
nil
|
|
103
|
-
end
|
|
104
|
-
end.response
|
|
105
|
-
end
|
|
80
|
+
puppet.client.join_room(room_id)
|
|
81
|
+
puppet.on("RIPLEY1").room(room_id).send_message(content.type, content.to_h)
|
|
106
82
|
```
|
|
107
83
|
|
|
108
|
-
|
|
109
|
-
and will not do h2c with prior knowledge, so a forced-HTTP2 listener never
|
|
110
|
-
receives an event.
|
|
84
|
+
`puppet.client` is the user; `puppet.on(device_id)` is the user at one of its devices, which is what keys belong to. Both take `ts:`, which is how a bridge gives a message the time it was really said. A localpart outside the registration's namespaces is refused here rather than at the homeserver.
|
|
111
85
|
|
|
112
|
-
|
|
86
|
+
Every one of those is an ordinary async-matrix call on a resource derived with `#with`. This gem adds no helpers of its own for talking to the homeserver, and it has no opinion about which calls a bridge makes.
|
|
113
87
|
|
|
114
|
-
|
|
115
|
-
as the fall-through of a router that matches its own routes first. Ask
|
|
116
|
-
`handled?` if you would rather compose explicitly.
|
|
88
|
+
## Encryption
|
|
117
89
|
|
|
118
|
-
|
|
119
|
-
Server.run do |request|
|
|
120
|
-
case request
|
|
121
|
-
in ["GET", "/healthz"] then "ok\n"
|
|
122
|
-
else Bridge::Endpoint.new(request, config: config, store: store) { |message| ... }.response
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
```
|
|
90
|
+
A transaction carries `events` and `ephemeral`, and no keys at all. So nothing pushed to a service can be decrypted by anything that push told it — and the specification is explicit about the way in: a service which wants `/sync` syncs as one of its own users.
|
|
126
91
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
The events of a transaction are dispatched in their own fibers, bounded by
|
|
130
|
-
`concurrency:` (default 8), and all of them are awaited before the response.
|
|
131
|
-
|
|
132
|
-
If the block raises, the response is `500` and the transaction id is **not**
|
|
133
|
-
recorded, so the homeserver retries it. Recording it and answering `200` would
|
|
134
|
-
make a failed handler drop its events permanently, since the retry would be
|
|
135
|
-
deduplicated away.
|
|
136
|
-
|
|
137
|
-
## Configuration
|
|
138
|
-
|
|
139
|
-
`Registration` reads the application service registration file -- the one your
|
|
140
|
-
homeserver already loads -- and validates it against the Matrix spec's own
|
|
141
|
-
schema, vendored verbatim from
|
|
142
|
-
[matrix-org/matrix-spec](https://github.com/matrix-org/matrix-spec/tree/main/data/api/application-service/definitions).
|
|
143
|
-
Nothing is restated: the tokens, the sender localpart and the namespaces are
|
|
144
|
-
read from the file the homeserver reads.
|
|
145
|
-
|
|
146
|
-
```yaml
|
|
147
|
-
# registration.yaml -- give this to your homeserver
|
|
148
|
-
id: agent
|
|
149
|
-
url: "http://appservice:9000"
|
|
150
|
-
as_token: "your-appservice-token"
|
|
151
|
-
hs_token: "your-homeserver-token"
|
|
152
|
-
sender_localpart: agent
|
|
153
|
-
namespaces:
|
|
154
|
-
users:
|
|
155
|
-
- exclusive: false
|
|
156
|
-
regex: "@.*:localhost"
|
|
157
|
-
aliases: []
|
|
158
|
-
rooms: []
|
|
159
|
-
```
|
|
92
|
+
So an encrypted bridge has two inputs which meet in one machine's store. The ciphertext is pushed, the keys are pulled, and neither input knows about the other:
|
|
160
93
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
homeserver: "http://synapse:8008", domain: "localhost")
|
|
94
|
+
- the `Service` block hands the ciphertext to the machine, which defers what it cannot read yet,
|
|
95
|
+
- a sync loop, running as a puppet, pulls the to-device traffic the keys arrive in, and the key releases the deferred event.
|
|
164
96
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
97
|
+
Synapse answers `/sync` for the service's own user with a `500`, so the user which reads is a puppet rather than the sender localpart. [`examples/bridge.rb`](examples/bridge.rb) is that, whole and working, in one file.
|
|
98
|
+
|
|
99
|
+
## The examples
|
|
100
|
+
|
|
101
|
+
Both examples run against the homeserver in `synapse/`, and the suite runs them:
|
|
102
|
+
|
|
103
|
+
- [`examples/bridge.rb`](examples/bridge.rb) — a bridge, both ways, between Matrix and a line protocol. A nick on the other network gets a user and a device of its own; an encrypted room is encrypted to, by claiming a key from every device and sharing a Megolm session over Olm; what Matrix says comes back out of the socket.
|
|
104
|
+
- [`examples/puppeting.rb`](examples/puppeting.rb) — identity assertion on its own: the service's own user, a puppet, a device, published keys, cross-signing, timestamp massaging, and the refusal of a user outside the namespaces.
|
|
169
105
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
106
|
+
``` bash
|
|
107
|
+
REGISTRATION=./synapse/appservice.yaml HOMESERVER=http://localhost:7400 CHANNEL=lobby ENCRYPTED=true ruby examples/bridge.rb
|
|
108
|
+
|
|
109
|
+
printf 'ripley\nhello from the network\n' | nc localhost 9100
|
|
110
|
+
```
|
|
175
111
|
|
|
176
112
|
## Development
|
|
177
113
|
|
|
178
|
-
```bash
|
|
179
|
-
bin/test #
|
|
114
|
+
``` bash
|
|
115
|
+
bin/test # brings the homeserver up, then runs sus
|
|
180
116
|
bin/rubocop
|
|
181
117
|
```
|
|
182
118
|
|
|
183
|
-
|
|
119
|
+
`bin/test` recreates the container rather than reusing it: its database is in tmpfs, and a transaction which failed once puts the homeserver's scheduler into a backoff reaching 512 seconds, which an example started afterwards would be waiting on rather than on itself.
|
|
120
|
+
|
|
121
|
+
async-matrix is an ordinary RubyGems dependency. To try a change to it against this gem, publish a prerelease or `bundle config local` a git checkout; do not add a `path:` entry to the Gemfile, because `bundlerEnv` cannot resolve a path source pointing outside the flake and `nix develop` then fails outright.
|
|
184
122
|
|
|
185
|
-
## Built
|
|
123
|
+
## Built with
|
|
186
124
|
|
|
187
|
-
- [async-matrix](https://github.com/general-intelligence-systems/async-matrix)
|
|
188
|
-
- [async](https://github.com/socketry/async)
|
|
189
|
-
- [protocol-http](https://github.com/socketry/protocol-http)
|
|
190
|
-
- [
|
|
191
|
-
- [scampi](https://github.com/general-intelligence-systems/scampi) -- inline co-located test framework
|
|
125
|
+
- [async-matrix](https://github.com/general-intelligence-systems/async-matrix) — the Matrix protocol layer: clients, events, E2EE
|
|
126
|
+
- [async-rest](https://github.com/socketry/async-rest) — resources and representations, which this gem's are
|
|
127
|
+
- [protocol-http](https://github.com/socketry/protocol-http) — the middleware a `Service` is
|
|
128
|
+
- [async](https://github.com/socketry/async) — fibers
|
|
192
129
|
|
|
193
130
|
## License
|
|
194
131
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Matrix
|
|
8
|
+
module Bridge
|
|
9
|
+
# One of the service's own users, and what it takes to bring them into existence.
|
|
10
|
+
#
|
|
11
|
+
# A puppet is made twice over. Registering makes the user, and a homeserver refuses to act as
|
|
12
|
+
# one it was never told about. Creating a device makes something which can hold keys, which
|
|
13
|
+
# from v1.17 a service does itself rather than logging in for.
|
|
14
|
+
class Puppet
|
|
15
|
+
# The login type which says a service is asking, rather than a person with a password.
|
|
16
|
+
LOGIN_TYPE = "m.login.application_service"
|
|
17
|
+
|
|
18
|
+
# What a homeserver answers when the user is already there, which is not a failure here.
|
|
19
|
+
IN_USE = "M_USER_IN_USE"
|
|
20
|
+
|
|
21
|
+
# @parameter registration [Registration] the service this user belongs to.
|
|
22
|
+
# @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
|
|
23
|
+
# @parameter localpart [String] the part of the ID which names them, without the sigil.
|
|
24
|
+
def initialize(registration, client, localpart)
|
|
25
|
+
@registration = registration
|
|
26
|
+
@client = client
|
|
27
|
+
@localpart = localpart
|
|
28
|
+
|
|
29
|
+
# Refused here rather than by the homeserver: the namespaces say which users this service
|
|
30
|
+
# may act as, so a localpart outside them is a mistake in the caller rather than a request.
|
|
31
|
+
unless registration.covers?("users", self.user_id)
|
|
32
|
+
raise ArgumentError, "#{self.user_id} is outside this service's namespaces!"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @attribute [String] the part of the ID which names them.
|
|
37
|
+
attr :localpart
|
|
38
|
+
|
|
39
|
+
# @returns [String] the fully qualified ID of this user.
|
|
40
|
+
def user_id = "@#{@localpart}:#{@registration.domain}"
|
|
41
|
+
|
|
42
|
+
# A client which acts as this user, on no particular device.
|
|
43
|
+
#
|
|
44
|
+
# @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
|
|
45
|
+
# @returns [Async::Matrix::ClientServer::Client] the client.
|
|
46
|
+
def client(ts: nil) = @registration.as(@client, self.user_id, ts: ts)
|
|
47
|
+
|
|
48
|
+
# A client which acts as this user on one of their devices, which is what encryption needs.
|
|
49
|
+
#
|
|
50
|
+
# @parameter device_id [String] the device to act as.
|
|
51
|
+
# @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
|
|
52
|
+
# @returns [Async::Matrix::ClientServer::Client] the client.
|
|
53
|
+
def on(device_id, ts: nil) = @registration.as(@client, self.user_id, device_id: device_id, ts: ts)
|
|
54
|
+
|
|
55
|
+
# Make the user, which is what lets the service act as them at all.
|
|
56
|
+
#
|
|
57
|
+
# Asking twice is not an error: a service does not remember which of its users it has made,
|
|
58
|
+
# and a homeserver which already has one says so rather than failing.
|
|
59
|
+
#
|
|
60
|
+
# @returns [Boolean] whether this call is what made them.
|
|
61
|
+
def register!
|
|
62
|
+
@registration.as(@client).registration.register(username: @localpart, type: LOGIN_TYPE)
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
rescue Async::Matrix::RequestError => error
|
|
66
|
+
unless error.errcode == IN_USE
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Make one of their devices, so there is something to hold keys and to act as.
|
|
74
|
+
#
|
|
75
|
+
# Asked as the user rather than as the device: acting as a device the homeserver has not got
|
|
76
|
+
# is what this call is here to fix, so it cannot be how the call is made.
|
|
77
|
+
#
|
|
78
|
+
# @parameter device_id [String] the device to make.
|
|
79
|
+
# @parameter display_name [String] what a user of the other network would recognise.
|
|
80
|
+
# @returns [Async::Matrix::Representation] the empty response.
|
|
81
|
+
def device!(device_id, display_name: @localpart)
|
|
82
|
+
self.client.devices.update_device(device_id, display_name: display_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect = "\#<#{self.class} #{self.user_id}>"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|