Files
the-button/README.md
T
2026-10-30 16:36:36 -04:00

33 KiB
Raw Blame History

The Button

A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If it reaches zero, that season's game ends and final stats are published. Each equinox and solstice brings a fresh season.

Inspired by Reddit's The Button experiment.

How to play

  1. Sign in with your eom.dev account through Dex. New accounts are registered in OpenLDAP from the eom.dev account portal.
  2. Press the button. The countdown resets to 24 hours. Your first press each season is always available; after that, two different players must press before you can press again.
  3. You score points based on the zone the countdown was in when you pressed. The longer you wait, the more you score, but if everyone waits too long, the game ends.

Zones

Higher-value zones are shorter, so scoring big is risky.

Zone Points Duration (24h timer)
Green 0 12h 11m
Blue 2 6h 6m
Yellow 4 3h 3m
Red 8 1h 31m
Black 16 46m
White 32 23m

Every press records the zone, the points, and the exact remaining time in milliseconds. Players can view their own score, presses per zone, and press history at any time. A public live leaderboard shows all players ranked by total points, with press counts; ties are sorted alphabetically. Scores refresh every five seconds and immediately after you press or join. Detailed global stats (presses per zone, closest call, full press log) stay hidden until the game ends.

Press eligibility is enforced by the server and counts distinct player accounts, not sessions or repeated presses. The first press each season is allowed; after each successful press, two different players must press before that player can take another turn. Eligibility resets when the season resets. With fewer than three participating accounts, a player who has pressed may have to wait until another player joins.

Logged-in players can also join the separate email-only mailing list; that subscription does not require an LDAP/Dex account.

The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. Administrators can remove messages through the Admin dashboard; players cannot edit messages.

Every successful button press also posts an automatic chat announcement with the player's name, exact time remaining, and points earned. These announcements are saved with the press and do not count toward the player's chat cooldown.

Administrator dashboard

Administrator controls are available in the Admin section after signing in with an admin account. The server checks administrator permission on every dashboard request and action; hiding the menu is not the authorization boundary. Public registration cannot grant admin access, and legacy JSON imports do not import privileged flags.

Assigning the first administrator

  1. Upgrade/start the server once so it adds the new database fields, then create a regular account through the eom.dev account portal and sign in through Dex if needed.
  2. Stop the server and back up the SQLite database. The game uses an in-memory snapshot; editing flags while it runs is unsupported and can be overwritten.
  3. Open the configured SQLite database with a trusted local SQLite editor. In Docker, the database lives in the persistent /data volume; stop the service before editing/copying it. Do not modify the legacy JSON backup.
  4. Find the intended account's exact ID, then promote only that ID:
SELECT id, name, is_admin, is_banned, is_deleted FROM players;
UPDATE players SET is_admin = 1 WHERE id = 'REPLACE_WITH_EXACT_PLAYER_ID'
	AND is_banned = 0 AND is_deleted = 0;
  1. Verify exactly one intended row changed, save/close the database, and restart the server. Sign in and open Admin. No public bootstrap endpoint or default admin password exists.

The database fields players.is_admin, players.is_banned, and players.is_deleted are boolean integers constrained to 0/1 and default to 0. Existing SQLite accounts receive non-admin defaults; their flags persist across normal saves, restarts, and seasons. For emergency recovery use the same stopped-server procedure to restore access to a non-deleted account.

