lingara-embed 0.0.0 → 0.1.0.pre.alpha.7
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/README.md +191 -3
- data/lib/lingara/embed/memory_directory.rb +52 -0
- data/lib/lingara/embed/player_router.rb +72 -0
- data/lib/lingara/embed/routed_event.rb +11 -0
- data/lib/lingara/embed/server.rb +57 -0
- data/lib/lingara/embed/token_handoff.rb +39 -0
- data/lib/lingara/embed/version.rb +7 -1
- data/lib/lingara/embed/widget_snippet.rb +106 -0
- data/lib/lingara/embed.rb +30 -1
- data/sig/lingara_embed.rbs +65 -0
- metadata +37 -12
- data/Gemfile +0 -8
- data/Rakefile +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad30f45110b5d96a048a1a9fa2fe6a576b471967e7518e6ad1f0398150242b80
|
|
4
|
+
data.tar.gz: 8501e28ac824097691621de281676538d34acf362b4b884631d020676405a736
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58c9d9dde0562e500e780361e5c3a3928a6625becbc842ace8dee85ceee6f1ef6cdeffb31ea4876e8be57b872e400b937080d3c1ca90f81cbc5d1cb9e8997306
|
|
7
|
+
data.tar.gz: d2f562b9cd9e91f16eb7fe577975d6c5d2a4ae2d6594d4f328a8e8a047ff7e934b66896e4c673b9060bd45caa02c60e91743ef51df5e3491ffc3ab4001bdda7d
|
data/README.md
CHANGED
|
@@ -1,4 +1,192 @@
|
|
|
1
|
-
# lingara-embed
|
|
1
|
+
# lingara-embed (Ruby)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
> `Lingara::Embed::MemoryDirectory` is lost on restart and not shared between
|
|
4
|
+
> processes: production code passes a directory over its own database
|
|
5
|
+
> ([below](#the-player-directory)).
|
|
6
|
+
|
|
7
|
+
The Ruby server kit for embedding [Lingara](https://getlingara.com) in a game
|
|
8
|
+
or a website. Your server already holds the client secret, so it vouches for
|
|
9
|
+
its players. That takes four small jobs, and this gem does each one in a few
|
|
10
|
+
lines over the [`lingara`](https://rubygems.org/gems/lingara) library:
|
|
11
|
+
|
|
12
|
+
1. **mint** a player's embed token;
|
|
13
|
+
2. **hand it over** to the player's game or browser;
|
|
14
|
+
3. **route** Lingara's signed webhooks to the player each one concerns;
|
|
15
|
+
4. **delete** a player.
|
|
16
|
+
|
|
17
|
+
It also renders the `<lingara-embed>` widget snippet for a web page.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gem install lingara-embed
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
or, in a Gemfile, `gem "lingara-embed"`. Ruby 3.3 or newer. The gem's one
|
|
26
|
+
runtime dependency is `lingara`. `require "lingara/embed"` loads both.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "lingara/embed"
|
|
30
|
+
|
|
31
|
+
client = Lingara::Client.new(client_id: ENV.fetch("LINGARA_CLIENT_ID"),
|
|
32
|
+
client_secret: ENV.fetch("LINGARA_CLIENT_SECRET"))
|
|
33
|
+
players = PlayerTable.new(DB) # your directory, below
|
|
34
|
+
server = Lingara::Embed::Server.new(client, directory: players)
|
|
35
|
+
router = Lingara::Embed::PlayerRouter.new(ENV.fetch("LINGARA_WEBHOOK_SECRET"), directory: players)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Only a **metered** client may mint. Every other client gets
|
|
39
|
+
`403 embed_needs_metered`.
|
|
40
|
+
|
|
41
|
+
## 1. Mint, and hand the token over
|
|
42
|
+
|
|
43
|
+
The player side asks your server for a token. This is your route, behind
|
|
44
|
+
your own sign-in, so it only ever mints for the user it has authenticated.
|
|
45
|
+
Here it is in Sinatra:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
post "/lingara/token" do
|
|
49
|
+
halt 401 unless current_user
|
|
50
|
+
minted = server.mint_token(current_user.id.to_s, origin: "https://game.example")
|
|
51
|
+
content_type :json
|
|
52
|
+
cache_control :no_store
|
|
53
|
+
Lingara::Embed.token_handoff(minted) # {"token":"lgr_et_…","expires_at":"…Z"}
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `mint_token(player_ref, scopes: nil, origin: nil)` makes exactly one
|
|
58
|
+
`POST /v1/embed/tokens` and returns the library's `Lingara::MintedToken`.
|
|
59
|
+
It then calls `directory.remember(player_ref, subject)`. Nothing is
|
|
60
|
+
cached. Each token lives 900 s, and the player kit asks again before it
|
|
61
|
+
expires.
|
|
62
|
+
- `origin:` is your page's origin. It must be one of your client's
|
|
63
|
+
registered embed origins. A game's server mints without one.
|
|
64
|
+
- `token_handoff` returns the one body every player kit and the widget read,
|
|
65
|
+
`{"token","expires_at"}`, with `expires_at` in UTC. It is the only place
|
|
66
|
+
the kit exposes the token. Everywhere else the token renders
|
|
67
|
+
`[REDACTED]`.
|
|
68
|
+
- **Rate-limit this route per session.** Lingara limits each player, but
|
|
69
|
+
your route is the door anyone can knock on.
|
|
70
|
+
- A failure raises the library's `Lingara::Error` (`ApiError`,
|
|
71
|
+
`OAuthError`, `MaintenanceError`, `TransportError`) unchanged, and nothing
|
|
72
|
+
is remembered. The library does the retries and the token refresh, so the
|
|
73
|
+
kit adds none of its own.
|
|
74
|
+
|
|
75
|
+
## 2. Route a webhook to its player
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
post "/lingara/webhook" do
|
|
79
|
+
status router.handle(request.body.read, request.env) { |routed|
|
|
80
|
+
next if seen?(routed.event.id) # delivery is at least once: dedup on event.id
|
|
81
|
+
PracticeJob.perform_later(routed.player_ref, routed.event.to_h) if routed.player_ref
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`handle(body, headers)` verifies the signature on the **raw** body first,
|
|
87
|
+
then looks the subject up in your directory, then calls your block with a
|
|
88
|
+
`RoutedEvent` (`event`, `player_ref`). It returns the status for your
|
|
89
|
+
route, always with an empty body:
|
|
90
|
+
|
|
91
|
+
| Outcome | Status |
|
|
92
|
+
|---|---|
|
|
93
|
+
| your block returned | `204` |
|
|
94
|
+
| a bad or missing signature, or a stale timestamp | `401`: neither the directory nor your block is called |
|
|
95
|
+
| a valid signature over a body that is not an event | `400` |
|
|
96
|
+
| your block or your directory raised | `500`: the error goes to `on_error:` |
|
|
97
|
+
|
|
98
|
+
`headers` is a Hash or a Rack `env`, as it is. `player_ref` is `nil` for a
|
|
99
|
+
subject your directory does not know, such as your own or a deleted
|
|
100
|
+
player's. An event type newer than the library arrives as
|
|
101
|
+
`Lingara::Events::UnknownEvent`. Answer it `204` like any other.
|
|
102
|
+
|
|
103
|
+
- **Deduplicate on `event.id`.** Lingara redelivers every non-`2xx`, so the
|
|
104
|
+
same event can arrive twice.
|
|
105
|
+
- Lingara waits 10 s for an answer. Enqueue slow work and return.
|
|
106
|
+
- `on_error:` defaults to `Kernel#warn`. Pass your logger:
|
|
107
|
+
`PlayerRouter.new(secrets, directory: players, on_error: ->(e) { Rails.logger.error(e) })`.
|
|
108
|
+
- During a secret rotation, pass both secrets:
|
|
109
|
+
`PlayerRouter.new([new_secret, old_secret], directory: players)`.
|
|
110
|
+
- `route(body, headers)` returns the `RoutedEvent`, or raises
|
|
111
|
+
`Lingara::Events::VerificationError`, for a route that maps statuses
|
|
112
|
+
itself.
|
|
113
|
+
|
|
114
|
+
## 3. Delete a player
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
def destroy_account(user)
|
|
118
|
+
server.delete_player(user.id.to_s) # revokes their tokens, then forgets their rows
|
|
119
|
+
user.destroy!
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Call this from your account-deletion path. Deleting an unknown player
|
|
124
|
+
succeeds, so a retry is safe. If the call fails, the directory keeps its
|
|
125
|
+
rows.
|
|
126
|
+
|
|
127
|
+
## 4. The widget snippet
|
|
128
|
+
|
|
129
|
+
```erb
|
|
130
|
+
<%= Lingara::Embed.widget_snippet(plan_id: plan.id, token_endpoint: "/lingara/token",
|
|
131
|
+
locale: "fr", nonce: content_security_policy_nonce).html_safe %>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This renders the `<lingara-embed>` element and its two module scripts. The
|
|
135
|
+
token is never in the markup. The element `POST`s to `token_endpoint` at
|
|
136
|
+
run time, with `credentials: "same-origin"`, and reads `token_handoff`'s
|
|
137
|
+
body.
|
|
138
|
+
|
|
139
|
+
- **The CSP nonce.** If your page's `script-src` allows no inline script,
|
|
140
|
+
pass the request's nonce. Without it, a strict page blocks the third line
|
|
141
|
+
without any error.
|
|
142
|
+
- **Same-origin credentials.** The token route is authenticated by your
|
|
143
|
+
page's cookies. An absolute `https://` endpoint on another origin gets no
|
|
144
|
+
cookies, so it must authenticate the user some other way.
|
|
145
|
+
- Keywords: `plan_id:` and `token_endpoint:` (required), `surface:`,
|
|
146
|
+
`theme:`, `locale:`, `lingara_origin:`, `title:`, `id:` (one per widget on
|
|
147
|
+
a page), `nonce:`, `element_src:`. An invalid value raises
|
|
148
|
+
`Lingara::Embed::SnippetError`, whose `field` names the option
|
|
149
|
+
(`"planId"`, `"tokenEndpoint"`, …).
|
|
150
|
+
|
|
151
|
+
## The player directory
|
|
152
|
+
|
|
153
|
+
`subject` is a pairwise identifier that Lingara computes. Your server cannot
|
|
154
|
+
derive a `player_ref` from it, so the mint records the pair, and the router
|
|
155
|
+
reads it back. A directory is any object that answers these three methods:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
class PlayerTable
|
|
159
|
+
def initialize(db) = @rows = db[:lingara_players]
|
|
160
|
+
|
|
161
|
+
def remember(player_ref, subject) # an upsert keyed on subject
|
|
162
|
+
@rows.insert_conflict(target: :subject, update: {player_ref: player_ref})
|
|
163
|
+
.insert(player_ref: player_ref, subject: subject)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def player_ref_for(subject) = @rows.where(subject: subject).get(:player_ref)
|
|
167
|
+
|
|
168
|
+
def forget(player_ref) = @rows.where(player_ref: player_ref).delete # every row, idempotent
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Store it beside your players, in two columns:
|
|
173
|
+
|
|
174
|
+
| `player_ref` | `subject` |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `TEXT NOT NULL`, plain index | `TEXT NOT NULL`, **unique** index |
|
|
177
|
+
|
|
178
|
+
A player may own more than one subject. A guest who links an account gets
|
|
179
|
+
a second one, so the table is unique on `subject` only. The directory is
|
|
180
|
+
optional for `Server` and required for `PlayerRouter`.
|
|
181
|
+
|
|
182
|
+
## Conformance
|
|
183
|
+
|
|
184
|
+
`make test-ruby` runs the unit tests (the shared snippet, handoff and
|
|
185
|
+
webhook vectors), RuboCop and `rbs validate`. `make conformance-es-ruby`
|
|
186
|
+
runs every case of the ES contract (`conformance/es/CONTRACT.md`) through
|
|
187
|
+
this kit. The receiver runs on rack and webrick from
|
|
188
|
+
`conformance-es/Gemfile`, which never reaches the gem.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Embed
|
|
5
|
+
# A process-local player directory, for tests and single-process demos.
|
|
6
|
+
# It is lost on restart and not shared between instances: production
|
|
7
|
+
# code passes any object answering the same three methods over its own
|
|
8
|
+
# database, a table of (player_ref, subject) unique on subject.
|
|
9
|
+
#
|
|
10
|
+
# `subject` is a pairwise HMAC computed by Lingara, so the mint's answer
|
|
11
|
+
# is the only moment a player_ref and its subject are in one place
|
|
12
|
+
# (ADR 1.10.26af D5). One player may own several subjects (a guest's,
|
|
13
|
+
# then a linked account's), so the store is keyed on subject only.
|
|
14
|
+
class MemoryDirectory
|
|
15
|
+
def initialize
|
|
16
|
+
@rows = {}
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Upserts the row keyed on +subject+. A guest's subject is stable
|
|
21
|
+
# across mints, so remembering it again changes nothing.
|
|
22
|
+
def remember(player_ref, subject)
|
|
23
|
+
@lock.synchronize { @rows[subject] = player_ref }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The player a subject belongs to, or nil.
|
|
28
|
+
def player_ref_for(subject)
|
|
29
|
+
@lock.synchronize { @rows[subject] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Removes every row for the player. Idempotent.
|
|
33
|
+
def forget(player_ref)
|
|
34
|
+
@lock.synchronize { @rows.delete_if { |_, owner| owner == player_ref } }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every row, subject => player_ref, as a copy.
|
|
39
|
+
def snapshot
|
|
40
|
+
@lock.synchronize { @rows.dup }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<Lingara::Embed::MemoryDirectory rows=#{@lock.synchronize { @rows.size }}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
inspect
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Embed
|
|
5
|
+
# Routes a Lingara webhook to its player (ADR 1.10.26af D8): the
|
|
6
|
+
# library's verifier on the raw body first, then the directory, so a
|
|
7
|
+
# forged event never reaches your store.
|
|
8
|
+
#
|
|
9
|
+
# router = Lingara::Embed::PlayerRouter.new(ENV.fetch("LINGARA_WEBHOOK_SECRET"), directory: players)
|
|
10
|
+
# status = router.handle(request.body.read, request.env) { |routed| enqueue(routed) }
|
|
11
|
+
#
|
|
12
|
+
# +secrets+ is one lgr_whsec_ secret or, during a rotation, a list; a
|
|
13
|
+
# malformed one raises the library's ArgumentError here, at startup.
|
|
14
|
+
# +directory+ is any object answering remember, player_ref_for and
|
|
15
|
+
# forget. +on_error+ receives what a 500 swallowed; its default writes
|
|
16
|
+
# one line through Kernel#warn.
|
|
17
|
+
class PlayerRouter
|
|
18
|
+
DEFAULT_ON_ERROR = ->(error) { Kernel.warn("lingara webhook: #{error.class}: #{error.message}") }
|
|
19
|
+
|
|
20
|
+
def initialize(secrets, directory:, on_error: DEFAULT_ON_ERROR)
|
|
21
|
+
@webhook = Lingara::Events::Webhook.new(secrets)
|
|
22
|
+
@directory = directory
|
|
23
|
+
@on_error = on_error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Verifies the raw +body+ (a String, exactly as received) against
|
|
27
|
+
# +headers+ (a Hash or a Rack env), then looks the subject up. Raises
|
|
28
|
+
# Lingara::Events::VerificationError before the directory is asked.
|
|
29
|
+
def route(body, headers)
|
|
30
|
+
event = @webhook.verify(body, headers)
|
|
31
|
+
RoutedEvent.new(event: event, player_ref: @directory.player_ref_for(event.subject))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# #route, then the block, answered as the status your route sends with
|
|
35
|
+
# an empty body: 204 handled, 401 a bad signature, 400 a malformed
|
|
36
|
+
# payload under a valid signature, 500 the directory or the block
|
|
37
|
+
# raised (the error goes to on_error only). Every non-2xx is
|
|
38
|
+
# redelivered, so a slow block should enqueue and return.
|
|
39
|
+
def handle(body, headers, &on_event)
|
|
40
|
+
raise ArgumentError, "handle needs a block, the event handler" unless on_event
|
|
41
|
+
dispatch(body, headers, on_event)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
"#<Lingara::Embed::PlayerRouter webhook=#{@webhook.inspect}>"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s
|
|
49
|
+
inspect
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def dispatch(body, headers, on_event)
|
|
55
|
+
routed = verified_route(body, headers)
|
|
56
|
+
return routed if routed.is_a?(Integer)
|
|
57
|
+
on_event.call(routed)
|
|
58
|
+
204
|
|
59
|
+
rescue => e
|
|
60
|
+
@on_error.call(e)
|
|
61
|
+
500
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The routed event, or the status a verification failure answers.
|
|
65
|
+
def verified_route(body, headers)
|
|
66
|
+
route(body, headers)
|
|
67
|
+
rescue Lingara::Events::VerificationError => e
|
|
68
|
+
(e.reason == :malformed_payload) ? 400 : 401
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Embed
|
|
5
|
+
# A verified event and the player it concerns (ADR 1.10.26af D8).
|
|
6
|
+
# +event+ is the library's Lingara::Events::Event (UnknownEvent for a
|
|
7
|
+
# type newer than the library); +player_ref+ is nil for a subject the
|
|
8
|
+
# directory does not know: the integrator's own, or a deleted player.
|
|
9
|
+
RoutedEvent = Data.define(:event, :player_ref)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Embed
|
|
5
|
+
# Mint and delete (ADR 1.10.26af D4, D9): one library call each, the
|
|
6
|
+
# directory written only after a success. There is no cache: every
|
|
7
|
+
# #mint_token is one POST, so a token #delete_player revoked on another
|
|
8
|
+
# instance is never handed out again.
|
|
9
|
+
#
|
|
10
|
+
# client = Lingara::Client.new(client_id: ENV.fetch("LINGARA_CLIENT_ID"),
|
|
11
|
+
# client_secret: ENV.fetch("LINGARA_CLIENT_SECRET"))
|
|
12
|
+
# server = Lingara::Embed::Server.new(client, directory: players)
|
|
13
|
+
#
|
|
14
|
+
# +client+ is a Lingara::Client holding your client credentials: its
|
|
15
|
+
# token cache, retries and deprecation hook are the library's. A failed
|
|
16
|
+
# call raises the library's Lingara::Error unchanged.
|
|
17
|
+
class Server
|
|
18
|
+
# +directory+, optional, is any object answering remember,
|
|
19
|
+
# player_ref_for and forget (MemoryDirectory's shape).
|
|
20
|
+
def initialize(client, directory: nil)
|
|
21
|
+
@client = client
|
|
22
|
+
@directory = directory
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Mints a token for one player: one POST /v1/embed/tokens, then
|
|
26
|
+
# directory.remember(player_ref, subject). +scopes+ and +origin+ are
|
|
27
|
+
# sent only when given; +origin+ is your page's origin, one of your
|
|
28
|
+
# client's embed origins (a game's server mints without one). Returns
|
|
29
|
+
# the library's Lingara::MintedToken. On a failure nothing is
|
|
30
|
+
# remembered.
|
|
31
|
+
def mint_token(player_ref, scopes: nil, origin: nil)
|
|
32
|
+
body = {player_ref: player_ref}
|
|
33
|
+
body[:scopes] = scopes unless scopes.nil?
|
|
34
|
+
body[:origin] = origin unless origin.nil?
|
|
35
|
+
minted = @client.create_embed_token(**body).value
|
|
36
|
+
@directory&.remember(player_ref, minted.subject)
|
|
37
|
+
minted
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Deletes a player and revokes their tokens, then forgets them. An
|
|
41
|
+
# unknown player is a success; on a failure the row is kept.
|
|
42
|
+
def delete_player(player_ref)
|
|
43
|
+
@client.delete_embed_player(player_ref)
|
|
44
|
+
@directory&.forget(player_ref)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect
|
|
49
|
+
"#<Lingara::Embed::Server client=#{@client.inspect} directory=#{!@directory.nil?}>"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s
|
|
53
|
+
inspect
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lingara
|
|
6
|
+
module Embed
|
|
7
|
+
# The handoff body (ADR 1.10.26af D6, ES6): exactly
|
|
8
|
+
# {"token":"lgr_et_…","expires_at":"…"}, the shape every player kit and
|
|
9
|
+
# the widget read. This is the one place the kit exposes the token on
|
|
10
|
+
# purpose.
|
|
11
|
+
module TokenHandoff
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The JSON a token route returns for +minted+, a Lingara::MintedToken.
|
|
15
|
+
# The library holds expires_at as a Time, so it is written back as
|
|
16
|
+
# RFC 3339 in UTC; expires_in and subject are not forwarded.
|
|
17
|
+
def from(minted)
|
|
18
|
+
body(minted.token.expose_secret, rfc3339(minted.expires_at))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The pure body over two strings, held to token-handoff.json: compact,
|
|
22
|
+
# these two keys in this order, / and non-ASCII unescaped. A String
|
|
23
|
+
# expires_at is copied byte for byte. Internal: integrators call
|
|
24
|
+
# Lingara::Embed.token_handoff.
|
|
25
|
+
def body(token, expires_at)
|
|
26
|
+
JSON.generate({"token" => token, "expires_at" => expires_at})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# +time+ in UTC with a Z, its fractional seconds only when non-zero:
|
|
30
|
+
# as many digits as it holds, at most nine, trailing zeros trimmed.
|
|
31
|
+
def rfc3339(time)
|
|
32
|
+
return time if time.is_a?(String)
|
|
33
|
+
utc = time.getutc
|
|
34
|
+
fraction = (utc.nsec.zero? ? "" : ".#{format("%09d", utc.nsec).sub(/0+\z/, "")}")
|
|
35
|
+
"#{utc.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}Z"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Generated by make codegen-ruby from VERSION. DO NOT EDIT.
|
|
4
|
+
|
|
3
5
|
module Lingara
|
|
4
6
|
module Embed
|
|
5
|
-
|
|
7
|
+
# This kit release, SemVer: the default elementSrc reads it.
|
|
8
|
+
VERSION = "0.1.0-alpha.7"
|
|
9
|
+
|
|
10
|
+
# The same version in the RubyGems spelling the gemspec uses.
|
|
11
|
+
GEM_VERSION = "0.1.0.pre.alpha.7"
|
|
6
12
|
end
|
|
7
13
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Embed
|
|
5
|
+
# The first widget_snippet option refused. +field+ is the option's
|
|
6
|
+
# contract name, camelCase (planId, tokenEndpoint, …), never its value.
|
|
7
|
+
class SnippetError < ArgumentError
|
|
8
|
+
attr_reader :field
|
|
9
|
+
|
|
10
|
+
def initialize(field)
|
|
11
|
+
@field = field
|
|
12
|
+
super("the widget snippet's #{field} is not valid")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The widget snippet (ADR 1.10.26af D7, ES4): the <lingara-embed>
|
|
17
|
+
# element and its two module scripts, rendered identically by all eight
|
|
18
|
+
# server kits. The token never appears in it, only the endpoint the
|
|
19
|
+
# element asks at run time.
|
|
20
|
+
module WidgetSnippet
|
|
21
|
+
DEFAULT_ELEMENT_SRC = "https://cdn.jsdelivr.net/npm/@lingara/embed@#{VERSION}/dist/lingara-embed.min.js"
|
|
22
|
+
|
|
23
|
+
# Unicode category Cc.
|
|
24
|
+
CONTROL = /[\u0000-\u001f\u007f-\u009f]/
|
|
25
|
+
# The contract's whitespace beyond Cc.
|
|
26
|
+
SPACE = /[\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/
|
|
27
|
+
ORIGIN = %r{\A(https://[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]{1,5})?|http://(localhost|127\.0\.0\.1)(:[0-9]{1,5})?)/?\z}
|
|
28
|
+
|
|
29
|
+
# Each option's rule, in the contract's order: the first refused is
|
|
30
|
+
# the one raised.
|
|
31
|
+
RULES = [
|
|
32
|
+
[:plan_id, "planId", ->(v) { v.match?(/\A[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}\z/) }],
|
|
33
|
+
[:surface, "surface", ->(v) { v.match?(/\A[a-z][a-z0-9-]{0,31}\z/) }],
|
|
34
|
+
[:theme, "theme", ->(v) { %w[light dark auto].include?(v) }],
|
|
35
|
+
[:locale, "locale", ->(v) { v.match?(/\A[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}\z/) }],
|
|
36
|
+
[:lingara_origin, "lingaraOrigin", ->(v) { v.match?(ORIGIN) }],
|
|
37
|
+
[:title, "title", ->(v) { v.length.between?(1, 80) && !v.match?(CONTROL) }],
|
|
38
|
+
[:id, "id", ->(v) { v.match?(/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/) }],
|
|
39
|
+
[:token_endpoint, "tokenEndpoint", ->(v) { v.match?(%r{\A(/(?!/)|https://)}) && WidgetSnippet.url_text?(v) }],
|
|
40
|
+
[:nonce, "nonce", ->(v) { v.match?(%r{\A[A-Za-z0-9+/=_-]{8,128}\z}) }],
|
|
41
|
+
[:element_src, "elementSrc", ->(v) { v.start_with?("https://") && WidgetSnippet.url_text?(v) }]
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
ATTRIBUTE_ESCAPES = {"&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'"}.freeze
|
|
45
|
+
SCRIPT_ESCAPES = {
|
|
46
|
+
'"' => '\\"', "\\" => "\\\\", "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026",
|
|
47
|
+
"\u2028" => "\\u2028", "\u2029" => "\\u2029"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# The three lines to paste into a page, with no trailing newline.
|
|
53
|
+
# +plan_id+ (the plan your server made for this player, a UUID) and
|
|
54
|
+
# +token_endpoint+ (your token route: a path, or an https:// URL) are
|
|
55
|
+
# required; +nonce+ is your page's CSP nonce, written on both scripts.
|
|
56
|
+
# Raises SnippetError naming the first option refused.
|
|
57
|
+
def render(plan_id: nil, token_endpoint: nil, surface: nil, theme: nil, locale: nil,
|
|
58
|
+
lingara_origin: nil, title: nil, id: nil, nonce: nil, element_src: nil)
|
|
59
|
+
values = {plan_id: plan_id.to_s, token_endpoint: token_endpoint.to_s, surface: surface || "practice",
|
|
60
|
+
theme: theme, locale: locale, lingara_origin: lingara_origin, title: title, id: id || "lingara-embed",
|
|
61
|
+
nonce: nonce, element_src: element_src || DEFAULT_ELEMENT_SRC}
|
|
62
|
+
check(values)
|
|
63
|
+
lines(values)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The first refused option, by its contract name, or nothing.
|
|
67
|
+
def check(values)
|
|
68
|
+
RULES.each do |key, field, ok|
|
|
69
|
+
value = values[key]
|
|
70
|
+
next if value.nil?
|
|
71
|
+
raise SnippetError, field unless value.is_a?(String) && value.valid_encoding? && ok.call(value)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def lines(v)
|
|
76
|
+
element = +"<lingara-embed"
|
|
77
|
+
[["id", v[:id]], ["surface", v[:surface]], ["plan-id", v[:plan_id]], ["theme", v[:theme]],
|
|
78
|
+
["locale", v[:locale]], ["lingara-origin", v[:lingara_origin]], ["title", v[:title]]].each do |name, value|
|
|
79
|
+
element << %( #{name}="#{attribute(value)}") unless value.nil?
|
|
80
|
+
end
|
|
81
|
+
nonce = v[:nonce] ? %( nonce="#{attribute(v[:nonce])}") : ""
|
|
82
|
+
fetch = "document.getElementById(#{script_string(v[:id])}).tokenProvider = async () => " \
|
|
83
|
+
"(await fetch(#{script_string(v[:token_endpoint])}, { method: \"POST\", credentials: \"same-origin\" })).json();"
|
|
84
|
+
"#{element}></lingara-embed>\n" \
|
|
85
|
+
"<script type=\"module\"#{nonce} src=\"#{attribute(v[:element_src])}\"></script>\n" \
|
|
86
|
+
"<script type=\"module\"#{nonce}>#{fetch}</script>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# At most 2048 UTF-8 bytes, with no control character or whitespace.
|
|
90
|
+
def url_text?(value)
|
|
91
|
+
value.bytesize <= 2048 && !value.match?(CONTROL) && !value.match?(SPACE)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An attribute value: the five characters, and nothing else.
|
|
95
|
+
def attribute(value)
|
|
96
|
+
value.gsub(/[&<>"']/, ATTRIBUTE_ESCAPES)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A JSON string literal that can never close the script or open a
|
|
100
|
+
# comment.
|
|
101
|
+
def script_string(value)
|
|
102
|
+
%("#{value.gsub(/["\\<>&\u2028\u2029]/, SCRIPT_ESCAPES)}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/lingara/embed.rb
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The Lingara embed server kit (ADR 1.10.26af): the four jobs a game's or a
|
|
4
|
+
# site's server does for its players, over the `lingara` library. Mint a
|
|
5
|
+
# player's token (Server#mint_token), hand it over (token_handoff), route a
|
|
6
|
+
# signed webhook to its player (PlayerRouter), and delete a player
|
|
7
|
+
# (Server#delete_player); widget_snippet renders the <lingara-embed> markup.
|
|
8
|
+
#
|
|
9
|
+
# This file is lib/lingara/embed.rb and the gem ships no lib/lingara.rb, so
|
|
10
|
+
# `require "lingara"` stays the library's.
|
|
11
|
+
|
|
12
|
+
require "lingara"
|
|
13
|
+
|
|
3
14
|
require_relative "embed/version"
|
|
15
|
+
require_relative "embed/routed_event"
|
|
16
|
+
require_relative "embed/memory_directory"
|
|
17
|
+
require_relative "embed/server"
|
|
18
|
+
require_relative "embed/player_router"
|
|
19
|
+
require_relative "embed/widget_snippet"
|
|
20
|
+
require_relative "embed/token_handoff"
|
|
4
21
|
|
|
5
22
|
module Lingara
|
|
6
|
-
#
|
|
23
|
+
# The server kit's namespace. Lingara::MintedToken, Lingara::Error and
|
|
24
|
+
# Lingara::Events are the library's.
|
|
7
25
|
module Embed
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The three lines to paste into a page; see WidgetSnippet.render.
|
|
29
|
+
def widget_snippet(**options)
|
|
30
|
+
WidgetSnippet.render(**options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The JSON body a token route returns; see TokenHandoff.
|
|
34
|
+
def token_handoff(minted)
|
|
35
|
+
TokenHandoff.from(minted)
|
|
36
|
+
end
|
|
8
37
|
end
|
|
9
38
|
end
|
data/sig/lingara_embed.rbs
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
|
+
# The public surface of the gem `lingara-embed` (ADR 1.10.26af). The
|
|
2
|
+
# library's own types (Lingara::Client, Lingara::MintedToken,
|
|
3
|
+
# Lingara::Events::Event and its VerificationError) are the `lingara` gem's
|
|
4
|
+
# signatures, so they are untyped here.
|
|
1
5
|
module Lingara
|
|
2
6
|
module Embed
|
|
3
7
|
VERSION: String
|
|
8
|
+
GEM_VERSION: String
|
|
9
|
+
|
|
10
|
+
# Any object answering these three is a player directory (D5).
|
|
11
|
+
interface _Directory
|
|
12
|
+
def remember: (String player_ref, String subject) -> untyped
|
|
13
|
+
def player_ref_for: (String subject) -> String?
|
|
14
|
+
def forget: (String player_ref) -> untyped
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.widget_snippet: (?plan_id: String?, ?token_endpoint: String?, ?surface: String?, ?theme: String?,
|
|
18
|
+
?locale: String?, ?lingara_origin: String?, ?title: String?, ?id: String?,
|
|
19
|
+
?nonce: String?, ?element_src: String?) -> String
|
|
20
|
+
def self.token_handoff: (untyped minted) -> String
|
|
21
|
+
|
|
22
|
+
class Server
|
|
23
|
+
def initialize: (untyped client, ?directory: _Directory?) -> void
|
|
24
|
+
def mint_token: (String player_ref, ?scopes: Array[String]?, ?origin: String?) -> untyped
|
|
25
|
+
def delete_player: (String player_ref) -> nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class PlayerRouter
|
|
29
|
+
DEFAULT_ON_ERROR: ^(StandardError) -> untyped
|
|
30
|
+
|
|
31
|
+
def initialize: (String | Array[String] secrets, directory: _Directory,
|
|
32
|
+
?on_error: ^(StandardError) -> untyped) -> void
|
|
33
|
+
def route: (String body, Hash[untyped, untyped] headers) -> RoutedEvent
|
|
34
|
+
def handle: (String body, Hash[untyped, untyped] headers) { (RoutedEvent) -> untyped } -> Integer
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class RoutedEvent < ::Data
|
|
38
|
+
attr_reader event: untyped
|
|
39
|
+
attr_reader player_ref: String?
|
|
40
|
+
def initialize: (event: untyped, player_ref: String?) -> void
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class MemoryDirectory
|
|
44
|
+
def initialize: () -> void
|
|
45
|
+
def remember: (String player_ref, String subject) -> nil
|
|
46
|
+
def player_ref_for: (String subject) -> String?
|
|
47
|
+
def forget: (String player_ref) -> nil
|
|
48
|
+
def snapshot: () -> Hash[String, String]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class SnippetError < ArgumentError
|
|
52
|
+
attr_reader field: String
|
|
53
|
+
def initialize: (String field) -> void
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module WidgetSnippet
|
|
57
|
+
DEFAULT_ELEMENT_SRC: String
|
|
58
|
+
def self.render: (?plan_id: String?, ?token_endpoint: String?, ?surface: String?, ?theme: String?,
|
|
59
|
+
?locale: String?, ?lingara_origin: String?, ?title: String?, ?id: String?,
|
|
60
|
+
?nonce: String?, ?element_src: String?) -> String
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Internal: integrators call Lingara::Embed.token_handoff.
|
|
64
|
+
module TokenHandoff
|
|
65
|
+
def self.from: (untyped minted) -> String
|
|
66
|
+
def self.body: (String token, String expires_at) -> String
|
|
67
|
+
def self.rfc3339: (Time | String time) -> String
|
|
68
|
+
end
|
|
4
69
|
end
|
|
5
70
|
end
|
metadata
CHANGED
|
@@ -1,35 +1,60 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lingara-embed
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.1.0.pre.alpha.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Spinning Cat Studios
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
11
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: lingara
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.2.0.pre.alpha.1
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '0.3'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 0.2.0.pre.alpha.1
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.3'
|
|
33
|
+
description: Mint a player's embed token, hand it over, route a signed Lingara webhook
|
|
34
|
+
to its player, delete a player, and render the <lingara-embed> widget snippet, over
|
|
35
|
+
the lingara library.
|
|
14
36
|
email:
|
|
15
|
-
- chrisjgoddard@gmail.com
|
|
16
37
|
executables: []
|
|
17
38
|
extensions: []
|
|
18
39
|
extra_rdoc_files: []
|
|
19
40
|
files:
|
|
20
|
-
- Gemfile
|
|
21
41
|
- LICENSE
|
|
22
42
|
- README.md
|
|
23
|
-
- Rakefile
|
|
24
43
|
- lib/lingara/embed.rb
|
|
44
|
+
- lib/lingara/embed/memory_directory.rb
|
|
45
|
+
- lib/lingara/embed/player_router.rb
|
|
46
|
+
- lib/lingara/embed/routed_event.rb
|
|
47
|
+
- lib/lingara/embed/server.rb
|
|
48
|
+
- lib/lingara/embed/token_handoff.rb
|
|
25
49
|
- lib/lingara/embed/version.rb
|
|
50
|
+
- lib/lingara/embed/widget_snippet.rb
|
|
26
51
|
- sig/lingara_embed.rbs
|
|
27
|
-
homepage: https://
|
|
52
|
+
homepage: https://github.com/Spinning-Cat-Studios/lingara_embeddable_sdk
|
|
28
53
|
licenses:
|
|
29
54
|
- MIT
|
|
30
55
|
metadata:
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
source_code_uri: https://github.com/Spinning-Cat-Studios/lingara_embeddable_sdk/tree/main/ruby
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
33
58
|
post_install_message:
|
|
34
59
|
rdoc_options: []
|
|
35
60
|
require_paths:
|
|
@@ -48,5 +73,5 @@ requirements: []
|
|
|
48
73
|
rubygems_version: 3.5.22
|
|
49
74
|
signing_key:
|
|
50
75
|
specification_version: 4
|
|
51
|
-
summary: The official Ruby
|
|
76
|
+
summary: The official Ruby server kit for embedding Lingara in games and websites
|
|
52
77
|
test_files: []
|
data/Gemfile
DELETED
data/Rakefile
DELETED