ANetBBS Changelog

Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0a2.61 (May 2026). Previous: v1.0a2.60.

v1.0a2.61 — Newsletter bugfix (May 2026)

Fixed 500 error when sending a newsletter. The admin newsletter route was
passing content=body when constructing PrivateMessage objects, but the
model field is body. No PMs were delivered; the newsletter record was
rolled back cleanly. Corrected to body=body.

v1.0a2.60 — In-browser DOS games: DOOM + Duke Nukem 3D (May 2026)

New game type door_dos_browser — play classic DOS games directly in
the browser via EmulatorJS (dosbox_pure libretro core). No DOSBox
install on the server required; games run fully client-side.

Two shareware titles now ship pre-bundled:

  • DOOM (Shareware) — id Software shareware, freely distributable.
    SoundBlaster audio via dosbox_pure's SB16 emulation (IRQ 7, DMA 1).
  • Duke Nukem 3D (Shareware) — 3D Realms shareware, freely
    distributable. Uses GUS/UltraSound emulation (--gus flag) because
    the v1.3D binary only accepts FXDevice=9 (Build Engine audiolib
    devices 3, 5, 13 are compiled out or silent in this release).

New files / changes:

  • anetbbs/web/games.pydoor_dos_browser play route,
    /games/dos-data/<file> ZIP server, /games/dos-frame/<slug> with
    COOP/COEP headers for SharedArrayBuffer.
  • anetbbs/templates/games/play_jsdos.html — landing page.
  • anetbbs/templates/games/play_jsdos_frame.html — EmulatorJS frame
    with pointer-lock exit overlay and GAME OVER replay loop.
  • anetbbs/features/games.py — terminal door menu now excludes
    door_dos_browser (and builtin_web) so browser-only games don't
    appear in the telnet/SSH game list.
  • tools/prepare_dos_games.py — packages a DOS game directory into a
    dosbox_pure ZIP bundle. New flags: --exclude (remove extra EXEs so
    dosbox_pure auto-starts), --gus (GUS/UltraSound emulation for
    Build Engine games), --dry-run.
  • data/dos-games/doom.zip and data/dos-games/duke3d.zip — the
    pre-built game bundles (included in the release tarball).
  • docs/14-door-games.md — new door_dos_browser section with setup
    guide, flags reference, GUS notes, and rebuild commands.

Deploy note for upgrades from v1.0.x:

The data/dos-games/ directory must be writable by the anetbbs
service user. SCP new ZIPs to /tmp/ on the server and move with
sudo:

