01The short version
The DOS bulletin board program runs inside a small FreeDOS machine that boots in the caller's own browser tab, on an x86 emulator compiled to WebAssembly. The board's files do not live in the browser. They live on the web host, in one folder, and the DOS machine reaches them through a network drive whose server side is a piece of JavaScript in the same tab, talking to a PHP script on the host one file operation at a time. The board program talks to the caller through COM1, a serial port the emulator provides, driven by a small FOSSIL driver of our own. What comes out of COM1 is painted on screen as the classic CP437 terminal; what the caller types goes back in. The board believes it has answered a modem call on a serial line, exactly as it did in 1993.
The server never runs DOS, never runs an emulator, and never keeps a process alive between requests. It serves files, arbitrates locks, and stands between the board program and the caller's password. That last part is the piece nobody else has built, and it is what makes the whole thing safe enough to put on the open internet. It has its own section below.
02Where it came from
ATA is the sibling of ATDT BBS. ATDT is a modern bulletin board written in PHP: message areas, files, users, a SysOp console, all designed to run on cheap shared hosting with no daemon and no build step, presented to callers as a terminal with a modem connect ceremony rather than a web page. ATDT dials out, in the sense that it builds a new board in the old style.
In its 1.7 series ATDT gained DOS doors. Door games (Legend of the Red Dragon, TradeWars, Usurper and the rest) are DOS programs that a BBS launched for a caller, handing over the serial line. ATDT could not run them on the server, because the server is shared hosting with no ability to run a long-lived process, let alone a DOS one. So the DOS machine was built to run in the caller's browser instead. That machine is the foundation of everything that follows, and it works like this:
- The v86 emulator, an x86 PC in JavaScript and WebAssembly, boots a 1.44 MB FreeDOS floppy image in about two seconds.
- The floppy carries HimemX for extended memory, the kernel loaded high, about 600 KB of conventional memory free, a packet driver for the emulated NE2000 network card, the EtherDFS network file system client, and a FOSSIL driver written for the project.
- The door's files sit on the PHP host. The tab runs the server half of the EtherDFS protocol in JavaScript, so drive
D:inside DOS is served by the browser, which forwards reads and writes to the host one at a time. - Record locks, the DOS calls that let several nodes share a file safely, are forwarded to the host and arbitrated there, so two callers in two browsers on two continents cannot tear the same file.
- Static files are cached in the browser; per-session scratch files never leave the tab; everything else is shared state on the host.
- A dead seat, meaning a closed tab, frees itself and its locks within ninety seconds.
With doors working, the obvious question followed. A BBS program is, from the outside, a very large door: a DOS program that reads and writes files in a directory and talks to a caller on COM1. If the machine could run a door for a caller, it could run the whole board for a caller, one node per browser. ATA is that inversion. The machine, the drive, the locks and the terminal were lifted from ATDT as they stood. Everything a board needs and a door does not was new.
03The machine in the tab
When a caller opens the board's URL, the page downloads the emulator (about 2.5 MB of WebAssembly and JavaScript plus the BIOS images) and the floppy image, all cached by the browser after the first visit. The page shows a modem dial while this happens: ATDT and the board's phone number, then RINGING, held until the machine is ready.
The floppy boots FreeDOS. CONFIG.SYS loads HimemX and puts DOS high. AUTOEXEC.BAT loads the packet driver, then the FOSSIL, then runs the EtherDFS client, which broadcasts for a server on the emulated Ethernet, finds the JavaScript in the tab, and maps the served volume as drive D:. The batch then calls a file the tab plants for this session only: it sets the clock from a value the server passed in (server time converted to the board's time zone, never the caller's clock), changes into the package's own directory, and runs the package's handoff line.
The handoff line is the invocation a front-end mailer used to hand a live call to the BBS: carrier up, skip the waiting-for-caller screen, put the logon prompt straight out the serial port. Every package has its own. Renegade takes RENEGADE.EXE -B{baud} -N{node} -Q. Fido takes FIDO.EXE {baud}/O. RBBS-PC takes its node, its definition file, a time cap and the baud. Maximus takes MAX.EXE -b{baud} -p1. T.A.G. takes keyword and value pairs. The profile for each package records the line, and the server substitutes the node number and the connect speed.
The board directory is mounted at the drive letter and path the package expects. Renegade 1.40 ships configured for D:\R, and the machine's native drive letter is D:, so its configuration files work untouched. No paths are patched and no binaries are modified, ever. The rule is that ATA does exactly what a SysOp would have done in DOS, and nothing more.
When the board program exits, because the caller logged off or carrier dropped, the batch ends, a sentinel goes out the serial port, the tab tears the machine down, and the server closes the node's lease. The caller sees NO CARRIER.
04The drive that is not there
Inside DOS, D: is an ordinary network drive. Every file call (open, read, write, seek, find first, lock) goes through the DOS redirector interface to EtherDFS, which packs it into an Ethernet frame and sends it to the emulated network card. The emulator hands the frame to the page, where a JavaScript implementation of the EtherDFS server side unpacks it, decides what to do, and sends the answer frame back. DOS never sees a real file handle; the JavaScript assigns the ids.
What the JavaScript does with each call depends on the file's class, declared per package in its profile:
- Static files (the executables, overlays, menus, art, help text) never change while the board runs. They are fetched whole before the machine boots, kept in the tab, remembered across visits, and revalidated in one round trip by size and stamp.
- Session files (swap files, per-node temp files, the planted batch) live only in the tab's memory. The server never sees them, and two nodes can never collide on them.
- Everything else is shared state and goes to the host: the user file, message bases, logs, node status files, door data. Reads pull the file through a cache that is flushed whenever the board's generation counter moves, since any write by anyone bumps it. Writes are acknowledged from the local copy and flushed behind, in strict order, so DOS is never left waiting on the host's latency for an ordinary write.
The wire to the host is plain HTTPS. The tab posts file operations to api.php, up to sixty-four in one request, every reply carrying the generation counter. The host stores the board in one folder. There is no database of files; the board directory is the board.
A DOS file call through EtherDFS gives up after about half a second, and a shared host's round trip can eat most of that. So the client warms the files a boot will need before the machine exists, answers a read against an in-flight pull the moment its own bytes have landed rather than when the file is complete, remembers directory listings while nothing has changed, and retries the odd failed transfer once after a breather. Several of these rules were learned the hard way, from a door dying mid-game because a 574 KB overlay's cold reload outlived a single read's patience.
Record locks are the one thing that must never be local. A lock call crosses the wire and is granted or refused by the host, so multi-node packages that rely on DOS record locking, which is every multi-node package of the era, behave as they did on a LAN. DOS releases a handle's locks when the handle closes and everything when the program exits, and neither event crosses the wire on its own, so the JavaScript says it for DOS. A player who left a battle in The Pit taught that rule: the winner spun forever against a lock nobody living held.
05The serial line
The board program talks to the caller through COM1. The emulator provides a serial UART at the standard address; the FOSSIL driver on the floppy implements the FTS-0061 interface that BBS software of the era expects, polled rather than interrupt driven because the emulated port runs at bus speed and buffering would only add latency. Packages that drive the UART directly instead of asking for a FOSSIL work on the same machine.
The FOSSIL is small, but it has history. Fido v12 drives its entire internal clock off one FOSSIL function; it hooks the interrupt the driver names and counts milliseconds in that handler, and every internal delay, including the goodbye's settle wait, spins on those counters. The driver originally answered that call behind a port check the specification says it must not have, and had two register roles reversed, so Fido hooked an interrupt nothing fires and waited forever at logoff. That was found by sampling the emulated CPU's instruction pointer from the page, fixed in the driver's assembly source, and the floppy rebuilt.
On the caller's side of the port sits the terminal: a CP437 canvas with the IBM VGA font, an ANSI parser, hotkey and line input modes, and a baud throttle so screens paint at a period-correct pace. The machine's own VGA screen is never shown to callers; only what comes out of COM1. The SysOp can see the machine's screen in the console.
The connect ceremony is the loading screen, and the dial tracks real progress: the emulator instantiating, the static bundle verifying, the boot, and then, on the first byte out of COM1, CONNECT with the speed. A board at its node cap, or one with a maintenance run in progress, answers BUSY before any emulator work is spent. Carrier is real on this line: the page can drop carrier detect into the guest, and the board sees a hangup the way it expects and gets its moment to save. That is how a SysOp's Disconnect button ends a call, how a board that talks to a modem at logoff is hurried along, and how a mailer-shell package like Fido, which prints its goodbye and waits for the caller's carrier to drop, is answered.
A few packages needed a modem that behaves more exactly. Door-mode Citadels raise DTR and drop it in the same millisecond on their way up, then park it low for the whole session, because a board behind a mailer trusts the mailer's modem; the page must not read that as a dead boot.
06Nodes, leases, and classes
The server keeps one row per live node, called a lease. It records the node number, a token the browser presents on every request, a heartbeat, and the node's class. The browser heartbeats every thirty seconds; a lease silent for ninety seconds is dead, and the sweep closes it, releases its locks, and resets the package's node files so a ghost does not linger in who's online. A closed tab is a dropped carrier; the sweep is what returns the board to a clean state afterward.
The class is the heart of the access model, and it is decided only on the server:
- Unbound. The machine booted, nobody has logged on. It may read the package's files, read the user file with every password masked, write only the node's own files and logs, and append a new user record if the SysOp's policy allows applications. Nothing else.
- Bound. A caller has been identified through the relay. Adds the shared paths and the caller's own user record, with the protected fields pinned. Other callers' records stay masked.
- SysOp. The bound record's level meets the package's SysOp threshold. Full board access from inside the BBS, but other callers' passwords stay masked even here.
- Maintenance. A nightly event boot. Exclusive, full access, and a snapshot is taken before it starts.
- Console. The SysOp's DOS page, node zero, behind the console login. Trusted at the gate.
Every file operation that reaches the host is classified against the profile's access list before it touches the board directory. The list is first match wins, with globs over paths, and it names the minimum class and whether writes are allowed. Unlisted paths fall to shared, bound-only, read-only, and are logged so the profile can be extended. Nothing falls open.
07The credential relay
This is the part that had to be invented. There is no web login: the DOS board's own logon prompt is the login. And yet the browser, which runs the board program, must never hold a real password, because a caller can run anything in their own browser, patched or not.
The problem, stated plainly. These programs keep their user accounts in their own data files, and most keep the password in plain text; Renegade stores a checksum of it. The program reads the user file, finds the record, compares what the caller typed to what the record holds, and lets them in. If the file is served to the browser as is, every caller's browser could read every password. If the file is not served, the program cannot log anyone on. The relay resolves this without changing the program.
For every node, the server generates a session stand-in at boot: a random string the length of a password, encoded the way this package stores passwords. Every user record served to the node has its password field replaced by the stand-in, through the package's own encoding, so the program holds a file in which every account has the same password and that password is meaningless. Fields the profile names as secrets, such as a forgot-password answer, are served as a lone asterisk. No real password bytes go down the wire, in any form, to anyone.
The server watches which records the node reads. Every record read since the second most recent typed line forms the candidate window. When the caller types a line and presses Enter while the node is unbound, the browser sends the line to the server before the board program sees the Enter. The server compares the typed line against the real password of every record in the window, decoded from the clean file on disk. Exactly one match binds the node to that record. Several matches, from shared weak passwords across a file the program scanned, are disambiguated by the handle the caller already typed. No match passes the line through unchanged.
On a bind, the browser erases what it typed into the program and types the stand-in instead. The program's own comparison passes, because the stand-in is exactly what its copy of the record holds. The caller typed their real password once, at the prompt they remember; the program never saw it. Once bound, the relay is out of the loop and keystrokes go straight to COM1.
New callers take the board's own application road with no interception at all, because the board is establishing a password rather than checking one. The server's rule for an unbound node is that it may write only past the user file's end as it stood at boot, plus any index files the profile names. On the first write the server binds the node to the new record, pins the new-user level and flags to the package's own defaults whatever bytes the client sent, and reads the password the program wrote, for next time. The SysOp's policy switch gates the whole road on the server, which is the only gate that holds against a tampered client, and a daily cap per address limits drive-by applications.
A malicious client sees the stand-in in every masked record and can patch its own copy of the program to accept any password. The result is a locally logged-in program on a node the server still considers unbound. Every shared write is refused, the message bases are unreadable, and the program errors out. Binding happens only on the server, only on a real password match, and nothing the client can type or patch changes that.
If the relay never fires, the node stays unbound and the first shared write after logon is refused. A relay bug can strand a caller at a prompt; it cannot hand one the board.
Failed candidates are throttled: eight per node drops the line, and twenty per address in fifteen minutes serves BUSY. Only lines checked against a record the board read after the first typed line spend the budget, so handles and application answers are never charged as password guesses. Every checked line leaves one breadcrumb in the log (typed length, window size, outcome, never the text), so a logon failure in the field is diagnosable from the log page alone.
08The write jail
The relay establishes who the caller is. The jail decides what their program may write, on the server, on every write, with no trust in the client.
- A bound node may write its own user record. On that write, the profile's protected fields (level, flags, download level, privilege bits) are overwritten with the values on disk before the write commits. No self-promotion, even from devtools.
- Writes aimed at any other caller's record are denied, logged, and tripwired, with one profile-scoped allowance: the package's own delivery bookkeeping. Renegade delivers email by rewriting the recipient's whole record with the waiting-mail byte bumped, so the profile names the fields a bound caller may land on a foreign record, and only those bytes pass.
- The password field is not pinned, because callers may change their password, but a program writing the served mask back has the real password restored underneath it.
- Packed fields pin only their bits. Fido keeps the privilege in three bits of a word that also carries the caller's own screen settings, so the jail splices those bits and lets the rest through.
- Node files follow an own-node rule: node one writes its own temp files and nothing else.
- Truncating or deleting shared bases from a caller's seat is denied.
The classic BBS security model assumed the executable was trusted and the caller was confined to what it allowed. ATA inverts that: the caller runs the executable. So the bridge is the only real boundary, and the product says so.
What the design prevents: reading other callers' passwords, privilege escalation, writing outside your own record, touching another node's files, exceeding the node count, colliding on a handle, and drive-by accounts when applications are closed.
What is detection and recovery rather than prevention: vandalism of message bases and door data by a caller who is already a member. That is covered by tripwires, snapshots, the package's own repair tools, and the ban list.
What is disclosed as not achievable in this design: private mail confidentiality inside the classic board, because a bound node can read the mail stores it is permitted to read. SysOps who care about it turn private mail off in the package.
09Package profiles
The machine, the drive, the relay, the jail, the locks, the leases, the snapshots and the events are package-independent. Everything that differs between packages lives in one profile, and every subsystem reads from it. A profile declares the files that identify the package and the exact versions it was verified against; the drive letter and path it expects, and the handoff line; the static and session globs and the access rows; the user file's record size and the offset, length and encoding of every field that matters; the SysOp level, the node files to reset, and the new-user road; and the console presets, the seeded nightly batch lines, and a plain-words note for the SysOp.
None of those numbers are guessed. A profile is written by bringing the package up for real: staging the install exactly as its documentation tells a SysOp to, booting it on the console, walking a real call over the wire, and reading the user file byte by byte against the package's own structures or published source. Each package had its own lessons:
- Renegade 1.40 stores the password as a checksum of its uppercased text, and the string next to it that looks like a password is the forgot-password answer. The first profile bound on the wrong field, and the Renegade development team's published Pascal source settled it in an afternoon.
- Fido v12 capitalizes a typed password like a name, so the stand-in must be capitalized letters only. It writes a new caller's record only after the goodbye's settle wait, so an applicant who never says goodbye is not registered. That is the era's truth, kept.
- RBBS-PC 17.4 keeps its user file as a hash table with signed levels, stamps a sentinel into a slot the moment a caller picks Register, and answers logoff by restarting itself unless its configuration says otherwise.
- Maximus 3.01 refuses to boot if its language file is not strictly older than its compiled configuration, so the installer preserves timestamps. Its phone prompt rejects any number containing 555-1212, by design of its source.
- The Citadel family's shipped sample configuration belongs to somebody's real machine on COM2 or COM4; the stagers set COM1. Asgard-86 drops characters fed back to back at its remote reader, so the test rigs type like a human.
- T.A.G. 2.7d2's user editor saves its file only on a clean quit, its phone and birthdate prompts take digits only, and its shipped security level allows one call per day.
Packages are distributed only with the rights holder's permission or under a license that allows it, and every package ships byte-identical to its release. The only files the stagers write are the ones a SysOp writes during installation. Door games are not bundled; SysOps install their own, and the board launches them exactly as it always did, on the same serial line, with drop files the board writes itself.
10Events and nightly maintenance
There is no idle DOS process anywhere. So the nightly event runs the way it ran in 1993: whichever machine is on the board alone when the duty is armed runs it.
The SysOp's event language is a batch file. NIGHTLY.BAT and WEEKLY.BAT live in an ATA folder at the board root; the package profile seeds them, and the SysOp owns them from then on. At the board's arm time, midnight in the board's time zone by default, cron or pseudo-cron marks the duty pending. The console has a Run now button. And the first caller to connect while a duty is pending and nobody else is on runs it on the way in: the dial holds on RINGING with a note, a maintenance machine boots, runs the batch, exits, and then the caller's own node boots and they see CONNECT. Seconds, on a warm nightly.
A maintenance boot is exclusive, has full access, and begins with a forced snapshot of every shared path. On completion the machine writes a stamp file through the drive, and the server treats that write, arriving through the gate on a maintenance lease, as the only valid completion signal. A maintenance lease that dies before the stamp is an abort: the snapshot is restored, the attempt is counted, and two aborts in a row halt automatic runs until the SysOp resets the duty, so a bad batch does not punish every caller of the morning.
The package's internal event scheduler is left unconfigured on purpose. The board never sits at its waiting-for-caller screen to fire it; ATA owns the event outright.
11The SysOp console
The console is a web page behind its own login, created by the install wizard. It shows the nodes in use with a Disconnect button, calls today and ever, cron and sweep health, pending duties and why they have not run, snapshot age, and the event log: relay binds and denials, jail denials, tripwires, sweeps, event runs.
The DOS page is a two-screen maintenance page, booted against the live board as node zero. The left half is the machine's own screen and keyboard, facing the SysOp; the right half is a terminal on the serial line, showing what a caller on this node would see. One-click presets come from the profile: Renegade's waiting-for-caller screen, Fido's console, the tool that compiles T.A.G.'s string definitions, a plain DOS prompt. Where a package needs keystrokes typed after it starts, the preset carries them and the page types them when the screen shows each marker. The page prints the live board directory's full path on the host, because that is where a SysOp uploads doors and files, and it warns when callers are live, because changes are immediate.
12Installing and packaging
The product is one zip: a folder the SysOp uploads as is to any host with PHP 8.1 or newer. There is no build step, no daemon, no third-party service, and no shell access assumed. Opening the installer runs a five-minute wizard: an environment check, a board picked from the bundled packages, the board's name and number and time zone, and the console credential. The wizard seeds the board, writes the configuration, locks itself, and prints the cron line, which is recommended but optional; without it the same housekeeping rides caller traffic. Everything the SysOp owns lives in one directory: the live board, the database, snapshots, logs. Upgrading is replacing the product files and keeping that directory where it is.
13How it is tested
Nothing in this design is assumed to work because it should. Each package has an end-to-end rig: a real Chromium driven by Playwright, against the real PHP bridge, booting the real machine, walking real calls over the wire. A rig logs the seed SysOp on through the relay, and checks that no stored password bytes appear in any response body, with base64 file data decoded. It rides the board's own screens to its main menu and logs off by the board's own road so the seat closes. Where the package supports it, the rig also registers a new caller through the package's own interview, checks the record on disk field by field, and calls back as that member. Renegade's rig plays a door end to end.
Beside the rigs, a PHP suite of several hundred checks exercises the relay, the gate, the jail, the drivers, the leases, the events and the snapshots against a synthetic test board with no browser and no emulator. A release ships only when every rig and the suite are green on the same commit.
The discipline that came out of this: measure on the wire, never infer from a hex dump; when a package's source is public, read it; re-harvest every fixture after any configurator session on a live board; and when a debug line prints a variable after the loop that patched it, it is reporting the patch.
14Honest limits and open items
- A fresh board runs one node. Multi-node operation is inherited machinery that has not yet earned its confidence across packages; that is the next round of work, with a door as the canary.
- Two further Citadel variants are parked: one stores its user log obfuscated with a per-installation key, so the relay cannot read it; the other has no door mode at all.
- Private mail inside the classic board is not confidential from other members, as disclosed above.
- SysOp break-in chat is planned: first surfacing the page request in the console, then routing console keystrokes into the caller's session if it proves possible.
- Mobile browsers work but are heavy; the emulator is a desktop-class load.
15Credits
The DOS machine stands on other people's work, shipped unmodified and documented in the runtime's license file: the v86 emulator by Fabian Hemmer and contributors (BSD), SeaBIOS (LGPL), the Bochs VGA BIOS (LGPL), the FreeDOS 1.2 kernel and shell (GPL), the Crynwr NE2000 packet driver by Russell Nelson (GPL, source included), EtherDFS by Mateusz Viste with Michael Ortmann's maintained fork (MIT), and HimemX by Japheth (public domain, derived from FD Himem). The FOSSIL driver, the EtherDFS server side, the terminal, and everything above the machine are ATDT's and ATA's own work, MIT licensed.
The boards are their authors': Renegade (the Renegade Development Team, distributed with T.J. McMillen's permission), Fido v12 (Tom Jennings, public domain), RBBS-PC (the Capital PC User Group, Tom Mack and company), Maximus (Scott J. Dudley, Lanius Corporation, GPL), Citadel-86e (Farokh Irani, from the Citadel-86 line), Asgard-86, T.A.G. (The T.A.G. Team; 2.7d2 by Alan Jurison and Victor Capton), and RemoteAccess (Andrew Milner, rights held by Bruce Morse). They run here exactly as released.
Questions, corrections, or a package you would like to see supported? Leave your address and it will reach me. The companion project, ATDT BBS, is free, MIT licensed, and running today.