samagotchi 0.119.0 → 0.121.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -19,7 +19,8 @@ module Samagotchi
19
19
  module Web
20
20
  # What a participant (a Web::Grants token) may do in chi web, mixed into
21
21
  # App: the gate's lookup of their grant, the refusals that are theirs
22
- # (token_revoked, token_expired with its ended page, another session's
22
+ # (token_revoked, token_expired with its ended page, token_bound for a
23
+ # link bound to another account, another session's
23
24
  # 404, not_permitted by role), the trimmed answers of their own routes
24
25
  # (info, the session, resolve, a wait on their turn), their turns, and
25
26
  # the running check that ends their streams when the grant does. What
@@ -30,6 +31,11 @@ module Samagotchi
30
31
  # A participant's refused routes are audited at most this often per
31
32
  # route (a page retrying would fill the log).
32
33
  REFUSED_AUDIT_INTERVAL = 60.0
34
+ # At most this many refusals a minute are remembered per reason, so
35
+ # audited: rotating paths or headers can't grow the map or the audit
36
+ # file without bound (as App::REFUSED_LOG_KEYS for the log), nor one
37
+ # reason's flood silence another's.
38
+ REFUSED_AUDIT_KEYS = 256
33
39
 
34
40
  ENDED = {
35
41
  revoked: ["token_revoked", ParticipantMessages::TOKEN_REVOKED],
@@ -65,8 +71,8 @@ module Samagotchi
65
71
  def participant_principal(req, tokens, identity: nil, proxied: false)
66
72
  tokens.each do |token|
67
73
  grant = @grants.lookup(token) or next
68
- if grant.identity && !(proxied && identity == grant.identity)
69
- req.env[ProxyGate::REFUSED_ENV] = "grant #{grant.id} is bound to another identity than #{identity || "(none)"}"
74
+ unless bound_ok?(grant, identity, proxied)
75
+ refuse_bound(req, grant, identity)
70
76
  next
71
77
  end
72
78
  grant = settle_grant(grant)
@@ -82,6 +88,36 @@ module Samagotchi
82
88
 
83
89
  GRANT_TOKEN_ENV = "samagotchi.grant_token"
84
90
  ENDED_GRANT_ENV = "samagotchi.ended_grant"
91
+ BOUND_GRANT_ENV = "samagotchi.bound_grant"
92
+
93
+ # Whether +grant+ may be used by this request: an unbound one anywhere,
94
+ # a bound one only proxied and only for its identity (the one check,
95
+ # for the gate and for a link code's trade).
96
+ def bound_ok?(grant, identity, proxied)
97
+ grant.identity.nil? || (proxied && !identity.nil? && identity == grant.identity)
98
+ end
99
+
100
+ # A bound grant presented by another identity, or not through the
101
+ # proxy: #grant_bound_response answers it (token_bound).
102
+ def refuse_bound(req, grant, identity)
103
+ req.env[ProxyGate::REFUSED_ENV] = "grant #{grant.id} is bound to another identity than #{identity || "(none)"}"
104
+ req.env[BOUND_GRANT_ENV] ||= [grant, identity]
105
+ end
106
+
107
+ # 401 token_bound for a bound grant's token (or link code) presented
108
+ # without its identity (the page's HTML says the same words), nil when
109
+ # none was: logged as a refusal and audited in the grant's session.
110
+ def grant_bound_response(req)
111
+ grant, identity = req.env[BOUND_GRANT_ENV]
112
+ return nil unless grant
113
+
114
+ log_refused(refused_peer(req), req.path_info, "grant #{grant.id} is bound to another identity than #{identity || "(none)"}")
115
+ audit_refused(req, grant, reason: "token_bound", per_route: false, identity: (identity || "(none)")[0, 200])
116
+ detail = ParticipantMessages::TOKEN_BOUND
117
+ return error_response(401, "token_bound", detail) if req.path_info.start_with?("/api/")
118
+
119
+ html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE, "#{detail[0].upcase}#{detail[1..]}."))
120
+ end
85
121
 
86
122
  def settle_grant(grant)
87
123
  return grant unless grant.active?(@grants.now)
