dsh-blackjack
Third-party community perk game: play blackjack inside dsh and win model credit spendable only through this plugin. Not affiliated with any model vendor. | 社区第三方福利小游戏:在 dsh 里玩 21 点,赢取仅限本插件内消费的模型额度。与任何模型厂商无关。
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 20, 2026
- Updated
- Aug 24, 2026
Introduction
dsh-blackjack
English | 简体中文
⚠️ Third-party community project disclaimer
This project is published and operated by an individual developer and is not affiliated with any model vendor: it is not an official project of any vendor, does not represent their position, is not endorsed or authorized by them, and does not use their names or logos as branding elements. The
dsh-prefix in the project name merely follows the community's plugin naming convention and identifies the compatibility target.The upstream that the metering proxy forwards to is an OpenAI-compatible endpoint configured by the operator; whoever deploys this project decides which service to point it at.
An open-source perk game for the dsh community: developers play blackjack inside their own dsh, winning CHIP — a game currency funded by the operator. CHIP converts one-way into "redeemed credit", and that credit can only be spent through this project's metering proxy. The core experience is: out of credit mid-task? Play a couple of hands to keep going.
The repository contains two parts:
- The plugin (installed into each developer's own dsh): the table UI, balance and redemption interactions, and the transparent fallback route.
- The central service (
packages/serverin this repo, self-hosted by the operator): authoritative game state, a four-bucket conservation ledger, the metering proxy, and the periodic settlement jobs.
Participation rules (important — read first)
- Participation is always free. A fixed number of free hands every day, at zero cost. There is no design — and never will be — that lets you pay for hands, pay for better odds, or pay for better payouts.
- The game currency is one-way and closed. For both CHIP and redeemed credit:
- not withdrawable, not convertible to any fiat currency, and no monetary amount is displayed anywhere in the UI;
- not transferable (including between players) and non-refundable;
- never lands in any model vendor's account;
- the only use is spending it through this project's metering proxy — there is no other exit.
- Inactive balances are recycled. Accounts with no games and no consumption for 7 consecutive days have their balance recycled into the prize pool (the criterion is "played a hand or spent credit"; polling the query endpoints does not count as activity).
- Redeemed credit does not currently expire. Earlier copy said "valid until the end of
the season", but season settlement (
seasonSettle) never touches redeemed credit at all — that sentence did not match the implementation, so it has been changed to describe reality. Whether to zero it out at season end is a product decision that has not been made yet; if it ever is, it will be announced here and inside the plugin first. - Your own API key never enters this system: it is not hosted, not held on your behalf, and never used as an in-game resource.
- Points are only a ranking and honor currency; they have no fixed exchange rate to CHIP.
Installing the plugin
To play inside your own dsh you install packages/plugin (npm package name
dsh-blackjack); you never need to touch the server code:
dsh plugin --profile web add -w dsh-blackjack
dsh --profile web
⚠️ Don't try it on the launch screen right after installing. Running
/blackjackon dsh's launch screen (the "Into the Unknown" view you get when you open a fresh dsh) actually executes the command, but nothing appears on screen — it looks exactly like a broken plugin. Send any message first so a conversation genuinely starts, or open an existing conversation from the sidebar, and then run/blackjack. Clicking "New conversation" does not help: that sends you back to the launch screen.This is not a defect in this plugin — the host behaves this way for every command registered by any plugin (verified with a minimal probe plugin). See the plugin README. Reported upstream: https://github.com/deepseek-ai/deepseek-harness/discussions/4066 (minimal reproduction in
docs/repro/dsh-probe-repro/).
Replace web with whichever profile you are actually installing into. The -w flag is
mandatory: a profile directory is itself a pnpm workspace, and without that flag pnpm
refuses to install. The plugin's own full documentation — command table, config options,
what triggers the fallback route, participation rules, how to uninstall — lives in
packages/plugin/README.md.
Compliance and contact
This project operates under the obligation-based requirements of China's Interim Measures for the Management of Generative AI Services: open registration, per-request traceable logging, and a built-in kill switch that can stop service instantly.
- Compliance enquiries, takedown requests, abuse reports:
<operator contact email placeholder: replace before deploying> - On receiving a request from a regulator or from upstream, the operator will stop service immediately with the kill switch (see the runbook below) and respond.
Quick start
Environment variables
Copy .env.example to .env (.env is already in .gitignore — never commit it):
| Variable | Description |
|---|---|
DATABASE_PATH | SQLite file path. Defaults to /data/app.db inside the container (on the mounted volume). |
PORT | HTTP port, defaults to 8787. |
ADMIN_TOKEN | Credential for the /admin/* endpoints (header X-Admin-Token). If unset, every admin endpoint returns 403. |
DEEPSEEK_API_KEY | API key for the upstream OpenAI-compatible endpoint (the variable name follows the existing name in the code). Only ever present in the server's environment. |
DEEPSEEK_BASE_URL | Upstream base URL; the proxy forwards to ${BASE_URL}/chat/completions. |
GITHUB_CLIENT_ID | client_id of the GitHub OAuth App (a public value, used to build the authorize link on the pairing page). Required for redemption. |
GITHUB_CLIENT_SECRET | client_secret of the same OAuth App, used only server-side to exchange the authorization code for an access token; it never appears in any response or log. Required for redemption. |
PUBLIC_URL | The publicly reachable base URL of this service (no trailing slash), used to build the /pair/<code> links. Required for redemption, and in production it must be the real domain. |
Redemption config self-check (any one missing stops redemption, not the service): if any
of the three variables marked "required for redemption" above is missing, the redemption flow
would produce a link that looks successful but is unusable — a missing PUBLIC_URL hands
every player http://localhost:8787/pair/<code>, a missing GITHUB_CLIENT_ID builds
authorize?client_id=&…, and a missing GITHUB_CLIENT_SECRET guarantees the callback's token
exchange fails. So the server logs a loud warning at startup listing what is missing, and
POST /api/pairing/start returns 503 across the board (with missingConfig and
operatorHint in the body) rather than handing out a dead code. The game itself (dealing,
actions, balances) and the metering proxy do not depend on these three variables and keep
working normally.
Running locally
pnpm install
pnpm --filter @dsh-blackjack/server test # full test suite
pnpm --filter @dsh-blackjack/server typecheck
pnpm dev # tsx watch
Docker
docker build -t dsh-blackjack -f packages/server/Dockerfile .
docker run -p 8787:8787 -v "$PWD/data":/data -e ADMIN_TOKEN=... -e DEEPSEEK_API_KEY=... dsh-blackjack
The image runs as the non-root user node (uid 1000), and the database lands in /data by
default.
Deploying on Railway
- Create a service pointing at this repository. The
railway.jsonat the repo root already points the builder atpackages/server/Dockerfile; nothing extra to configure in the panel. - You must mount a persistent volume at
/data(railway volume add --mount-path /data). Without a volume the database is written into the container's writable layer and every redeploy silently loses every player balance.DATABASE_PATHalready defaults to/data/app.db. - The volume is owned by root, which overrides the
chowndone at image build time. This repo's entrypoint handles it: the container starts as root, hands only the data directory tonode, then drops privileges withsetprivand execs the app. Do not switch to theRAILWAY_RUN_UID=0approach suggested in Railway's docs — that runs the whole app as root and throws away the non-root hardening. - Set
PORTexplicitly to8787, or change the service domain's target port to thePORTRailway injects (8080 by default). When the two disagree the platform shows Online while every external request gets a 502. - Configure the environment variables above, then confirm liveness with
/api/health. With the two GitHub variables missing the service still starts normally; onlyPOST /api/pairing/startreturns 503 and lists which ones are absent.
Points 2–4 above were all learned the hard way during this project's first real deployment: Railway's builder outright rejects a
VOLUMEinstruction in a Dockerfile, so persistence must go through a platform-side volume.
Single-instance constraint (important)
This service can only run as a single process, and that process must own the one SQLite file exclusively. The following safety properties all depend on "serialized write transactions inside one process":
- the daily free-hand cap and the "only one round in progress at a time" check;
- the per-player concurrency limit and per-minute rate limit in the metering proxy;
- atomic transfers in the four-bucket ledger.
Scaling horizontally (multiple replicas / processes / a shared volume) makes each of these checks count on its own and drains the prize pool outright. Scaling out requires first moving the ledger onto storage with real cross-process transactions, which is out of scope for v1.
Identity binding: device-code pairing + GitHub OAuth authorization code flow
Redemption requires binding a GitHub account, and one GitHub identity maps to exactly one player wallet: a player who is already bound cannot rebind (403), and once a GitHub id is claimed it belongs to that player permanently (binding it to someone else returns 409). This is the only anti-sockpuppet gate on the prize pool's exit, and the binding rules themselves do not change.
Binding goes through the full GitHub OAuth authorization code flow, replacing the earlier approach of "take an access token and call GitHub's user endpoint directly" (that approach did not verify who issued the token, so a token issued for another application — or leaked from a dev machine — could be used to bind; that hole is now closed):
-
The plugin calls
POST /api/pairing/start(player-authenticated) and gets a single-use pairing code with a short TTL (900 seconds by default,pairing_ttl_seconds) plus a${PUBLIC_URL}/pair/<code>link, and prompts the user to open it in a browser. -
The user opens that link in a browser: this step sets an HttpOnly,
SameSite=Laxbinding cookie in that browser, and the page first shows a warning (this will bind your GitHub account to a wallet and cannot be rebound; only continue if you generated this link yourself, and close it immediately if someone sent it to you) before the "Continue with GitHub" button, which redirects to GitHub's official authorize page (thestateparameter is the pairing code). -
GitHub sends the user back to
GET /auth/github/callback. The server first checks that the cookie on the request matches the hash recorded when that pairing code was bound (a mismatch or a missing cookie is a 403 and nothing is written), then uses theGITHUB_CLIENT_SECRET— which only the server holds — to exchange the authorization code for an access token and read the user identity. No token ever passes through the plugin or the user's hands, so there is no question of an untrusted token origin.Be precise about what this cookie does and does not prevent: it proves that "the browser completing the OAuth callback" is "the browser that opened our own
/pair/<code>page". What it blocks is an attacker bypassing that page — forging a GitHub authorize link or calling the callback URL directly — so that a victim's browser completes a binding without ever having touched our domain. What it does not prevent is luring a victim into personally opening an attacker-generated/pair/<code>link, seeing our own page with their own eyes, and clicking "Continue with GitHub" themselves: the victim's browser picks up the cookie normally and passes the check, and their GitHub identity still ends up bound to the attacker's wallet, irrevocably under the one-identity-one-wallet rule. The real mitigation for that second technique is the in-page warning in step 2 — putting "this binds your identity, and you should only continue if you generated this link yourself" in front of the user before they click, so they judge the link's provenance themselves, rather than leaning on the cookie (which is powerless here by construction — it proves "this browser visited our page", not "this browser's owner generated the pairing code"). -
Once the check passes, the binding is written and the pairing code is rotated into a brand new one (the original is voided, so the code value that passed through GitHub and browser history no longer represents any capability), followed by a 302 to the new code's pairing page, where the redemption form is submitted (
POST /pair/<new-code>/exchange). The code is voided again immediately after a successful redemption — the same code cannot be reused to redeem repeatedly.
Unknown, expired and already-used pairing codes all return an indistinguishable 404, to prevent enumeration.
After binding, you never make this trip again. The only reason the browser flow exists is
that GitHub's authorization code flow can only be completed in a browser, and binding only has
to happen once. Once a player is bound, the plugin calls POST /api/exchange directly with the
player token it already holds (binding required, otherwise 403
github-binding-required), which is what /blackjack exchange <amount> does. Both paths share
the same threshold and ledger checks: below the threshold is a 403 below-exchange-threshold
(with thresholdChips in the response), more than the balance is a 402 insufficient-balance,
and neither touches the ledger.
Player-visible error bodies
Every player-visible error has this same shape:
{ "error": { "code": "below-exchange-threshold", "message": "below exchange threshold", "thresholdChips": 30000 } }
codeis a stable kebab-case identifier and is the contract between the server and the plugin; the full list isPLAYER_ERROR_CODESinpackages/server/src/http.ts, and that table constrains both the parameter type of the server'splayerError()and the mapping tests on the plugin side, so renaming one without the other fails compilation or tests.messageis for logs andcurldebugging only. The server does not know the caller's language; the wording a player sees always comes from the plugin's own copy tables, looked up bycode(packages/plugin/src/i18n/).- The remaining fields inside
errorare structured facts (thresholdChips,capChips,maxChips, …) for the plugin to word its message with. - A missing token, or one the server does not recognize: 401
unknown-player-token.
The metering proxy (POST /v1/chat/completions) is excluded from this: externally it must
keep the OpenAI-compatible { "error": { "message", "type" } } shape — the host's model routing
layer decides whether to fall back based on that type vocabulary, and switching to the shape
above would break it. This inconsistency is deliberate, not an oversight.
Operations runbook
Every tunable parameter lives in the database's config table (not hardcoded, not an
environment variable), is read and written through the admin endpoints, and takes effect
immediately (max_body_bytes is the exception — it needs a process restart):
# Ledger snapshot (four buckets + conservation verdict + kill switch state)
curl -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/snapshot
# Operator view: DAU, funnel, free/raised round mix, pool flows, and the live value of every config key
curl -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/metrics
# Change any config key (the key must be a known config key)
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"key":"free_hands_per_day","value":"2"}' https://<host>/admin/config
# Grant credit to an account directly (pool → game balance → redeemed credit, all in one transaction).
# For seed accounts, compensation, and for testing anything (like the fallback route) that needs
# credit to exist first. Editing the database by hand breaks the four-bucket identity and trips the
# daily reconciliation kill switch, so this endpoint is the only way.
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"githubLogin":"someone","amountMicro":300000}' https://<host>/admin/grant
# Also accepts {"playerId":"..."}; returns 402 with the ledger untouched if the pool is short
/admin/config is write-only — after changing something, read it back from the config
field of /admin/metrics to confirm. Writing blind once corrupted several economic parameters
and made the service return 500s.
Fields in /admin/metrics:
| Field | Meaning |
|---|---|
dau | Distinct players who opened a round on each of the last 30 days |
funnel | Registered → played → bound GitHub → redeemed → spent, five levels. The gap between "played" and "bound GitHub" is exactly the population that won but cannot cash out, and is the prize pool's largest source of leakage |
rounds | Round counts and distinct player counts for free and raised rounds respectively — i.e. whether anyone actually plays raised rounds |
poolFlow | payoutToFreeMicro / payoutToRaiseMicro (what the pool pays out to each kind of round) and betsToPoolMicro (what players bet back into the pool). Comparing the latter two tells you whether raised rounds are replenishing or draining the pool |
config | The live value of every config key |
Config keys that are easy to get wrong
| Key | Current value | Why it is this value |
|---|---|---|
pool_model | deepseek-v4-flash | The pool always serves with this model, not the one the caller named. It spends the operator's upstream balance, so whoever pays picks the model; it also eliminates the whole "unknown model" class of failures at the source. The cost: the model shown in the player's UI may differ from the one actually serving, which the plugin README discloses |
price_table | v4 series, peak tier | Upstream has peak and off-peak tiers that differ by 2×, while the ledger can only hold one fixed price. Filling in the off-peak price would let the real bill exceed the consumed_micro the ledger recorded, breaking consumed ≤ deposited; filling in the peak price merely over-charges player credit during off-peak hours, which is safe in the right direction |
proxy_est_max_out_tokens | 8192 | How many output tokens to assume when pre-authorizing. It must not be proxy_max_tokens_cap: when the caller sends no max_tokens that is 131,072, which alone freezes about ¥1.18 — 85% of the total hold — and would force the redemption threshold above ¥1.4 |
proxy_max_concurrent | 4 | The turn in which dsh creates a session fires 3 LLM calls at once, not 1 (measured on a real machine on 2026-08-22). With the gate at 1 the other two were rejected with 429, so a player out of credit still saw "insufficient balance" on exactly the first message where the fallback should have caught them. 4 = the 3 measured plus one slot of headroom. The original argument for 1 was "a balance only fits one pre-authorization, so the second would 402 on insufficient balance, which is a vague error" — every player-visible error now carries a stable code that the plugin localizes, and the 402 itself reports need/have, so that concern no longer holds |
exchange_threshold_chips | 200000 (¥0.40) | It must cover one request's pre-authorization (about ¥0.275), not its cost (about ¥0.002–0.05). The deposit-style hold is what has always gated players, not the cost |
base_bet_chips | 50000 | Its ratio to the threshold determines how many wins in a row an all-in needs. The threshold has to land in (2× stake, 4× stake] → exactly two wins (about 18%). Below that bound one win crosses the line; above it three are needed (7.5%), and since a lost all-in zeroes you out, that path becomes a trap |
exchange_cap_per_season_chips | 3000000 (¥6) | Per-player per-season redemption cap, to stop one player emptying the pool. The cumulative total is summed from the ledger over op='exchange' and must not read players.exchanged_micro — that is the current balance, which goes down as credit is spent |
register_max_per_ip_per_hour / register_max_per_hour | 5 / 30 | Rate limits on anonymous registration. The threat is not spending (redemption requires GitHub binding, one identity one wallet) but scripted mass registration farming free hands until the pool is empty and real players see "the prize pool is exhausted". Only a salted hash of the IP is stored. These are starting values and should be tightened once there is real data |
inactive_recycle_days | 7 | A season is only a month long, so 30 days would mean nothing is ever recycled within a season and the settled balances never get handed out |
Known divergence between copy and implementation: /api/exchange and earlier copy used to
say redeemed credit was "valid until the end of the season", but seasonSettle never touches
exchanged_micro — in practice it does not expire. The copy has been corrected to describe
reality; whether to zero it out at season end is a product decision that has not been made.
Kill switch
curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"on":true}' https://<host>/admin/killswitch
- Once on, the metering proxy immediately returns 503 for every request (the game and query endpoints are unaffected), and the plugin falls back to the player's own configuration.
- It doubles as the compliance response plan: on a request from a regulator or from upstream, pull the switch first and handle it afterwards.
- The system pulls the switch automatically when: hourly consumption exceeds
breaker_hourly_micro; the daily reconciliation finds four-bucket conservation violated; or the daily reconciliation finds "cost recorded in the logs" and "consumption in the ledger" disagree. - Reset manually with
{"on":false}after troubleshooting; before resetting, read the latestreconciliation_reportsrow.
Periodic jobs
| Job | Trigger | What it does |
|---|---|---|
reconcile | Daily at 00:05 | Four-bucket conservation assertions + log/ledger cross-check + per-model consumption breakdown, written to reconciliation_reports; pulls the kill switch automatically if it fails |
| Orphan hold sweep | Hourly + at startup | Releases pre-authorizations unsettled for over an hour, refunding the balance and freeing the concurrency slot |
recycle | Daily at 00:15 | Recycles the balances of accounts with no games and no consumption for inactive_recycle_days consecutive days back into the pool |
season | Manual only | Season settlement, see below |
Manual trigger: curl -X POST -H "X-Admin-Token: $ADMIN_TOKEN" https://<host>/admin/jobs/<name>
Season rollover
- Pull the kill switch first, to stop new consumption.
- Run
/admin/jobs/reconcileand confirmok: true(if it fails, stop and audit the books before going further). - Run
/admin/jobs/season: game balances below the redemption threshold are converted to points atseason_carryover_ratioand returned to the pool; balances above it carry over. Redeemed credit is untouched — inactivity recycling is what bounds its grace period. - Re-lock this season's exchange rate against the upstream price list as it stands then: update
chip_rate_microandprice_table. - Update
season_id(and optionallysponsor_text), then turn the kill switch off.
Routine checks
okin/admin/snapshotmust betrueat all times;falseis an outage-level problem.consumptionCrossCheck.okin the latestreconciliation_reportsrow must likewise be true.- The last line of defense is a separate upstream budget account: if every other defense fails, the worst outcome is that this round of the event ends early.
License
MIT, see LICENSE. Open-sourcing the server is this project's core asset: every line of pricing logic in the ledger and the proxy can be audited.