samagotchi 0.107.0 → 0.109.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/configuration.md +81 -0
  5. data/docs/internals/engine.md +17 -6
  6. data/docs/internals/participants.md +28 -8
  7. data/docs/internals/remote-sessions.md +6 -2
  8. data/docs/internals/working-set.md +2 -1
  9. data/docs/memory.md +20 -0
  10. data/docs/plugins.md +9 -2
  11. data/docs/releasing.md +11 -6
  12. data/docs/sessions.md +3 -3
  13. data/docs/sub-agent.md +1 -1
  14. data/docs/testing.md +5 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  16. data/lib/samagotchi/bridge.rb +28 -2
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  23. data/lib/samagotchi/child_reports.rb +2 -1
  24. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  25. data/lib/samagotchi/config.rb +4 -0
  26. data/lib/samagotchi/context_sources.rb +1 -6
  27. data/lib/samagotchi/continue_offer.rb +8 -6
  28. data/lib/samagotchi/engine.rb +36 -25
  29. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  30. data/lib/samagotchi/idle_scheduler.rb +17 -2
  31. data/lib/samagotchi/last_turn.rb +7 -0
  32. data/lib/samagotchi/log_subscriber.rb +2 -1
  33. data/lib/samagotchi/memory_orphans.rb +74 -0
  34. data/lib/samagotchi/memory_paths.rb +4 -4
  35. data/lib/samagotchi/parent_report.rb +11 -3
  36. data/lib/samagotchi/proxy_trace.rb +45 -0
  37. data/lib/samagotchi/reply_wait.rb +7 -2
  38. data/lib/samagotchi/send_command/busy.rb +5 -2
  39. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  40. data/lib/samagotchi/session_access/local.rb +46 -10
  41. data/lib/samagotchi/session_inbox.rb +12 -8
  42. data/lib/samagotchi/session_metrics.rb +7 -6
  43. data/lib/samagotchi/session_participants.rb +3 -1
  44. data/lib/samagotchi/steer.rb +11 -1
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  46. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  47. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  48. data/lib/samagotchi/tools/memory.rb +16 -9
  49. data/lib/samagotchi/turn_origin.rb +68 -0
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +233 -26
  52. data/lib/samagotchi/web/grants.rb +8 -4
  53. data/lib/samagotchi/web/info_probe.rb +5 -1
  54. data/lib/samagotchi/web/link_codes.rb +146 -0
  55. data/lib/samagotchi/web/participant_access.rb +15 -118
  56. data/lib/samagotchi/web/participant_review.rb +1 -1
  57. data/lib/samagotchi/web/participant_view.rb +7 -13
  58. data/lib/samagotchi/web/participants_admin.rb +136 -0
  59. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  60. data/lib/samagotchi/web/public/app.js +24 -0
  61. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  62. data/lib/samagotchi/web/public/data.js +29 -5
  63. data/lib/samagotchi/web/public/participant.js +19 -5
  64. data/lib/samagotchi/web/public/participant_model.js +5 -0
  65. data/lib/samagotchi/web/public/participant_names.js +2 -2
  66. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  67. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  68. data/lib/samagotchi/web/public_url.rb +87 -0
  69. data/lib/samagotchi/web/server.rb +70 -10
  70. data/lib/samagotchi/worker.rb +30 -25
  71. data/lib/samagotchi/worker_inbound.rb +12 -9
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. data/lib/samagotchi/working_set.rb +3 -2
  74. metadata +10 -1