@@ -93,6 +129,9 @@ module Samagotchi
93
129
 
94
130
  Participants.revoke(@grants, session_id: grant.session_id, id: grant.id, by: by, state_dir: default_state_dir)
95
131
  @grants.find(grant.id) || grant
132
+ rescue Grants::Unwritable
133
+ # A store this chi can't write (a newer chi's): refused all the same.
134
+ grant.with(revoked_at: @grants.now.utc.iso8601, revoked_by: by)
96
135
  end
97
136
 
98
137
  # 401 token_revoked / token_expired for an ended grant's token (the
@@ -115,16 +154,24 @@ module Samagotchi
115
154
 
116
155
  # A participant's request, past the gate: its use is recorded (the
117
156
  # grant's last_used_at, at most once a minute) and a peer not seen
118
- # since chi web started goes into the audit trail.
157
+ # since chi web started goes into the audit trail. Behind a proxy
158
+ # every peer is 127.0.0.1: there the peer is the client's identity
159
+ # (else the proxy's X-Forwarded-For hop, #refused_peer), and the line
160
+ # names the identity.
119
161
  def participant_seen(req, principal)
120
162
  grant = principal.grant
121
163
  @grants.touch(grant.id)
122
- peer = req.get_header("REMOTE_ADDR").to_s
164
+ peer = refused_peer(req)
123
165
  fresh = @participant_mutex.synchronize { @participant_peers[grant.id].add?(peer) }
124
166
  return unless fresh
125
167
 
126
- Participants.log(grant.session_id, :peer, state_dir: default_state_dir, participant: grant.id, ip: peer,
127
- user_agent: req.get_header("HTTP_USER_AGENT").to_s[0, 200])
168
+ fields = { ip: req.get_header("REMOTE_ADDR").to_s, user_agent: req.get_header("HTTP_USER_AGENT").to_s[0, 200] }
169
+ if proxied?(req)
170
+ state, identity = proxy_identity(req)
171
+ fields[:identity] = identity if state == :ok
172
+ fields[:peer] = peer
173
+ end
174
+ Participants.log(grant.session_id, :peer, state_dir: default_state_dir, participant: grant.id, **fields)
128
175
  end
129
176
 
130
177
  # A participant asking for a session that isn't their grant's: 404 as
@@ -146,19 +193,27 @@ module Samagotchi
146
193
  error_response(403, "not_permitted", NOT_PERMITTED)
147
194
  end
148
195
 
149
- def audit_refused(req, grant)
150
- key = [grant.id, req.request_method, req.path_info]
196
+ # +reason+ and +fields+ go into the line (a bound refusal names the
197
+ # identity that presented it). Once a minute per route for
198
+ # not_permitted; once a minute per grant and identity for a bound
199
+ # refusal, whatever the path (+per_route+ false); at most
200
+ # REFUSED_AUDIT_KEYS keys a minute.
201
+ def audit_refused(req, grant, reason: "not_permitted", per_route: true, **fields)
202
+ key = per_route ? [grant.id, req.request_method, req.path_info, reason, fields] : [grant.id, reason, fields]
151
203
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
204
  due = @participant_mutex.synchronize do
153
- @participant_refused.delete_if { |_, at| now - at >= REFUSED_AUDIT_INTERVAL } if @participant_refused.size > 256
154
- last = @participant_refused[key]
155
- (last.nil? || now - last >= REFUSED_AUDIT_INTERVAL).tap { |fresh| @participant_refused[key] = now if fresh }
205
+ seen = (@participant_refused[reason] ||= {})
206
+ seen.delete_if { |_, at| now - at >= REFUSED_AUDIT_INTERVAL } if seen.size > 64
207
+ next false if seen.size >= REFUSED_AUDIT_KEYS && !seen.key?(key)
208
+
209
+ last = seen[key]
210
+ (last.nil? || now - last >= REFUSED_AUDIT_INTERVAL).tap { |fresh| seen[key] = now if fresh }
156
211
  end
157
212
  return unless due
158
213
 