Controls and safeguards

  • Overview: current season/game status, player count, season presses, session counts, and stored chat-row count. Password hashes, session tokens, and mailing-list contact data are never returned by the dashboard.
  • Admin access: grant/revoke admin status for another account. Any role change revokes that account's current sessions, requiring login again.
  • Ban/unban: bans immediately revoke all sessions and presence and block login and authenticated actions. Existing scores and messages remain. Unbanning does not revive old sessions. Banned players cannot be promoted until unbanned. This is an account ban, not an IP ban; it does not prevent creating another account.
  • Delete account: irreversible logical deletion. Removes the display name, password hash, sessions, subscription flag, and privileges; anonymizes authored chat and press announcements and matching archived display names. The account disappears from admin account lists and its original name can be registered again. An inert Deleted player tombstone retains the internal ID/scored presses so historical scores, archives, and foreign keys remain consistent. Old archived standings have no player IDs, so exact duplicate names are conservatively anonymized together. Anonymized scores may remain on leaderboards.
  • Remove chat: removes a message from the visible feed and scrubs its text while retaining row identity/timestamps for cooldowns and stable ordering. Also supports automatic press/milestone messages. Suppression survives restarts and seasonal milestone archival; removing a milestone announcement does not undo the unlock. Only messages in the latest 100 visible feed are currently offered for moderation.
  • Safety: confirmation prompts for each action; cannot remove your own access or the last active administrator. Admin actions and audit writes commit together; failed actions do not partially update game memory. The last 50 successful actions appear in the dashboard. The SQLite admin_audit table retains action, actor/target IDs, and timestamp, not message bodies, passwords, or tokens.

Account deletion is not a comprehensive personal-data erasure tool: the separate mailing-list CSV, backups, exported data, and third-party copies are not edited automatically. Handle those separately when necessary. Audit identifiers and anonymized game history are deliberately retained. Dashboard controls refresh manually to avoid moving focused actions unexpectedly; access is still rechecked by the server each time. No game reset/end controls are included.

Admin access uses the existing per-game session authorization, with cross-site request rejection and no permissive CORS. The OIDC flow establishes a game session only after server-side token validation; its opaque token is held in an HttpOnly, SameSite cookie and is not exposed to JavaScript, a URL fragment, or localStorage. POST requests require an exact same-origin Origin. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices and require MFA at Dex for privileged identities.

Admin API: authenticated admin-only GET /api/admin; POST /api/admin/action accepts {action, target_id, value?} for set_admin, set_banned, and delete_player, or {action: "delete_message", target_id, at}. Boolean flags must be actual JSON booleans; IDs/timestamps must match the selected record. Anonymous requests receive 401, non-admin accounts 403, invalid inputs 400, missing targets 404, and protected actions 409. All JSON responses use Cache-Control: no-store.

Seasons and champions

Chat supports emoji autocomplete: type : followed by a name (for example :sm, :heart, or :fire:), then choose a suggestion with the arrow keys and Enter/Tab, or click/tap it. Escape dismisses suggestions. Selection inserts the actual Unicode emoji at the cursor without sending the message; press Enter again or Send to post. Unknown shortcodes remain unchanged. Suggestions are a built-in curated list, with no external service, and respect the message length limit.

Seasons begin at 00:00 UTC on the UTC date of each astronomical equinox or solstice, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as Season I, Season II, Season III, etc., including in the subtitle under THE BUTTON. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101.

At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, timing achievements, and milestone progress reset. The four lifetime season awards persist. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat.

A season finishes when the countdown expires or the next seasonal boundary is reached. Pressing cannot extend the timer beyond that boundary. If the timer expires early, pressing and new registrations remain unavailable until the next season; existing players can still log in and chat. The watcher continues running and also catches up boundaries after downtime. A missed season starts at its scheduled midnight, not when the server restarts: without presses its countdown may already be over. Missed seasons have no invented winners.

