samagotchi 0.108.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/docs/architecture.md +7 -2
- data/docs/configuration.md +81 -0
- data/docs/internals/participants.md +23 -6
- data/docs/internals/remote-sessions.md +6 -2
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/session_access/local.rb +46 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +231 -26
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +24 -0
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/participant.js +17 -3
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/server.rb +70 -10
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +7 -1
|
@@ -57,10 +57,10 @@ module Samagotchi
|
|
|
57
57
|
|
|
58
58
|
issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
|
|
59
59
|
ttl: ttl, cap: cap, state_dir: default_state_dir)
|
|
60
|
-
link = participant_link(req, id, issued.token)
|
|
60
|
+
link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
|
|
61
61
|
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
62
62
|
json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
|
|
63
|
-
link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
63
|
+
link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
64
64
|
rescue Grants::Invalid => e
|
|
65
65
|
error_response(400, "invalid_participant", e.message)
|
|
66
66
|
end
|
|
@@ -115,10 +115,17 @@ module Samagotchi
|
|
|
115
115
|
error_response(400, "invalid_participant", e.message)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
# The link a participant opens:
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
118
|
+
# The link a participant opens: web.public_url's with a one-time code
|
|
119
|
+
# for the token in the fragment (LinkCodes, until the grant expires;
|
|
120
|
+
# the page trades it, POST /api/token); else this chi web's LAN address
|
|
121
|
+
# when it has one (the owner may be asking on loopback), else the Host
|
|
122
|
+
# asked; the token traded for the cookie, the session in the fragment.
|
|
123
|
+
def participant_link(req, id, token, expires_at: nil)
|
|
124
|
+
if @public_url
|
|
125
|
+
ttl = expires_at ? [Time.iso8601(expires_at) - @grants.now, 1].max : LinkCodes::OWNER_TTL
|
|
126
|
+
return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
|
|
127
|
+
end
|
|
128
|
+
|
|
122
129
|
host = req.get_header("HTTP_HOST").to_s
|
|
123
130
|
port = host[/:(\d+)\z/, 1] || req.port.to_s
|
|
124
131
|
name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "link_codes"
|
|
4
|
+
require_relative "principal"
|
|
5
|
+
require_relative "public_url"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
require_relative "../proxy_trace"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Web
|
|
11
|
+
# chi web behind an auth proxy (web.public_url), mixed into App. With a
|
|
12
|
+
# PublicUrl every request needs a token (this machine's too: a proxy
|
|
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:
|
|
17
|
+
# - is the owner with the owner token, and, when web.proxy.identity_header
|
|
18
|
+
# is set, only if that header is one clean value equal to
|
|
19
|
+
# web.proxy.owner (D-a: identity AND token);
|
|
20
|
+
# - is a participant with a live grant's token (a grant bound to an
|
|
21
|
+
# identity only for that identity, and only proxied);
|
|
22
|
+
# - else nil: the gate's 401, its reason in the detail (only the
|
|
23
|
+
# requester's own identity) and the log.
|
|
24
|
+
# The identity header is refused when repeated, with a comma, empty, or
|
|
25
|
+
# sent under two names or with an underscore: WEBrick folds
|
|
26
|
+
# X_Forwarded_Email into the same Rack key as X-Forwarded-Email, the
|
|
27
|
+
# last one winning, so the gate reads the raw names (RawHeaderNames,
|
|
28
|
+
# installed by Server). See docs/configuration.md "Behind an auth proxy".
|
|
29
|
+
module ProxyGate
|
|
30
|
+
# The header names a request came with, as WEBrick read them
|
|
31
|
+
# (lower-cased, "_" kept), in the Rack env. Prepended to
|
|
32
|
+
# WEBrick::HTTPRequest by Server.
|
|
33
|
+
module RawHeaderNames
|
|
34
|
+
def meta_vars
|
|
35
|
+
super.merge(RAW_NAMES_ENV => header.keys)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
RAW_NAMES_ENV = "samagotchi.raw_header_names"
|
|
40
|
+
# Why the gate refused a proxied request, for the 401's detail.
|
|
41
|
+
REFUSED_ENV = "samagotchi.proxy_refused"
|
|
42
|
+
# The token cookie on a proxied (https) request: Secure, Path=/, no
|
|
43
|
+
# Domain (other host names under the domain, a pod's other ports, don't
|
|
44
|
+
# get it). Cookies aren't port-scoped: a server on the same host name
|
|
45
|
+
# and another port does.
|
|
46
|
+
SECURE_COOKIE = "__Host-chi_token"
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def init_proxy_gate(public_url, token)
|
|
51
|
+
@public_url = public_url
|
|
52
|
+
@public_token = token
|
|
53
|
+
@link_codes = LinkCodes.for_grants(@grants.path)
|
|
54
|
+
@proxy_hint_mutex = Mutex.new
|
|
55
|
+
@proxy_hint_logged = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def proxied?(req)
|
|
59
|
+
return false unless @public_url
|
|
60
|
+
|
|
61
|
+
env = req.env
|
|
62
|
+
return true if @public_url.host?(env["HTTP_HOST"])
|
|
63
|
+
return true if @public_url.identity_env && env.key?(@public_url.identity_env)
|
|
64
|
+
|
|
65
|
+
names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
|
|
66
|
+
names.any? { |name| ProxyTrace.header?(name) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# [:ok, email] | [:none, nil] | [:bad, reason]: the configured identity
|
|
70
|
+
# header of a proxied request; :none without one configured.
|
|
71
|
+
def proxy_identity(req)
|
|
72
|
+
env_key = @public_url.identity_env
|
|
73
|
+
return [:none, nil] unless env_key
|
|
74
|
+
|
|
75
|
+
raw = req.env[RAW_NAMES_ENV]
|
|
76
|
+
return [:bad, "the server gave no raw header names to check the identity header against"] unless raw
|
|
77
|
+
|
|
78
|
+
names = raw.select { |name| PublicUrl.env_key(name) == env_key }.uniq
|
|
79
|
+
return [:bad, "identity header sent under #{names.size} names (#{names.join(", ")})"] if names.size > 1
|
|
80
|
+
return [:bad, "identity header sent with an underscore (#{names.first})"] if names.first&.include?("_")
|
|
81
|
+
|
|
82
|
+
value = req.env[env_key]
|
|
83
|
+
return [:none, nil] if value.nil?
|
|
84
|
+
return [:bad, "identity header not among the raw header names"] if names.empty?
|
|
85
|
+
|
|
86
|
+
value = value.strip
|
|
87
|
+
return [:bad, "identity header empty"] if value.empty?
|
|
88
|
+
return [:bad, "identity header repeated or with a comma"] if value.include?(",")
|
|
89
|
+
|
|
90
|
+
[:ok, value.downcase]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The principal of a proxied request, or nil (the reason in the env).
|
|
94
|
+
def proxied_principal(req, given)
|
|
95
|
+
state, identity = proxy_identity(req)
|
|
96
|
+
return proxy_refuse(req, identity) if state == :bad
|
|
97
|
+
|
|
98
|
+
notice_identity_headers(req) if state == :none
|
|
99
|
+
shown = identity || "(none)"
|
|
100
|
+
if owner_token?(given)
|
|
101
|
+
return Principal::OWNER if @public_url.identity_env.nil?
|
|
102
|
+
return Principal::OWNER if @public_url.owner && identity == @public_url.owner
|
|
103
|
+
|
|
104
|
+
return proxy_refuse(req, "owner token with identity #{shown}#{identity_hint(req)}")
|
|
105
|
+
end
|
|
106
|
+
grant_tokens = given.select { |value| Grants.token?(value) }
|
|
107
|
+
return proxy_refuse(req, "no token (identity #{shown})") if grant_tokens.empty?
|
|
108
|
+
|
|
109
|
+
participant_principal(req, grant_tokens, identity: identity, proxied: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# chi's own probe of a chi web behind a proxy (InfoProbe: a second chi
|
|
113
|
+
# web, chi share, chi update) sends no token: GET /api/info from this
|
|
114
|
+
# machine, not through the proxy, with no token at all, gets the
|
|
115
|
+
# fields it checks (nothing secret), so no probe ever hands the token
|
|
116
|
+
# to whatever listens on the port.
|
|
117
|
+
def probe_info?(req)
|
|
118
|
+
return false unless @public_url && req.get? && req.path_info == "/api/info"
|
|
119
|
+
return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
|
|
120
|
+
|
|
121
|
+
req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def probe_info
|
|
125
|
+
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
|
|
126
|
+
lan: @lan && @lan[:ip] })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def proxy_refuse(req, reason)
|
|
130
|
+
req.env[REFUSED_ENV] = reason
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The known identity headers (ProxyTrace::IDENTITY_HEADERS) a proxied
|
|
135
|
+
# request carries other than the configured one.
|
|
136
|
+
def other_identity_headers(req)
|
|
137
|
+
ProxyTrace::IDENTITY_HEADERS.select do |name|
|
|
138
|
+
key = PublicUrl.env_key(name)
|
|
139
|
+
key != @public_url.identity_env && req.env.key?(key)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def identity_hint(req)
|
|
144
|
+
return "" if @public_url.identity_env.nil? || req.env.key?(@public_url.identity_env)
|
|
145
|
+
|
|
146
|
+
others = other_identity_headers(req)
|
|
147
|
+
others.empty? ? "" : "; the proxy sends #{others.join(", ")}, not #{@public_url.identity_header}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Once per start: a proxied request without the configured identity
|
|
151
|
+
# header (or with none configured) but with a header a known setup
|
|
152
|
+
# puts the identity in. A wrong header locks the owner out, and one the
|
|
153
|
+
# proxy doesn't replace is a client-chosen string: the line says which.
|
|
154
|
+
def notice_identity_headers(req)
|
|
155
|
+
return if @proxy_hint_logged
|
|
156
|
+
|
|
157
|
+
others = other_identity_headers(req)
|
|
158
|
+
return if others.empty?
|
|
159
|
+
return unless @proxy_hint_mutex.synchronize { !@proxy_hint_logged && (@proxy_hint_logged = true) }
|
|
160
|
+
|
|
161
|
+
if @public_url.identity_env
|
|
162
|
+
Log.warn(:web, "proxy_identity_header", configured: @public_url.identity_header, sent: others.join(","),
|
|
163
|
+
echo: "Warning: chi web: the proxy sends #{others.join(", ")}, not " \
|
|
164
|
+
"web.proxy.identity_header #{@public_url.identity_header}")
|
|
165
|
+
else
|
|
166
|
+
Log.info(:web, "proxy_identity_header", configured: "none", sent: others.join(","),
|
|
167
|
+
echo: "chi web: the proxy sends #{others.join(", ")}; with " \
|
|
168
|
+
"web.proxy.identity_header and web.proxy.owner it is checked " \
|
|
169
|
+
"next to the token (only a header the proxy replaces)")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -24,7 +24,10 @@ import {
|
|
|
24
24
|
addContext,
|
|
25
25
|
renderText,
|
|
26
26
|
getWorkingSet,
|
|
27
|
+
onAuthLost,
|
|
27
28
|
} from "./data.js";
|
|
29
|
+
import { authProbe, createAuthLost } from "./auth_lost.js";
|
|
30
|
+
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
28
31
|
import { createContextChips } from "./context_chips.js";
|
|
29
32
|
import { createLlmContextControl } from "./llm_context_chip.js";
|
|
30
33
|
import { createParticipantsControl } from "./participants_panel.js";
|
|
@@ -96,6 +99,16 @@ import { createSessionActions } from "./session_actions.js";
|
|
|
96
99
|
import { createRename } from "./session_rename.js";
|
|
97
100
|
import { createSendTurn } from "./send_turn.js";
|
|
98
101
|
|
|
102
|
+
// A link's #code= (behind an auth proxy): traded for the cookie before
|
|
103
|
+
// anything else runs; the page reloads without it.
|
|
104
|
+
// This page has a session already: the trade would replace it (a link
|
|
105
|
+
// someone sent to swap this browser's session for theirs), so ask first.
|
|
106
|
+
if (fragmentCode(globalThis.location?.hash)
|
|
107
|
+
&& await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
|
|
108
|
+
// Signed out (the proxy's session or chi's token): a banner, or one reload.
|
|
109
|
+
const authLost = createAuthLost();
|
|
110
|
+
onAuthLost(authLost);
|
|
111
|
+
|
|
99
112
|
const $ = (s) => document.querySelector(s);
|
|
100
113
|
const topStripEl = $("#topStrip");
|
|
101
114
|
const allListEl = $("#allList");
|
|
@@ -449,9 +462,20 @@ function openSessionEvents() {
|
|
|
449
462
|
eventsControl = null;
|
|
450
463
|
refresh();
|
|
451
464
|
},
|
|
465
|
+
// The browser gave up after frames: signed out (the proxy's sign-in
|
|
466
|
+
// page answered the reconnect), or chi web went away for a while.
|
|
467
|
+
onClosed: () => {
|
|
468
|
+
eventsControl = null;
|
|
469
|
+
authProbe().then((state) => {
|
|
470
|
+
if (state === "lost") authLost("events");
|
|
471
|
+
else setTimeout(openSessionEvents, EVENTS_REOPEN_MS);
|
|
472
|
+
});
|
|
473
|
+
},
|
|
452
474
|
});
|
|
453
475
|
}
|
|
454
476
|
|
|
477
|
+
const EVENTS_REOPEN_MS = 3000;
|
|
478
|
+
|
|
455
479
|
// Which chi serves this page now and which is the newest installed (the
|
|
456
480
|
// events snapshot, and `chi` frames when that changes). A toast says when
|
|
457
481
|
// chi web was upgraded while this tab stayed open (offering the reload,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Signed out: the auth proxy's session (Okta…) or chi's token is gone. An
|
|
2
|
+
// /api answer says so in one of these ways: a 401 (chi's unauthorized, or
|
|
3
|
+
// the proxy's own 401 {} for Accept: application/json), a fetch that was
|
|
4
|
+
// redirected or came back as HTML (a sign-in page), or, from the probe,
|
|
5
|
+
// a redirect fetch can't follow (real IdPs send no CORS: a TypeError, so
|
|
6
|
+
// the probe asks with redirect: "manual"). The page then says so in a
|
|
7
|
+
// banner with Reload, and reloads by itself at most once a minute (the
|
|
8
|
+
// proxy's sign-in brings the user back), never when it can't keep the time.
|
|
9
|
+
|
|
10
|
+
export const AUTH_RELOAD_KEY = "chi.authReloadAt";
|
|
11
|
+
export const AUTH_RELOAD_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
// Whether a fetch Response says the sign-in is gone.
|
|
14
|
+
export function lostAnswer(res) {
|
|
15
|
+
if (!res) return false;
|
|
16
|
+
if (res.status === 401 || res.redirected || res.type === "opaqueredirect") return true;
|
|
17
|
+
const type = res.headers?.get?.("content-type") || "";
|
|
18
|
+
return type.includes("text/html");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// "lost" | "ok" | "down" (chi web unreachable: no banner, the page's own
|
|
22
|
+
// retries go on).
|
|
23
|
+
export async function authProbe(fetchImpl = globalThis.fetch) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchImpl("/api/info", {
|
|
26
|
+
credentials: "same-origin", redirect: "manual", headers: { Accept: "application/json" },
|
|
27
|
+
});
|
|
28
|
+
return lostAnswer(res) ? "lost" : "ok";
|
|
29
|
+
} catch (_) {
|
|
30
|
+
return "down";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const AUTH_LOST_TEXT = "Signed out (the proxy's sign-in or chi's token ended).";
|
|
35
|
+
|
|
36
|
+
// The page's authLost(reason): once, a banner (doc) and, unless one ran in
|
|
37
|
+
// the last minute, a reload. storage: sessionStorage (may be missing or throw).
|
|
38
|
+
export function createAuthLost({
|
|
39
|
+
doc = globalThis.document, storage = () => globalThis.sessionStorage, reload = () => globalThis.location.reload(),
|
|
40
|
+
now = () => Date.now(), log = (reason) => console.warn("chi: signed out", reason),
|
|
41
|
+
} = {}) {
|
|
42
|
+
let shown = false;
|
|
43
|
+
return function authLost(reason) {
|
|
44
|
+
if (shown) return;
|
|
45
|
+
shown = true;
|
|
46
|
+
log(reason);
|
|
47
|
+
// The reload only when its stamp can be kept: without storage (a
|
|
48
|
+
// private window) a page that keeps losing its sign-in would reload
|
|
49
|
+
// forever, so it gets the banner.
|
|
50
|
+
let due = false;
|
|
51
|
+
try {
|
|
52
|
+
due = now() - (Number(storage().getItem(AUTH_RELOAD_KEY)) || 0) >= AUTH_RELOAD_MS;
|
|
53
|
+
if (due) storage().setItem(AUTH_RELOAD_KEY, String(now()));
|
|
54
|
+
} catch (_) {
|
|
55
|
+
due = false;
|
|
56
|
+
}
|
|
57
|
+
if (due) {
|
|
58
|
+
reload();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const bar = doc.createElement("div");
|
|
62
|
+
bar.id = "authLost";
|
|
63
|
+
bar.setAttribute("role", "alert");
|
|
64
|
+
bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:10000;padding:8px 16px;font-size:14px;"
|
|
65
|
+
+ "color:var(--warn-text);background:var(--warn-bg);border-bottom:1px solid var(--warn-border);"
|
|
66
|
+
+ "display:flex;gap:12px;align-items:center;justify-content:center";
|
|
67
|
+
const text = doc.createElement("span");
|
|
68
|
+
text.textContent = AUTH_LOST_TEXT;
|
|
69
|
+
const button = doc.createElement("button");
|
|
70
|
+
button.type = "button";
|
|
71
|
+
button.textContent = "Reload";
|
|
72
|
+
button.addEventListener("click", () => reload());
|
|
73
|
+
bar.append(text, button);
|
|
74
|
+
doc.body.append(bar);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -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 (
|
|
313
|
+
if (!live || es.readyState !== 2) return;
|
|
294
314
|
live = false;
|
|
295
315
|
try { es.close(); } catch (_) {}
|
|
296
|
-
if (
|
|
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() {
|
|
@@ -9,6 +9,16 @@ import {
|
|
|
9
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)
|
|
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
|
|
|
@@ -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
|