sudo mv /tmp/*.zip /opt/anetbbs/data/dos-games/
sudo chown anetbbs:anetbbs /opt/anetbbs/data/dos-games/*.zip

Add the two Game rows manually if they're missing (see
docs/14-door-games.md → Pre-installed games).


Versions are internal build numbers. Public releases are tagged
separately. Previous internal series: v1.0a2.36 — alpha 2 (internal v287.35,
May 2026). v1.0a — alpha 1 (internal v196).

v287.35 — FTPS: pyOpenSSL dep + soft-fail import (May 2026)

After v1.0a2.34 wired up the cert-perms and supplementary group, FTP
started crashing on the actual TLS_FTPHandler import:

ImportError: cannot import name 'TLS_FTPHandler' from 'pyftpdlib.handlers'

pyftpdlib only exposes TLS_FTPHandler when pyOpenSSL is importable
— it's a soft optional dep, but FTPS doesn't work without it. We
never declared it, so every install was a coin-flip depending on
what else happened to pull pyOpenSSL into the venv.

Two fixes:

  1. pyopenssl>=24.0.0 added to requirements.txt + setup.py
    update.sh's pip install -e . step now pulls it on every run.
  2. build_server() catches the ImportError and falls back to
    plain FTP with a clear, actionable warning that names the package
  3. the venv pip command, so a future drift can't silently disable
    FTPS again.

After deploying this release the FTP listener finally comes up on
:21 with TLS enabled — AUTH TLS upgrades succeed and the journal
shows FTP: TLS enabled (/etc/letsencrypt/…).

v287.34 — install.sh em-dash banner alignment (May 2026)

Sysop has been hand-patching install.sh for every release: two
boxed banner lines contain an em-dash (, U+2014, 3 bytes UTF-8
but 1 terminal column) and the byte-counted whitespace layout was
1 column short of the right-edge . Fixed in the source tree so
future releases don't need the manual fix-up:

  • Line ~53: ║ ANetBBS — UNINSTALL ║
  • Line ~190: ║ ANetBBS — Installation Wizard ║

Each one gets one extra trailing space before the closing . The
sysop's notes are now stored in auto-memory so future agent
sessions remember the em-dash gotcha — anytime banner whitespace
gets regenerated.

v287.33 — FTPS auto-enable + renewal-hook (May 2026)

update.sh now wires up FTPS end-to-end whenever
FTP_TLS_CERTFILE in .env points at /etc/letsencrypt/…:

  1. ssl-cert group: created (system group) if missing.
  2. Service user → ssl-cert: added via usermod -aG so the
    group membership exists on disk.
  3. letsencrypt perms normalised: chgrp -R ssl-cert /etc/letsencrypt/{live,archive}, chmod g+rX on the dirs,
    0640 on privkey*.pem, 0644 on the other *.pem files.
  4. anetbbs.service patched: SupplementaryGroups=ssl-cert
    injected via sed (idempotent). Without this systemd's
    User=stingray doesn't pick up the new group at process start —
    systemd doesn't call initgroups() itself, supplementary groups
    have to be declared explicitly.
  5. Certbot renewal hook:
    /etc/letsencrypt/renewal-hooks/deploy/anetbbs-ssl-cert-perms.sh
    gets installed. Certbot otherwise resets archive/ to
    0700 root:root on every renewal, silently breaking FTPS
    overnight. The hook restores the ssl-cert group + perms
    after every successful cert renewal.

After deploying this release, restart anetbbs once so it picks
up the new supplementary group, then FTPS should work — AUTH TLS
upgrade on port 21 succeeds and the listener logs
FTP: TLS enabled (/etc/letsencrypt/…) instead of falling back to
plain.

The whole block is conditional on FTP_TLS_CERTFILE pointing at
/etc/letsencrypt/…. Sysops using other cert paths (self-signed
in data/ssl/, ACME from a different client) see no change.
Sysops with FTP_TLS_CERTFILE blank also see no change — FTP
keeps starting in plain mode.

v287.32 — CLI logger crash fix (May 2026)

Hotfix on v1.0a2.32: anetbbs --version (and any CLI invocation
from a non-install CWD) was crashing at module-import time with
PermissionError: '/tmp/bbs.log'. main.py ran
logging.basicConfig(... FileHandler('bbs.log')) at the top of the
module, with 'bbs.log' as a relative path — so running from /tmp
opened /tmp/bbs.log, which existed left over from an earlier
root-run and refused write to the service user.

Two changes:

  • bbs.log is now resolved to an absolute path next to the
    installed package (or honours ANETBBS_LOG_FILE= if you want to
    point it elsewhere — journald-only setups can =/dev/null).
  • The FileHandler is best-effort: if open fails (read-only fs,
    permission denied, noexec mount), we log only to stdout and write
    a one-line warning to stderr. systemd captures stdout regardless,
    so journald-side logging is unaffected.

This unblocks anetbbs --version / anetbbs-web --version from
every CWD.

v287.31 — Quality-of-life follow-ups (May 2026)

Five field-driven fixes from one diagnostic session on bbs.a-net.fyi.

FTP listener now survives a leftover root-owned tree.
_build_symlink_tree was crashing the whole FTP thread on a single
un-unlink()-able symlink (left over from a previous run as a
different user — typical when migrating from root → service user).
Now caught with a warning that names the file + the exact chown
command to fix it, and the rest of the tree builds. Belt-and-braces:
update.sh chown -Rs data/ftp_root to the service user on every
run, so the situation can't drift.

SCC Restart buttons work again. Past releases shipped
anetbbs-web.service with
CapabilityBoundingSet=CAP_NET_BIND_SERVICE. That bounding set
prevents the gunicorn worker's sudo -n systemctl restart … child
from re-acquiring CAP_SETUID / CAP_SETGID / CAP_AUDIT_WRITE
from its setuid-root bit, so every Restart click failed with
"sudo: unable to change to root gid" + "error initializing audit
plugin sudoers_audit". Removed the bounding set; AmbientCapabilities
alone is enough to bind MSP/SYSTAT on privileged ports. update.sh
strips the line from any pre-existing unit and daemon-reloads
before Step 8 picks up the change.

BBS journal is quiet during SCC probes. v1.0a2.31 killed the big
session-loop tracebacks but the per-call print("Error sending telnet command: …") / print("Write error: …") one-liners were
still hitting stderr → systemd journal on every probe. Now caught as
expected BrokenPipeError / ConnectionResetError /
ConnectionAbortedError and swallowed; anything else is logged at
DEBUG. Also turned asyncssh's logger down to ERROR so its own
"socket.send() raised exception" warnings stop multiplying.

Username validation now matches the web form. Terminal
registration was using str.isalnum() so "Dr Test" came back
invalid and the user had to type "drtest" — but the web form has
never been that strict. Aligned the two: 3–80 chars, must start
with a letter or digit, allow spaces / . / _ / - / ' in the
rest. Whitespace runs collapse, leading/trailing whitespace strips.

Version is now visible everywhere.

  • New anetbbs/version.py reads VERSION once at import time.
  • anetbbs --version and anetbbs-web --version print and exit.
  • Every web page footer now shows the running version next to the
    copyright. ({{ anetbbs_version }} is exposed via a context
    processor, so any template can use it.)

v287.30 — FTP TLS soft-fail + SCC probe cleanup (May 2026)

Three follow-ups from the v1.0a2.30 field test on bbs.a-net.fyi.

FTP TLS now soft-fails instead of crashing the listener. The unit
referenced /etc/letsencrypt/live/bbs.a-net.fyi/{fullchain,privkey}.pem
but the archive/ directory was 0700 root-only, so the service user
couldn't actually read the cert. The previous check used
os.path.exists() — which returns True for an unreadable file — so
TLS_FTPHandler initialized, then crashed during SSL-context build,
killing the whole FTP thread silently. New code probes both files
with os.access(R_OK) + an open() round-trip, and if either fails
logs a clear warning ("add this user to the ssl-cert group OR copy
the cert to a readable path") and starts plain FTP instead.

FTP thread crashes now hit the journal. anetbbs/main.py only
caught exceptions from thread.start(), so any error inside the
thread's run() was lost. The wrapper now logger.exceptions with
the most-likely causes inline — sysop won't have to ask why FTP is
silent again.

BBS sessions no longer dump BrokenPipeError stacks during SCC port
probes.
The /admin/control/ page probes telnet/SSH/rlogin every
few seconds. Each probe greets the protocol then closes, so the
BBS's first writer.drain() raised BrokenPipeError and the
session loop dumped a 40-line traceback for every probe. start()
now treats BrokenPipeError / ConnectionResetError /
ConnectionAbortedError the same way it treats CarrierLost
silent unwind. Also removed a redundant writer.drain() from
clear_screen() (it ran after self.write() had already drained
and swallowed the error — so the second one bubbled).

SCC probe results are cached for 4 s. Opening N admin tabs (or
multi-poll dashboards) no longer multiplies probe load on the BBS.
First call per (host, port, proto) actually probes; subsequent calls
within 4 s read from an in-memory dict. With the BBS-side noise
suppression above, even uncached probes are now harmless — the cache
is just hygiene.

v287.29 — SCC: surface failing listener + FTP cap fix (May 2026)

Field report on v1.0a2.29: SCC banner said "1 Listener problems" but
the sysop couldn't tell which port was failing — the only signal
was a colored dot on the chip, easy to miss especially in a text
dump or screen reader.

Root cause (FTP). anetbbs.service is the unit that owns
telnet / SSH / rlogin / FTP / LMTP. Telnet 2233, SSH 2234, rlogin
5132 are all unprivileged ports — they bind fine as the stingray
user. FTP 21 is privileged and the unit didn't grant
CAP_NET_BIND_SERVICE, so the FTP listener silently failed to bind
on every startup. The other listeners with privileged ports
(anetbbs-web's MSP:18, anetbbs-finger's :79) already had the cap on
their respective units; this one didn't.

Update.sh patches the live unit in place. Adding the cap to the
template alone wouldn't help any existing install — the template
only runs on first install. The new logic detects an existing
anetbbs.service missing the cap, surgically sed-injects the
two lines after EnvironmentFile=, keeps a .bak backup, runs
systemctl daemon-reload, and lets Step 8 pick up the new caps on
restart. Sysop customizations elsewhere in the unit survive.

UI: named listener-status line per card. Every service card now
shows an explicit text row above the port chips:

  • All listeners up → green check All listeners up (4/4).
  • One or more down → amber/red triangle 3/4 listening — down: FTP:21.

So the sysop sees the failing listener by name without hovering for
a tooltip, and the colored-dot port chips remain as the visual quick
reference.

v287.28 — Release Downloads: rename + move to Files dropdown (May 2026)

Sysop polish on the new /downloads/ section:

  • Renamed everywhere from "Downloads" to "Release Downloads":
    page title, hero H1, and the nav-bar item. Makes intent clearer
    alongside the existing File Gallery / File Areas / File Shares.
  • Moved the nav entry from the Tools / More dropdown into the
    Files dropdown, where it sits with the other file-serving
    features. Added a divider above it so it visually groups separately
    from the user-uploaded-file features.

The on-disk directory path ({{ base_dir }}) shown in the hero is
already gated by current_user.is_admin — non-sysop visitors never
see it. No change needed there.

v287.27 — SCC: sudoers path mismatch + sudo-free reads (May 2026)

Field report from v1.0a2.27: every service card on /admin/control/
showed PID and state unknown, even though every service
was actually running and the live CPU / RAM / threads numbers were
populated. Banner said "0/4 Services up".

Root cause was a path mismatch between sudo's secure_path and the
sudoers rule. On Ubuntu 18.04+ sudo resolves systemctl to
/usr/bin/systemctl, but the rule we shipped said /bin/systemctl
sudo compares the resolved path literally against the rule (it
deliberately does not follow the /bin -> /usr/bin symlink, for
security). The rule never matched → every read returned "permission
denied" → the panel fell through to its unknown defaults. The
metrics sampler kept working because it shells out to plain
systemctl show without sudo (which any unprivileged user can do).

Two fixes:

  1. anetbbs/web/control.py split _systemctl() into
    _systemctl_read() and _systemctl_change().
    The read variant
    never invokes sudo — systemctl show / status and journalctl
    work for any user. Only start / stop / restart / reload still
    need sudo. This makes the panel correct regardless of which
    systemctl path sudo resolves to.
  2. deploy/sudoers.anetbbs now lists both /bin/systemctl and
    /usr/bin/systemctl paths
    , wrapped in a Cmnd_Alias to keep
    the file readable. Future Ubuntu / Debian / Fedora variations
    that pick either path are covered.

Side benefit: the "permission denied" toast on Restart now includes
sudo's stderr verbatim, so future path-mismatch problems surface
immediately instead of looking like a silent no-op.

v287.26 — SCC Phase 2 graphs + hub auto-self-register (May 2026)

Service Control Center — live graphs (Phase 2).

  • New anetbbs/web/metrics.py — single daemon thread in the gunicorn
    worker that samples each known systemd unit's MainPID every 2 s and
    stores the last 150 values (5 min) of CPU %, RSS in MB, and thread
    count in a per-(unit, metric) ring buffer. Reads /proc via psutil —
    no privileges, no sudo. Rebuilds the psutil.Process handle when
    MainPID changes so cpu_percent baselines reset cleanly across
    service restarts.
  • New endpoint /admin/control/metrics.json returns a JSON-serializable
    snapshot of every ring buffer. Soft-fails to available: false if
    psutil isn't installed, and the front-end shows a banner telling
    the sysop to run update.sh instead of a broken chart.
  • New endpoint /admin/control/connections.json rolls up per-protocol
    connection counts (web / telnet / SSH / rlogin) from NodeActivity +
    UserSession over the last 5 min.
  • Front-end: Chart.js 4.4 loaded only on the control page.
  • Per-card sparkline of CPU % over the last 5 min, colored per
    unit and matched against the aggregate charts.
  • Aggregate dashboard: two charts at the top of the page —
    "CPU % per service" (overlapping lines) and "Memory (MB)"
    (stacked area). Auto-refresh every 2 s.
  • Banner connection chips for web / telnet / SSH / rlogin
    counts, plus a new "Active connections" total tile.
  • Live metric row in every card showing the latest CPU %, RAM
    MB, and thread count alongside the existing PID + uptime.
  • Front-end summary counts (services-up, listener-problems) are now
    recomputed from the live /status.json payload on every refresh,
    so the banner stays correct after a restart that flips a unit's
    state without a page reload.

Federation hub — auto-self-register.

New module anetbbs/msp/hub_self_register.py. When
REGISTRY_MODE_ENABLED=true, the hub seeds its OWN RegistryEntry
row on startup (pre-verified, pre-approved, listed) and a daemon
thread refreshes last_heartbeat_at every 6 hours so the entry
never trips the 48-hour stale cutoff. Without this, the hub's own
/anetbbs.lst was empty of itself, so /imsg/directory never saw
the hub's own BBS on the directory pull — only the manually-pinned
"Your BBS" card from v1.0a2.26 surfaced it.

New config key (sensible default in code, not in .env.example):
- REGISTRY_HUB_SELF_HEARTBEAT_SEC — default 21 600 (6 h).

Public Downloads page (/downloads/).

Sysops can now drop a release tarball into a designated directory
and it auto-appears on /downloads/ — no copy to personal_pages/,
no manual link plumbing. Scans DOWNLOADS_DIR (default
{INSTALL_DIR}/data/releases) on every (cached) page load, sorts
newest-first, shows filename + size + mtime + MIME type, with a
Download button and a SHA-256 sidecar button per file.

Defense in depth:

  • Filename whitelist by extension (DOWNLOADS_EXTENSIONS, default
    covers archives, ISOs, checksum sidecars, and BBS-era text drops).
  • Filename regex blocks any name containing .. or other path
    separators.
  • realpath() check on the resolved file path before serving —
    symlinks and traversal can't reach outside the configured dir.
  • No subdirectory listing or recursion.

SHA-256 sidecars (<file>.sha256) are generated on demand the first
time they're requested and cached on disk next to the file, so
repeat hits don't re-hash multi-GB ISOs.

Three new config keys (defaults sensible — .env.example updated):

  • DOWNLOADS_ENABLED (default true)
  • DOWNLOADS_DIR (default {INSTALL_DIR}/data/releases)
  • DOWNLOADS_EXTENSIONS (default
    tar.gz,tgz,zip,7z,bz2,xz,rar,iso,img,asc,sig,sha256,md5,txt,nfo,diz)

Nav: added a "Downloads" item in the More dropdown when enabled.

Dependencies.

Added psutil>=5.9.0 to requirements.txt and setup.py. Also
backfilled aiosmtpd + aiosmtplib into setup.py (they were in
requirements.txt only, so fresh pip install -e . installs
missed them).

v287.25 — Sysop Service Control Center, Phase 1 (May 2026)

Pivoted the v1.0a3 roadmap: Email is on the back burner; the headline
feature is now a proper Service Control Center. Phase 1 ships the
foundation; Phase 2 (live graphs, psutil-driven CPU/memory ring buffer)
and Phase 3 (live log tail, threshold alerts) land in the next two
releases.

What changed in Phase 1:

  • Stale unit list fixed. anetbbs/web/control.py::KNOWN_UNITS was
    still listing the pre-merge anetbbs-telnet / anetbbs-ssh /
    anetbbs-rlogin units — the panel showed "unknown" for half the
    service tree and Restart buttons silently no-op'd because sudoers
    matched names that no longer exist. KNOWN_UNITS now matches the
    current four: anetbbs-web, anetbbs (unified terminal),
    anetbbs-mrc-bridge, anetbbs-finger.

  • Per-listener TCP/UDP probes. Each unit declares its associated
    ports; the panel probes them independently of systemctl, so the
    sysop can tell a healthy process from one where the listener
    crashed. TCP via socket.create_connection; UDP via /proc/net/udp
    scan (UDP can't be probed by connect() alone).

  • New /admin/control/status.json endpoint. Drives a 5-second
    live refresh of every card without a full-page reload.

  • Card-grid UI. Dark professional layout, status pill per service,
    per-port chips with green/red dots, restart/stop/start + log
    buttons. Replaces the cramped table-only layout.

  • Journal modal + .txt download. "Logs" opens an in-page modal
    with a line-count selector (100 / 500 / 2 000 / 5 000). Each card
    also gets a one-click .txt download of the last 2 000 lines for
    bug reports.

  • sudoers auto-refresh. update.sh now rewrites
    /etc/sudoers.d/anetbbs on every run, substituting the live service
    user into the canonical template. Past releases shipped a sudoers
    file with the pre-merge unit names — fresh installs got Restart
    buttons that 403'd. Templated and visudo-validated before swap so
    a syntax error can't lock the sysop out.

Inter-BBS Directory: "Your BBS" pin.

Sysops reported their own BBS not showing on /imsg/directory — the
directory only renders rows that exist in BbsDirectoryEntry (pulled
from Vertrauen's sbbsimsg.lst + the federation hub's anetbbs.lst).
For your own BBS to appear there it needs an approved RegistryEntry
on the hub. The hub at bbs.a-net.fyi hadn't yet auto-self-registered
its own row.

Quick UX fix: a pinned "Your BBS" card at the top of the directory
that's populated from local config (BBS_NAME, BBS_DOMAIN,
SYSOP_NAME, BBS_LOCATION, MSP_PORT, SYSTAT_PORT) — always
visible, with a status badge showing whether the BBS is Listed /
Pending / Not registered on the federation hub. Independent of
registry state, so it works on day-1 installs that haven't gone
through self-registration yet.

v287.24 — Terminal: carrier-drop spin + door-exit menu loop (May 2026)

Two related telnet/SSH bugs that both showed up as the same symptom —
a tight redraw loop that pinned a CPU core:

  1. Carrier drop at any menu spiked CPU. read_key and read_line
    returned '' for both "user pressed bare Enter" and "transport went
    away." The menu engine's if not choice: continue then redrew the
    menu and immediately re-prompted against an EOF stream — infinite
    loop. Now the four read primitives (read_raw, read_key,
    read_line, read_password) raise a new CarrierLost exception
    on EOF and on the idle-timeout path. session.start() and
    menu_engine.run_menu catch it and unwind cleanly; bare-Enter still
    returns '' so menu redraw semantics are unchanged.

  2. Game menu looped forever after exiting any door (LORD, etc.).
    door_runner cancelled its input/output pumps with t.cancel() but
    never awaited them. The in-pump's pending session.reader.read(1)
    was still registered as the StreamReader's waiter when the
    post-game Press Enter prompt fired — the two collided and the
    reader returned b'' on every subsequent read, which then tripped
    the same EOF-spin as bug #1 in the game menu. Now all three door
    paths (play_door_game_telnet, play_rlogin_telnet,
    play_dos_game_telnet) drain their pumps via
    asyncio.gather(..., return_exceptions=True) before the post-game
    prompt. Also switched the prompts from raw session.reader.readline()
    to the wrapped session.read_line() so leftover telnet IAC bytes
    from the door don't choke the prompt terminator.

The number-guessing game was unaffected — no door pump, so the
StreamReader was never put in a wedged state.

v287.23 — Echomail import: PATH-loop misfire fix (May 2026)

After v287.22 fixed the transaction rollback, 25k messages still didn't
land — only ~2,300 made it. The log finally surfaced the culprit:

INFO — Echomail loop: dropping msg with our addr 3/231 in PATH

The previous loop check rejected ANY inbound message whose PATH kludge
contained our address. But per FTS-0004, the sending tosser
correctly appends the destination address to PATH right before
shipping. So Mystic was putting 1337:3/231 (us) into PATH on every
single message destined for us — and our check dropped the entire
feed as a "loop."

Real echomail loop detection happens via:
- msg_id deduplication (per-area unique constraint) — covers the
"same message arrived twice" case.
- SEEN-BY checks during forwarding decisions — covers the
"don't relay back to a node that's already seen this" case.

Neither needs a PATH-based import filter. Removed the check entirely.

After this + the v287.22 savepoint fix + the v287.21 BinkP receive
rewrite, rescanning TQWnet should land the full 25k+ message archive
into the proper TQW_* areas.

v287.22 — Echomail import: per-message savepoint (May 2026)

After v287.21 wired up correct BinkP receive, ~50,000 messages parsed
out of a TQW rescan but the per-area count stayed at 0. The poller
log showed:

ERROR — Poller loop error: This Session's transaction has been
rolled back due to a previous exception during flush. To begin a new
transaction with this Session, first issue Session.rollback().
Original exception was: Can't reconnect until invalid transaction is
rolled back.

Root cause: _import_message was adding every message to the same
SQLAlchemy session and the loop committed only after all messages
landed. ONE bad row (length overflow, FK mismatch, unique-clash race
on msg_id) raised on the next flush and left the session in 'invalid'
state. Every subsequent add silently piled onto the broken transaction,
and the final commit() rolled back the ENTIRE batch. 50k messages
vanished even though every one of them was structurally a valid
FTS-0001 packet.

Fix: wrap each insert in a with db.session.begin_nested():
(SAVEPOINT). A bad row now rolls back only its own savepoint; the
outer transaction stays valid; the next message inserts cleanly. Plus:

  • Length-truncate the inbound fields to their column maxes
    (from_name[:120], subject[:200], etc.) so the most common cause
    of IntegrityError — a misbehaving sender exceeding column lengths
    — silently truncates instead of crashing.
  • Force db.session.flush() inside the savepoint so the constraint
    check happens NOW, not at commit-time half a batch later.
  • Log a single WARNING per skipped message with msgid + from-name +
    the underlying exception, so a future "where did message X go?"
    trace is one grep away.

After this fix, rescanning the same 50k batch should land them in
their proper areas. The user's existing %RESCAN gave no new messages
because Mystic already shipped them once; need another %RESCAN to
re-pack.

v287.21 — BinkP CLIENT: real fix for ZIP-wrapped mail (May 2026)

Root cause of the entire TQWnet-not-flowing saga. v287.20 fixed the
extension regex in the BinkP LISTENER (binkp_server.py) but the
outbound POLLER uses binkp.py (the CLIENT side) which had a much
worse bug: file-completion was detected by looking for \x00\x00 at
the tail of the data stream — the FTS-0001 raw-packet end marker.
Mystic (and most modern hubs) ship echomail as ZIP-wrapped bundles
which never end in \x00\x00. So:

  • 5 files arrived ✓ (logged as BinkP: receiving file ...)
  • 0 files reached the "completion → parse → ACK" path ✗
  • 0 messages imported, 0 M_GOT ACKs sent to the hub
  • Poller: tqwnet — sent=0 received=0 every cycle

Rewrote _receive_messages to:

  • Parse the byte-count from CMD_FILE (name size mtime offset) and
    detect completion by byte-count, not by content marker.
  • Dispatch the completed file: raw FTS-0001 → parse; ZIP → unzip and
    parse each packet member; anything else → stash to
    data/binkp/inbound for the TIC scanner.
  • Log BinkP: imported N msg(s) from <file> so the sysop sees progress.
  • Send M_GOT promptly so the hub stops re-queueing.

Same defensive content sniffing as the listener side. Loop budget
raised from 500 to 5000 frames so very fat batches (a year-of-rescan
in one session) don't truncate.

Recovery: after deploying, send another %RESCAN R=5000 to the
TQW hub. Mystic will re-pack the 15,038 / 26,909 / whatever messages
and this time they'll actually land.

v287.20 — BinkP: day-of-week bundle extensions + persistent inbound (May 2026)

Mystic hubs deliver bundled mail to nodes using FTS-5003 day-of-week
extensions: .mo[0-z] (Monday), .tu[0-z] (Tuesday), … .fr[0-z]
(Friday), .sa[0-z], .su[0-z]. Our acceptor regex only covered
Wednesday (.we[0-9a-f]) — every other day's mail got silently
filed to the inbound dir and ignored by the TIC scanner. StingRay's
%RESCAN of 26,909 messages dropped on the floor as .frk through
.fro (Friday bundles k-o).

Also fixed two adjacent issues that made this hard to diagnose:

  • BINKP_INBOUND_DIR defaulted to /tmp/binkp-inbound — tmpfs on
    most Linux distros, so unrecognized files vanished on every service
    restart. New default: data/binkp/inbound (persistent).
  • No log line when a file failed to match anything. Now logs an INFO
    line on every unrecognised file: filename, size, where it landed.
    Future "where did my mail go?" debugging is one journalctl | grep
    away.

Recovery for the v287.20 deploy: after deploying + restarting, the
sysop should send another %RESCAN to the hub — Mystic will re-bundle
and the new regex will now accept the resulting .fr* files.

v287.19 — Federation self-registration client (May 2026)

Day 4 of the federation build. Completes the federation loop:
- v287.13 added the hub side (accept registrations).
- v287.15 added the puller (read anetbbs.lst → BbsDirectoryEntry).
- v287.19 (this) adds the self-register / heartbeat client so a
brand-new ANetBBS install can opt in to the federation directory by
flipping one flag in .env.

How it works:

  1. Set REGISTRY_SELF_REGISTER=true, BBS_DOMAIN=<your-public-hostname>,
    SYSOP_EMAIL=<sysop-inbox>, and the friendly fields (SYSOP_NAME,
    BBS_LOCATION) in .env.
  2. On service start, a daemon thread POSTs /registry/api/v1/register
    to the configured REGISTRY_URL (default https://bbs.a-net.fyi).
  3. The hub returns a verify token + URL, which we persist to
    data/registry_state.json (sysop-private, not committed).
  4. Daily, the thread heartbeats to keep last_seen current. If the
    hub 404s us (we got removed, or rehosted), it falls back to a
    full re-register.
  5. New admin page /admin/registry/self shows: hub URL, our
    metadata, last hub response, the verify URL (so the sysop can
    click it without digging through gunicorn logs), and a "Register /
    Heartbeat Now" button for manual ticks.

Three new config keys: SYSOP_NAME, SYSOP_EMAIL, BBS_LOCATION.
Required for self-registration; otherwise harmless metadata.

v287.18 — Dialout: telnet IAC + raw key reads (May 2026)

First bug filed against the pre-alpha public release. The dialout
feature (terminal → another BBS via outbound telnet) was broken in
two visible ways:

  1. No ANSI rendering on the remote — we never negotiated the
    telnet protocol, so the remote BBS asked our terminal "do you
    support TTYPE / BINARY / NAWS?" and got no reply. It fell back
    to dumb-terminal mode and stripped ANSI escapes from everything
    it sent back.
  2. Single keypresses didn't reach the remote_proxy read the
    user's input via session.read_line(), which is line-buffered and
    blocks until Enter. Hotkeys (ESC, *, menu shortcuts, bot-defense
    challenges) never made it across until the user pressed Enter.

Rewrote _proxy in anetbbs/features/dialout.py with:

  • Minimal telnet IAC state machine on the remote→user direction
    that strips protocol bytes, handles DO/DONT/WILL/WONT, responds
    to subnegotiation (TTYPE → "ANSI"), and announces our capabilities
    up-front so the remote enters full-ANSI mode.
  • Raw single-byte reads via session.read_raw(1) on the user→
    remote direction. Each keypress is shuttled immediately, IAC bytes
    are doubled per RFC 854.
  • Ctrl+] escape actually works now — was a half-implemented dead
    constant before. Ctrl+], Q to quit; any other key resumes.

v287.17 — Admin user delete cascade fix (May 2026)

Admin → Users → Delete returned 500 Internal Server Error because
UserSession.user_id is NOT NULL but the relationship had no cascade.
SQLAlchemy tried UPDATE user_sessions SET user_id=NULL to detach the
session before deleting the user, which the constraint rejected.

Fixed by switching the backref to db.backref('session', uselist=False, cascade='all, delete-orphan') so the session row is deleted instead
of unlinked. Bonus: corrects the relationship cardinality (UserSession
is 1:1 with User via the unique=True user_id, so uselist=False is
the right shape anyway).

v287.16 — SYSTAT now sees web users (May 2026)

Peer BBSes querying /imsg/directory/<host>/who against ANetBBS hit
SYSTAT (UDP/11), which historically read only the NodeActivity table
— the multi-node terminal slot tracker. Any user signed in via the web
front-end (the majority on ANetBBS) lived in UserSession and was
invisible to SYSTAT. Result: "Who's online" pages on peer BBSes
showed No users currently active. even when the BBS was busy.

Fixed by unioning NodeActivity (terminal) + UserSession (web)
inside _build_response. Dedupes on username so a user logged in via
both transports counts once. Web sessions get synthetic slot names
web1, web2, ... and their page paths are sanitized through the
same _friendly_where() map as /who/ so peer BBSes don't learn the
exact URL each user is on.

v287.15 — anetbbs.lst → BbsDirectoryEntry pull (May 2026)

Bridges the federation registry into the existing inter-BBS IM
directory. Without this, peers registered against the hub appeared in
anetbbs.lst but not in /imsg/directory/, because that view
reads from BbsDirectoryEntry (historically populated only by
Vertrauen's sbbsimsg.lst for Synchronet hosts).

  • New module anetbbs/msp/anetbbs_directory.py — pulls
    REGISTRY_URL/anetbbs.lst daily, upserts each peer into
    BbsDirectoryEntry with source='anetbbs'.
  • New columns on bbs_directory: sysop, location, software,
    software_version, msp_port, systat_port, source. Synchronet
    rows keep their source='sbbsimsg'; ANetBBS rows get 'anetbbs'.
    _lightweight_migrate adds the columns automatically.
  • Pruning: rows with source='anetbbs' that disappear from the
    upstream list get deleted on the next refresh. Synchronet + manual
    rows are left untouched (owned by other refresh paths).
  • /imsg/directory/ template now shows Software and Sysop /
    Location
    columns with a badge per BBS family (ANetBBS = blue,
    Synchronet = grey).
  • Refresher runs in a daemon thread on every install with
    REGISTRY_URL set (default https://bbs.a-net.fyi), independent of
    the hub-mode flag.

v287.14 — Registry CSRF hotfix (May 2026)

v287.13 shipped the registry API but the POST /registry/api/v1/*
endpoints required a CSRF token — fine for browser forms, not for
peer ANetBBS hosts calling the API. First attempt to register against
the live hub returned 400 The CSRF token is missing.. Fixed by
exempting the entire registry blueprint from CSRF protection at
register-time (csrf.exempt(registry_bp) in web_app.create_app).

Admin-side routes at /admin/registry/* still require CSRF because
they're part of the admin blueprint, which keeps its protection.

v287.13 — Federation registry, days 2-3 (May 2026)

First two days of the v1.0a3 federation registry build. Adds the
"central hub" half — the side that accepts registrations + emits
anetbbs.lst. The peer-side client (auto-register + heartbeat + daily
pull) lands in v287.14 tomorrow.

Day 2 — registry API:
- New RegistryEntry model (registry_entries table)
- New anetbbs/web/registry.py blueprint:
- POST /registry/api/v1/register — peer announces, gets verify token
- POST /registry/api/v1/heartbeat — daily keep-alive + soft-metadata update
- GET /registry/verify/<token> — sysop confirms ownership via email link
- GET /anetbbs.lst + GET /registry/api/v1/list — JSON of listed peers
- Rate limits: per-host (5s register / 10s heartbeat) + per-IP hourly caps
- Hub-mode gate (REGISTRY_MODE_ENABLED=true) — endpoints 404 on non-hub installs

Day 3 — sysop admin UI + prober:
- /admin/registry/ — full approval queue UI:
- Counts cards: pending verify / pending approval / listed / total
- Approve / reject / edit / delete per-entry
- Inline edit form for soft metadata (name, sysop, location, ports, notes)
- Approve button gated on is_verified=True (can't approve unverified
entries — prevents drive-by sysop approval of typo'd emails)
- Reject de-lists but keeps the row; delete is one-click-with-confirm
- New anetbbs/msp/probe.py — periodic SYSTAT prober:
- Runs in a daemon thread inside the hub's web service
- Probes every approved+verified+active entry on
REGISTRY_PROBE_INTERVAL_SEC (default 1 hour)
- Drops is_listed=False after REGISTRY_PROBE_FAILURE_THRESHOLD
consecutive failures (default 3)
- Auto-re-lists if a previously-dropped entry starts probing OK again
- Admin nav link added under Subsystems

Two acceptance gates before an entry shows up on the public list:
email verification + sysop approval. Designed to keep
anetbbs.lst clean even if the hub is publicly exposed.

v287.12 — FTP user docs (May 2026)

The FTP server shipped in v287.8 but the user docs hadn't caught up.
Updated:

  • docs/PORTS.md — new rows for FTP control (21) + passive range
    (40000-40050), updated privileged-ports section with the systemd
    drop-in recipe for CAP_NET_BIND_SERVICE, added the iptables rule.
  • docs/07-file-areas.md — new FTP access section explaining the
    three permission tiers (anonymous / authenticated / sysop) and how
    uploads create FileUpload rows.
  • docs/01-installing.md — port list updated.
  • docs/00-overview.md — architecture diagram now shows FTP next to
    telnet/SSH/rlogin inside anetbbs.service.
  • README.md — front-page protocol list mentions FTP + IFC.
  • FEATURES.md — protocol table row.

v287.11 — FTP settings in /admin/settings (May 2026)

Added eight FTP rows to the sysop settings page so the config matches
the other protocol settings (telnet / SSH / rlogin) rather than being
.env-only:

  • FTP_ENABLED
  • FTP_PORT
  • FTP_ANON_ENABLED
  • FTP_PASV_PORTS
  • FTP_TLS_CERTFILE
  • FTP_TLS_KEYFILE
  • FTP_ROOT_DIR
  • FTP_BANNER

All marked requires_restart=True (same as the other protocol toggles)
because the FTP daemon binds its ports at startup.

v287.10 — FTP: hide server-side path in symlink listings (May 2026)

LIST output was leaking the absolute server-side storage_path to
every client (including anon) as the symlink target:

lrwxrwxrwx 1 anetbbs anetbbs 39 May 15 01:34 FILES.GAMES -> /home/stingray/anetbbs/data/files/games

That exposes internal directory structure and could help a remote
attacker fingerprint the install. Fixed by overriding lstat() in
SymlinkAwareFS to use os.stat() instead, so symlinks resolve
through and look like regular directories to the FTP client:

drwxrwxr-x 2 anetbbs anetbbs 4096 May 15 01:34 FILES.GAMES

v287.9 — FTP hotfix: don't break telnet/SSH login (May 2026)

v287.8 shipped FTP integration that broke telnet/SSH login with
Session error: cannot notify on un-acquired lock. Root cause:
anetbbs.main was calling anetbbs.web_app.create_app() to obtain a
Flask app for the FTP daemon thread — but that path registers all
~50 blueprints AND starts the echomail / RSS / MSP / SYSTAT
background pollers, each of which uses threading primitives. Turning
the previously pure-asyncio terminal-server process into a mixed
asyncio+threading process broke the threading.Condition semantics
that the login flow's session lock relies on.

Fixed by adding anetbbs.ftp.server.build_minimal_app() — a 5-line
Flask app builder that initializes only db.init_app(app) and the
config. Zero blueprints, zero pollers. anetbbs.main now uses this
instead of create_app(), so the FTP thread has just enough to do
User.check_password + FileUpload writes without dragging the
full app surface into the process.

Verified post-fix:
- Minimal app: 0 blueprints registered, DB queries work.
- SSH/telnet login no longer raises the lock error when FTP is enabled.
- FTP server still serves anonymous + authenticated correctly.

v287.8 — FTP server (May 2026)

FTP front-end. A new anetbbs/ftp/ module serves the existing
FileArea tree to the internet, completing the four-protocol set
(web / telnet / SSH / rlogin / FTP). Earns you the FTN nodelist IFC
flag (Internet File transfer Capability) once you advertise it.

Architecture:

  • pyftpdlib backend. Mature, async-friendly. Drives the whole wire
    protocol — we add the auth + filesystem + upload-tracking layers.
  • Auth via existing User.check_password — same bcrypt as web /
    telnet / SSH. Anonymous login is enabled by default (FTP_ANON_ENABLED=true).
  • Three-tier symlink trees at data/ftp_root/{anon,users,admin}/,
    rebuilt on every server start:
  • anon/ — public areas (is_active AND NOT is_sysop_only), read-only.
  • users/ — non-sysop-only areas, full r/w subject to per-area perms.
  • admin/ — every active area including sysop-only.
    Each tree is just symlinks pointing at FileArea.storage_path. The
    authorizer maps the right tree onto each session at login.
  • SymlinkAwareFS — pyftpdlib's default AbstractedFS uses
    os.path.realpath() for path safety, which dereferences symlinks and
    treats every CWD through one as "outside the user's home." We
    override realpath()abspath() and validpath() to compare
    against abspath — preserves .. traversal safety while letting our
    deliberate symlink tree work.
  • Upload tracking. on_file_received hook creates a FileUpload
    row keyed to the parent directory's FileArea, so files uploaded
    via FTP show up in the web UI's file-area browser. Per-area
    upload_permission enforced post-hoc: if the user lacks permission
    the file is deleted and the violation logged.
  • Optional FTPS — set FTP_TLS_CERTFILE + FTP_TLS_KEYFILE (reuse
    the same Let's Encrypt cert nginx uses) and connections can AUTH TLS
    on the same port.
  • Passive port range configurable via FTP_PASV_PORTS (default
    40000-40050). Open + forward those on the firewall.
  • Process integration. Runs in a daemon thread inside the existing
    anetbbs.service — no new systemd unit. Driven by FTP_ENABLED in
    the .env file. If pyftpdlib is missing, the server logs a warning
    and skips startup instead of crashing the BBS.

Verified end-to-end with curl:
- Anonymous lists only public areas, can download.
- Anonymous upload is denied at the auth layer (perm string is elr).
- Authenticated user sees non-sysop-only areas; admin sees all.
- Authenticated upload lands on disk and creates a FileUpload row.

v287.7 — Nodelist auto-import + send-netmail-to-sysop (May 2026)

Two enhancements inspired by Craig Hendricks's (codefenix) NetLister
door for Synchronet (https://conchaos.synchro.net):

  • FileArea → Nodelist auto-import. New is_nodelist_source +
    nodelist_domain columns on file_areas. When the sysop flags an
    area (e.g. Z1DAILY for FidoNet, tqwinfo for TQWnet) and sets a
    domain, every inbound TIC for that area is unwrapped (ZIPs included
    — TQWnet's tqwnet.zNN style works without naming the .zip
    extension) and auto-imported into the Nodelist table. Tagged by
    domain so /nodelist/?domain=tqwnet filters work. Verified
    end-to-end against real-world archives — tqwnet.z46 (145 entries),
    fsxnet.zip (323), Z1DAILY.ZIP (1208).
  • Send-Netmail-to-Sysop button on /nodelist/<id>. One click jumps
    to the existing netmail composer with to_address + to_name
    pre-filled from the nodelist row. The compose route already auto-picks
    the FROM AKA whose zone matches the destination, so clicking a TQWnet
    entry composes from your 1337:3/231 AKA, a fsxnet entry from your
    21:1/100 AKA, etc.
  • Bulk-import admin route at /nodelist/admin/bulk-import. Scans a
    configurable directory (default data/nodelists, override via
    NODELIST_SCAN_DIR), lists every plausible nodelist file or ZIP,
    and lets the sysop tick + tag → import in a single submit. Useful
    for first-run when the sysop already has a stash of nodelist
    archives on disk (e.g. from infopack downloads).
  • import_from_path() is the new public entry point in
    anetbbs/echomail/nodelist.py. Accepts text files, archives, or
    ZIPs with non-standard extensions (.z46, .a07, etc — detected by
    magic bytes, not suffix). Picks the highest-priority nodelist member
    from a ZIP using _looks_like_nodelist() heuristics.

v287.6 — /who/ privacy + inbound netmail status (May 2026)

Two fixes surfaced while a sysop was watching their own /who/ page:

  • /who/ "Where" column sanitized for non-admins. The column used
    to leak the exact URL each user was viewing (e.g. /echomail/53/25407)
    to every other logged-in user. Anyone could copy-paste the link and
    follow someone around. Sysops still see raw paths; non-admins now see
    a coarse area label ("Echomail", "Profile", "Boards", "MRC Chat", …).
    The map is _WEB_AREA_LABELS in anetbbs/web/who.py — extend as new
    blueprints land. Telnet/SSH/rlogin sessions are unaffected (their
    where is already a friendly menu/game name, not a URL).
  • Inbound netmail status was sometimes draft. The listener path
    (binkp_server.py) correctly set status='received' on incoming
    netmail, but the poller path (poller.py, where we dial out to
    pull mail) was creating NetmailMessage rows without setting status —
    so they inherited the model's draft default (which is right for the
    compose flow but wrong for inbound). AREAFIX responses received via
    poll-out vanished from the sysop's inbox UI as a result. Fixed:
    poller.py:458 now sets status='received' + received_at. Existing
    stuck rows can be backfilled with
    UPDATE netmail_messages SET status='received', received_at=created_at WHERE direction='inbound' AND status='draft';

v287.5 — profile form nested-<form> fix, deploy-script path bomb (May 2026)

Two more profile-edit bugs found while v287.4 was being deployed:

  • Profile edits never submitted at alltemplates/profile/edit.html
    had a nested <form action="/profile/avatar/remove"> inside the main
    profile form. HTML forbids nested forms; browsers auto-close the outer
    form at the inner <form> tag, so the Privacy / Signature / Tagline /
    Theme dropdown / Update Profile submit button ended up outside any
    form. Clicking Update did literally nothing — no POST left the browser,
    no field ever saved. Why nobody's users.theme_id was ever non-NULL.
    Fixed by lifting the avatar-remove form out of the main form and
    pointing the Remove Avatar button at it via the HTML5 form= attribute,
    so the visual layout is unchanged but the markup is now valid.
  • anetbbs-deploy-latest.sh step 4.5 path bomb — the script was
    copying deploy/*.service straight into /etc/systemd/system. The
    source units ship /opt/anetbbs as the install path, but real installs
    often live elsewhere (e.g. /home/stingray/anetbbs). After a deploy,
    systemd would try to exec /opt/anetbbs/venv/bin/gunicorn, fail with
    status=203/EXEC, and crash-loop the web service. Step 4.5 now rewrites
    /opt/anetbbs$INSTALL (the script's own install-path variable)
    before installing the unit. Existing live boxes that already got
    clobbered need a one-time sudo sed -i 's|/opt/anetbbs|/home/stingray/anetbbs|g' /etc/systemd/system/anetbbs*.service && sudo systemctl daemon-reload.

v287.4 — profile form + nginx upload limit (May 2026)

Two reported bugs in one cut:

  • Profile edits silently failed for anyone with a .local email.
    WTForms Email() delegates to email-validator 2.x, which rejects
    RFC 6761 special-use TLDs (.local, .test, .invalid, .example).
    The seeded admin user is admin@anetbbs.local, so every profile save
    — including the theme dropdown — bounced off email validation before
    reaching the DB. Theme picker looked broken, but the real problem
    was upstream. Replaced Email with a permissive regex validator
    (anetbbs/web/validators.py:PermissiveEmail) in auth.py,
    profile.py, and admin.py. Any RFC-shaped address now passes,
    including FidoNet-style aliases and internal-only TLDs.
  • Avatar uploads under 2 MB returned 413 Request Entity Too Large.
    The bundled nginx template (deploy/anetbbs-nginx.conf.template)
    inherited nginx's 1 MB default client_max_body_size. Added an
    explicit client_max_body_size 110m; directive (covers the 100 MB
    UPLOAD_MAX_SIZE plus headroom). On upgrade, regenerate the live
    nginx config or add the directive by hand inside the server {}
    block and nginx -t && systemctl reload nginx.

v1.0b.1 — branding consistency pass (May 2026)

Tiny but everywhere: replaced ANET BBS / Anet BBS / ANet BBS /
anet bbs / ANET-BBSANetBBS across 61 source files.
User-visible places include the "Powered by …" footer line, default
BBS_NAME fallbacks, systat banner, copyright lines, log messages,
admin tool descriptions, etc.

Functional code unchanged. Just one consistent spelling.

v1.0b — alpha 2 (May 2026)

Cut from internal build v287.1. Bundles every internal release from
v197 through v287.1; the per-build notes below capture the granular
changes. Headline additions:

  • LORD ships pre-installed and plays end-to-end under the Node +
    Synchronet compat shim (v283.x–v287 added the deep API stubs the
    game exercises).
  • Wiki at /wiki/ with revision history, diff, search,
    wanted/orphan reports, 41 seeded pages (v284).
  • RSS reader — web + terminal (v281).
  • Web doors at 80×25 with the real CGA palette, plus pre-join
    output buffering so welcome screens render immediately (v287, v287.1).
  • NodeSpy kick for stuck terminal sessions, cross-process via
    DB-flag/poll (v283.x).
  • Echomail terminal reader properly paged with [Q]=quit,
    CP437 body passthrough (v283.5–v283.7).

v287.1 — Web doors: buffer pre-join output (May 2026)

Some doors (ANetSIMS, A-Net Sixel TV, anything that draws a welcome
screen then waits for input before the user does anything) showed
nothing in the web terminal until the user pressed Enter. The
keystroke produced new output which then DID appear. Telnet / SSH /
rlogin worked fine because they don't have this race.

Root cause was a race in web/games.py:handle_start_game:

  1. launch_door_game() starts the PTY-reader thread synchronously.
    Within milliseconds the reader calls _emit_output(welcome_bytes).
  2. At that instant sid_box[0] is still None (set on the next
    line), so _emit_output returned early — bytes lost.
  3. Even after sid_box[0] was set, the next emit went to
    room=str(session_id) — but join_room() hadn't run yet, so the
    client wasn't in the room. Also dropped.
  4. First user keystroke → door writes more output → by then
    join_room() had run → bytes flow.

Fix: _emit_output now appends to a pre-join buffer (under a lock,
the reader is on its own thread) while a _buffering flag is true.
After launch_door_game returns, sid_box is set, the room is
joined, game_started is emitted, and _flush_pre_join_buffer()
drains the buffer as one chunk and flips the flag — subsequent
reads bypass the buffer and emit directly.

v287 — Web door terminal: pin to 80x25, real CGA palette (May 2026)

After LORD saved/loaded cleanly in v286.10 there were two remaining
visual issues:

  1. ANSI art bled into menus. Violet's portrait and her flirt
    menu appeared overlapped. Cause: play_terminal.html started the
    xterm at 80×24 but immediately ran fitAddon.fit() to fill the
    viewport — typically 150+ columns. LORD's gotoxy(50, 12) then
    landed at the wrong column and the menu overprinted the art.
  2. Gray where colors should be. xterm.js's default red/green/blue
    are web-ish midtones, not the CGA primaries doors are written for.

Fixes:

  • Pin xterm.js to 80×25 in play_terminal.html. Removed the fit
    addon and the window-resize handler. Wrapped the terminal in a
    centered, shrink-to-fit container so it sits cleanly in any
    browser width.
  • Real CGA palette — full 16 colors set on the xterm theme.
    Matches what door authors saw on a PC (DOS bright red is
    #ff5555, not whatever the browser thought).
  • screen_rows = 25 in synchronet_compat.py (was 24).
  • PTY initial winsize 80×25 in door_runner.py. Some kernels
    default new ptys to (0, 0) — without an explicit TIOCSWINSZ
    the door reads console.screen_rows as 0 and gotoxy breaks.
  • Font bumped to 16px Cascadia Mono / Consolas / Courier New for
    a sharper CP437 render.

v286.10 — LORD: File.write at-cursor + exit-hook silence (May 2026)

CriticalFile.prototype.write(str, len) in the compat shim
was this._content += strappend to EOF, ignore position.
But recordfile.js calls this.file.write(wr, len) for every
String / Date / Float field at a seeked record offset
(this.file.position = rec * RecordLength then a chain of writes).
Every string field on every record landed at the file's tail
instead of its proper slot. That's why the player-list rankings
showed character-name fragments mixed with timestamps, and
nonsense XP values: not corrupt bytes, just bytes in the wrong
columns.

Fixed write() to honor _pos and overwrite at the cursor
(padding with NULs if seeked past EOF), matching writeBin/writeStr.
writeln() now goes through the same path.

Also silenced the cosmetic [BBS] exit hook failed: player is not defined line that prints at every clean quit — LORD's
js.on_exit cleanup references variables that are already out
of scope by exit. The hook's purpose was already-redundant
cleanup; swallow the error. Set BBS_DEBUG_EXIT_HOOKS=1 if you
ever want to see them.

Save data after this update: old player.bin / state.bin
files were written with the broken layout. They'll READ wrong
in the new code (and may corrupt further on write). Recommended
to delete and start fresh:

sudo -u stingray rm -f \
  /home/stingray/anetbbs/anetbbs/games/sbbs_doors/lord/state.bin \
  /home/stingray/anetbbs/anetbbs/games/sbbs_doors/lord/player.bin \
  /home/stingray/anetbbs/anetbbs/games/sbbs_doors/lord/*.lock

v286.9 — LORD: check_gameover null-deref guard (May 2026)

Same pattern as v286.8's flirt-with-Violet patch:
check_gameover runs at the top of every door entry. If
state.won_by >= 0 (someone "won"), it does
wb = player_get(state.won_by) then immediately wb.name.
When the referenced player record is gone, the door crashes
with Cannot read property 'name' of null — and the next
entry crashes again because the bad flag is still in state.

Patched lord.js to null-check wb, reset state.won_by = -1,
put_state(), and let the door continue. The game effectively
re-opens for play instead of being stuck on a perpetual "game
over" screen.

v286.8 — LORD: flirt-with-Violet null-deref guard (May 2026)

In-game crash patch: flirt_with_violet() assumed
player_get(state.married_to_violet) always returns a record,
and dereferenced op.name directly. If state.married_to_violet
points at a stale record (record deleted between marriage and
the next flirt; or our fresh state file has a non-(-1) default),
player_get returns null and the door bombs with
Cannot read property 'name' of null.

Patched lord.js to:
- Check op === null after player_get
- Reset state.married_to_violet = -1 and put_state() (so the
flag stays cleared after the door exits)
- Show a generic Grizelda-kisses-you-painfully line instead of
the personalised you curse <name> text

This is an upstream LORD-JS bug we should ideally push back; for
now the patch is marked // ANetBBS patch: in the source for
easy review.

v286.7 — LORD: File.readBin / writeBin / Str + full mode parsing (May 2026)

Next missing piece: RecordFile.writeFieldthis.file.writeBin(val, N).
Synchronet's File has typed-binary I/O (readBin(N) / writeBin(value, N))
for fixed-width LE unsigned integers, plus readStr(N) / writeStr(s, N)
for fixed-width strings. recordfile.js (the library LORD uses to store
the per-record game state in lord.dat etc.) wires every field through
them.

Added in this round:

  • File.prototype.readBin(bytes) — read N-byte LE unsigned int
  • File.prototype.writeBin(value, bytes) — write same, overwriting at pos
  • File.prototype.readStr(n) / writeStr(str, n) — fixed-width strings
    with space-pad/truncate semantics

Also overhauled File.open(mode) to honor the full fopen language:
'r', 'w', 'a', 'r+', 'w+', 'a+' (with the 'b' suffix
ignored — same as glibc). Previously only the presence of 'w' was
detected, so opening rb+ was silently treated as read-only and writes
never flushed. Now tracks _can_write separately from the truncate /
append flags, and writeBin/writeStr/flush/close all gate on that
single signal.

v286.6 — LORD: File.lock / unlock / flush / truncate (May 2026)

After file_mutex, LORD's next stop was RecordFile.lock()
this.file.lock(rec*RecordLength, RecordLength). recordfile.js
uses Synchronet's File range-locking on every record open/close.
Single-node ANetBBS has no contention so:

  • File.prototype.lock(start, length) → returns true (always grant)
  • File.prototype.unlock(start, length) → returns true
  • File.prototype.flush() → persists in-memory _content to disk
  • File.prototype.truncate(n) → trims _content and the on-disk
    file (LORD uses this for the "reset save" path)

All added to synchronet_compat.py File prototype.

v286.5 — LORD: file_mutex stub (May 2026)

After v286.4 cleared the RIP-probe stall, the next missing
built-in tripped: ReferenceError: file_mutex is not defined at
LORD's first get_state() call. Synchronet's file_mutex is an
atomic single-writer lock primitive — creates a .lock file
carrying an owner identity, returns false if a peer holds it.

Single-node BBSes never contend, so a stub that always grants
the lock (and writes contents if provided, since LORD uses
the lock file as a write-once data drop for things like war
reports, mail messages, and fairy logs) is enough.

Stub added to synchronet_compat.py, exposed on globalThis.

v286.4 — LORD: skip the 10-second RIP probe (May 2026)

The DOS LORD had a /NORIP command-line flag to disable the RIP
terminal-detection probe. The JS port doesn't — it unconditionally
sends \x1b[!\x1b[6n and blocks read_str(10000, /RIPSCRIP/) for
up to ten seconds waiting for a RIPSCRIP response that no modern
terminal (xterm.js, SyncTERM, NetRunner, mTelnet) sends. That's
the 10+ second stall users felt before the welcome ANSI appeared.

Patched anetbbs/games/sbbs_doors/lord/lord.js to comment out
the probe + the if-block that loads RIP icons. rip stays false
(its var default), the welcome screen and main menu render
immediately. To re-enable: uncomment the block — it's marked with
// ANetBBS patch: in the source.

v286.3 — LORD: input plumbing actually works now (May 2026)

v286.2 cached Queue("name") so same-name calls returned the same
instance — but missed the case where two scripts pass DIFFERENT
suffixes:

  • dorkit.jsnew Queue("dorkit_input" + bbs.node_num)
    "dorkit_input1"
  • ansi_input.jsnew Queue("dorkit_input" + (argv[0] ?? ''))
    "dorkit_input" (argv is empty in our shim)

Different names = different cache entries = still two queues =
bytes still nowhere. Fixed in sbbs_stubs/dorkit/sbbs_input.js by
explicitly re-pointing ai.input_queue = dk.console.input_queue
after loading ansi_input.js, so processed keystrokes definitely
land where dorkit polls.

v286.2 — LORD: input + draw-speed fix (May 2026)

Two issues from a live launch of v286:

  1. Input did nothing. LORD's welcome screen drew but
    keystrokes were eaten. Cause: Synchronet's Queue("name")
    is a named IPC channel — two new Queue("dorkit_input"+N)
    calls in different scripts (dorkit.js + ansi_input.js) bind
    to the same wire. Our shim's Queue was a plain JS class, so
    the two became separate objects. ai.add(byte) wrote
    processed keystrokes into instance B; dorkit polled instance A.
    Forever empty. Fixed by name-caching: same name returns the
    same Queue instance.

  2. Welcome screen drew slowly. ~20 s on the live BBS. Cause:
    dk.console.print writes to BOTH local_io and remote_io.
    In sbbs mode local_io is unused — but our compat forces
    local_console.js to load unconditionally (it's required at
    the bottom of dorkit.js), leaving local_io defined. Every
    print byte went through a 24×80 Screen grid with per-cell
    setCell updates. Patched sbbs_console.js to
    delete dk.console.local_io so writes go straight to
    remote_io → stdout.

Test instructions if you're scripting smoke tests against the
shim: keep the test window >= 12 s for LORD's start path — it
has a hard-coded 10 s RIP-probe timeout before the welcome
display happens.

v286.1 — Games admin: silent-save fix (May 2026)

Clicking Save on /admin/games/<id>/edit did nothing — appeared
to be a no-op. Cause: the drop_file_type SelectField (and to a
lesser extent category) rejected empty/NULL values via WTForms'
default "must be in choices" validator. LORD's seeded row leaves
drop_file_type NULL, so editing it would silently fail
validation; the page re-rendered identically with no flash, no
error display, no apparent action.

Fixed in two places:

  • web/games_admin.py:GameFormdrop_file_type and category
    now use validate_choice=False, accepting empty / NULL as
    "no value".
  • templates/games/admin/form.html — added an alert-danger
    block at the top of the form that lists every field error
    when save fails. Future invisible-save bugs become visible
    immediately.

v286 — LORD: now boots under Node compat shim (May 2026)

Following v285 which bundled the LORD source and recommended real
Synchronet jsexec, this release closes the remaining gaps so LORD
actually renders under Node — no Synchronet install required. The
welcome ANSI screen draws; the input loop reads keystrokes; play
proceeds.

Compat-shim additions in anetbbs/games/synchronet_compat.py

  • server and client global stubs — without those, dorkit's
    dk.system.mode test fell through to undefined and no console
    driver loaded, so LORD ran to completion with zero output. Now
    dorkit picks 'sbbs' mode and loads sbbs_console.js.
  • Beefed-up bbs.*: logon_time, get_time_left(), online,
    sys_status, start_time. system.*: node_dir, data_dir,
    text_dir, ctrl_dir, exec_dir, mods_dir, qwk_id,
    os_version, matchuser/matchuserdata/username no-ops.
  • Beefed-up user.* (security.password, stats.bytes_uploaded/
    downloaded, laston_date, expiration_date, alias, location, …).
  • console.right/left/up/down(n) aliases — sbbs_console.js calls
    these names rather than cursor_right etc.
  • console.ctrlkey_passthru slot so doors can set the bitmask.
  • load(true, "file.js", args) background form returns a stub
    Queue for the on-exit cleanup hook.
  • File.readln() returns null at EOF (not '') so LORD's
    build_txt_index loop terminates instead of spinning.
  • File.position becomes a real getter/setter (used by
    build_txt_index to record byte offsets).
  • File.length is now a property (not a method) — matches what
    sauce_lib and others expect.
  • Load resolver prefers <stubs_dir>/dorkit/<file> over the flat
    <stubs_dir>/<file> so the dorkit-internal Screen/Graphic with
    the right prototype methods wins over older bare copies.

Node-side input plumbing (sbbs_stubs/dorkit/sbbs_input.js)

Replaced upstream's busy-loop background-thread with a callback
that registers on dk.console.input_queue_callback and runs from
dorkit's own waitkey() loop. Sets stty min 0 time 1 once at load
so each readSync returns within 100 ms — no per-iteration stty
thrash, no busy-wait, the door is responsive.

Seed Game row flipped to active

_create_default_data inserts LORD with is_active=True now. The
door appears in /games/ ready to play on first start; sysops who
prefer jsexec can install it later and the door_runner auto-prefers
real Synchronet binaries when found.

Honest caveats

  • The compat shim handles LORD specifically. Other Synchronet doors
    vary; this is the foundation, not a guarantee everything works.
  • The Node Queue is a single-process in-memory FIFO; doors that
    rely on inter-process communication via named queues will need
    more work.
  • File operations stay in latin-1 binary mode for CP437 fidelity;
    pure-text doors that expect UTF-8 might surprise.

v285 — LORD: Synchronet JS port pre-installed (May 2026)

Bundles Synchronet's JavaScript port of Legend of the Red Dragon
inside the BBS, plus the upstream dorkit/ helper library it needs.

What ships

  • anetbbs/games/sbbs_doors/lord/ — full upstream xtrn/lord/
    tree from github.com/SynchronetBBS/sbbs (lord.js, lordsrv.js,
    recorddefs.js, IGM subdirs, ANSI art, name lists; 16 MB total).
  • anetbbs/games/sbbs_stubs/dorkit/ — the upstream xtrn/dorkit/
    console drivers (screen.js, local_console.js, ansi_console.js,
    ansi_input.js, attribute.js, graphic.js, …) so LORD's
    require("screen.js") chain resolves.
  • dorkit.js + recordfile.js synced to current upstream.

Compat-shim improvements

The synchronet_compat.py shim grew the bits LORD (and any other
real Synchronet door) reaches for:

  • Queue class — Synchronet's inter-script FIFO; backed by stdin
    reads when the queue name starts with dorkit_input.
  • strftime(fmt, unix_seconds) — C-style with the common
    conversion specifiers (%H %M %S %Y %m %d %a %A %b %B …).
  • js.load_path_list, js.on_exit(code), js.exec(), js.gc(),
    js.global, js.auto_terminate, js.terminate_signaled.
  • require() now accepts the scope-prefix form
    (require(scope, "cnflib.js", "CNF")) used by LORD.
  • load() consults js.load_path_list first, then the new
    conventional <exec_dir>/{dorkit,load}/ fallbacks.
  • Queue exposed on globalThis via the existing global-registry
    sweep so vm.runInThisContext'd sub-files see it.
  • sbbs_stubs/cnflib.js: SpiderMonkey for each (var p in struct)
    → standard Object.keys(struct).forEach(...) so V8 parses it.

Pre-seeded Game row

_create_default_data inserts a "Legend of the Red Dragon" game
(game_type='door_synchronet') pointing at the bundled LORD. Sysop
must flip is_active=true once Synchronet's jsexec runtime is on
the host — see the updated [[LORD Setup]] wiki page for the three
ways to get jsexec (apt, build-from-source, or point SBBS_JSEXEC
env at an existing install).

Why jsexec instead of Node

The compat shim gets simpler doors running under Node, but LORD's
dorkit library binds its console driver to a full Synchronet
bbs/server/client/user/console global quintet and depends on the
forked input-thread model. Emulating that on top of Node's
single-threaded loop is a much deeper rewrite. Real jsexec is a
small standalone binary that gives upstream behaviour for free.

v284 — Wiki (May 2026)

A full community wiki at /wiki/ — collaborative documentation
with revisions, diff, search, and markdown + [[wiki-links]].

Models

  • WikiPage — slug-keyed page with current body, title, summary,
    view count, lock flag, soft-delete flag, created/updated audit.
  • WikiRevision — every edit gets one. Stores the full body
    (no compression — sqlite is fine at this scale), edit summary,
    author user-id, author IP, rev-num monotonic per page.
  • Auto-sweep adds both tables on next anetbbs-web start.

Renderer (anetbbs/wiki/render.py)

  • Python-markdown with fenced_code, tables, nl2br,
    attr_list, toc, sane_lists.
  • [[Page Title]]/wiki/page-title. Missing pages render as
    red dashed-underline links so editors notice.
  • [[slug|display text]] and [[Page#anchor]] both supported.
  • Wiki-link preprocessing skips fenced code blocks and inline
    \code`` spans so example tokens don't become real links.
  • Output sanitized by bleach with a whitelist that keeps headings,
    tables, code, images, our wiki-link CSS classes, and heading
    anchor IDs.

Slug helpers (anetbbs/wiki/slug.py)

  • NFKD-fold + lowercase + dash-collapse.
  • "Café — édition" → cafe-edition.
  • "BinkP & QWK" → binkp-and-qwk.

Routes (anetbbs/web/wiki.py)

URL What
/wiki/ Home page render + recent edits sidebar
/wiki/<slug> View a page (red-link template if missing)
/wiki/<slug>/edit Edit form with live preview
/wiki/<slug>/preview JS-called preview endpoint
/wiki/<slug>/history Revision list w/ compare picker
/wiki/<slug>/rev/<n> View a specific old revision
/wiki/<slug>/diff/<a>/<b> Unified diff between revisions
/wiki/<slug>/revert/<n> Roll back to revision N
/wiki/<slug>/lock (admin) Toggle edit lock
/wiki/<slug>/delete (admin) Soft-delete
/wiki/<slug>/restore (admin) Undo soft-delete
/wiki/<slug>/rename (admin) Change slug
/wiki/new Create-new flow with suggested slug
/wiki/all Alphabetical index
/wiki/recent Every edit, newest first
/wiki/search?q=… Full-text across title + body
/wiki/wanted Pages linked-to but not created
/wiki/orphans Pages no other page links to

Templates — 13 Jinja templates extending base.html, all
dark-theme-aware. Wiki-specific CSS (red links for missing pages,
diff colourization) lives inline in wiki/_layout.html.

Auth

  • Anyone (incl. logged-out) can read.
  • Logged-in users can edit and create.
  • Locked pages: only admins can edit.
  • Lock, delete, restore, rename: admin-only.

Seed content — 41 pages covering: connecting via web /
telnet / SSH / rlogin / gemini / finger; reading and posting in
boards, echomail, netmail, PMs, instant messages, RSS, files;
playing doors (web, rlogin, DOS); sysop guide; door setup; BinkP
setup; LORD-specific recipe; DosBridge architecture; the codepage
story; NodeSpy; backup; full architecture overview; QWK; TIC
processor; IRC and MRC bridges; web terminal; and a glossary of
BBS jargon. 41 revisions in history on day one — each seed entry
gets an r1.

Nav

  • /wiki/ link added to the Help dropdown next to Documentation.

Note on seeded pages — they're a starting point, not the final
word. Anyone with an account can improve them, and the wanted
pages report at /wiki/wanted shows the queue of pages that
existing pages already link to.

v283.7 — Echomail: CP437 body passthrough + Q-skip (May 2026)

Two issues from the Echomail reader:

CP437 / ANSI bodies were mangled. Echomail bodies received via
BinkP from FidoNet/Synchronet networks contain CP437 line-drawing,
block characters, and embedded ANSI color escapes — stored as
latin-1 mojibake (each original byte 0xNN → codepoint U+00NN).
The reader was passing them through session.write() which
re-encodes everything to CP437, scrambling the bytes. Body lines
now go straight to the writer via line.encode('latin-1'), so
the original bytes reach the user's CP437 terminal unchanged.
Falls back to cp437 encoding with replacement if the line has
genuine unicode codepoints above 0xFF.

Q-skip on -- more -- prompts. Walking past 100 areas to
find #22 was painful. Every paging prompt in the echomail flow
(area list under E, message index after picking an area, message
body, area list under C compose) now reads:
-- more (Enter, Q=stop listing) --. Pressing Q at any of them
breaks out of the loop and goes straight to the picker (or back
to the message index, for body view). Mirrors the bulletin
reader's existing [Q]=quit behavior.

v283.6 — Echomail message reader: color + paging (May 2026)

After picking an area under Echomail (E), the resulting message
list and message body were plain monochrome and dumped without
paging. Now matches the rest of the terminal UI:

  • Banner + footer wrap on both list and body screens
  • Colored columns on the message index (yellow #, white subject,
    green from, cyan date)
  • -- more (Enter) -- paging every 18 lines on both the index
    and the message body, so long posts don't scroll past on a
    24-row terminal
  • Same paging treatment in Compose Echomail (C) for the area-empty
    screen, prompts, and the queued-for-BinkP confirmation

v283.5 — Echomail (E) area list paging (May 2026)

The terminal main menu's Echomail (E) area listing scrolled past on
24-row terminals — compose_echomail (C) already paged every 18
lines with -- more (Enter) --, but list_echo_areas dumped
everything at once. Now uses the same paging.

v283.4 — Dark-theme: list-group + table row tints (May 2026)

Bootstrap's default .list-group-item and .table-warning/-info/...
row tints render light-on-light, which clashes with the dark theme.
The most visible offenders were:

  • The RSS Reader feed list and River feed (white cards, hard to read).
  • The Inter-BBS Instant Messages inbox unread-row highlight (cream
    yellow on white).
  • @mention autocomplete popup, leaderboards, stats, profile,
    oneliners, calendar, docs nav — all used .list-group-item.

base.html now overrides these classes to use the same dark palette
as the .card and .alert-* styles. No template changes needed —
all consumers benefit automatically.

v283.3 — NodeSpy kick audit-log fix (May 2026)

The kick endpoint in v283.2 raised
TypeError: 'action' is an invalid keyword argument for UserActivity
on every kick attempt — the audit-log column is activity_type, not
action. (The legacy v283 code had the same bug but never ran far
enough to trip it.) Now uses the correct field and also records
ip_address and service='web'.

v283.2 — NodeSpy kick: cross-process fix (May 2026)

The kick button in v283 / v283.1 appeared to work but didn't actually
disconnect anyone. Root cause: anetbbs-web (gunicorn) and
anetbbs-telnet are separate systemd services with separate Python
processes. The kick endpoint flipped a flag in the web process's
in-memory _NODES dict, but the active terminal session lived in the
telnet process — different memory, no effect.

Now the kick crosses the process boundary via the database:

  • NodeActivity gains two columns: kick_requested (bool) and
    kick_reason (string). Auto-sweep adds them on next gunicorn start.
  • /admin/control/nodespy/<slot>/kick simply updates those columns
    and commits.
  • A new _kick_watchdog task in BBSSession polls its own
    NodeActivity row every 5 seconds. When kick_requested is set, it
    writes the goodbye banner, closes the writer, and the session
    unwinds via the normal teardown path.
  • The watchdog is cancelled on session close, before
    _close_node_activity deletes the row.

Worst-case latency: ~5 seconds between kick click and disconnect.
Stuck/idle users are unaffected (the watchdog runs on its own timer,
not on user input).

v283 — NodeSpy kick (May 2026)

Sysop can now disconnect a stuck or misbehaving terminal user
straight from the NodeSpy panel:

  • Kick button in the per-row NodeSpy table (red door-arrow icon)
  • Kick form in the per-node detail page with an optional reason
    text field
  • New endpoint: POST /admin/control/nodespy/<slot>/kick (CSRF-
    protected, admin-only)
  • New function: multinode.kick_node(slot, reason) — pushes a 'kick'
    payload to the session's chat queue, writes a goodbye line to the
    user's terminal, then closes the underlying transport. The session's
    reader EOFs and the session cleans up naturally.
  • NodeEntry gained a session reference (passed by acquire_slot
    in core/session.py) so the kick handler has a handle to close.
  • Audit log entry in user_activity for every kick (action=kick_node,
    details = "slot N (username): reason"). So sysop kicks are
    traceable.

Different from a ban — this just drops the current connection. The
user can reconnect immediately. To prevent reconnect, sysop must
also add an IP ban under /admin/ip-bans/.

v282 — consolidation cut (May 2026)

Clean release after the v280-v281.3 hotfix series. No new behavior
beyond what the v281.3 patches already shipped — this is purely
a docs + memory + audit-pass roll-up so the next deploy carries
matching docs alongside the running code.

  • Docs updated: CHANGELOG, FEATURES, 14-door-games (door_rlogin
    worked example, dosemu removal note), 16-rss-reader.
  • Memory entries updated: project_status reflects current cursor
  • RSS + door_rlogin work; rlogin handshake quirk recorded at
    project_rlogin_format.md; LORD setup guidance at
    project_lord_setup.md; door-files-hands-off feedback at
    feedback_door_files.md.
  • Final audit: 50 blueprints, 319 routes, 189 templates parse,
    zero broken url_for() references, app boots clean. Pyflakes
    has no undefined-name / redefinition warnings — only intentional
    drains and load-bearing imports remain.

v1.0a — RSS reader (May 2026, internal v281)

Built-in RSS / Atom feed reader for both web and terminal. Sysop
manages feeds at /admin/rss/. Background poller refreshes every
30 minutes (configurable via RSS_POLL_INTERVAL env var). X-News
seeded by default so a fresh install has at least one feed populated.

New tables (auto-created by the lightweight migration sweep):
- rss_feeds — sysop-configured feeds
- rss_items — articles (deduped per-feed by GUID)
- rss_read_status — per-user read markers

New blueprints: rss at /rss/, rss_admin at /admin/rss/.

New file: anetbbs/rss/poller.py — background daemon thread.

New dependency: feedparser>=6.0 (handles RSS 2.0 / Atom / RSS 1.0).

Web UI: Tools → RSS Reader. Feed list with unread badges, "all
feeds" river view, paginated per-feed item lists, single-item full
content view, mark-as-read tracking per user.

Terminal UI: Main BBS Menu → R. Feed picker, river, paginated
item lists, single-item viewer with word-wrapped body. Same mark-read
state shared with the web UI.

Pre-seeded feed: https://x-bit.org/rss/rss.xml (X-News).

v281.1 — fixed Jinja name collision. Renamed template var from
unread to feed_unread because base.html does {% set unread = ... %}
for the PM badge counter, which shadowed our context variable inside
child templates and turned the dict into an int.

v281.2 — added RSS to the data-driven menu engine. v281 only
added it to the hardcoded BBSMenuUI.show_main() fallback; the
running terminal sessions use the BbsMenu table-driven menu, which
needed a new rss action_type registration + a R hotkey entry in
DEFAULT_MENUS (auto-backfilled to existing installs by
seed_default_menus).

v281.3 — typo: FG['ylw']FG['yel']. Crashed show_rss()
on first launch.

See docs/16-rss-reader.md for full feature docs.

v1.0a — A-Net Game Server / rlogin doors (May 2026, internal v280)

New game type: A-Net Game Server (rlogin). Lets BBS users
transparently rlogin into a remote Synchronet door game server
(or DoorParty, or any other rlogin-accepting BBS host). The user
sees one continuous session — no second login prompt.

Architecture:

  • New anetbbs/games/rlogin_bridge.py with RloginConnection
    same write/stop/bind_emit shape as DosBridge so it slots into
    the existing DoorSession machinery (send_input, terminate_session,
    cleanup all work without changes).
  • New launch_rlogin_session() in door_runner.py for the web flow.
  • New play_rlogin_telnet() in door_runner.py for the terminal
    flow. Both use the same RloginConnection.
  • Game type door_rlogin selectable in /admin/games/ with its own
    helper section in the form (server host:port + user template +
    password + optional terminal hint for direct-to-door launches).

Wire format: Synchronet's BBS-mode rlogin server uses INVERTED
field order vs RFC 1282 — the password goes in the client-user-name
slot (1st field) and the BBS username in the server-user-name slot
(2nd). Verified against game.a-net-online.lol on 2026-05-09. Memory
note saved at memory/project_rlogin_format.md.

dosemu removed. The half-finished door_dosemu game type was
yanked from the dropdown — drive-letter detection was wrong and the
admin form fields were missing. _build_dosemu_command stays in the
source tree as dead code in case someone wants to validate it
properly later.

v280.1–v280.4 — admin form polish + rename: dropdown renamed
"Synchronet xtrn / DoorParty" → "A-Net Game Server (rlogin)". Added
a dedicated form section so the executable_path / command_line_args
fields are visible when door_rlogin is selected (same fix pattern as
the gallery_admin disappearing-fields bug). Updated helper text with
working examples for game.a-net-online.lol direct-to-door
(xtrn=LORD408, xtrn=ASSASSIN, etc.).

v1.0a — door I/O safety + auto-recovery (May 2026, internal v279.1–v279.9)

A series of hotfixes after v279 wired up the DosBridge — fixing
real-world door issues that surfaced during testing.

  • v279.1: _build_dos_command now accepts token_ctx=None for
    validation-only callers (the terminal launcher previewed the
    command before allocating a bridge port; without this it crashed
    with "unexpected keyword argument").
  • v279.2: DOOR.SYS / DORINFO / DOOR32.SYS now identify as COM1:
  • 38400 baud + comm type 1 (FOSSIL) instead of COM0: + 0 + comm
    type 2. Most classic doors (LORD especially) interpret COM0/0 as
    "local console — bypass FOSSIL, write to BIOS only", which dropped
    all output before it could reach the bridge.
  • v279.3: bridge diagnostic logging — first 5 chunks logged
    verbatim, "0 bytes after 10s" warning if door isn't writing to
    FOSSIL, total chunk/byte count on close.
  • v279.4: terminal door_dos now uses the unified launch path
    (launch_door_game via play_door_game_telnet). The legacy
    play_dos_game_telnet had its own DOSBox-launching code that
    predated the bridge wiring and never got updated; terminal users
    were getting silent hangs.
  • v279.5: waitpid watcher thread. xvfb-run can leave Xvfb running
    past the door exit (Xvfb inherits the PTY slave_fd, keeping
    master_fd from EOFing), which means our PTY watcher never wakes
    to fire cleanup. The new watcher does os.waitpid(pid, 0) and
    triggers _cleanup_session when the door subprocess actually
    exits — independent of PTY EOF.
  • v279.6: bridge close → cleanup signal. When DOSBox closes its
    end of the TCP nullmodem (which it does on exit), the bridge fires
    a new on_close callback that runs _cleanup_session immediately.
    Faster than waitpid for the common case.
  • v279.7: real Ctrl+]q abort. The launch banner has been telling
    users "press Ctrl+] then q to abort" since forever, but the input
    pump just forwarded those bytes to the door. Now the pump
    actually watches for the sequence and triggers cleanup.
  • v279.8: idle-timeout watchdog (default 300s). If zero bytes
    flow in either direction for the timeout, the bridge force-closes
    and triggers cleanup. Catches stuck-door cases (LORD's exit-loop,
    infinite "press any key" prompts on hidden screens).
  • v279.9: idle timeout reduced to 60s default + made configurable
    via DOOR_IDLE_TIMEOUT. Also force-kills the door process group
    via SIGTERM (then SIGKILL 2s later) when timeout fires, instead of
    just trusting _cleanup_session to do it.

End result: stuck DOS doors always return to the BBS within 60
seconds, the BBS user's session never permanently hangs, orphan
DOSBox processes get reaped.

v1.0a — DOS door bridge wired up (May 2026, internal v279)

v279 — actual DOS door I/O. The previous DOSBox configs wrote
serial1=stdio, which DOSBox-staging 0.80+ silently downgraded to
dummy — meaning DOS door output and keystrokes never reached BBS
users. The whole DOS door path was end-to-end broken on modern DOSBox.

This release wires dos_bridge.py (which has been complete-but-unused
in the tree for a while) into the launch flow:

  • DosBridge picks a free TCP port (5000–5100) and listens.
  • DOSBox config is serial1=nullmodem server:127.0.0.1 port:NNNN transparent:1. DOSBox dials in at boot.
  • New DosBridge.bind_emit(emit_fn) reads bytes off the TCP socket and
    forwards them to the BBS user's terminal via socketio_emit_fn.
  • New DosBridge.write(data) accepts BBS-side keystrokes and pushes
    them onto the TCP socket so DOSBox's COM1 receives them.
  • DoorSession.write() routes through the bridge when present, so the
    rest of the BBS doesn't have to care which transport is in use.
  • DoorSession.close() also stops the bridge so the listener doesn't
    leak.
  • The PTY remains open on door_dos sessions but only as a process-exit
    watcher; door I/O no longer flows through it.
  • DOSBox config also stripped invalid output=null and autolock
    options that staging 0.82.x rejects.

End result: vanilla DOSBox 0.74-3, DOSBox-staging 0.82.x, and DOSBox-X
all work for door_dos games via TCP nullmodem. No special build
required.

v1.0a — door-runtime polish (May 2026, internal v277–v278)

Hot-fixes plus the dosemu2 path, deployed on top of the v276 audit pass.

v278:
- New door_dosemu game type — uses dosemu2 (-dumb stdio) instead of
DOSBox. Better latency, no FOSSIL gymnastics over TCP nullmodem,
apt-packaged via dosemu2 PPA on Ubuntu / native on Debian. Mounts
game dir + per-node temp dir + bundled FOSSIL driver dir, autoexec
loads BNU.COM on COM1 for parity with the DOSBox path so the same
LORDCFG / TWCFG settings work either way.
- Snap-confine detector in _build_dos_command: when dosbox /
dosbox-staging resolves to a /snap/ path, we reject it up front
with a clear "remove snap, install apt or AppImage instead" message.
Avoids the cryptic cap_dac_override not found error on first
launch from systemd.

v277:
- DOSBox per-node mount: the per-node temp dir is now mounted as drive
E: in addition to the game dir on C:. Sysops set LORDCFG's "Path to
dropfile" to E:\ and multi-node LORD / TradeWars / TQW just works
without overwriting each other's drop files.
- docs/14-door-games.md and docs/15-synchronet-compat.md published.

v1.0a — pre-release polish (May 2026, internal v195–v276)

Final shake-out before tagging the alpha.

New: Image galleries
- /gallery/ — paginated thumbnail grid + full-screen modal viewer.
Browser-native, lazy-loaded.
- /admin/galleries/ — full CRUD on collections (label/slug/path/sort/
active) plus per-gallery file management (drag-drop multi-upload,
click-to-delete, pagination).
- Storage: gallery-config.json at install root, auto-seeded on first
run from any DSR gifs / swim directories that exist. Excluded
from deploy so sysop edits survive rsync --delete.
- Standalone terminal viewer: /home/<user>/anet-gallery.sh (chafa /
img2sixel) — kept outside the install dir so deploys don't remove it.
- Galleries link lives under Tools in the top nav (kept off the
primary bar to avoid wrapping on smaller widths).
- New doc: 13-image-galleries.md.

Removed: DSR (Digital Showroom)
- The Synchronet sixel image-viewer door is no longer wired into the
games menu. The convert → .sixel pipeline returned rc=0 with no
output file when the door was launched from a gunicorn-spawned PTY
child (worked manually but not under the web service); root cause
not identified after extensive investigation. The web /gallery/ +
terminal anet-gallery.sh cover the same use-case at higher quality.
- DSR files remain on disk under doors/sbbs/dsr/ so a future
reinstatement is possible without re-extracting from the .zip.

Codebase audit
- Real bug found and fixed: current_app referenced in web/admin.py
but missing from the flask import line (would have NameError'd at the
affected admin routes).
- Real bug found and fixed: missing requests from setup.py
install_requires — webhooks.py imports it unconditionally so a fresh
pip install -e . would crash on first webhook dispatch.
- Real bug found and fixed: missing admin/time_budgets.html template
/admin/time-budgets would 500 on GET. Template added matching the
rest of the admin UI.
- Real bug found and fixed: 4 broken url_for() references that would
raise BuildError at render time (echomail.view_message,
boards.view_thread, pm.read_message, echomail.list_messages
endpoints renamed in earlier work but the saved-messages and
permalinks blueprints still pointed at the old names).
- Real bug found and fixed: 3 duplicate method definitions in
core/session.py (write / read_line / clear_screen) — Python
silently kept only the later definitions, the first ones were dead
code. Removed.
- Real bug found and fixed: 3 stray f''-prefixed strings without
placeholders in web/echomail_admin.py and web/irc_web.py.
- Real bug found and fixed: update.sh rsync was missing --exclude
for /doors/ and /gallery-config.json — closes the same hole that
caused the v195 deploy incident.
- Duplicate import json in echomail/tic.py removed.
- Duplicate from typing import Dict in core/session.py consolidated.
- Explicit __all__ added to anetbbs/core/__init__.py (silences
pyflakes false-positive on re-exports).
- Orphan templates removed: admin/callers.html (replaced by
admin/caller_log.html), chat/index.html (legacy local-chat,
replaced by MRC).
- ~80 unused imports trimmed across the package — pyflakes warning
count down from ~110 to 22, with the remaining warnings all
intentional (load-bearing SessionProtocol re-export, protocol-level
drain variables, dead global declarations).
- All 180 Jinja templates parse cleanly.
- All 308 url_for() references resolve to a registered endpoint.
- 79/81 SQLAlchemy models are queried in code; the two stragglers
(ChatMessage, MenuTranslation) are leftover scaffold tables that
stay in the schema for now to avoid alpha migration churn.
- Zero outstanding TODO/FIXME/XXX/HACK markers in our code
(matches in vendored Synchronet .js stubs are upstream's).
- New top-level FEATURES.md — single-page inventory
of every user-visible feature for the alpha.
- docs/00-overview.md updated with the gallery feature.
- docs/PORTS.md + deploy/README.md now document the Finger
service (port 79).

Error pages + nav coverage
- New branded 404.html / 403.html / 500.html templates wired up
via errorhandler() (previous behaviour fell through to Flask's
default monochrome error pages).
- Sysop nav: added Time Budgets link to the Admin → Users dropdown
(the /admin/time-budgets page was registered but unreachable from
the UI — only the URL bar got you there).

Config completeness
- .env.example expanded with previously-undocumented but code-used
settings: SYSOP_NAME, BBS_DOMAIN, BBS_PUBLIC_HOST, BBS_EMAIL,
BBS_LOCATION, BBS_NODES, IDLE_TIMEOUT_SECONDS,
FINGER_LISTEN_HOST/PORT, BINKP_LISTEN_HOST/PORT,
BINKP_OUR_ADDRESS, BINKP_SYSTEM_NAME, CLAMSCAN_PATH,
IRC_LOG_CHANNELS, NUV_ENABLED, RATIO_MIN.

Door-game runtime parity with Synchronet / Mystic

A real BBS gives each active node its own scratch directory and
substitutes shortcodes (Synchronet %f, Mystic %P etc.) in door
command lines. Both were missing; both shipped this session.

  • New anetbbs/games/node_paths.py — per-node temp dirs at
    <install>/data/temp/nodeN/ (1..BBS_NODES), auto-created on app
    boot. Owns the substitution table.
  • Synchronet %-token vocabulary supported in Game.executable_path,
    Game.working_directory, Game.command_line_args, and
    Game.drop_file_path: %a, %c, %d, %e, %f, %g, %h,
    %i, %j, %k, %l, %m, %n, %o, %p, %r, %s, %t,
    %u, %w, %y, %z, %!, %%.
  • Mystic %-token vocabulary supported in the same fields:
    %P (per-node temp dir w/ trailing /), %N, %M, %U, %T,
    %H, %R, %E, %S, %L. So %Pdoor32.sys correctly resolves
    to e.g. /data/temp/node3/door32.sys.
  • Game.drop_file_path is now %-token expanded (was only {node}).
  • New doc: docs/14-door-games.md — full token
    table, LORD walk-through, and the rest of the door-config story.
  • New doc: docs/15-synchronet-compat.md
    xtrn coverage survey, what stock Synchronet doors are likely to work
    through our Node shim vs. need a real jsexec.

Mystic .mps auto-compile
- New _ensure_mps_compiled() in door_runner.py — if mplc is on
the host, automatically compile .mps source → .mpx bytecode
before launch (only if source is newer than bytecode). Falls back to
an existing .mpx next to the source if mplc isn't installed.
- New MYSTIC_MPLC_PATH env var for explicit override.

Installer modes (test vs production)
- install.sh now starts with an "Install mode" prompt:
- production = real BBS facing the public internet — wants
nginx, SSL, a domain pointed at the box, privileged-port services
(Finger/MSP/SYSTAT) reachable from peers.
- test = local-only / behind NAT / no static IP / just trying it
out — auto-skips nginx, certbot, Finger/MSP/SYSTAT/BinkP. Gunicorn
binds 0.0.0.0:5000 directly. Sysop can flip individual flags later
without re-installing.
- New optional install step: download Mystic Linux freeware tarball,
extract mystic + mplc to /opt/mystic/, symlink into
/usr/local/bin/, write MYSTIC_MPLC_PATH to .env. Soft-fails
if the download URL is unreachable (no network, mysticbbs.com down).
- anetbbs-finger.service unit now installed by install.sh when
ENABLE_FINGER=y (was never deployed by the installer despite the
unit file existing in deploy/).
- Test-mode summary message at end of install spells out exactly what
was skipped and how to re-enable later.

Deploy / ops
- update.sh and the documented rsync recipe explicitly
--exclude=doors --exclude=gallery-config.json --exclude=data/ to
prevent the v195-era incident where a --delete deploy wiped the
doors tree (DSR, BotWars, RDQ3, the 6500-GIF library) from a
production install.

Misc
- permission denied regressions on the MRC bridge fixed by ensuring
service unit User=stingray matches actual file ownership on the
reference deployment (was getting accidentally chown'd to anetbbs).

v1.0a — alpha refresh (May 2026, internal v194)

Subsequent rolling internal builds (v165-v194) refine the alpha. Public
tarball at ANetBBS-v1.0a.tar.gz always tracks the latest internal cut.

Highlights since the v164 alpha:

  • MSP / Inter-BBS IM correctness — the previous wire format was a
    homegrown 5-field variant that Synchronet rejected silently and we
    mis-parsed in the inbound direction. v168 ships a real RFC 1312 MSP-2
    encoder/decoder (7 fields with a leading 'B'/'A' type byte), strips
    @host from inbound recipients, optionally answers acked-mode requests.
  • Synchronet @CODE@ substitution in our Synchronet stub layer's
    printfile@BBS@, @USER@, @SYSOP@, @TIME@, @DATE@,
    @SECURITY@, @CALLS@, @NODE@, etc. resolve from the BBS_*
    environment variables that door_runner.py exports.
  • Display-code preprocessor for ANSI screens and BbsMenu screens —
    Synchronet @-codes (@USER@, @BBS@, @TIME@, ...) and Mystic
    named codes (|UN, |BN, |DT, ...). Color-pipe codes like |07
    remain handled by the existing _pipe_to_ansi.
  • QWK polling improvements — Synchronet's 550 No QWK packet created (no new messages) is now treated as benign instead of a
    fatal poll error (was generating thousands of false ERROR lines per
    day). Poll interval clamped to ≥5 minutes to prevent a misconfigured
    DB from hammering remote hubs.
  • Web-stack hardeningKillMode=mixed + RestartSec=10 on the
    gunicorn unit so eventlet workers don't leak past the master and
    cause EADDRINUSE crash loops; _create_default_data os shadowing
    bug fixed; body keyword for PrivateMessage (was content);
    /help endpoint repaired.
  • Permissions — install.sh asserts ssh_host_key + sbbs_stubs/
  • doors/ permissions on every run (not only at first generation),
    with sbbs_stubs source files now shipped world-readable so future
    rsync deploys don't reset BotWars/RDQ3 to broken.
  • Terminal MRC client — extensive UX work:
    • anetmrc-style stationary status bar at the top (room, topic,
      mention badge, server latency)
    • DECSTBM scroll region for chat below it
    • HH:MM timestamp prefix on every chat line, ANSI-aware word-wrap
      with continuation indent
    • Tab-complete usernames (active list seeded from chat events,
      /who, /chatters)
    • New slash commands: /afk [msg], /back, /status,
      /roomconfig, /termsize, /whoon alias of /who
    • /info <id> now passes the BBS index to the server
    • /scroll removed (terminal-native scrollback works fine)
    • Removed the doubling-on-wrap bug (input is slide-windowed; chat
      uses ANSI-aware word wrap instead of native terminal wrap)
  • Terminal BBS UI — single-key hotkeys (no Enter required), screen
    clear before each menu, Q logoff confirms with Y/N, banner+colored
    rendering on Boards / Echomail / Bulletins / Who's Online / Sysop
    Tools / Profile / PM compose / IM compose / inboxes, paged area
    picker for compose-echomail, terminal IM reader ([I] and [J]).
  • Web nav reorg — Inter-BBS IM badge in the top bar (next to PM
    envelope and notification bell), Web Terminal moved from Chat
    dropdown to top of Tools dropdown.
  • Privacy — IP column on /who now hidden from non-admins.
  • CP437 mojibake recovery for ANSI screens stored as Latin-1 in
    the DB (encode→bytes→write-as-CP437 path so SyncTERM and modern
    terminals both render correctly).
  • Dialout default directory trimmed to a single seed entry
    (sysop adds the rest via /admin/dialout).
  • Door runner — door cleanup callback now runs inside a captured
    Flask app context (was leaking "Working outside of application
    context" errors when sessions ended).
  • Web service unit — config templated for /opt/anetbbs/; the
    install.sh rewrite handles INSTALL_DIR substitution properly.

v1.0a — first alpha (May 2026)

Bundles all internal versions through v164.

Major features:
- Web BBS (Flask + SocketIO) + telnet + SSH + rlogin front-ends
- FidoNet binkp echomail + netmail with full kludge support (MSGID, REPLY,
INTL, FMPT/TOPT, CHRS, PID, TZUTC), CRAM-MD5 BinkP auth, optional TLS
- DOVE-Net QWK echomail with REP packet outbound, CONTROL.DAT-driven
area auto-create, 1-on-1 QWK netmail compose
- Inter-BBS Instant Messaging via MSP (RFC 1312) on TCP/18 + SYSTAT/
ActiveUser on UDP/11, with the Synchronet sbbsimsg.lst directory
mirrored daily and a built-in BBS picker UI
- Terminal MRC chat (telnet/SSH/rlogin) — talks to the same local
websocket bridge that web users hit, so terminal and browser users
share the same hub identity and chat rooms. Full command set
(/identify /msg /me /join /list /who /motd /banners /topic /scroll /mentions /trust /broadcast …); ←/→ arrow keys cycle outgoing text
color; /identify mid-line password masking; @mention highlighting
with bell; outgoing 140-char cap with auto-split on word boundaries;
ROOMTOPIC banner + topic snippet baked into the prompt
- Local boards with voting, search, sticky/lock, threading, ANSI banners,
per-board moderators
- File bases with TIC ingest, outbound TIC hatching, FILE_ID.DIZ
auto-extract, per-area upload permissions
- Doors: DOSBox (auto-detect staging/x/vanilla), Mystic .mps and .mpy,
Synchronet .js (real jsexec when available, Node.js shim fallback),
native binaries — all with DOOR.SYS / DOOR32.SYS / DORINFO drop files
- Private messages, FTN netmail, CP437 + ANSI rendering everywhere
- AreaFix in/out with per-network admin queue, BadArea sysop-review queue
- Admin: rate limits on login/MSP/votes, random initial admin password
written to data/admin_password.txt, SECRET_KEY guard refusing dev
default in production, open-redirect-hardened login

Internal version history (build numbers)

ver summary
v164 MRC: ROOMTOPIC captured + shown in prompt + topic banner; mention bell + brighter highlight; /scroll empty-state; /mentions to clear counter
v163 MRC terminal: dropped DECSTBM scroll regions (unreliable on BBS terminals); switched to inline-redraw model
v162 MRC terminal: truncate-to-width on emit, dropped SAVE/RESTORE_CURSOR
v161 Taglines append on echomail + QWK netmail (was netmail-only)
v160 MRC terminal: separator/input row collision fix; ←/→ arrow-key color cycling
v159 MRC terminal: split-screen UI, 140-char outgoing cap, scrollback, mention highlighting, full command roster
v158 MRC terminal: trigger-space stays visible on /identify; broader server-noise suppression (BANNER, TYPING, CAPABILITIES, NEWROOM)
v157 MRC terminal: inline /identify password masking; USERLIST: protocol-leak suppression
v156 MRC terminal: /join uses NEWROOM (was resetting trust state via re-handshake)
v155 MRC terminal: dropped MRC submenu — choice 3 takes you straight in; masked-input tip
v154 MRC terminal: IDENTIFY/REGISTER/UPDATE corrected to top-level verbs (not TRUST subcommands)
v153 MRC terminal: full slash-command set (motd/banners/topic/roompass/lastseen/last/etc.); cleaner event renderer
v152 Restored SessionProtocol import (telnet/SSH service crash fix)
v151 Terminal MRC client (talks to local websocket bridge — terminal+web users share rooms)
v150 Hands-off installer: CAP_NET_BIND_SERVICE in systemd unit; optional-deps prompts (DOSBox/ClamAV/lhasa); MRC bridge gated; UFW prompt
v149 Cleanup: stale tarballs, pycache, egg-info, dead imports; file-delete bug (uploader_id vs user_id)
v148 Upgrade-wizard fix: auto-heals SECRET_KEY in .env; ANETBBS_SCHEMA_MIGRATE_ONLY bypass for migration subprocess
v147 SECRET_KEY guard relaxed for migration mode (incomplete; superseded by v148)
v146 Security pass: random admin pw, SECRET_KEY guard, decorator fix, open-redirect, rate limits
v145 TZUTC netmail kludge + board search route
v144 File-area scoping on uploads + upload_permission enforcement
v143 Synchronet doors prefer real jsexec; SBBSEXEC/SBBSCTRL/SBBSDATA/SBBSNODE env in fork
v142 MSP send form accepts user@host paste; wire-tested to a-net-online.lol
v141 SYSTAT/ActiveUser UDP service + sbbsimsg.lst directory + BBS browser UI
v140 MSP / RFC 1312 Inter-BBS Instant Messaging — server, client, inbox, send form
v139 FidoNet netmail compose actually sends (was dead-letter); FTS-0001 12-byte routing header fixed; @INTL/@FMPT/@TOPT
v138 Up/down voting on board posts, echomail, PMs
v137 File upload: bug fixes + FILE_ID.DIZ / README auto-extract; ALLOWED_EXTENSIONS broadened
v136 AreaFix log viewer
v135 QWK auto-create areas from CONTROL.DAT; poll-log no-longer-shows-negative-counts fix
v134 AreaFix-on-subscribe; QWK quick-add UI
v133 Auto-sweep schema migration; BadAreaLog viewer
v132 SBBSecho parity: CRAM-MD5 BinkP, soft-CR strip, UTF-8 detect, PATH dupe, BadAreaLog model
v131 CP437/ANSI rendering filter + image-link helper for boards
v130 CP437/ANSI Jinja filter; QWK netmail (private 1-on-1) compose + inbox + REP-with-status='*'
v129 QWK parser fix — num_chunks is ASCII not binary (caused garbage areas + crammed bodies)
v128 Echomail nav link, misc UI
... (earlier history archived)

The full per-version diff lives in git.