feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
Build / Build and release (push) Failing after 19s

app/otm.py is the Python port of pickup's ticket verifier (HS256, mandatory
exp, audience check, alg only read to reject, never raises) plus a signed
session cookie and an in-memory single-use jti guard. portable.py gates every
route but PUBLIC_PATHS behind a session and fails CLOSED (503) with no secret
— it is a fetch proxy. /api/health reports the version, /changelog renders
CHANGELOG.md, the footer carries version/sign-out/Ideas, tides beacon from
TIDES_HOST/TIDES_SITE_ID. Dockerfile: OTM labels + HEALTHCHECK. 17 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LCQmFvKshyAJsE8C3B5ET
This commit is contained in:
2026-10-18 22:22:27 -05:00
co-authored by Claude Fable 5.1
parent d2d8c55d46
commit 71abf89784
8 changed files with 586 additions and 8 deletions
+109 -1
View File
@@ -11,8 +11,110 @@ from urllib.parse import urljoin, urlparse, quote, unquote
from flask import request, Response
from bs4 import BeautifulSoup
import otm
app = flask.Flask(__name__)
# ── OTM first-party plumbing (see otm.py) ─────────────────────────────────────
# Every route except otm.PUBLIC_PATHS needs a Sign-in-with-OTM session. With no
# secret configured the app fails CLOSED — it is a fetch proxy, so an open
# deployment would be a free proxy for anyone.
_jti_guard = otm.JtiGuard()
@app.before_request
def _otm_gate():
flask.g.otm_session = None
path = request.path
if path in otm.PUBLIC_PATHS:
return None
secret = otm.session_secret()
if not secret:
return Response(otm.login_page("/", None, configured=False), status=503, mimetype="text/html")
sess = otm.decode_session(secret, request.cookies.get(otm.SESSION_COOKIE))
if sess:
flask.g.otm_session = sess
return None
if request.method == "GET":
nxt = request.full_path if request.query_string else request.path
return flask.redirect("/login?" + urlencode_qs({"next": nxt}), code=302)
return Response(json.dumps({"error": "sign in required"}), status=401, mimetype="application/json")
def urlencode_qs(d):
from urllib.parse import urlencode
return urlencode(d)
def _esc(text):
import html as _h
return _h.escape(str(text))
@app.route("/api/health")
def otm_health():
return flask.jsonify({"status": "ok", "version": otm.APP_VERSION})
@app.route("/login")
def otm_login():
nxt = otm.safe_next(request.args.get("next"), "/")
return Response(otm.login_page(nxt, request.args.get("reason"), configured=bool(otm.session_secret())), mimetype="text/html")
@app.route("/logout")
def otm_logout():
resp = flask.redirect("/login", code=302)
resp.delete_cookie(otm.SESSION_COOKIE, path="/")
return resp
@app.route("/api/auth/otm-sso")
def otm_sso_callback():
def fail(reason):
return flask.redirect(f"/login?reason={reason}", code=302)
secret = (os.environ.get("OTM_SSO_SECRET") or "").strip()
sess_secret = otm.session_secret()
if not secret or not sess_secret:
return fail("not_enabled")
payload = otm.verify_sso_ticket(secret, request.args.get("token"), expected_aud=otm.OTM_AUDIENCE)
if not payload:
return fail("invalid_token")
# Claim the jti BEFORE minting any session so a replay never produces a cookie.
if not _jti_guard.claim(payload["jti"]):
return fail("link_already_used")
now = int(time.time())
sess = {"sub": payload["sub"], "email": payload["email"], "name": payload.get("name"), "exp": now + otm.SESSION_TTL_SEC}
resp = flask.redirect(otm.safe_next(payload.get("next"), "/"), code=302)
secure = request.headers.get("X-Forwarded-Proto", request.scheme).split(",")[0].strip() == "https"
resp.set_cookie(otm.SESSION_COOKIE, otm.encode_session(sess_secret, sess), max_age=otm.SESSION_TTL_SEC,
httponly=True, secure=secure, samesite="Lax", path="/")
return resp
@app.route("/changelog")
def otm_changelog():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CHANGELOG.md")
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except OSError:
text = f"v{otm.APP_VERSION}"
return Response(otm.changelog_page(text), mimetype="text/html")
@app.route("/api/ideas", methods=["POST"])
def otm_ideas():
who = (flask.g.otm_session or {}).get("email", "")
ok, msg = otm.create_idea(request.form.get("title", ""), who, request.headers.get("Referer", "/"))
if request.accept_mimetypes.best == "application/json":
return flask.jsonify({"ok": ok, "result": msg}), (200 if ok else 400)
note = "Thanks — filed." if ok else f"Couldn't file that: {msg}"
return Response(
f'<!doctype html><meta charset="utf-8"><p style="font-family:system-ui;margin:3rem auto;max-width:28rem">{_esc(note)}'
f' <a href="/">Back</a></p>', mimetype="text/html", status=200 if ok else 400)
_DEFAULT_STRINGS = {
"heading": "Enter Website Link",
"label": "Link of the website you want to remove paywall for:",
@@ -596,12 +698,18 @@ def render_main_page():
"elapsed_template": strings["elapsed_template"],
"connection_lost_error": strings["connection_lost_error"],
}
return flask.render_template_string(
rendered = flask.render_template_string(
html,
steps=build_steps(strings),
ui_strings=ui_strings,
**strings,
)
# OTM: tides beacon in <head>, footer (version · changelog · sign out ·
# Ideas) before </body>. Post-processed so upstream's template stays
# mergeable.
email = (getattr(flask.g, "otm_session", None) or {}).get("email")
rendered = rendered.replace("</head>", otm.tides_beacon() + "</head>", 1)
return rendered.replace("</body>", otm.footer_html(email) + "</body>", 1)
def process_html_document(html_content, original_url):