ANetBBS Changelog

Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0b2.33 (July 2026). Full release: August 1 2026.

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.