159
214
  Participants.log(grant.session_id, :refused, state_dir: default_state_dir, participant: grant.id,
160
215
  method: req.request_method, path: req.path_info[0, 200],
161
- reason: "not_permitted")
216
+ reason: reason, **fields)
162
217
  end
163
218
 
164
219
  # GET /api/info for a participant: what SessionAccess::Remote's
@@ -405,6 +460,7 @@ module Samagotchi
405
460
  <p>{sentence}</p>
406
461
  </body></html>
407
462
  HTML
463
+ BOUND_PAGE = ENDED_PAGE.sub("<title>chi: review link ended</title>", "<title>chi: review link for another account</title>")
408
464
  end
409
465
  end
410
466
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Web
5
+ # What a participant may learn of their review's usage, by their grant's
6
+ # exposure level (Grants::Grant#exposure; the review-composer plan's D3):
7
+ # the policy that maps a level to the usage fields the review route
8
+ # gives them (ParticipantReview#review_usage). One level today:
9
+ #
10
+ # - +own+ (every grant's): their own spend and cap, their own queued
11
+ # turns and queue limit, their link's expiry, the session's model.
12
+ # Never another participant's or the owner's usage, nor the session's.
13
+ #
14
+ # A later level (a tier that also shows the session's spend, say) adds
15
+ # its row to LEVELS and its fields' sources in ParticipantReview: one
16
+ # place each. A level this chi doesn't know (written by a newer chi, or
17
+ # a hand-edited store) reads as DEFAULT, the narrowest: an unknown level
18
+ # never shows more. See docs/internals/participants.md (Exposure).
19
+ module ParticipantExposure
20
+ OWN = "own"
21
+ DEFAULT = OWN
22
+ # level => the usage fields it shows, in the order the page reads them.
23
+ LEVELS = {
24
+ OWN => %i[spent cap queued queue_limit expires_at model].freeze
25
+ }.freeze
26
+
27
+ # +value+ as a level this chi knows, else DEFAULT.
28
+ def self.level(value) = LEVELS.key?(value) ? value : DEFAULT
29
+
30
+ # @return [Array<Symbol>] the fields +value+'s level shows
31
+ def self.fields(value) = LEVELS.fetch(level(value))
32
+
33
+ # The usage +value+'s level shows, each field read from +sources+
34
+ # (field => a callable): only the fields shown are computed, and a
35
+ # field a level names with no source raises (KeyError), never shows
36
+ # nothing silently.
37
+ # @param sources [Hash{Symbol => #call}]
38
+ # @return [Hash{Symbol => Object}]
39
+ def self.usage(value, sources)
40
+ fields(value).to_h { |field| [field, sources.fetch(field).call] }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "participant_view"
4
4
  require_relative "participant_stream_filter"
5
+ require_relative "participant_exposure"
5
6
  require_relative "../session_metrics"
6
7
  require_relative "../review_start"
7
8
  require_relative "../guardrails/review_paths"
@@ -58,15 +59,18 @@ module Samagotchi
58
59
  automatic = automatic_turn_ids(id, session, shown, current, state)
59
60
  waiting = !!(current&.dig("waiting_for_owner") || turn_snapshot&.dig("continue_offer") ||
60
61
  (turn_snapshot && !turn_snapshot["current_turn"] && turn_snapshot["pending_question"]))
62
+ queued = turn_snapshot && snapshot_list(turn_snapshot, "queued")
63
+ status = displayed_status(session, state)
64
+ status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
61
65
  json_response(200, {
62
- viewer: review_viewer(req, id),
63
- session: { id: session.id, title: participant_title(session), status: displayed_status(session, state) },
66
+ viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer),
67
+ session: { id: session.id, title: participant_title(session), status: status },
64
68
  subjects: review_subjects(session, viewer),
65
69
  participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
66
70
  messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
67
71
  current_turn: current,
68
72
  waiting_for_owner: waiting,
69
- queued: review_queue(snapshot_list(turn_snapshot, "queued"), viewer),
73
+ queued: review_queue(queued || [], viewer),
70
74
  last_event_id: turn_snapshot && turn_snapshot["event_id"]
71
75
  })
72
76
  end