The Seasons section shows the current season and next start, a champions table, and expandable final standings. Each result shows the winner(s), points per winner, and how long play lasted from actual game start to timeout/boundary (not the calendar season's full length). Equal top scores share the win; alphabetical ordering only orders the display. A player must have pressed at least once to win; a zero-point press qualifies, but an idle account does not. A season without presses has no winner.

Upgrade behavior: the existing JSON/SQLite game is adopted unchanged into the calendar season containing the upgrade time, including all old scores and presses. Its actual original start is retained for duration reporting, even if it predates that calendar season. A brand-new installation similarly starts a partial first season immediately rather than fabricating past play. All subsequent seasons begin at the scheduled UTC midnight. Archives and current-season metadata are added automatically in SQLite; no manual conversion is needed. Keep a database backup before deployment. The first watcher/state request archives a game that was already ended; historical seasons are not reconstructed from an undivided pre-season game.

Public GET /api/seasons returns {current, seasons} with current boundary metadata/standings and immutable finished results. GET /api/state includes season metadata. The main leaderboard, achievements, rivals, and final statistics refer to the current season. Client views refresh across seasonal boundaries without a reload; local alert preferences remain enabled but alerts are re-baselined for the new season. One-life mode is still separate and unimplemented.

Engagement roadmap

The goal is to make waiting social and consequential without rewarding button spam or changing the current game's rules. Planned delivery order:

  1. Optional live presence — implemented. Show participating players watching the clock and who is viewing Chat. On by default, with a remembered opt-out; ephemeral heartbeats, automatic expiry, and no activity history. See the details below.
  2. Personal bests and rivalries — implemented. Your score shows closest call, highest-scoring press, leaderboard position, and the player immediately ahead with points needed to catch up. Existing history is credited automatically; no data migration is needed.
  3. Achievements — implemented. Close call, Full spectrum, and Keeping us alive appear under Your score with unlock dates and progress. Existing press history counts, with no bonus points or press-volume rewards.
  4. Community milestones — implemented. Twelve one-time shared goals post gold announcements in chat. Unlocks persist across restarts, and historical milestones are silently credited on upgrade.
  5. Optional zone alerts — implemented. Choose a scoring zone and opt into a chime, browser notifications, or both in Alerts. Off by default; notifications require explicit permission. Works while the page remains open, not after closing it.
  6. Separate one-life mode — exploratory. Allow one press per player per round in a separate mode with its own rounds and leaderboard. Never silently change the current game or reset its data.

Keep new features consistent with the Catppuccin Mocha styling and selectable-section navigation. Preserve visible countdown/button controls on desktop and mobile, authenticated mutations, bounded input, safe text rendering, and server-authoritative scoring. Verify changes using isolated databases rather than live game data.

Personal bests and rivalries

Your score includes private personal records derived from your existing press history. Closest call means the least time remaining; highest-scoring press means the most points in a single press. Ties prefer the lower remaining time, then the earlier press. A zero-point press is still a valid record; new players see placeholders until their first press.

Leaderboard positions match the public ordering (points descending, then name alphabetically), not shared competition ranks. The rival is the player immediately above you, including alphabetical ties. The score card shows the gap to match their total and the minimum additional whole-number points to exceed it; actual presses still award fixed zone values, so you may jump past that threshold. Equal top scores are described as a shared lead; solo players and outright leaders receive their own messages. These cards refresh every five seconds while Your score is open and after pressing. Rival information exposes only public names and totals, never another player's records or press history.

Achievements

Your score includes twelve private achievements: eight current-season timing badges and four lifetime awards based on completed seasons. All are credited from existing history:

Achievement Requirement
Close call A successful press with strictly less than 60 seconds remaining (and more than zero).
Full spectrum At least one press in each of the six scoring zones; repeated presses in one zone do not advance it.
Red alert Press in the red zone.
Into the dark Press in the black zone.
White knuckle Press in the white zone.
Photo finish Press with strictly less than ten seconds remaining, and more than zero.
Last second Press with strictly less than one second remaining, and more than zero.
Part of the story Make at least one press in a completed season.
Back for another Participate in two completed seasons.
Podium finish Finish a completed season with competition rank 1, 2, or 3 by points.
Season champion Finish a completed season with the highest score; joint winners qualify.
Keeping us alive Press after a global quiet stretch of at least half the saved game countdown: 12 hours for the standard 24-hour game. Scales with shorter test games.

Quiet time is measured from the previous press by any player, or from game start if there was no previous press. It is not the time since your own last press. The earliest qualifying press determines each unlock date. Progress is based on completed presses, not idle time accumulating while the page is open. These are badges only: they do not change scores, publish chat announcements, or reward repeated resets.

Historical JSON imports and existing SQLite history are credited automatically. No new table or manual migration is required for personal achievements; badges are recomputed from history after restart. They remain available after game over and refresh with Your score. Personal achievements are returned only by authenticated GET /api/me, not by the public leaderboard or final leaderboard.

Community milestones

Lifetime season awards use archived presses and stable player IDs, not display names. They are awarded only after timeout or seasonal rollover, including existing archives. One press (even zero points) qualifies as participation; idle accounts do not. Podium uses competition ranking: rank is one plus the number of participating players with strictly higher points, so tied scores share rank and may expand the podium. Joint top scores all earn champion. Awards retain their first qualifying season's finish timestamp and never change scores. An ongoing leaderboard lead does not unlock a final-result award.

New shared-event definitions already met on upgrade are silently credited individually; unmet goals still announce when reached. Existing milestone unlocks are not reset by adding new definitions.

There are twelve community milestones, matching the twelve personal achievements. Each posts once per season to the shared chat, with distinct gold styling:

Milestone Community goal
One day together Survive 24 hours.
A week of nerve Survive 7 days.
Thirty days strong Survive 30 days.
A growing crowd Reach 10 registered players.
Fifty strong Reach 50 registered players.
A hundred second chances Reach 100 presses.
A thousand moments Reach 1,000 presses.
Our first photo finish First successful press with more than zero but less than 10 seconds left.
Every shade together The community visits all six scoring zones this season.
A race worth watching Ten distinct players earn positive points this season.
A thousand points together Reach 1,000 combined seasonal points.
A season to remember Reach 10,000 combined seasonal points.

Survival milestones use real elapsed time since game start, not scaled countdown durations, and never count time after the game deadline/end. They are checked by the server watcher and normal state requests, even without presses. The photo finish is a single community first, not an announcement for every improved record. No milestone gives bonus points.

Milestone unlocks are stored in SQLite separately from player chat, and share the latest-100-message feed. They have no player dependency and do not affect chat cooldowns. Registration/press milestone unlocks commit with the corresponding game update. Repeated requests, watchers, and restarts cannot announce a goal twice. The first upgrade/import silently records already-reached goals to avoid flooding chat; future newly-reached goals announce normally. On later restarts, newly crossed survival milestones are announced once. Missed goals are never replayed repeatedly.

Optional zone alerts

The Alerts section is available without an account. Select blue, yellow, red, black, or white, enable alerts, choose a chime and/or browser notification, and save. Alerts are off by default. Settings are saved locally in this browser and synchronized between its open tabs, not stored with your account.

Alerts use server snapshots, normally every five seconds, rather than trusting the animated countdown alone. They fire only when an observed countdown advances into the selected zone, once per selected zone per countdown deadline. Opening/reloading the page, saving settings, game over, stale responses, and a changed deadline do not trigger an alert. A reset establishes a fresh baseline. If polling skips the entire selected zone, no late alert is sent. The latest alerted deadline/zones are remembered in browser storage to reduce duplicates between tabs; simultaneous-tab races are possible.

Notification permission is requested only when explicitly saving enabled notification settings. Unsupported browsers, insecure origins, denied/dismissed permissions, and blocked audio produce guidance rather than repeatedly prompting. Sound uses a short generated chime; Test chime lets you enable/test audio after opening the page. Audio may require another gesture after reopening, depending on browser policy.

Keep the page open. Background throttling, suspension, network failures, and OS notification settings can delay or suppress delivery. No service worker, push subscription, or closed-browser notification delivery is included. The selected section need not remain open. One-life mode remains exploratory and is not implemented.

Live presence

Show me online is enabled by default for logged-in players. Players can disable it in their score section; an existing saved opt-out is preserved, including across reloads and open tabs. The count near the clock includes only players sharing presence with a visible tab; Chat also lists those currently viewing that section. Multiple tabs/sessions count as one player. Updates run every 15 seconds, and stale presence expires after 45 seconds. Hiding a tab or opting out removes that tab's presence; logging out removes all presence for that session. Failed disconnect updates fall back to expiry. Presence lives only in server memory and is cleared on restart. Guests and players who turn the setting off are not tracked, so the count is not a total visitor count.

Public GET /api/presence returns {online_count, chat_players}. Authenticated POST /api/presence accepts {tab_id, active, in_chat} with a 32-character lowercase hexadecimal tab ID and boolean flags. At most 10 active tabs per login session are accepted. No player IDs, session IDs, or last-seen timestamps are returned publicly.

Presence tests cover authentication, validation, expiry and heartbeat renewal, tab/session deduplication, chat membership, revoked sessions, bounded entries, and logout. Browser checks cover opt-in/out, tab visibility changes, chat membership, logout, and responsive layout.

Running

Copy .env.example to a private .env, set the Dex client secret and required access group, then start the service:

docker compose up -d --build

The service refuses to start without complete Dex configuration. There is no local-password fallback.

Open http://localhost:8000. The SQLite game database and the mailing list are stored in the button-data volume. Existing JSON game data in that volume is automatically migrated on the first startup after upgrading.

docker compose logs -f                                    # view logs
docker compose cp thebutton:/data/mailing_list.csv .      # export the mailing list
docker compose down                                       # stop (data is kept)
docker compose down -v                                    # stop and delete all data (new game)

Without Docker

Requires Python 3.9+. Install requirements.txt and set the required Dex variables from .env.example before running. The game has no local-password login mode.

python server.py

Open http://127.0.0.1:8000. Game state is stored in data.sqlite3.

Migrating existing data

On startup, if the database has no saved game and data.json exists, the server automatically imports the countdown, players, legacy password hashes, active sessions, subscription flags, and complete press history in one transaction. Password hashes are retained for data compatibility but are never accepted for login. Older single-token player records are also supported. No manual conversion or additional dependencies are needed.

The JSON file is left untouched as a backup. Once the database contains a game, it is authoritative: subsequent restarts never re-import the JSON file. Invalid source data causes startup to fail rather than silently starting a new game; fix the source and restart to retry. The mailing-list CSV is unchanged.

Custom BUTTON_DATA_FILE paths continue to locate legacy JSON data; by default, the SQLite database is created alongside it with a .sqlite3 suffix. BUTTON_DATABASE_FILE can override the database path. Docker keeps both paths under /data, so existing named volumes migrate automatically.

To start a new game, stop the server and delete the SQLite database and remove or move the legacy JSON backup; leaving the JSON file in place would import the old game again.

Configuration

Set via environment variables (in Docker, add them under environment: in compose.yaml).

Variable Default Description
BUTTON_DURATION_SECONDS 86400 Countdown length. Zones scale with it.
BUTTON_HOST 127.0.0.1 (0.0.0.0 in Docker) Address to listen on.
BUTTON_PORT 8000 Port to listen on.
BUTTON_DATA_FILE data.json Legacy JSON file to import if no saved SQLite game exists.
BUTTON_DATABASE_FILE data.sqlite3 SQLite game database; defaults to BUTTON_DATA_FILE with a .sqlite3 suffix.
BUTTON_MAILING_LIST_FILE mailing_list.csv Mailing list file.
BUTTON_OIDC_ISSUER required Dex issuer URL (HTTPS), normally https://dex.eom.dev.
BUTTON_OIDC_CLIENT_ID required Dex OIDC client ID registered for The Button (the-button).
BUTTON_OIDC_CLIENT_SECRET required Client secret; provide through a private deployment secret, not Git.
BUTTON_OIDC_REDIRECT_URI required Exact HTTPS callback URI registered in Dex, normally https://button.eom.dev/auth/oidc/callback.
BUTTON_OIDC_REQUIRED_GROUP required Required Dex groups claim value for The Button access.
BUTTON_OIDC_ADMIN_GROUP unset Optional Dex group granting The Button administrator role.
BUTTON_OIDC_SUBJECT_LINKS_JSON {} Temporary map from OIDC sub values to existing player IDs, preserving game history.
BUTTON_OIDC_ADMIN_SUBJECTS_JSON [] Optional explicit Dex subject allowlist for bootstrap administrators.
BUTTON_OIDC_REGISTER_URL account portal URL Link to account registration.
BUTTON_OIDC_COOKIE_SECURE 1 Mark the OIDC state cookie Secure; disable only for isolated local tests.

Copy .env.example to a private deployment environment and register the exact callback URI with Dex. Request openid, profile, email, and groups scopes; configure Dex to emit a stable sub, verified email, preferred_username (the LDAP uid), the LDAP displayName as the OIDC name claim, and the required service group. If displayName is empty, the player name falls back to uid. Dex is mandatory: the server fails closed if its configuration is missing, and local password login and registration are not available.

Use Dex sub as the immutable identity key. Do not automatically link a local player just because an email or display name matches. For score/history continuity, obtain the correct Dex subject through a trusted admin process, map it to that existing player ID with BUTTON_OIDC_SUBJECT_LINKS_JSON, have the player sign in once, then remove the temporary mapping after oidc_sub is saved. Never replace player IDs or rewrite press/season history. A name collision without an explicit subject link is rejected rather than silently merging accounts.

The Button validates the issuer, audience, signature (RS256), expiry, nonce, verified-email claim, and required group. Dex groups gate access; The Button retains its own scores, bans, and administrator roles. Only map the Button's admin group/subjects to administrators. Protect Dex/LDAP and callback traffic with TLS, keep the client secret private, and test sign-in, role changes, and revocation before rollout. Existing local accounts are not automatically migrated or deleted.

Tip: BUTTON_DURATION_SECONDS=60 makes for quick testing.

API

Method Path Auth Description
GET /api/state Deadline, server time, zones, whether ended
POST /api/register Retired; returns 403. Create accounts through the eom.dev portal.
POST /api/login Retired; returns 403. Sign in through Dex.
POST /api/logout ✓ Revoke the current session token
POST /api/press ✓ Press the button
GET /api/me ✓ Your score, zone counts, and press history
GET /api/leaderboard Public player names, total points, and press counts
GET /api/seasons Current season and archived champions, scores, durations, and standings
GET /api/chat Latest 100 public chat messages
POST /api/chat ✓ {message} → saved message; 200-character limit
GET /api/presence Opted-in online count and chat display names
POST /api/presence ✓ {tab_id, active, in_chat} presence heartbeat or removal
GET /api/stats Global stats (403 until the game ends)
POST /api/subscribe ✓ {first_name, email, last_name?, phone?}

Authenticated requests use the server-issued HttpOnly session cookie; JavaScript does not receive a bearer token. Dex is the only login method.

Security notes

  • Legacy local passwords are hashed with PBKDF2-SHA256 (600k iterations) for import compatibility only; session tokens are stored only as SHA-256 hashes.
  • Legacy local logins previously locked for 15 minutes after 5 failed attempts per name; those endpoints are now retired.
  • New Button players are linked to stable Dex sub values; passwords remain in OpenLDAP and are never copied to The Button. Imported local hashes remain stored for migration compatibility but cannot authenticate.
  • Configure BUTTON_OIDC_REQUIRED_GROUP to a Dex group backed by the appropriate OpenLDAP groupOfUniqueNames. The account portal updates that group only after confirming the registration email.
  • The account portal accepts only allowlisted service targets. After email confirmation, the password is sent via LDAP Password Modify over verified TLS; slapd generates the hash. Production provisioning is gated on LDAP_EXPECTED_PASSWORD_SCHEME={ARGON2}. This is an operator acknowledgement, not a live read of cn=config; independently verify the server's Argon2 module and olcPasswordHash setting.
  • Do not use MD5-crypt, {CRYPT}, MD5, or cleartext for new accounts. Current osixia OpenLDAP v2 documentation includes Argon2 support/defaults; v1 may require a deliberate data/config migration before enabling account provisioning.
  • Use BUTTON_OIDC_SUBJECT_LINKS_JSON only for explicit one-time legacy-account linking. Do not link by matching email or display name alone; a name collision without an administrator-approved subject mapping is rejected to protect player scores.
  • The server speaks plain HTTP. Put it behind a TLS-terminating reverse proxy (e.g. Caddy or nginx) before exposing it to the internet.
  • Registration and mailing-list signup are not rate limited.

Project layout

server.py          Game server and API
static/            Frontend (HTML, CSS, vanilla JS)
Dockerfile         Container image
compose.yaml       Docker Compose service and data volume

Credits

Created by Eric Meehan. Built with GitHub Copilot.