lingara-apps 0.1.0.pre.alpha.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8a61747c3cc110da1bb7c6c8a740469f1e7c012e8fa9b10bf4c0f39bbc00434e
4
+ data.tar.gz: 2cb2dafce84916289cd6c126781491c87204643845ec55c4285c17dc364b4756
5
+ SHA512:
6
+ metadata.gz: 74dd035b96142df84a02710383c548ffbefcf3f7993656870fa3776f437da71de16ce43a8babfaa1a3807804a6ec1c5080fce30b92c83e80843eb7159d87729b
7
+ data.tar.gz: a37f11e1c5fcc280987ebcfeffc3a1a485d9aec224ea0410d580d618341082aa308186a90dce2fb7eb52a9e91216ca55f257972889810f21eef8e0bd97633a7c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spinning Cat Studios
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # lingara-apps (Ruby)
2
+
3
+ The Ruby kit for building a [Lingara](https://getlingara.com) app: a server
4
+ that answers Lingara's two signed requests, `app.render` and `app.action`,
5
+ with a card. The kit verifies each request, decodes it, calls your block,
6
+ checks the card it returns against Lingara's card rules and sends it. Its
7
+ one runtime dependency is the
8
+ [`lingara` gem](https://github.com/Spinning-Cat-Studios/lingara_api_clients/tree/main/ruby),
9
+ whose webhook verifier checks every signature; the adapter speaks the Rack
10
+ protocol without depending on the `rack` gem.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ gem install lingara-apps
16
+ ```
17
+
18
+ or, in a Gemfile, `gem "lingara-apps"`. **Ruby 3.3 or newer**, the library's
19
+ floor. While both are pre-releases, add `--pre` (or name the version).
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ # config.ru
25
+ require "lingara/apps"
26
+
27
+ # The app's signing secret, lgr_whsec_…, or an Array of both during a
28
+ # rotation. A malformed secret raises ArgumentError here, at startup.
29
+ app = Lingara::Apps::App.new(secret: ENV.fetch("LINGARA_APP_SECRET"))
30
+
31
+ app.render do |request|
32
+ Lingara::Apps.card
33
+ .heading("Today", 1)
34
+ .term(word: "雨", reading: "yǔ", gloss: "rain", lang: "zh")
35
+ .button("Next", "next")
36
+ .build
37
+ end
38
+
39
+ app.action("next") do |request|
40
+ card = Lingara::Apps.card.heading("Nice work", 1).build
41
+ Lingara::Apps.reply(card).tutor_note("The learner finished today's word.")
42
+ end
43
+
44
+ run Lingara::Apps::RackApp.new(app)
45
+ ```
46
+
47
+ `Lingara::Apps::RackApp.new(app)` is a Rack endpoint (`#call(env)`), so it
48
+ runs under Puma, Falcon or `rackup`, and mounts in Rails
49
+ (`mount Lingara::Apps::RackApp.new(app), at: "/lingara"`) or Sinatra. It
50
+ reads `rack.input` itself, as raw bytes, before anything parses it: mount it
51
+ where no middleware has already read or decoded the body (no JSON
52
+ body-parsing middleware in front of it), because the signature covers the
53
+ exact bytes Lingara sent.
54
+
55
+ ### What your blocks receive
56
+
57
+ `Lingara::Apps::AppRenderRequest` and `AppActionRequest` carry `id` (the
58
+ `lgr_msg_…` request id), `install_id`, `subject` (the `lgr_sub_…` value your
59
+ app's events carry too: key per-learner state on it), `slot`, `locale` and
60
+ `context`, and an action adds `action_id` (the button's `action`) and
61
+ `card_etag`. `card_etag` is opaque: it tells two presses of one card apart,
62
+ and nothing more.
63
+
64
+ `context` holds the slices the learner agreed to share, each an arm of the
65
+ sealed `Lingara::Apps::ContextSlice`: match on `ContextSliceLanguages`,
66
+ `ContextSlicePlanSummary`, `ContextSliceReviewDue` or
67
+ `ContextSliceTutorTopic`, for example
68
+ `in Lingara::Apps::ContextSlicePlanSummary(plan_id:)`. A kind this kit does
69
+ not know yet is skipped and logged, never an error. The context is read-only
70
+ data. To read the plan behind a `plan_summary` slice, call the library's
71
+ `client.get_lesson_plan(plan_id)`. For a private app, that is your app's own
72
+ client-credentials client (`Lingara::Client.new(client_id:, client_secret:)`),
73
+ which speaks for its owner; for any other learner it needs that learner's
74
+ own OAuth token.
75
+
76
+ ### What your blocks return
77
+
78
+ A `Lingara::Apps::Card` (from `Lingara::Apps.card…build`), or
79
+ `Lingara::Apps.reply(card).tutor_note(text)` for a card with a tutor note:
80
+ plain text, at most 280 characters, that Lingara's tutor may use. The kit
81
+ runs `Lingara::Apps.validate_reply` on every reply. A block that raises, or a
82
+ reply that breaks a card rule or encodes to more than 32 768 bytes, is
83
+ answered `500 {"error":"handler_failed"}` and logged through the `lingara`
84
+ library's logger (the operation, the install id and the reason, never the
85
+ body, a secret or a signature). Pass `logger:` (anything with `#warn`, such
86
+ as `Rails.logger`) to use your own.
87
+
88
+ ## The builders refuse; they never cut
89
+
90
+ Lingara cuts, strips or drops whatever in a card breaks its rules. The kit
91
+ refuses instead, so that never happens to a card you built:
92
+ `CardBuilder#build` raises `Lingara::Apps::CardLimitError`, whose `reason`
93
+ names the rule (`:text_length`, `:list_items`, `:link` and so on). The limits
94
+ are constants in `Lingara::Apps::Limits`. Characters are Unicode scalar
95
+ values (`String#length` on UTF-8). `Lingara::Apps.truncate(text, limit)` gives
96
+ a string the clamp's shape (its first `limit − 1` characters and `…`) when
97
+ you want one; the kit never calls it for you.
98
+
99
+ `Lingara::Apps.manifest` builds the manifest you upload in the console;
100
+ `build` raises `Lingara::Apps::ManifestError`, whose `rule` names the rule it
101
+ breaks, and `to_json` writes `manifest.json`. A bare `name("…")` is the name
102
+ in the default locale.
103
+
104
+ ## Things to know
105
+
106
+ - **The user agent.** Lingara's requests carry
107
+ `user-agent: Lingara-Apps/1 (+https://getlingara.com/docs/apps/)`, so you
108
+ can recognise them in your logs. It is unsigned, so never use it as a gate:
109
+ the signature is the gate.
110
+ - **Time budgets.** Lingara waits 3 s for a render and 5 s for an action, and
111
+ never retries. A slow render shows the learner a stale or fallback card.
112
+ The kit enforces no timeout of its own.
113
+ - **Actions may arrive twice.** The kit stores nothing, so make each action
114
+ block safe to run twice.
115
+ - **Events are the library's.** `app.installed`, `app.uninstalled` and the
116
+ other events reach a webhook endpoint, verified by the library's own
117
+ `Lingara::Events::Webhook#verify` and its event types, not by this kit.
118
+
119
+ ## The contract
120
+
121
+ Every Lingara app kit keeps one contract, checked by one conformance host
122
+ against a fixture app in each language:
123
+ [`../conformance/CONTRACT.md`](../conformance/CONTRACT.md).
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lingara
6
+ module Apps
7
+ # An app (ADR 30.9.26am D4): its verifier, built once, its render
8
+ # function and its action functions, and the framework-neutral core every
9
+ # adapter calls. The signature check is the library's
10
+ # (Lingara::Events::Webhook#verify_signature), never the kit's own.
11
+ #
12
+ # app = Lingara::Apps::App.new(secret: ENV.fetch("LINGARA_APP_SECRET"))
13
+ # app.render { |request| Lingara::Apps.card.heading(request.slot).build }
14
+ # app.action("next") { |request| ... }
15
+ #
16
+ # A function returns a Card, or Lingara::Apps.reply(card).tutor_note(text).
17
+ class App
18
+ JSON_TYPE = {"content-type" => "application/json"}.freeze
19
+ BAD_REQUEST = [400, JSON_TYPE, '{"error":"bad_request"}'].freeze
20
+ HANDLER_FAILED = [500, JSON_TYPE, '{"error":"handler_failed"}'].freeze
21
+
22
+ # Each request message the core answers, and how. A unit test holds
23
+ # its keys to the generated Operations::REQUESTS.
24
+ DISPATCH = {
25
+ Operations::RENDER => :render_function,
26
+ Operations::ACTION => :action_function
27
+ }.freeze
28
+
29
+ attr_reader :webhook
30
+
31
+ # +secret+ is the app's signing secret (lgr_whsec_…), or an Array of
32
+ # them during a rotation. The library's Webhook is built here, so a
33
+ # malformed secret is ArgumentError at startup, not on a request.
34
+ # +logger+ is any object with #warn; the library's own by default.
35
+ def initialize(secret:, logger: Lingara::DefaultLogger.new)
36
+ @webhook = Lingara::Events::Webhook.new(secret)
37
+ @logger = logger
38
+ @render = nil
39
+ @actions = {}
40
+ end
41
+
42
+ # Registers the render function: it receives an AppRenderRequest.
43
+ def render(&block)
44
+ raise ArgumentError, "render needs a block" unless block
45
+ @render = block
46
+ self
47
+ end
48
+
49
+ # Registers the function for one button +action+: it receives an
50
+ # AppActionRequest whose action_id is +id+.
51
+ def action(id, &block)
52
+ raise ArgumentError, "action needs a block" unless block
53
+ @actions[id.to_s] = block
54
+ self
55
+ end
56
+
57
+ # One request, start to finish (AK1–AK4): [status, headers, body].
58
+ # +read+ is called once with the most bytes to read and returns the
59
+ # body read so far, a String.
60
+ def handle(method, headers, read)
61
+ return [405, {}, ""] unless method == "POST"
62
+ body = read.call(Limits::REQUEST_MAX_BYTES + 1).to_s
63
+ return [413, {}, ""] if body.bytesize > Limits::REQUEST_MAX_BYTES
64
+ begin
65
+ @webhook.verify_signature(body, headers)
66
+ rescue Lingara::Events::VerificationError
67
+ return [401, {}, ""]
68
+ end
69
+ request = Decoder.new(@logger).decode(body)
70
+ fn = request && send(DISPATCH.fetch(request.type), request)
71
+ fn ? answer(request, fn) : BAD_REQUEST
72
+ end
73
+
74
+ private
75
+
76
+ # No render function registered is the app's failure, so a 500.
77
+ def render_function(_request)
78
+ @render || ->(_) { raise "no render function is registered" }
79
+ end
80
+
81
+ def action_function(request)
82
+ @actions[request.action_id]
83
+ end
84
+
85
+ def answer(request, fn)
86
+ result = fn.call(request)
87
+ wire = result.is_a?(Reply) ? result.to_wire : {"card" => Apps.card_wire(result)}
88
+ [200, JSON_TYPE, Limits.validate_reply(wire)]
89
+ rescue => e
90
+ log(request, reason(e))
91
+ HANDLER_FAILED
92
+ end
93
+
94
+ def reason(error)
95
+ return "reply refused: #{error.reason}" if error.is_a?(CardLimitError)
96
+ "raised #{error.class}: #{error.message}"
97
+ end
98
+
99
+ # The operation, the install and the reason: never the body, a secret
100
+ # or a signature.
101
+ def log(request, reason)
102
+ @logger.warn("lingara-apps: #{request.type} for install #{request.install_id} failed: #{reason}")
103
+ end
104
+ end
105
+
106
+ # The request, decoded leniently into the generated type: an unknown
107
+ # field is ignored, and a context slice of an unknown kind is skipped
108
+ # with a log line, element by element, before the union is read (AK2).
109
+ class Decoder
110
+ # The members every request carries as strings, and an action's two.
111
+ STRINGS = %w[id install_id subject locale].freeze
112
+ ACTION_STRINGS = %w[action_id card_etag].freeze
113
+
114
+ def initialize(logger)
115
+ @logger = logger
116
+ end
117
+
118
+ # The generated request, or nil (a 400).
119
+ def decode(body)
120
+ raw = parse(body)
121
+ return nil unless raw.is_a?(Hash) && App::DISPATCH.key?(raw["type"])
122
+ return nil unless ManifestRules::SLOT_NAMES.include?(raw["slot"]) && strings?(raw)
123
+ context = context(raw["context"])
124
+ return nil unless context
125
+ request = Apps.const_get(Operations::REQUESTS.fetch(raw["type"])).build_from_hash(raw.merge("context" => []))
126
+ request.context = context
127
+ request
128
+ rescue
129
+ nil
130
+ end
131
+
132
+ private
133
+
134
+ def strings?(raw)
135
+ names = (raw["type"] == Operations::ACTION) ? STRINGS + ACTION_STRINGS : STRINGS
136
+ names.all? { |name| raw[name].is_a?(String) }
137
+ end
138
+
139
+ def parse(body)
140
+ text = body.dup.force_encoding(Encoding::UTF_8)
141
+ text.valid_encoding? ? JSON.parse(text) : nil
142
+ rescue JSON::ParserError
143
+ nil
144
+ end
145
+
146
+ def context(raw)
147
+ return nil unless raw.is_a?(Array)
148
+ raw.each_with_object([]) do |element, slices|
149
+ raise ArgumentError, "a context slice is not an object" unless element.is_a?(Hash) && element["kind"].is_a?(String)
150
+ slice = ContextSlice.decode(element)
151
+ slice ? slices << slice : skip(element["kind"])
152
+ end
153
+ end
154
+
155
+ def skip(kind)
156
+ @logger.warn("lingara-apps: skipped a context slice of unknown kind #{kind[0, 64].inspect}")
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lingara
4
+ module Apps
5
+ # List items for CardBuilder#list: ListItemText and ListItemTerm.
6
+ module Item
7
+ module_function
8
+
9
+ def text(text, lang: nil)
10
+ ListItemText.new(text: text, lang: lang)
11
+ end
12
+
13
+ def term(word:, reading: nil, gloss: nil, lang: nil)
14
+ ListItemTerm.new(word: word, reading: reading, gloss: gloss, lang: lang)
15
+ end
16
+ end
17
+
18
+ # A fluent card (ADR 30.9.26am D5): one method per element, each taking
19
+ # its generated arm's closed member set, and #build checks the card
20
+ # rules. A kit refuses what the relay would clamp, so the clamp never
21
+ # fires on a kit-built card.
22
+ #
23
+ # Lingara::Apps.card.heading("Today").term(word: "雨", reading: "yǔ").button("Next", "next").build
24
+ class CardBuilder
25
+ def initialize
26
+ @elements = []
27
+ end
28
+
29
+ def heading(text, level = 1)
30
+ push(CardElementHeading.new(text: text, level: level))
31
+ end
32
+
33
+ def text(text, lang: nil)
34
+ push(CardElementText.new(text: text, lang: lang))
35
+ end
36
+
37
+ def term(word:, reading: nil, gloss: nil, lang: nil)
38
+ push(CardElementTerm.new(word: word, reading: reading, gloss: gloss, lang: lang))
39
+ end
40
+
41
+ # +items+ are Item.text and Item.term values.
42
+ def list(items)
43
+ push(CardElementList.new(items: items.to_a.dup.freeze))
44
+ end
45
+
46
+ def progress(value, label)
47
+ push(CardElementProgress.new(value: value, label: label))
48
+ end
49
+
50
+ def divider
51
+ push(CardElementDivider.new)
52
+ end
53
+
54
+ # +action+ comes back as the request's action_id; +style+ is a
55
+ # ButtonStyle value ("primary" or "secondary").
56
+ def button(label, action, style: nil)
57
+ push(CardElementButton.new(label: label, action: action, style: style))
58
+ end
59
+
60
+ def link(label, url)
61
+ push(CardElementLink.new(label: label, url: url))
62
+ end
63
+
64
+ # The generated Card, or CardLimitError naming the first card rule it
65
+ # breaks.
66
+ def build
67
+ card = Card.new(elements: @elements.dup.freeze)
68
+ Limits.check_card(Apps.card_wire(card))
69
+ card
70
+ end
71
+
72
+ private
73
+
74
+ def push(element)
75
+ @elements << element
76
+ self
77
+ end
78
+ end
79
+
80
+ # A card and, optionally, a tutor note: what a render or action function
81
+ # may return instead of a bare card.
82
+ class Reply
83
+ attr_reader :card
84
+
85
+ def initialize(card)
86
+ raise ArgumentError, "a reply carries a Lingara::Apps::Card" unless card.is_a?(Card)
87
+ @card = card
88
+ @tutor_note = nil
89
+ end
90
+
91
+ # Plain text, at most 280 characters once `\n` becomes a space and the
92
+ # ends are trimmed; CardLimitError otherwise.
93
+ def tutor_note(text = nil)
94
+ return @tutor_note if text.nil?
95
+ Limits.check_tutor_note(text)
96
+ @tutor_note = text
97
+ self
98
+ end
99
+
100
+ # The wire form, {card, tutor_note?}: an absent note is omitted.
101
+ def to_wire
102
+ wire = {"card" => Apps.card_wire(@card)}
103
+ wire["tutor_note"] = @tutor_note unless @tutor_note.nil?
104
+ wire
105
+ end
106
+ end
107
+
108
+ module_function
109
+
110
+ # Starts a card.
111
+ def card
112
+ CardBuilder.new
113
+ end
114
+
115
+ # The list-item constructors: `Lingara::Apps.item.text("one")`.
116
+ def item
117
+ Item
118
+ end
119
+
120
+ # Wraps a built card so a tutor note can ride with it.
121
+ def reply(card)
122
+ Reply.new(card)
123
+ end
124
+
125
+ # A generated Card's wire form, {elements: [...]}, each arm in its own.
126
+ def card_wire(card)
127
+ {"elements" => card.elements.map { |e| e.respond_to?(:to_wire) ? e.to_wire : e }}
128
+ end
129
+
130
+ # The relay's cut, when asked for: Limits.truncate.
131
+ def truncate(text, limit)
132
+ Limits.truncate(text, limit)
133
+ end
134
+
135
+ # Limits.validate_reply: the bytes to send, or CardLimitError.
136
+ def validate_reply(reply)
137
+ Limits.validate_reply(reply)
138
+ end
139
+ end
140
+ end