@@ -140,15 +144,71 @@ module Samagotchi
140
144
  def delegate_child?(session) = !session.parent_id.to_s.empty?
141
145
 
142
146
  # Who is reading: a participant (their id, name, whether they may
143
- # comment: not once their cap is reached), or the owner looking
144
- # through the participant view (no composer).
145
- def review_viewer(req, id)
147
+ # comment: not once their cap is reached, and their +usage+ as their
148
+ # grant's exposure shows it), or the owner looking through the
149
+ # participant view (no composer, no usage: the owner has /stats).
150
+ # +state+ the live worker's session state (nil without one), +queued+
151
+ # its queue.
152
+ def review_viewer(req, id, session:, state:, queued:, viewer:)
146
153
  return { kind: "owner", can_comment: false } if principal(req).owner?
147
154
 
148
155
  grant = principal(req).grant
149
156
  capped = !participant_cap_refusal(grant).nil?
150
157
  { kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, can_comment: !capped,
151
- cap_reached: capped, session_id: id }
158
+ cap_reached: capped, session_id: id,
159
+ usage: review_usage(grant, session: session, state: state, queued: queued, viewer: viewer) }
160
+ end
161
+
162
+ # A participant's usage, the fields their grant's exposure level
163
+ # shows (ParticipantExposure), each computed only when shown. Every
164
+ # source reads the grant's own author: never another participant's or
165
+ # the owner's.
166
+ def review_usage(grant, session:, state:, queued:, viewer:)
167
+ usage = ParticipantExposure.usage(grant.exposure, {
168
+ spent: -> { participant_spent(grant, state) },
169
+ cap: -> { grant.spend_cap },
170
+ queued: -> { queued&.count { |entry| entry.is_a?(Hash) && viewer.own?(entry["author"]) } },
171
+ queue_limit: -> { grant.queue_limit },
172
+ expires_at: -> { grant.expires_at },
173
+ model: -> { participant_model(session, state, viewer) }
174
+ })
175
+ usage.delete(:queued) unless queued
176
+ usage
177
+ end
178
+
179
+ # What the participant has cost so far, in dollars: the saved
180
+ # analytics' count (#participant_cap_refusal's), or the live worker's
181
+ # when it is more (SessionMetrics#spend: its saved turns, the running
182
+ # turn's finished generations and the side requests; a running turn
183
+ # is saved only at its end). The worker's count already holds the
184
+ # saved one, so the two are never added.
185
+ def participant_spent(grant, state)
186
+ dir = Session.session_dir(grant.session_id, state_dir: default_state_dir)
187
+ saved = SessionMetrics.saved_spend(dir, grant.author)
188
+ live = live_spend(state, grant.author)
189
+ [saved, live].max.round(4)
190
+ end
191
+
192
+ # +author+'s cost in the live worker's metrics (spend_by_author), 0.0
193
+ # without one.
194
+ def live_spend(state, author)
195
+ cost = state.is_a?(Hash) ? state.dig("metrics", "spend_by_author", author, "cost") : nil
196
+ cost.is_a?(Numeric) && cost.to_f.finite? ? cost.to_f : 0.0
197
+ rescue TypeError
198
+ 0.0
199
+ end
200
+
201
+ # The model the session runs on, as its host is sent it (the alias
202
+ # applied, no host name): the live worker's, else the saved one's;
203
+ # nil when it doesn't resolve.
204
+ def participant_model(session, state, viewer)
205
+ ref = (state.is_a?(Hash) && state["model_name"]) || session.model_name
206
+ return nil if ref.to_s.empty?
207
+
208
+ name = host_registry.bare_name(ref.to_s)
209
+ name.to_s.empty? ? nil : ParticipantView::Redact.text(name.to_s, viewer)
210
+ rescue StandardError
211
+ nil
152
212
  end
153
213
 
154
214
  # The queue as a participant sees it: their own queued turns (the
@@ -185,8 +245,10 @@ module Samagotchi
185
245
  grant = principal(req).grant
186
246
  attrs.merge!("viewer" => "participant", "session" => grant.session_id, "name" => grant.name)
187
247
  end