@@ -1,9 +1,27 @@
1
+ import { authProbe, lostAnswer } from "./auth_lost.js";
2
+
3
+ // What the page does when an answer says the sign-in is gone (auth_lost.js;
4
+ // app.js sets it). A no-op until then.
5
+ let authLostHandler = () => {};
6
+ export function onAuthLost(handler) {
7
+ authLostHandler = typeof handler === "function" ? handler : () => {};
8
+ }
9
+
10
+ // Accept: application/json: an auth proxy whose session ended answers 401
11
+ // {} (oauth2-proxy) rather than a redirect to its sign-in. A fetch that
12
+ // fails outright asks authProbe whether that was a sign-in redirect.
1
13
  export function api(path, opts = {}) {
2
14
  const { fetchImpl = globalThis.fetch } = opts;
3
15
  return fetchImpl(path, {
4
- headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
5
16
  ...opts,
17
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...(opts.headers || {}) },
18
+ }).catch((error) => {
19
+ if (error instanceof TypeError) {
20
+ authProbe(fetchImpl).then((state) => { if (state === "lost") authLostHandler(`fetch ${path}`); }, () => {});
21
+ }
22
+ throw error;
6
23
  }).then(async (r) => {
24
+ if (lostAnswer(r)) authLostHandler(`${r.status} ${path}`);
7
25
  const j = await r.json().catch(() => ({}));
8
26
  if (!r.ok) {
9
27
  // The server's words as they are: what the page shows the user. The
@@ -279,10 +297,12 @@ export function openStream(id, fromSeq, handlers = {}, opts = {}) {
279
297
  // only when the browser gave up before the first frame (a chi web without
280
298
  // a hub answers 503, a bad ?dir 400): the caller falls back to fetching.
281
299
  // Later errors are left to EventSource's own reconnect, and a reconnect
282
- // always starts with a fresh snapshot.
300
+ // always starts with a fresh snapshot, unless the browser gave up for good
301
+ // (readyState CLOSED: an auth proxy answered the reconnect with its sign-in
302
+ // page): onClosed then, and the caller probes and opens it again.
283
303
  // @return {{close(): void}}
284
304
  export function openEvents(handlers = {}, opts = {}) {
285
- const { EventSourceImpl = globalThis.EventSource, dir, onError } = opts;
305
+ const { EventSourceImpl = globalThis.EventSource, dir, onError, onClosed } = opts;
286
306
  const url = dir ? `/api/events?dir=${encodeURIComponent(dir)}` : "/api/events";
287
307
  const es = new EventSourceImpl(url);
288
308
  let frameReceived = false;
@@ -290,10 +310,14 @@ export function openEvents(handlers = {}, opts = {}) {
290
310
  let live = true;
291
311
  es.onerror = () => {
292
312
  // readyState 2 (CLOSED): the browser won't retry on its own.
293
- if (frameReceived || !live || es.readyState !== 2) return;
313
+ if (!live || es.readyState !== 2) return;
294
314
  live = false;
295
315
  try { es.close(); } catch (_) {}
296
- if (typeof onError === "function") onError();
316
+ if (!frameReceived) {
317
+ if (typeof onError === "function") onError();
318
+ } else if (typeof onClosed === "function") {
319
+ onClosed();
320
+ }
297
321
  };
298
322
  return {
299
323
  close() {
@@ -6,9 +6,19 @@
6
6
  // progress from the stream in between. A participant's comment is
7
7
  // POST …/turn (text only); their queued ones come back with Dequeue.
8
8
  import {
9
- authorColor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
9
+ authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
10
10
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
11
11
  } from "./participant_model.js";
12
+ import { createAuthLost, lostAnswer } from "./auth_lost.js";
13
+ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
14
+
15
+ // A link's #code= opened in a browser that already has a cookie: traded
16
+ // first, the page reloads without it.
17
+ // This page has a session already: the trade would replace it (a link
18
+ // someone sent to swap this browser's session for theirs), so ask first.
19
+ if (fragmentCode(globalThis.location?.hash)
20
+ && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
21
+ const authLost = createAuthLost();
12
22
 
13
23
  const $ = (id) => document.getElementById(id);
14
24
  const body = document.body.dataset;
@@ -47,17 +57,21 @@ function el(tag, cls, text) {
47
57
  }
48
58
 
49
59
  // A request's answer; a network failure is status 0 (the page says it
50
- // can't reach the session), never a throw.
60
+ // can't reach the session), never a throw. A 401 with chi's words is the
61
+ // link ending (revoked, expired); one without (an auth proxy's own 401 {}),
62
+ // a redirect or an HTML answer is the sign-in ending (auth_lost.js).
51
63
  async function api(path, options = {}) {
52
64
  let res;
53
65
  try {
54
- res = await fetch(path, { credentials: "same-origin", ...options });
66
+ res = await fetch(path, { credentials: "same-origin", ...options,
67
+ headers: { Accept: "application/json", ...(options.headers || {}) } });
55
68
  } catch {
56
69
  return { status: 0, ok: false, body: null };
57
70
  }
58
71
  let json = null;
59
72
  try { json = await res.json(); } catch { json = null; }
60
- if (res.status === 401) showEnded(json);
73
+ if (lostAnswer(res) && !(res.status === 401 && json?.error)) authLost(`${res.status} ${path}`);
74
+ else if (res.status === 401) showEnded(json);
61
75
  return { status: res.status, ok: res.ok, body: json };
62
76
  }
63
77
 
@@ -160,7 +174,7 @@ function render() {
160
174
  status.className = `p-status ${waiting ? "waiting" : session.status === "running" ? "running" : ""}`;
161
175
  $("people").replaceChildren(...(data.participants || []).map((p) => {
162
176
  const chip = el("span", "p-person");
163
- chip.style.setProperty("--author-color", authorColor(`p:${p.id}`));
177
+ chip.style.setProperty("--author-color", authorColor(participantAuthor(p.id)));
164
178
  chip.append(el("span", "p-dot"), `${p.name}${p.kind === "agent" ? " (agent)" : ""}`);
165
179
  return chip;
166
180
  }));
@@ -3,6 +3,11 @@
3
3
  // refusal in words, the stream's URL, and the live turn as filtered events
4
4
  // build it (ParticipantView on the server decides what arrives at all).
5
5
 
6
+ // A participant's author: "p:" and its grant id, as the server writes it
7
+ // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
8
+ export const PARTICIPANT_PREFIX = "p:";
9
+ export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
10
+
6
11
  // Six author colours (tokens.css --author-1..6), one per participant id,
7
12
  // the same on every page that shows them (the owner's badges too).
8
13
  export const AUTHOR_SLOTS = 6;
@@ -4,7 +4,7 @@
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
- import { authorColor } from "./participant_model.js";
7
+ import { authorColor, participantAuthor } from "./participant_model.js";
8
8
 
9
9
  export const UNNAMED = "a participant";
10
10
 
@@ -13,7 +13,7 @@ let names = new Map();
13
13
  // @return whether the names changed (the panel then names the badges drawn
14
14
  // without them)
15
15
  export function setParticipantNames(grants) {
16
- const next = new Map((grants || []).filter((g) => g?.id).map((g) => [`p:${g.id}`, String(g.name || "")]));
16
+ const next = new Map((grants || []).filter((g) => g?.id).map((g) => [participantAuthor(g.id), String(g.name || "")]));
17
17
  const changed = next.size !== names.size || [...next].some(([author, name]) => names.get(author) !== name);
18
18
  names = next;
19
19
  return changed;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { html, nothing, render } from "./vendor/lit-html.js";
16
16
  import { createChipPopover } from "./chip_popover.js";
17
- import { authorColor } from "./participant_model.js";
17
+ import { authorColor, participantAuthor } from "./participant_model.js";
18
18
  import { setParticipantNames } from "./participant_names.js";
19
19
 
20
20
  // Seen this recently: here now (the chip's dot). The gate writes
@@ -63,7 +63,7 @@ export function rowModel(grant, now) {
63
63
  kind: grant.kind,
64
64
  active,
65
65
  present,
66
- color: authorColor(`p:${grant.id}`),
66
+ color: authorColor(participantAuthor(grant.id)),
67
67
  state: active ? (present ? "here now" : `seen ${agoText(grant.last_used_at, now)}`)
68
68
  : grant.state === "expired" ? "expired" : `revoked${grant.revoked_by ? ` (${grant.revoked_by})` : ""}`,
69
69
  expires: active ? `expires ${untilText(grant.expires_at, now)}` : "",
@@ -0,0 +1,57 @@
1
+ // A link's one-time code in its fragment (https://<public url>/#code=…&s=ID,
2
+ // what chi web makes behind an auth proxy: never a token, since a fragment
3
+ // outlives the sign-in redirects and stays in the browser's history). The
4
+ // page posts it to POST /api/token, which uses it up and sets the cookie,
5
+ // then loads itself again without it. The 401 page runs it (chi web serves
6
+ // this file without the token); the owner's and participant's pages do on
7
+ // load, asking first, since the trade replaces the browser's session. Plain
8
+ // module, no imports.
9
+
10
+ // {code, rest}: the fragment's code and what else it says ("s=ID"), or
11
+ // null without one.
12
+ export function fragmentCode(hash) {
13
+ const params = new URLSearchParams(String(hash || "").replace(/^#/, ""));
14
+ const code = params.get("code");
15
+ if (!code) return null;
16
+ params.delete("code");
17
+ return { code, rest: params.toString() };
18
+ }
19
+
20
+ export const REPLACE_SESSION_TEXT = "This link signs this browser in as its holder, in place of the session it has now. Open it?";
21
+
22
+ // Trades the fragment's code, if there is one: the URL loses it at once
23
+ // (history.replaceState), the page reloads once the cookie is set. ask():
24
+ // whether to go ahead (a page that already has a session); declined, the
25
+ // code is dropped unused. Resolves true when it reloads, false when there
26
+ // was nothing to trade, it was declined, or the code was refused
27
+ // (onRefused gets the server's words).
28
+ export async function tradeFragmentCode({
29
+ loc = globalThis.location, hist = globalThis.history, fetchImpl = globalThis.fetch, onRefused = () => {},
30
+ ask = () => true,
31
+ } = {}) {
32
+ const found = fragmentCode(loc?.hash);
33
+ if (!found) return false;
34
+ try {
35
+ hist.replaceState(null, "", `${loc.pathname}${loc.search}${found.rest ? `#${found.rest}` : ""}`);
36
+ } catch (_) {}
37
+ if (!ask()) return false;
38
+ let res;
39
+ try {
40
+ res = await fetchImpl("/api/token", {
41
+ method: "POST",
42
+ credentials: "same-origin",
43
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
44
+ body: JSON.stringify({ code: found.code }),
45
+ });
46
+ } catch (_) {
47
+ onRefused("chi web can't be reached: reload to try again.");
48
+ return false;
49
+ }
50
+ if (res.ok) {
51
+ loc.reload();
52
+ return true;
53
+ }
54
+ const body = await res.json().catch(() => ({}));
55
+ onRefused(body.detail || `That link was refused (HTTP ${res.status}): it works once, and an owner's link for 10 minutes.`);
56
+ return false;
57
+ }
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../redacted_inspect"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ # chi web behind an auth proxy (web.public_url, docs/configuration.md
9
+ # "Behind an auth proxy"): the https origin people open, the request
10
+ # header the proxy puts the signed-in user's email in (nil: none is
11
+ # trusted, the chi token alone decides) and the owner's email. Set, it
12
+ # turns on proxied mode (Web::ProxyGate): every request needs a token.
13
+ PublicUrl = Data.define(:origin, :host, :identity_header, :owner) do
14
+ # The Rack env key of the identity header (HTTP_X_FORWARDED_EMAIL),
15
+ # nil without one.
16
+ def identity_env = identity_header && PublicUrl.env_key(identity_header)
17
+
18
+ # Whether a Host header names the public host: any case, a trailing
19
+ # dot, and an explicit :443 for a 443 origin are the same host.
20
+ def host?(header)
21
+ name, port = header.to_s.downcase.match(/\A(.*?)(?::(\d+))?\z/).captures
22
+ name = name.delete_suffix(".")
23
+ port = nil if port == "443"
24
+ host == (port ? "#{name}:#{port}" : name)
25
+ end
26
+
27
+ # The header's name as people write it, or "none".
28
+ def identity_label = identity_header || "none"
29
+
30
+ # https://host[:port]/#code=CODE[&FRAGMENT]: a link with a one-time
31
+ # code (LinkCodes) in its fragment, never a token: a fragment outlives
32
+ # the sign-in redirects and stays in the browser's history.
33
+ def link(code, fragment = nil) = "#{origin}/##{["code=#{code}", fragment].compact.join("&")}"
34
+ end
35
+
36
+ class PublicUrl
37
+ class Invalid < StandardError; end
38
+
39
+ # An HTTP header name (RFC 9110 token characters chi accepts).
40
+ HEADER_NAME = /\A[A-Za-z0-9][A-Za-z0-9-]*\z/
41
+
42
+ # @return [PublicUrl, nil] nil when +url+ is unset
43
+ # @raise [Invalid] a url that isn't an https origin, a bad header name,
44
+ # or an owner without an identity header to check it against
45
+ def self.from_config(url:, identity_header: nil, owner: nil)
46
+ text = url.to_s.strip
47
+ return nil if text.empty?
48
+
49
+ origin, host = parse_origin(text)
50
+ header = identity_header.to_s.strip
51
+ header = nil if header.empty? || header.casecmp?("none")
52
+ if header && !HEADER_NAME.match?(header)
53
+ raise Invalid, "web.proxy.identity_header must be a header name (letters, digits and -), not #{header}"
54
+ end
55
+
56
+ owner = owner.to_s.strip.downcase
57
+ owner = nil if owner.empty?
58
+ if owner && header.nil?
59
+ raise Invalid, "web.proxy.owner needs web.proxy.identity_header (the header the proxy puts #{owner} in); " \
60
+ "without one only the chi token decides, so leave owner unset"
61
+ end
62
+
63
+ new(origin: origin, host: host, identity_header: header, owner: owner)
64
+ end
65
+
66
+ def self.parse_origin(text)
67
+ uri = URI.parse(text)
68
+ bare = uri.path.to_s.empty? || uri.path == "/"
69
+ unless uri.scheme&.downcase == "https" && uri.host && !uri.host.empty? && bare && uri.query.nil? &&
70
+ uri.fragment.nil? && uri.userinfo.nil?
71
+ raise Invalid, invalid_url(text)
72
+ end
73
+
74
+ name = uri.host.downcase
75
+ host = uri.port == 443 ? name : "#{name}:#{uri.port}"
76
+ ["https://#{host}", host]
77
+ rescue URI::InvalidURIError
78
+ raise Invalid, invalid_url(text)
79
+ end
80
+
81
+ def self.invalid_url(text) = "web.public_url must be an https origin (https://host or https://host:port), not #{text}"
82
+
83
+ # The Rack env key WEBrick makes of a header name.
84
+ def self.env_key(name) = "HTTP_#{name.upcase.tr("-", "_")}"
85
+ end
86
+ end
87
+ end
@@ -9,6 +9,9 @@ require "webrick"
9
9
  require_relative "app"
10
10
  require_relative "info_probe"
11
11
  require_relative "lan"
12
+ require_relative "link_codes"
13
+ require_relative "proxy_gate"
14
+ require_relative "public_url"
12
15
  require_relative "qr"
13
16
  require_relative "running"
14
17
  require_relative "session_hub"
@@ -58,6 +61,10 @@ module Samagotchi
58
61
  # How long `chi web` waits for a server already on its port to answer.
59
62
  PROBE_TIMEOUT = 0.3
60
63
 
64
+ # The raw header names next to Rack's env (ProxyGate's identity check):
65
+ # chi web is the only WEBrick server chi runs.
66
+ WEBrick::HTTPRequest.prepend(ProxyGate::RawHeaderNames) unless WEBrick::HTTPRequest < ProxyGate::RawHeaderNames
67
+
61
68
  # `chi web`: one server serves every project, the scope is in the page
62
69
  # URL. If a chi web already runs on the port, print (and with --open,
63
70
  # open) its page for +dir+ and leave it be; else start one.
@@ -70,6 +77,7 @@ module Samagotchi
70
77
  editor: Config::BY_KEY["web.editor"].default, new_token: false,
71
78
  working_set: Config::BY_KEY["web.working_set"].default)
72
79
  rotate_token if new_token
80
+ public_url = public_url_setting
73
81
  setting = host_setting(host)
74
82
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
75
83
  host = resolve_host(setting)
@@ -85,6 +93,7 @@ module Samagotchi
85
93
  end
86
94
  puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
87
95
  puts running_lan_lines(found["lan"], port) if found["lan"]
96
+ puts running_public_lines(public_url) if public_url
88
97
  puts lan_off_line(setting) if new_token && !found["lan"]
89
98
  open_url(url) if open_browser
90
99
  0
@@ -92,20 +101,21 @@ module Samagotchi
92
101
  warn in_use_message(port)
93
102
  1
94
103
  else
95
- if new_token && !lan
104
+ if new_token && !lan && !public_url
96
105
  puts lan_off_line(setting)
97
106
  return 0
98
107
  end
99
108
  # The participants' grants: never served from a store others can read.
100
109
  Grants.check_permissions!(Grants.path)
101
110
  if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
102
- annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
111
+ annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
112
+ public_url: public_url)
103
113
  0
104
114
  else
105
115
  1
106
116
  end
107
117
  end
108
- rescue Lan::Error, Grants::Insecure => e
118
+ rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid => e
109
119
  warn "Error: #{e.message}"
110
120
  1
111
121
  rescue Interrupt
@@ -120,11 +130,13 @@ module Samagotchi
120
130
  # @param lan [Lan::Choice, nil] also listen on this LAN address, where
121
131
  # every request but this machine's needs the access token
122
132
  # @param token_path [String] the LAN access token's file
133
+ # @param public_url [PublicUrl, nil] behind an auth proxy
134
+ # (web.public_url): every request needs the access token
123
135
  # @return [Boolean] false when the port was taken (said so on stderr)
124
136
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
125
137
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
126
138
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
127
- working_set: Config::BY_KEY["web.working_set"].default)
139
+ working_set: Config::BY_KEY["web.working_set"].default, public_url: nil)
128
140
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
129
141
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
130
142
  end
@@ -133,14 +145,18 @@ module Samagotchi
133
145
  url ||= "http://#{url_host(host)}:#{port}/"
134
146
 
135
147
  hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
136
- if lan
137
- Token.load_or_create(token_path)
138
- lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
139
- end
148
+ Token.load_or_create(token_path) if lan || public_url
149
+ lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
150
+ public_token = Token::Source.new(token_path) if public_url
140
151
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
141
- annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set)
152
+ annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
153
+ public_url: public_url, public_token: public_token)
142
154
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
143
155
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
156
+ if public_url
157
+ Samagotchi::Log.info(:web, "public_url", origin: public_url.origin, identity_header: public_url.identity_label,
158
+ owner: public_url.owner ? "set" : "none")
159
+ end
144
160
  # Subscribed before the first scan, which makes the first check.
145
161
  hub.subscribe(method(:on_hub_event))
146
162
  hub.start
@@ -151,12 +167,15 @@ module Samagotchi
151
167
  record_running(running_path, port)
152
168
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
153
169
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
170
+ puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir)) if public_url
154
171
  puts "Press Ctrl-C to stop."
155
172
  $stdout.flush # a log file isn't line-buffered
156
173
  if open_browser
174
+ # With web.public_url the page opens through the proxy.
175
+ open_target = public_url ? public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)) : url
157
176
  Thread.new do
158
177
  sleep 0.8
159
- open_url(url)
178
+ open_url(open_target)
160
179
  end
161
180
  end
162
181
  end
@@ -224,6 +243,47 @@ module Samagotchi
224
243
  raise LanListenError, e.message
225
244
  end
226
245
 
246
+ # web.public_url as Server runs it, nil when unset.
247
+ # @raise [PublicUrl::Invalid]
248
+ def self.public_url_setting
249
+ PublicUrl.from_config(url: Samagotchi::Config.get("web.public_url"),
250
+ identity_header: Samagotchi::Config.get("web.proxy.identity_header"),
251
+ owner: Samagotchi::Config.get("web.proxy.owner"))
252
+ end
253
+
254
+ # What web.public_url adds to the start lines: the proxied link (the
255
+ # token in its fragment) and who the owner is. On this machine the
256
+ # page opens through the proxy too; a direct request takes the token
257
+ # as Authorization: Bearer only (no cookie for 127.0.0.1, which every
258
+ # local port would get).
259
+ def self.public_lines(public_url, token:, codes: owner_codes)
260
+ unless token
261
+ return ["Proxied: #{public_url.origin}/ (no access token: its file is gone; chi web --new-token makes one)"]
262
+ end
263
+
264
+ who = if public_url.identity_header.nil? then "token only, no identity header"
265
+ elsif public_url.owner then "owner #{public_url.owner} by #{public_url.identity_header}, and the token"
266
+ else "nobody is the owner by identity: web.proxy.owner is unset"
267
+ end
268
+ code = codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)
269
+ ["Proxied: #{public_url.link(code)} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
270
+ "(chi web again prints a new one)",
271
+ " (#{who}; on this machine the page opens through the proxy too)"]
272
+ end
273
+
274
+ # The owner's one-time link codes, next to the grants.
275
+ def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
276
+
277
+ # A second chi web, when this machine's config has web.public_url: the
278
+ # link from this config and the token file, never from what answered
279
+ # on the port.
280
+ def self.running_public_lines(public_url, token_path: Token.path, codes: owner_codes)
281
+ return ["Proxied: #{public_url.origin}/"] unless Token.read(token_path)
282
+
283
+ ["Proxied: #{public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))} (once, within " \
284
+ "#{LinkCodes::OWNER_TTL / 60} minutes)"]
285
+ end
286
+
227
287
  def self.host_setting(host)
228
288
  (host || Samagotchi::Config.get("web.host")).to_s.strip
229
289
  end