samagotchi 0.121.0 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -1
- data/bin/chi +7 -3
- data/docs/architecture.md +37 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +11 -4
- data/docs/configuration.md +36 -7
- data/docs/guardrails.md +63 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +16 -8
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/memory.md +33 -0
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +21 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +12 -11
- data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
- data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +107 -1
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/guardrail_wiring.rb +24 -6
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/review_start.rb +6 -2
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +37 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +133 -29
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +12 -6
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +44 -9
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +95 -10
- data/lib/samagotchi/toolkit_binding.rb +115 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +318 -0
- data/lib/samagotchi/toolkit_suggest.rb +211 -0
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +127 -69
- data/lib/samagotchi/web/participant_access.rb +30 -8
- data/lib/samagotchi/web/participant_review.rb +8 -5
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +25 -10
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +28 -20
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +17 -3
- data/lib/samagotchi/worker_link.rb +214 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +6 -1
|
@@ -93,8 +93,9 @@ module Samagotchi
|
|
|
93
93
|
SessionInbox.prune_old(session_dir, SETTLED_DIR, max_age: max_age)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def self.settled_record(id, ended_at: nil, seconds: nil)
|
|
97
|
-
LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id
|
|
96
|
+
def self.settled_record(id, ended_at: nil, seconds: nil, author: nil)
|
|
97
|
+
LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id,
|
|
98
|
+
author: author)
|
|
98
99
|
end
|
|
99
100
|
private_class_method :settled_record
|
|
100
101
|
|
|
@@ -123,13 +124,15 @@ module Samagotchi
|
|
|
123
124
|
|
|
124
125
|
# A participant's queued turn the worker refused (Worker#refuse_participant_turn:
|
|
125
126
|
# +reason+ revoked or cap_reached) ends its sender's wait: no turn ran,
|
|
126
|
-
# canceled for that reason (ParentReport reads it as refused).
|
|
127
|
-
|
|
127
|
+
# canceled for that reason (ParentReport reads it as refused). Its
|
|
128
|
+
# +author+ (the participant's "p:<id>") says whose wait it is to a chi
|
|
129
|
+
# web restarted since (ParticipantAccess#turn_author).
|
|
130
|
+
def self.record_refused(session_dir, enqueued_id, reason:, author: nil)
|
|
128
131
|
id = enqueued_id.to_s
|
|
129
132
|
return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
130
133
|
|
|
131
134
|
write_settled(session_dir, id, LastTurn.new(outcome: "canceled", cancel_reason: reason.to_s,
|
|
132
|
-
ended_at: Time.now.iso8601(3), enqueued_id: id))
|
|
135
|
+
ended_at: Time.now.iso8601(3), enqueued_id: id, author: author))
|
|
133
136
|
rescue SystemCallError, IOError => e
|
|
134
137
|
Log.warn(:worker, "refused_turn_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
|
|
135
138
|
end
|
|
@@ -138,7 +141,7 @@ module Samagotchi
|
|
|
138
141
|
id = mark.enqueued_id.to_s
|
|
139
142
|
return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
140
143
|
|
|
141
|
-
write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
|
|
144
|
+
write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds, author: mark.author))
|
|
142
145
|
rescue SystemCallError => e
|
|
143
146
|
# The settle goes on; a wait for that turn runs to its timeout.
|
|
144
147
|
Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
|
|
@@ -163,6 +166,7 @@ module Samagotchi
|
|
|
163
166
|
@written_at = nil
|
|
164
167
|
@started_at = nil
|
|
165
168
|
@enqueued_id = nil
|
|
169
|
+
@author = nil
|
|
166
170
|
end
|
|
167
171
|
|
|
168
172
|
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
@@ -170,7 +174,8 @@ module Samagotchi
|
|
|
170
174
|
# images, no turn id), marked at iteration 0, so a worker killed before
|
|
171
175
|
# the loop's first boundary leaves the prompt and a mark the next one
|
|
172
176
|
# settles. A continue turn (no prompt) is the history, marked.
|
|
173
|
-
# Every mark of the turn carries +enqueued_id+
|
|
177
|
+
# Every mark of the turn carries +enqueued_id+ and a participant's
|
|
178
|
+
# +author+ (InterruptedTurn's).
|
|
174
179
|
# @param prompt [String, nil]
|
|
175
180
|
# @param enqueued_id [String, nil] the input the turn runs
|
|
176
181
|
# @param author [Hash] a participant's prompt's author fields
|
|
@@ -179,6 +184,7 @@ module Samagotchi
|
|
|
179
184
|
def begin_turn(prompt: nil, enqueued_id: nil, author: {})
|
|
180
185
|
@boundaries = 0
|
|
181
186
|
@enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
187
|
+
@author = author[:author]
|
|
182
188
|
@written_at = nil
|
|
183
189
|
@started_at = Time.now.iso8601(3)
|
|
184
190
|
messages = Array(@session.messages).dup
|
|
@@ -186,7 +192,7 @@ module Samagotchi
|
|
|
186
192
|
# stamp (Engine's Turn#wall), which the turn's first boundary saves.
|
|
187
193
|
messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
|
|
188
194
|
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
|
|
189
|
-
enqueued_id: @enqueued_id) }
|
|
195
|
+
enqueued_id: @enqueued_id, author: @author) }
|
|
190
196
|
end
|
|
191
197
|
|
|
192
198
|
# The turn ended (or never began): the session saves its own messages.
|
|
@@ -212,7 +218,7 @@ module Samagotchi
|
|
|
212
218
|
# forget stub) while a save on another thread serializes this one.
|
|
213
219
|
messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
|
|
214
220
|
marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
|
|
215
|
-
enqueued_id: @enqueued_id)
|
|
221
|
+
enqueued_id: @enqueued_id, author: @author)
|
|
216
222
|
@session.turn_checkpoint = { messages: messages, marker: marker }
|
|
217
223
|
return false unless due? || !merged.empty?
|
|
218
224
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "../answer_tail"
|
|
|
16
16
|
require_relative "../answer_refs"
|
|
17
17
|
require_relative "../question_asked"
|
|
18
18
|
require_relative "../bridge_client"
|
|
19
|
+
require_relative "../worker_link"
|
|
19
20
|
require_relative "../bridge/bounded_queue"
|
|
20
21
|
require_relative "../bridge/card_store"
|
|
21
22
|
require_relative "../session"
|
|
@@ -54,6 +55,7 @@ require_relative "markdown_renderer"
|
|
|
54
55
|
require_relative "capabilities"
|
|
55
56
|
require_relative "editor"
|
|
56
57
|
require_relative "../message_parts"
|
|
58
|
+
require_relative "../launch_mode"
|
|
57
59
|
require_relative "session_summary"
|
|
58
60
|
require_relative "session_access_routes"
|
|
59
61
|
require_relative "principal"
|
|
@@ -321,6 +323,11 @@ module Samagotchi
|
|
|
321
323
|
# Anything else is a file under public_dir (/assets/*, /public/* or
|
|
322
324
|
# a bare path such as /style.css), or 404.
|
|
323
325
|
serve_static(req)
|
|
326
|
+
rescue Toolkit::Refused => e
|
|
327
|
+
# A new session (create, continue, fork, review) on a config kit
|
|
328
|
+
# with an invalid groups: (Toolkit.check_new!), whichever handler
|
|
329
|
+
# spawned it.
|
|
330
|
+
error_response(400, "toolkit_refused", e.message)
|
|
324
331
|
rescue StandardError => e
|
|
325
332
|
Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
|
|
326
333
|
# A participant never reads an exception's words (they may name a
|
|
@@ -1086,7 +1093,7 @@ module Samagotchi
|
|
|
1086
1093
|
reply = request.call(client)
|
|
1087
1094
|
# Not the route's own 403 (a parent's option refused): the worker
|
|
1088
1095
|
# asked for a secret this chi web didn't send.
|
|
1089
|
-
return error_response(502, BridgeSecret::ERROR, BridgeSecret
|
|
1096
|
+
return error_response(502, BridgeSecret::ERROR, BridgeSecret.refused(id)) if reply.secret_refused?
|
|
1090
1097
|
|
|
1091
1098
|
mapped = yield(reply)
|
|
1092
1099
|
return mapped if mapped
|
|
@@ -1106,7 +1113,7 @@ module Samagotchi
|
|
|
1106
1113
|
end
|
|
1107
1114
|
|
|
1108
1115
|
def not_live(id)
|
|
1109
|
-
error_response(503, "not_live",
|
|
1116
|
+
error_response(503, "not_live", WorkerLink.not_live(id))
|
|
1110
1117
|
end
|
|
1111
1118
|
|
|
1112
1119
|
# A live worker refused or failed the request (a 500 in its handler, a
|
|
@@ -1134,8 +1141,9 @@ module Samagotchi
|
|
|
1134
1141
|
# The session's Bridge, waiting briefly for one a resume just spawned.
|
|
1135
1142
|
# @return [BridgeClient, nil]
|
|
1136
1143
|
def live_bridge_client(id)
|
|
1137
|
-
|
|
1138
|
-
|
|
1144
|
+
link = worker_link(id)
|
|
1145
|
+
link = await_worker_link(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min) unless link.live?
|
|
1146
|
+
link_client(id, link)
|
|
1139
1147
|
end
|
|
1140
1148
|
|
|
1141
1149
|
# The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
|
|
@@ -1217,7 +1225,7 @@ module Samagotchi
|
|
|
1217
1225
|
# (?full=1: as the list's).
|
|
1218
1226
|
def handle_context_show(req, id, name)
|
|
1219
1227
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1220
|
-
attached = find_context(session, name) or return error_response(404, "
|
|
1228
|
+
attached = find_context(session, name) or return error_response(404, "context_missing", "no source #{name[0, 40]}")
|
|
1221
1229
|
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1222
1230
|
row = context_row(attached, own, own.subscriptions, full: truthy_param?(req.params["full"]))
|
|
1223
1231
|
json_response(200, row.merge(text: attached.snapshot.text))
|
|
@@ -1297,7 +1305,7 @@ module Samagotchi
|
|
|
1297
1305
|
return context_remove_own(id, name) if truthy_param?(req.params["own"])
|
|
1298
1306
|
|
|
1299
1307
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1300
|
-
attached = find_context(session, name) or return error_response(404, "
|
|
1308
|
+
attached = find_context(session, name) or return error_response(404, "context_missing", "no source #{name[0, 40]}")
|
|
1301
1309
|
if attached.location.session?
|
|
1302
1310
|
attached.location.remove(attached.name)
|
|
1303
1311
|
json_response(200, { status: "detached", name: attached.name })
|
|
@@ -1312,10 +1320,10 @@ module Samagotchi
|
|
|
1312
1320
|
when :removed then json_response(200, { status: "detached", name: name })
|
|
1313
1321
|
when :project
|
|
1314
1322
|
error_response(409, "project_source", ContextCommand.project_source_message(name))
|
|
1315
|
-
else error_response(404, "
|
|
1323
|
+
else error_response(404, "context_missing", "no source #{name[0, 40]}")
|
|
1316
1324
|
end
|
|
1317
1325
|
rescue ContextSources::Invalid
|
|
1318
|
-
error_response(404, "
|
|
1326
|
+
error_response(404, "context_missing", "no source #{name[0, 40]}")
|
|
1319
1327
|
end
|
|
1320
1328
|
|
|
1321
1329
|
def attached_context(session, shadowed: false)
|
|
@@ -1355,47 +1363,55 @@ module Samagotchi
|
|
|
1355
1363
|
return error_response(404, "not_found", "Session not found: #{id}")
|
|
1356
1364
|
end
|
|
1357
1365
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
# Wait briefly for it so the first SSE connect lands on a live bridge
|
|
1364
|
-
# instead of an instantly-closed empty 200 / 503 during startup.
|
|
1365
|
-
if sidecar.nil? && @bridge_wait_timeout.to_i.positive?
|
|
1366
|
-
sidecar = await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
|
|
1366
|
+
link = worker_link(id)
|
|
1367
|
+
if link.is_a?(WorkerLink::Starting)
|
|
1368
|
+
return error_response(503, "not_live", format(LaunchMode::BRIDGE_LATE, id)) if link.stuck
|
|
1369
|
+
|
|
1370
|
+
link = await_worker_link(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
|
|
1367
1371
|
end
|
|
1368
|
-
return
|
|
1372
|
+
return stream_link_error(id, link) unless link.live?
|
|
1369
1373
|
|
|
1370
|
-
client =
|
|
1374
|
+
client = link_client(id, link)
|
|
1375
|
+
filter = stream_filter(req, id)
|
|
1376
|
+
query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
|
|
1377
|
+
opened = client.open_stream(query: query, last_event_id: req.env["HTTP_LAST_EVENT_ID"])
|
|
1378
|
+
return stream_open_error(id, opened) unless opened.is_a?(BridgeClient::StreamOpen::Opened)
|
|
1371
1379
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1380
|
+
handed_off = false
|
|
1381
|
+
begin
|
|
1382
|
+
headers = {
|
|
1383
|
+
"Content-Type" => "text/event-stream",
|
|
1384
|
+
"Cache-Control" => "no-cache",
|
|
1385
|
+
"Connection" => "keep-alive",
|
|
1386
|
+
"X-Accel-Buffering" => "no"
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
# Handlers that buffer enumerable bodies before responding (rackup's
|
|
1390
|
+
# WEBrick does `body.each` to completion) hang forever on an unbounded
|
|
1391
|
+
# SSE body — the browser never even receives the status line. When the
|
|
1392
|
+
# handler supports hijacking, stream frames straight to the socket
|
|
1393
|
+
# instead of returning an enumerable.
|
|
1394
|
+
if req.env["rack.hijack?"]
|
|
1395
|
+
hijack = sse_hijack(id, req, client, opened, filter)
|
|
1396
|
+
handed_off = true
|
|
1397
|
+
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1398
|
+
end
|
|
1378
1399
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if req.env["rack.hijack?"]
|
|
1385
|
-
hijack = sse_hijack(id, req, client)
|
|
1386
|
-
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1400
|
+
body = proxy_stream_body(id, req, client, opened, filter)
|
|
1401
|
+
handed_off = true
|
|
1402
|
+
[200, headers, body]
|
|
1403
|
+
ensure
|
|
1404
|
+
opened.sock.close unless handed_off
|
|
1387
1405
|
end
|
|
1388
|
-
|
|
1389
|
-
[200, headers, proxy_stream_body(id, req, client)]
|
|
1390
1406
|
end
|
|
1391
1407
|
|
|
1392
1408
|
# The one ProxyStreamBody both of #handle_stream's transports send:
|
|
1393
1409
|
# the session's Bridge stream, through the request principal's frame
|
|
1394
1410
|
# filter (#stream_filter).
|
|
1395
|
-
def proxy_stream_body(
|
|
1411
|
+
def proxy_stream_body(_id, req, client, opened, filter)
|
|
1396
1412
|
query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
|
|
1397
|
-
ProxyStreamBody.new(client: client, query: query, headers: req.env,
|
|
1398
|
-
server_running: stream_running(principal(req)), filter:
|
|
1413
|
+
ProxyStreamBody.new(client: client, opened: opened, query: query, headers: req.env,
|
|
1414
|
+
server_running: stream_running(principal(req)), filter: filter)
|
|
1399
1415
|
end
|
|
1400
1416
|
|
|
1401
1417
|
# What a request's stream goes through on its way from the Bridge:
|
|
@@ -1465,10 +1481,10 @@ module Samagotchi
|
|
|
1465
1481
|
# frames directly to the client socket until it disconnects. The handler
|
|
1466
1482
|
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
1467
1483
|
# only body bytes go to the socket here.
|
|
1468
|
-
def sse_hijack(id, req, client)
|
|
1484
|
+
def sse_hijack(id, req, client, opened, filter)
|
|
1469
1485
|
# Made before the status line goes out: a principal with no filter
|
|
1470
1486
|
# is refused (#stream_filter), never streamed.
|
|
1471
|
-
body = proxy_stream_body(id, req, client)
|
|
1487
|
+
body = proxy_stream_body(id, req, client, opened, filter)
|
|
1472
1488
|
lambda do |io|
|
|
1473
1489
|
body.each { |chunk| io.write(chunk) }
|
|
1474
1490
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
@@ -1476,36 +1492,67 @@ module Samagotchi
|
|
|
1476
1492
|
rescue StandardError => e
|
|
1477
1493
|
Log.warn(:web, "proxy_failed", sid: id, port: client.port, error: e.class.name, msg: e.message)
|
|
1478
1494
|
nil
|
|
1495
|
+
ensure
|
|
1496
|
+
body.close
|
|
1479
1497
|
end
|
|
1480
1498
|
end
|
|
1481
1499
|
|
|
1482
|
-
# Bounded wait for a freshly-spawned worker's
|
|
1483
|
-
#
|
|
1484
|
-
#
|
|
1485
|
-
#
|
|
1486
|
-
|
|
1487
|
-
|
|
1500
|
+
# Bounded wait for a freshly-spawned worker's Bridge so the response
|
|
1501
|
+
# hands the client a live transport without client-side polling (the
|
|
1502
|
+
# web's WorkerLink.await: #worker_link polled). Returns the first Live,
|
|
1503
|
+
# else the last outcome (None when +timeout+ allows no wait): the
|
|
1504
|
+
# session still exists; EventSource auto-reconnect covers late binders.
|
|
1505
|
+
# @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
|
|
1506
|
+
def await_worker_link(session_id, timeout: @bridge_wait_timeout)
|
|
1507
|
+
return WorkerLink::None.new if timeout.nil? || timeout <= 0
|
|
1508
|
+
|
|
1509
|
+
WorkerLink.await(session_id, session_dir: @session_class.session_dir(session_id, state_dir: default_state_dir),
|
|
1510
|
+
timeout: timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL,
|
|
1511
|
+
give_up: lambda do |outcome|
|
|
1512
|
+
outcome.is_a?(WorkerLink::Leaving) || outcome.is_a?(WorkerLink::Stale) ||
|
|
1513
|
+
outcome.is_a?(WorkerLink::Skew) ||
|
|
1514
|
+
(outcome.is_a?(WorkerLink::Starting) && outcome.stuck)
|
|
1515
|
+
end,
|
|
1516
|
+
host: DEFAULT_HOST)
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
def stream_link_error(id, link)
|
|
1520
|
+
return error_response(502, "worker_newer", link.message(id)) if link.is_a?(WorkerLink::Skew)
|
|
1521
|
+
if link.is_a?(WorkerLink::Starting) && link.stuck
|
|
1522
|
+
return error_response(503, "not_live", format(LaunchMode::BRIDGE_LATE, id))
|
|
1523
|
+
end
|
|
1524
|
+
if link.is_a?(WorkerLink::Leaving)
|
|
1525
|
+
return json_response(503, { error: "not_live", detail: WorkerLink.not_live(id),
|
|
1526
|
+
leaving: true, leave_seconds: link.left.round(1) })
|
|
1527
|
+
end
|
|
1488
1528
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1529
|
+
error_response(503, "not_live", WorkerLink.not_live(id))
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
def stream_open_error(id, outcome)
|
|
1533
|
+
return error_response(503, "not_live", WorkerLink.unanswered(id)) if outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
|
|
1534
|
+
return error_response(503, "not_live", WorkerLink.not_live(id)) if outcome.reason == :unknown_session
|
|
1535
|
+
return error_response(502, "bridge_secret", BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
|
|
1536
|
+
|
|
1537
|
+
error_response(502, "bridge_error", outcome.detail || "the session's worker answered #{outcome.status}")
|
|
1492
1538
|
end
|
|
1493
1539
|
|
|
1494
|
-
# The session's
|
|
1495
|
-
# read again for every request, so a respawned worker's port
|
|
1496
|
-
# are the ones used.
|
|
1497
|
-
# @return [
|
|
1498
|
-
def
|
|
1540
|
+
# The session's worker link (WorkerLink.resolve; stale sidecars are
|
|
1541
|
+
# removed): read again for every request, so a respawned worker's port
|
|
1542
|
+
# and secret are the ones used.
|
|
1543
|
+
# @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
|
|
1544
|
+
def worker_link(session_id)
|
|
1499
1545
|
dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
|
|
1500
|
-
|
|
1546
|
+
WorkerLink.resolve(dir, unlink: true, host: DEFAULT_HOST)
|
|
1501
1547
|
rescue StandardError
|
|
1502
|
-
|
|
1548
|
+
WorkerLink::None.new
|
|
1503
1549
|
end
|
|
1504
1550
|
|
|
1505
1551
|
# @return [BridgeClient, nil] a client for the session's live Bridge
|
|
1506
|
-
def bridge_client(session_id) =
|
|
1552
|
+
def bridge_client(session_id) = link_client(session_id, worker_link(session_id))
|
|
1507
1553
|
|
|
1508
|
-
|
|
1554
|
+
# @return [BridgeClient, nil] +link+'s client when it is Live
|
|
1555
|
+
def link_client(session_id, link) = link.live? ? link.client(session_id, host: DEFAULT_HOST) : nil
|
|
1509
1556
|
|
|
1510
1557
|
# GET a read path of the session's live Bridge (+path+: "state",
|
|
1511
1558
|
# "snapshot", "stats", ...).
|
|
@@ -1533,7 +1580,8 @@ module Samagotchi
|
|
|
1533
1580
|
|
|
1534
1581
|
path = File.join(@public_dir, "index.html")
|
|
1535
1582
|
if File.file?(path)
|
|
1536
|
-
|
|
1583
|
+
# A block: a preset's \& or \' is text, never a backreference.
|
|
1584
|
+
body = File.read(path).sub("<body>") { "<body #{index_data_attributes(req)}>" }
|
|
1537
1585
|
[200, {
|
|
1538
1586
|
"Content-Type" => "text/html; charset=utf-8",
|
|
1539
1587
|
"Content-Length" => body.bytesize.to_s,
|
|
@@ -1823,9 +1871,12 @@ module Samagotchi
|
|
|
1823
1871
|
# is kept (the env carries the token cookie, never held or inspected)
|
|
1824
1872
|
# @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
|
|
1825
1873
|
# it sees the Bridge's reads as they come, not whole frames
|
|
1826
|
-
# @param client [BridgeClient] the session's Bridge (App#
|
|
1827
|
-
|
|
1874
|
+
# @param client [BridgeClient] the session's Bridge (App#link_client)
|
|
1875
|
+
# @param opened [BridgeClient::StreamOpen::Opened] the socket whose
|
|
1876
|
+
# 200 was answered to the caller
|
|
1877
|
+
def initialize(client:, opened:, query:, headers:, server_running: -> { true }, filter: nil)
|
|
1828
1878
|
@client = client
|
|
1879
|
+
@opened = opened
|
|
1829
1880
|
@query = query
|
|
1830
1881
|
@last_event_id = headers["HTTP_LAST_EVENT_ID"]
|
|
1831
1882
|
@server_running = server_running
|
|
@@ -1833,16 +1884,23 @@ module Samagotchi
|
|
|
1833
1884
|
end
|
|
1834
1885
|
|
|
1835
1886
|
def each(&)
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
@client.stream(query: @query, last_event_id: @last_event_id, running: @server_running) do |chunk|
|
|
1843
|
-
out = @filter.call(chunk)
|
|
1887
|
+
opened = @opened
|
|
1888
|
+
return unless opened
|
|
1889
|
+
|
|
1890
|
+
@client.read_stream(opened, running: @server_running) do |chunk|
|
|
1891
|
+
out = @filter ? @filter.call(chunk) : chunk
|
|
1844
1892
|
yield(out) unless out.nil? || out.empty?
|
|
1845
1893
|
end
|
|
1894
|
+
ensure
|
|
1895
|
+
@opened = nil
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
def close
|
|
1899
|
+
opened = @opened
|
|
1900
|
+
@opened = nil
|
|
1901
|
+
opened&.sock&.close
|
|
1902
|
+
rescue IOError, SystemCallError
|
|
1903
|
+
nil
|
|
1846
1904
|
end
|
|
1847
1905
|
end
|
|
1848
1906
|
end
|
|
@@ -13,6 +13,7 @@ require_relative "../parent_report"
|
|
|
13
13
|
require_relative "../participant_messages"
|
|
14
14
|
require_relative "../participants"
|
|
15
15
|
require_relative "../session"
|
|
16
|
+
require_relative "../turn_checkpoint"
|
|
16
17
|
require_relative "../log"
|
|
17
18
|
|
|
18
19
|
module Samagotchi
|
|
@@ -116,7 +117,7 @@ module Samagotchi
|
|
|
116
117
|
detail = ParticipantMessages::TOKEN_BOUND
|
|
117
118
|
return error_response(401, "token_bound", detail) if req.path_info.start_with?("/api/")
|
|
118
119
|
|
|
119
|
-
html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE
|
|
120
|
+
html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE) { "#{detail[0].upcase}#{detail[1..]}." })
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
def settle_grant(grant)
|
|
@@ -144,7 +145,7 @@ module Samagotchi
|
|
|
144
145
|
code, detail = ENDED.fetch(state)
|
|
145
146
|
return error_response(401, code, detail) if req.path_info.start_with?("/api/")
|
|
146
147
|
|
|
147
|
-
html_response(401, ENDED_PAGE.sub(ENDED_SENTENCE
|
|
148
|
+
html_response(401, ENDED_PAGE.sub(ENDED_SENTENCE) { "#{detail[0].upcase}#{detail[1..]}." })
|
|
148
149
|
end
|
|
149
150
|
|
|
150
151
|
def audit_expired(grant)
|
|
@@ -336,20 +337,38 @@ module Samagotchi
|
|
|
336
337
|
end
|
|
337
338
|
|
|
338
339
|
# Whether the turn +enqueued_id+ of session +id+ is +grant+'s: chi
|
|
339
|
-
# web's record of who sent it, else (a chi web restarted since)
|
|
340
|
-
#
|
|
340
|
+
# web's record of who sent it, else (a chi web restarted since) the
|
|
341
|
+
# author the session's files keep for it (#turn_author), which only a
|
|
342
|
+
# principal stamps.
|
|
341
343
|
def own_turn?(grant, id, enqueued_id)
|
|
342
344
|
return false unless enqueued_id.is_a?(String) && !enqueued_id.empty?
|
|
343
345
|
|
|
344
346
|
sender = @participant_mutex.synchronize { @participant_turns[enqueued_id] }
|
|
345
347
|
return sender == grant.id if sender
|
|
346
348
|
|
|
347
|
-
|
|
349
|
+
turn_author(id, enqueued_id) == grant.author
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Who queued the turn +enqueued_id+, as the session's files say: its
|
|
353
|
+
# input file's author while it waits or runs (the claimed
|
|
354
|
+
# *.json.processing stays until the turn ends), then the turn's end:
|
|
355
|
+
# last_turn's author, or its settled record's (a refused turn's).
|
|
356
|
+
# nil for the owner's turn, or one nothing names.
|
|
357
|
+
def turn_author(id, enqueued_id)
|
|
358
|
+
dir = Session.session_dir(id, state_dir: default_state_dir)
|
|
359
|
+
files = SessionInbox.find_new_input_files(dir) + SessionInbox.claimed_input_files(dir)
|
|
360
|
+
files.each do |file|
|
|
348
361
|
data = JSON.parse(File.read(file))
|
|
349
|
-
data.is_a?(Hash) && data["enqueued_id"] == enqueued_id
|
|
362
|
+
return data["author"] if data.is_a?(Hash) && data["enqueued_id"] == enqueued_id
|
|
350
363
|
rescue JSON::ParserError, SystemCallError
|
|
351
|
-
|
|
364
|
+
next
|
|
352
365
|
end
|
|
366
|
+
last = session_access.show(id).last_turn
|
|
367
|
+
return last.author if last&.enqueued_id == enqueued_id
|
|
368
|
+
|
|
369
|
+
TurnCheckpoint.settled_turn(dir, enqueued_id)&.author
|
|
370
|
+
rescue ArgumentError, JSON::ParserError, SystemCallError
|
|
371
|
+
nil
|
|
353
372
|
end
|
|
354
373
|
|
|
355
374
|
# GET /api/sessions/resolve?id= for a participant: their grant's
|
|
@@ -401,7 +420,10 @@ module Samagotchi
|
|
|
401
420
|
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
402
421
|
cancelled: -> { !@server_running.call || !@grants.active?(grant.id) })
|
|
403
422
|
if result.status == :canceled
|
|
404
|
-
|
|
423
|
+
current = @grants.find(grant.id)
|
|
424
|
+
return error_response(401, *ENDED.fetch(:revoked)) unless current
|
|
425
|
+
|
|
426
|
+
ended = current.state(@grants.now)
|
|
405
427
|
return error_response(401, *ENDED.fetch(ended)) if ENDED.key?(ended)
|
|
406
428
|
|
|
407
429
|
return error_response(503, "server_stopping", "chi web is stopping; wait again")
|
|
@@ -6,6 +6,7 @@ require_relative "participant_exposure"
|
|
|
6
6
|
require_relative "../session_metrics"
|
|
7
7
|
require_relative "../review_start"
|
|
8
8
|
require_relative "../guardrails/review_paths"
|
|
9
|
+
require_relative "../participant_mode"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Web
|
|
@@ -49,6 +50,7 @@ module Samagotchi
|
|
|
49
50
|
# current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
|
|
50
51
|
def handle_review(req, id)
|
|
51
52
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
53
|
+
mode = ParticipantMode.of(session).to_s
|
|
52
54
|
viewer = participant_viewer(req, session)
|
|
53
55
|
live = bridge_get_json(id, "snapshot")
|
|
54
56
|
turn_snapshot = live && live["snapshot"]
|
|
@@ -63,7 +65,7 @@ module Samagotchi
|
|
|
63
65
|
status = displayed_status(session, state)
|
|
64
66
|
status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
|
|
65
67
|
json_response(200, {
|
|
66
|
-
viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer),
|
|
68
|
+
viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
|
|
67
69
|
session: { id: session.id, title: participant_title(session), status: status },
|
|
68
70
|
subjects: review_subjects(session, viewer),
|
|
69
71
|
participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
|
|
@@ -149,12 +151,12 @@ module Samagotchi
|
|
|
149
151
|
# participant view (no composer, no usage: the owner has /stats).
|
|
150
152
|
# +state+ the live worker's session state (nil without one), +queued+
|
|
151
153
|
# its queue.
|
|
152
|
-
def review_viewer(req, id, session:, state:, queued:, viewer:)
|
|
153
|
-
return { kind: "owner", can_comment: false } if principal(req).owner?
|
|
154
|
+
def review_viewer(req, id, session:, state:, queued:, viewer:, mode:)
|
|
155
|
+
return { kind: "owner", can_comment: false, mode: mode } if principal(req).owner?
|
|
154
156
|
|
|
155
157
|
grant = principal(req).grant
|
|
156
158
|
capped = !participant_cap_refusal(grant).nil?
|
|
157
|
-
{ kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, can_comment: !capped,
|
|
159
|
+
{ kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, mode: mode, can_comment: !capped,
|
|
158
160
|
cap_reached: capped, session_id: id,
|
|
159
161
|
usage: review_usage(grant, session: session, state: state, queued: queued, viewer: viewer) }
|
|
160
162
|
end
|
|
@@ -248,7 +250,8 @@ module Samagotchi
|
|
|
248
250
|
# The owner's participant view has no composer; a participant's
|
|
249
251
|
# selection quotes with the same presets as the owner's page.
|
|
250
252
|
attrs.merge!(annotate_presets_data) unless principal(req).owner?
|
|
251
|
-
|
|
253
|
+
# A block: a name's \& or \' is text, never a backreference.
|
|
254
|
+
html_response(200, File.read(path).sub("<body>") { "<body #{body_data(attrs)}>" })
|
|
252
255
|
end
|
|
253
256
|
|
|
254
257
|
# A filtered stream's frame filter (App#stream_filter), for the
|
|
@@ -374,14 +374,18 @@ module Samagotchi
|
|
|
374
374
|
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
|
|
375
375
|
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
376
376
|
/\bchp_[A-Za-z0-9_-]{20,}/,
|
|
377
|
+
# A one-time link code (LinkCodes::CODE_RE): a bearer credential
|
|
378
|
+
# until its grant expires (an owner's, 10 minutes).
|
|
379
|
+
/\bchc_[A-Za-z0-9_-]{43}/,
|
|
377
380
|
/\bAIza[0-9A-Za-z_-]{30,}/,
|
|
378
381
|
/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/,
|
|
379
382
|
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
|
|
380
383
|
%r{https://hooks\.slack\.com/services/[A-Za-z0-9/_-]+}
|
|
381
384
|
].freeze
|
|
382
|
-
# The value after an Authorization
|
|
385
|
+
# The value after an Authorization (or a Bridge's secret,
|
|
386
|
+
# BridgeSecret::HEADER) header or a token=/key= pair.
|
|
383
387
|
VALUES = [
|
|
384
|
-
/(\
|
|
388
|
+
/(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/i,
|
|
385
389
|
/((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/i,
|
|
386
390
|
# A URL's password: scheme://user:PASSWORD@host.
|
|
387
391
|
%r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@\s/]+(?=@)}i
|
|
@@ -389,7 +393,7 @@ module Samagotchi
|
|
|
389
393
|
# A text that ends where one of VALUES' values (or a private key's
|
|
390
394
|
# BEGIN line) would go on: a stream cut there would send the value
|
|
391
395
|
# on its own, unredacted (ParticipantStreamFilter never cuts there).
|
|
392
|
-
OPEN_VALUE = /(?:\
|
|
396
|
+
OPEN_VALUE = /(?:\b(?:Authorization|X-Chi-Bridge):\s*(?:(?:Bearer|Basic|Token)\s*)?|[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*(?:[=:]\s*["']?)?|-----BEGIN [A-Z0-9 ]*)\z/i
|
|
393
397
|
|
|
394
398
|
module_function
|
|
395
399
|
|
|
@@ -33,8 +33,15 @@ module Samagotchi
|
|
|
33
33
|
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
34
34
|
json_response(200, { participants: with_spend(id, grants), warning: warning,
|
|
35
35
|
identity_header: @public_url&.identity_header })
|
|
36
|
+
rescue Grants::Unwritable => e
|
|
37
|
+
grants_unwritable(e)
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# 409 grants_unwritable for a store chi won't read or write over
|
|
41
|
+
# (Grants::Corrupt, Grants::Newer), in the store's own words: a
|
|
42
|
+
# refusal the panel shows, not a 500 with a backtrace per refresh.
|
|
43
|
+
def grants_unwritable(error) = error_response(409, "grants_unwritable", error.message)
|
|
44
|
+
|
|
38
45
|
# One session's grants with what each spent and how many turns they
|
|
39
46
|
# ran (its saved analytics: SessionMetrics.saved_by_author), for the
|
|
40
47
|
# owner's participants panel.
|
|
@@ -69,6 +76,8 @@ module Samagotchi
|
|
|
69
76
|
link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
70
77
|
rescue Grants::Invalid => e
|
|
71
78
|
error_response(400, "invalid_participant", e.message)
|
|
79
|
+
rescue Grants::Unwritable => e
|
|
80
|
+
grants_unwritable(e)
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
# GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
|
|
@@ -102,6 +111,8 @@ module Samagotchi
|
|
|
102
111
|
revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
|
|
103
112
|
now = @grants.now
|
|
104
113
|
json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
|
|
114
|
+
rescue Grants::Unwritable => e
|
|
115
|
+
grants_unwritable(e)
|
|
105
116
|
end
|
|
106
117
|
|
|
107
118
|
# POST /api/sessions/:id/participants/:participant/cap {cap}: the
|
|
@@ -119,6 +130,8 @@ module Samagotchi
|
|
|
119
130
|
json_response(200, { participant: grant.public_h(@grants.now) })
|
|
120
131
|
rescue Grants::Invalid => e
|
|
121
132
|
error_response(400, "invalid_participant", e.message)
|
|
133
|
+
rescue Grants::Unwritable => e
|
|
134
|
+
grants_unwritable(e)
|
|
122
135
|
end
|
|
123
136
|
|
|
124
137
|
# The link a participant opens: web.public_url's with a one-time code
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Web
|
|
5
|
-
# Who a chi web request is (App#principal_for)
|
|
6
|
-
#
|
|
7
|
-
# Web::Token access token. The routes' permission column
|
|
5
|
+
# Who a chi web request is (App#principal_for): the owner or a
|
|
6
|
+
# participant admitted by a grant. The routes' permission column
|
|
8
7
|
# (App::ROUTES, :owner or :participant) is checked against it, and a
|
|
9
8
|
# turn's author comes from it, never from the request body. See
|
|
10
9
|
# docs/internals/remote-sessions.md (Trust model).
|
|
11
10
|
#
|
|
12
|
-
# +kind+ :owner; +grant+ what a
|
|
11
|
+
# +kind+ :owner or :participant; +grant+ what admitted a participant
|
|
13
12
|
# (nil for the owner).
|
|
14
13
|
Principal = Data.define(:kind, :grant) do
|
|
15
14
|
def owner? = kind == :owner
|