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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 922bfc696c0c0562bb8f3bc28249c072bfd44dec0de17199bb7fd957579c50ac
4
- data.tar.gz: ef210c75eca3478291a4a1574e99d082fbe4b902e1bf3f48caa674b9216e78b8
3
+ metadata.gz: ad30f45110b5d96a048a1a9fa2fe6a576b471967e7518e6ad1f0398150242b80
4
+ data.tar.gz: 8501e28ac824097691621de281676538d34acf362b4b884631d020676405a736
5
5
  SHA512:
6
- metadata.gz: 9666c98c7ad4771b36147a21f7b9a06ca3062b5d43b7ac45b004b9968a7db15a95c82ab3c68b38a695491fd39b4cc155c170685f4d64deea54eeeb2c510d8b21
7
- data.tar.gz: 0bc95779729f24db56b3f5c590b0e3043b7c619136dde8c677a387219c60e9a027df336f07c13f2c37c05890639eb34bc6375a8b1c3874c8637b15e61bf2a96f
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
- The official Ruby SDK for embedding Lingara in games and other platforms. Under development.
4
- This name is reserved; the first release will follow Lingara's webhooks and apps work.
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
- VERSION = "0.0.0"
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 = {"&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;"}.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
- # Placeholder: the Lingara embed SDK for Ruby is under development.
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
@@ -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.0
4
+ version: 0.1.0.pre.alpha.7
5
5
  platform: ruby
6
6
  authors:
7
- - Christopher Goddard
7
+ - Spinning Cat Studios
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Embed Lingara in games and other platforms, with Ruby.
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://getlingara.com
52
+ homepage: https://github.com/Spinning-Cat-Studios/lingara_embeddable_sdk
28
53
  licenses:
29
54
  - MIT
30
55
  metadata:
31
- allowed_push_host: https://rubygems.org
32
- homepage_uri: https://getlingara.com
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 SDK for embedding Lingara (under development).
76
+ summary: The official Ruby server kit for embedding Lingara in games and websites
52
77
  test_files: []
data/Gemfile DELETED
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source "https://rubygems.org"
4
-
5
- # Specify your gem's dependencies in lingara-embed.gemspec
6
- gemspec
7
-
8
- gem "rake", "~> 13.0"
data/Rakefile DELETED
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- task default: %i[]