188
- data = attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
189
- html_response(200, File.read(path).sub("<body>", "<body #{data}>"))
248
+ # The owner's participant view has no composer; a participant's
249
+ # selection quotes with the same presets as the owner's page.
250
+ attrs.merge!(annotate_presets_data) unless principal(req).owner?
251
+ html_response(200, File.read(path).sub("<body>", "<body #{body_data(attrs)}>"))
190
252
  end
191
253
 
192
254
  # A filtered stream's frame filter (App#stream_filter), for the
@@ -22,14 +22,17 @@ module Samagotchi
22
22
  # grants (one session's, or every one), in every state, never a token
23
23
  # or digest. One session's also carries +warning+: the share warning
24
24
  # for a session that isn't a review session (ReviewStart.share_warning,
25
- # null for one), which the panel shows before its Invite.
25
+ # null for one), which the panel shows before its Invite; and
26
+ # +identity_header+: the header this chi web's auth proxy names the
27
+ # signed-in user in (null without one), for the Invite's email field.
26
28
  def handle_participants_list(_req, id = nil)
27
29
  now = @grants.now
28
30
  grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
29
31
  return json_response(200, { participants: grants }) unless id
30
32
 
31
33
  warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
32
- json_response(200, { participants: with_spend(id, grants), warning: warning })
34
+ json_response(200, { participants: with_spend(id, grants), warning: warning,
35
+ identity_header: @public_url&.identity_header })
33
36
  end
34
37
 
35
38
  # One session's grants with what each spent and how many turns they
@@ -44,9 +47,11 @@ module Samagotchi
44
47
  end
45
48
 
46
49
  # POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
47
- # cap? (dollars)}: a new grant. The answer carries the token and the
48
- # link once (chi keeps only its digest), and +warning+ for a session
49
- # that isn't a review session (ReviewStart.share_warning; null for one).
50
+ # cap? (dollars), identity? (an email: the grant is bound to it, only
51
+ # behind an auth proxy with an identity header)}: a new grant. The
52
+ # answer carries the token and the link once (chi keeps only its
53
+ # digest), and +warning+ for a session that isn't a review session
54
+ # (ReviewStart.share_warning; null for one).
50
55
  def handle_participants_invite(req, id)
51
56
  body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
52
57
  ttl = body["ttl"]
@@ -56,7 +61,8 @@ module Samagotchi
56
61
  return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
57
62
 
58
63
  issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
59
- ttl: ttl, cap: cap, state_dir: default_state_dir)
64
+ ttl: ttl, cap: cap, identity: body["identity"], public_url: @public_url,
65
+ state_dir: default_state_dir)
60
66
  link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
61
67
  warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
62
68
  json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
@@ -11,9 +11,12 @@ module Samagotchi
11
11
  # chi web behind an auth proxy (web.public_url), mixed into App. With a
12
12
  # PublicUrl every request needs a token (this machine's too: a proxy
13
13
  # with no trace and a rewritten Host looks local). A request is
14
- # *proxied* when it carries a proxy trace (ProxyTrace: X-Forwarded-*,
15
- # Forwarded, X-Real-IP, a known identity header…), the configured
16
- # identity header, or the public Host. A proxied request:
14
+ # *proxied* when it comes from a loopback peer (the proxy runs on this
15
+ # machine: the pod's Caddy/oauth2-proxy, Tailscale Serve) and carries a
16
+ # proxy trace (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, a known
17
+ # identity header…), the configured identity header, or the public Host.
18
+ # A LAN peer's traces are its own words, never the proxy's: it is a LAN
19
+ # request (the LAN token rules; a bound grant refused). A proxied request:
17
20
  # - is the owner with the owner token, and, when web.proxy.identity_header
18
21
  # is set, only if that header is one clean value equal to
19
22
  # web.proxy.owner (D-a: identity AND token);
@@ -45,6 +48,13 @@ module Samagotchi
45
48
  # and another port does.
46
49
  SECURE_COOKIE = "__Host-chi_token"
47
50
 
51
+ # 127.0.0.0/8, ::1 and their IPv4-mapped form: the only peers whose
52
+ # proxy traces and identity header are trusted (the proxy is local).
53
+ def self.loopback_peer?(addr)
54
+ addr = addr.to_s
55
+ addr.start_with?("127.", "::ffff:127.") || addr == "::1"
56
+ end
57
+
48
58
  private
49
59
 
50
60
  def init_proxy_gate(public_url, token)
@@ -59,6 +69,8 @@ module Samagotchi
59
69
  return false unless @public_url
60
70
 
61
71
  env = req.env
72
+ return false unless ProxyGate.loopback_peer?(env["REMOTE_ADDR"])
73
+
62
74
  return true if @public_url.host?(env["HTTP_HOST"])
63
75
  return true if @public_url.identity_env && env.key?(@public_url.identity_env)
64
76
 
@@ -86,6 +98,9 @@ module Samagotchi
86
98
  value = value.strip
87
99
  return [:bad, "identity header empty"] if value.empty?
88
100
  return [:bad, "identity header repeated or with a comma"] if value.include?(",")
101
+ # Grants.clean_identity takes ASCII only: a non-ASCII header could
102
+ # never match a grant or the owner, so it is refused as such.
103
+ return [:bad, "identity header not ASCII"] unless value.ascii_only?
89
104
 
90
105
  [:ok, value.downcase]
91
106
  end
@@ -0,0 +1,18 @@
1
+ /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), small pills with a gap (they
2
+ wrap on a narrow page). Both pages link it: the owner's (app.js) and the participant page (participant.js). Colours
3
+ are tokens.css's. */
4
+ #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
5
+ #annotateBar[hidden]{display:none}
6
+ #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
7
+ /* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
8
+ #annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
9
+ #annotateBar .annotate-actions[hidden]{display:none}
10
+ #annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
11
+ #annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
12
+ #annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
13
+ #annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
14
+ #annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
15
+ #annotateBar.docked{right:8px;justify-content:center}
16
+ @media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
17
+ #annotateBar .annotate-more-list[hidden]{display:none}
18
+ #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -39,12 +39,19 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
39
39
  // @param dock () => the element the bar docks above on a touch screen
40
40
  // (the composer's dock); without one, or when it isn't laid out, the
41
41
  // bar docks at the bottom of the window
42
+ // @param source (node) => where a selection starting at +node+ came from,
43
+ // {kind, root, …}, or null where the page isn't annotatable
44
+ // (annotations.js annotationSource by default; the participant page
45
+ // passes its own, participant_annotate.js)
46
+ // @param quote (text, source) => the quote block for the selected +text+
47
+ // (annotations.js quoteBlock by default), or null for no text
42
48
  // @param doc, win the page's document and window
43
49
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
44
50
  // next frame (call it on scroll)
45
51
  export function createAnnotateBar({
46
52
  presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
47
- dock = () => null, doc = globalThis.document, win = globalThis.window,
53
+ dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock,
54
+ doc = globalThis.document, win = globalThis.window,
48
55
  }) {
49
56
  const bar = doc.createElement("div");
50
57
  bar.id = "annotateBar";
@@ -131,7 +138,7 @@ export function createAnnotateBar({
131
138
  if (!canAnnotate() || !sel || sel.isCollapsed || !sel.rangeCount) return hide();
132
139
  const range = sel.getRangeAt(0);
133
140
  if (!roots().some((root) => root.contains(range.startContainer))) return hide();
134
- const source = annotationSource(range.startContainer);
141
+ const source = sourceOf(range.startContainer);
135
142
  // A running turn's narration box shows complete sentences and its thinking
136
143
  // is appended by delta; a selection in either is refused while the step is
137
144
  // live — the quote lands once it closes (then on the full text).
@@ -140,7 +147,7 @@ export function createAnnotateBar({
140
147
  const clipped = range.cloneRange();
141
148
  if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
142
149
  const raw = clipped.toString();
143
- const block = quoteBlock(raw, source);
150
+ const block = quote(raw, source);
144
151
  if (!block) return hide();
145
152
  pendingQuote = block;
146
153
  pendingFields = selectionFields(raw, source);