ckb-session-kit
v1.0.0
Published
Browser-held self-custody sessions for CKB applications
Maintainers
Readme
ckb-session-kit
Browser-held self-custody sessions for CKB applications.
Live demo: ckb-session-kit.vercel.app — connect a wallet, sign once to open a session, pay with no popups while the network enforces the limits, sign once to close (CKB testnet).
Status: v1.0, on CKB testnet (not audited, no mainnet deployment). Built in the open during my CKBuilder programme.
| When | Milestone | |---|---| | w/c 5 Oct 2026 | ✅ Scaffold, tests, CI | | w/c 12 Oct | ✅ Session key held in the browser signs a testnet transfer; live demo | | w/c 19 Oct | ✅ Session lock script (Rust) — outflow limit, recipient, rate limit enforced on-chain; deployed to testnet | | w/c 26 Oct | ✅ Delegate, act repeatedly with no wallet dialog, revoke — library + demo (v0.2) | | w/c 2 Nov | ✅ Survives reload; ✅ device-loss recovery | | w/c 23 Nov | v1.0 on npm |
The problem
A CKB application that needs to act repeatedly on a user's behalf — paying per API call, submitting a transaction per interaction, anything with a loop in it — runs into the same wall: every action opens a wallet dialog. Approving a hundred signatures to make a hundred calls is not a usable product.
The obvious fix is to hand a key to the server, and that destroys the point.
What this is meant to be
A session layer where:
- The session key is created and held in the browser, and never leaves it.
- It can sign within a declared scope, enforced on-chain — and nothing outside it.
- The session survives a page reload, and expires on terms the user set.
- The owner can revoke it on-chain at any moment.
- Losing the device does not mean losing the funds.
Its first example application is pay-per-read: a reader unlocks articles for 1 CKB each with no wallet prompt, and the session can only ever pay the creator. The problem is not specific to that application.
Try it
npm install
npm test # unit + CKB-VM tests, no network needed
npm run demo # the Next.js web demo at http://localhost:3000The demo (v0.2) connects a wallet through CCC (JoyID, MetaMask, OKX, UniSat, …). The wallet signs once to open a session: it funds a session cell under the on-chain session lock plus a small key cell that pays the session's fees. Payments are then signed by a key held only in the browser, with no wallet popup, and the network enforces the limits. The wallet signs once more to close the session and take everything back.
New here? docs/WALKTHROUGH.md tours every demo page on
testnet in about fifteen minutes, with one wallet.
import { createSession, openSession, spendInSession, closeSession, TESTNET_DEPLOYMENT as deployment } from "ckb-session-kit";
const session = createSession({ expiresAt, scope: { maxPerTx: 100n * 10n ** 8n } });
const { binding } = await openSession(wallet, session, deployment, { budget: 400n * 10n ** 8n });
await spendInSession(session, client, deployment, binding, { to, amount }); // no wallet
await closeSession(wallet, session, deployment, binding); // one signaturenpm run smoke:flow runs exactly that against testnet with a key standing in for
the wallet. Run on 8 Oct 2026: open
(owner signs) → pay 100 CKB
and pay 80 CKB
(session key only) → 150 CKB refused → key cell returned
and session swept (owner signs).
Lost the device? The wallet alone gets it back
The session key lives only in one browser. If that browser is gone, the owner's wallet can still recover every session it ever opened:
await recoverSessions(wallet, deployment); // finds them all, usually one signatureSession-lock args begin with the owner's lock hash, so an indexer prefix search
finds the session cells without knowing the lost key, and owner mode sweeps them.
Cells whose args are malformed are skipped: the lock rejects them even in owner
mode, and anyone could plant one with the owner's hash as a prefix to block a
sweep. Anyone can also plant a well-formed cell with the owner's hash and their
own key, and spend it just before the sweep lands; if the single sweep fails,
each session is swept on its own (one signature each), so only the planted cell
is left out. What cannot come back is the key cell (the fee budget), which only
the session key can spend; keep it small. It must stay big enough to go back to
the owner after paying fees, so openSession sizes it from the owner's lock: 62
CKB by default for a secp256k1 owner, 64 for JoyID or Omnilock (MetaMask, UniSat,
OKX), whose cells need 63. The extra 1 CKB pays thousands of fees.
npm run smoke:recover runs it on testnet. Run on 9 Oct 2026: two sessions
opened
(second),
one spent from,
a decoy
planted, both keys dropped → one owner transaction
swept every session cell back, decoy skipped.
Examples and API
examples/node-quickstart.ts: open, pay twice with no wallet, close, in one file (OWNER_KEY=0x… npx tsx --tsconfig examples/tsconfig.json examples/node-quickstart.ts). Run on 13 Oct 2026: open → pay 61 → pay 65 → sweep and key cell back.examples/browser-session.ts: what an app does around any CCC wallet: save before funding, one session per tab, restore on load.examples/paywall-server.ts: a paywall with no database, onnode:http.docs/API.md: every export, grouped by task. CI typechecks the examples and fails if an export is missing from the reference.
Example: pay-per-read
A reader's wallet signs once to open a session scoped to one creator. Each article unlock is then a 1 CKB payment signed by the session key alone. Two things make this work on CKB:
- Payments smaller than a cell. A new cell needs at least 61 CKB, so a 1 CKB
payment cannot create one. Instead the creator keeps an
anyone-can-pay
cell, and each payment tops it up:
spendInSession(…, { to, amount, topUp: true }). The anyone-can-pay lock accepts the top-up without the creator's signature. - A stolen key can only pay the creator. The session's recipient is the
creator's anyone-can-pay address, so the session lock refuses any other output,
and
max_per_txcaps each unlock.
const session = createSession({ expiresAt, scope: { maxPerTx: 5n * CKB, recipients: [creatorAcp] } });
const { binding } = await openSession(wallet, session, deployment, { budget: 200n * CKB });
await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true });Server-verified unlocks, no database. Each payment names its article in a
memo (spendInSession(…, { memo })), stored in the witness the session key signs,
so the payment commits to what it buys. To read, the browser signs a request with
the same key (signAccess), and the server checks the chain (verifyAccess):
committed, the creator gained at least the price, the memo matches, and the key
that signed the request is the key that paid. Anyone can see a payment on-chain;
only its payer can use it, and only for that article. The article text is never
in the page bundle.
// browser
const tx = await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true, memo: paymentMemo("read:since") });
const proof = await signAccess(session, tx, paymentMemo("read:since"));
// server
const check = await verifyAccess(client, proof, { to: creatorLock, minAmount: 1n * CKB, memo: paymentMemo("read:since") });npm run smoke:pay-per-read runs it on testnet; with ARTICLE_API set it also
claims each article through the demo's /api/article route. Run on 9 Oct 2026
against the production build:
session opened
(reader signs once, recipient = creator) → reads
#1,
#2,
#3
at 1 CKB each, session key only, each released by the route to the payer, while
a claim by another key and a claim for another article were refused →
paying anyone else and paying 6 CKB (limit 5) refused before signing →
key cell returned
and session swept.
The on-chain side of the refusals is covered by the CKB-VM tests below.
Try it in the browser: ckb-session-kit.vercel.app/read
(the /read page of the demo): open a reading session with one wallet signature,
then unlock articles for 1 CKB each with no popup.
A session cell with a recipient occupies 153 CKB, so a 200 CKB session has 47 CKB
to spend; the rest comes back on close. Each payment consumes one of the creator's
cells, so readers paying at the same moment compete for them; the library picks
one at random, and npm run setup:creator gives the demo's creator three.
Example: CKB Place, a pixel board on testnet
/place: a shared 32×32 board. Open a
session once (one wallet signature; the session lock lets it pay only the board),
then every batch of up to 32 pixels is one 0.1 CKB-per-pixel payment signed by
the session key, with no wallet popup. The pixels travel in the payment's memo
("px1" + x, y, colour per pixel); the board is rebuilt from the chain by
reading those memos in chain order, counting only payments that carry exactly one
memo, signed by the payer, for at least the price. No server, no database:
src/place.ts.
npm run smoke:place paints "CKB" on testnet. Run on 14 Oct 2026:
board cells created
→ session opened
→ 29 pixels, 2.9 CKB, no wallet
→ swept
and key cell back.
Example: pay-per-minute
/listen: a player for three
generative tracks (Web Audio, composed per minute on the server). Open a session
once; while it plays, the session key pays 0.5 CKB for the next minute 30 s
before it is needed, with memo listen:<track>:<minute>, and the server releases
that minute only to the key that paid for it (verifyAccess). Pause, and the
payments stop. No database.
npm run smoke:listen runs it on testnet with the server's own gating. Run on 14 Oct 2026:
session opened
→ minute 0
→ minute 1,
each released by the server; minute 2 claimed with minute 0's payment: refused
→ swept
and key cell back.
Token sessions (xUDT)
A session can hold a token as well as CKB: a stablecoin, game credits, a creator's token. The owner signs once; the session key pays in the token with no wallet, and the lock caps the units per spend the way it caps shannons, with the same recipient and rate-limit rules. Payments top up the recipient's anyone-can-pay token cell, so no CKB leaves the session. Closing or recovering returns the tokens as tokens.
const { binding } = await openSession(wallet, session, TESTNET_DEPLOYMENT, {
budget: 300n * CKB,
token: { type: xudtType, amount: 100n, maxPerTx: 10n },
});
await spendInSession(session, client, TESTNET_DEPLOYMENT, binding, { to: creator, token: 3n, topUp: true });Design, threats and both reviews: docs/design/token-sessions.md.
npm run smoke:token runs it on testnet. Run on 14 Oct 2026, with a test xUDT:
session opened, 100 units
→ 3 units
→ 3 units,
no wallet; 11 units in a 10-unit session: rejected by the network (code 20)
→ 94 units swept back.
Try it in the browser: ckb-session-kit.vercel.app/tokens:
pay-per-read again, 2 tokens per article. The demo token is free to mint from the
page: its issuer is an anyone-can-pay cell nobody holds the key to, and xUDT lets
whoever spends an issuer cell mint, so anyone can (it is worth nothing). The
server releases an article after verifyAccess(…, { token }) finds, on-chain,
that this key paid 2 units of this token for it. npm run setup:token creates
the issuer cells; npm run smoke:read-tokens runs the page's flow on testnet.
Run on 14 Oct 2026:
minted 20 units with only the minter's key
→ session opened
→ 2 units for an article,
released by the demo's server route; at a 3-unit price the same payment is refused
→ 18 units back.
Subscriptions
The same lock with the roles moved: the subscriber owns the cell, the creator's
server holds the session key. The network lets the creator charge at most the
price, at most once per period, and only to the creator's address, while the
subscriber is offline; the subscriber cancels alone at any time. No change to
the deployed lock: the price is max_per_tx, the period is min_interval,
the payout address is the recipient.
// subscriber: one signature; the first period is paid at once
const { subscription } = await openSubscription(wallet, TESTNET_DEPLOYMENT, {
charger, payTo, price: 10n * CKB, periodBlocks: 10n, budget: 173n * CKB, account,
});
// creator's server, once per period, no subscriber involved
await chargeSubscription(chargerKey, TESTNET_DEPLOYMENT, subscription, { payTo, topUp: true });
// creator's server, on every request, with no database
const r = await subscriptionStatus(client, TESTNET_DEPLOYMENT, subscription, { openTx, account });
// subscriber, any time
await cancelSubscription(wallet, TESTNET_DEPLOYMENT, subscription);The design was attacked on paper before any code
(docs/design/subscriptions.md). The
subscriber's money held; the creator's side did not, and the library now covers it:
- the opening pays the first period, since a new cell cannot be charged for a whole period;
- access follows payments, not the live cell, so re-creating the cell to reset its age gains nothing;
- the opening commits to an account id (hashed) under the subscriber's signature, so nobody can present someone else's subscription.
A charge costs the session lock 20,999 cycles.
npm run smoke:subscription runs it on testnet. Run on 26 Oct 2026:
opened, first period paid
→ the creator's check accepts the terms, the first payment and the account, and refuses another account
→ a charge 1 block in: rejected by the network (code 14)
→ charge 1
→ charge 2, a period later, charger key only
→ a third charge refused: what is left cannot cover a price and a cell
→ cancelled by the subscriber alone, 153 CKB back.
Paid in a token. The same flow in an xUDT (a stablecoin, say): pass
token: { type, amount } to openSubscription. The lock's per-payment CKB limit
is 0, so no CKB ever leaves the cell; the price is the per-payment token limit, and
charges top up the creator's anyone-can-pay token cell. npm run smoke:subscription-token
ran it on 27 Oct 2026 with the demo token:
opened, first 2 units paid
→ a charge 1 block in: rejected (code 14); a charge moving 1 shannon of CKB too: rejected (code 12)
→ charge 1
→ charge 2, each 2 units with no CKB out
→ a third refused: no tokens left
→ cancelled by the subscriber alone.
Try it in the browser: ckb-session-kit.vercel.app/subscribe: 2 CKB per 60 blocks; members' notes the server releases only while you are paid up.
Which wallets can own a session
The owner signs to open, close and recover, so the owner's wallet must work. npm
run matrix:wallets runs the session and subscription flows on testnet with each
kind of owner lock the demo's wallet picker gives, signing the way that wallet
signs (EIP-191 for Ethereum wallets, Bitcoin message signing, and so on). Run on
26-27 Oct 2026:
| Owner wallet | session opened | paid 1 CKB | session swept | key cell back | subscribed | cancelled | |---|---|---|---|---|---|---| | CKB default lock (secp256k1): Neuron, CKB CLI, most libraries | 0x5e14b73f… | 0xd89e75ff… | 0xd9336db9… | 0x881ca37f… | 0x2b0274df… | 0x9aeff7dc… | | Omnilock, Ethereum auth (0x12): MetaMask, OKX (EVM) | 0xae66203d… | 0x3b0211b0… | 0xe5bc64e9… | 0xf795b033… | 0x6f5b5666… | 0x9e963bbf… | | Omnilock, Bitcoin auth: UniSat, OKX (BTC) | 0x9a1beefc… | 0xe5db9f8b… | 0x5f7f4d69… | 0x44a0e09d… | 0xf44697da… | 0x38141883… | | Omnilock, Dogecoin auth | 0xbd693312… | 0x1f7069e9… | 0x356fb5f1… | 0x093d5331… | 0x6d8c67d7… | 0x0e98ab80… | | Nostr lock: NIP-07 extensions | 0x24e6503a… | 0x297eaa51… | 0x05dfbc09… | 0xd9139c2e… | 0x832c08a3… | 0xf6cfb847… |
The first run refused the MetaMask row: the check that refuses owner locks which verify no signature did not know Omnilock's auth flag 0x12, the one CCC gives Ethereum wallets. Fixed, with a test built from the lock CCC derives for an Ethereum account. JoyID signs with a passkey and cannot be scripted; it was tested by hand in the demo (10 Oct).
The on-chain session lock
contracts/session-lock is a CKB lock script in
Rust. A cell under it can be spent two ways:
- Owner mode — the transaction also spends a cell locked by the owner's lock. Anything goes: revoke, sweep, top up.
- Session mode — the transaction also spends the session key's key cell. The
network then enforces, on-chain:
- outflow limit — at most
max_per_txshannons leave per transaction, fees included, so the fee cannot be used to drain the cell; - recipient allowlist (optional) — payments may only go to one lock (plus change, the key cell, and the owner). The key cell may come back smaller (it pays the fee) but never larger, so the key cannot park session funds in its own cell and pay anyone from there;
- rate limit (optional) — each session cell must have aged
min_intervalblocks before it can be spent again (relativesince). The limit is per cell: with n session cells, the key can spend from each of them in the same block.openSessioncreates one cell; - no splitting — a session spend may send at most one cell back to the
session lock, so the key can merge session cells but never multiply them,
which makes the per-cell rate limit a per-session one for sessions opened
with
openSession. (Anyone can add cells under a session lock, but only with their own money: it never multiplies what the owner put in); - no type scripts — in a plain session, the session cells spent and their
change carry no type script: a change cell given a type the key controls could
later refuse every spend, the owner's sweep included, freezing the whole
balance. A token session allows exactly one type, its own xUDT, and caps the
units that leave per spend (
max_per_tx_token) the way it caps shannons.
- outflow limit — at most
Signatures are delegated to the standard secp256k1 locks of the owner and the session key (the pattern sUDT's owner mode uses), so the script carries no cryptography: 7.6 KB, no allocator, no C, 15,651 cycles per run — about 0.9% of a session spend (29,031 on a token spend, 1.7%). 74 tests run it in the real CKB-VM, most asserting a specific rejection code, and CI builds the contract and requires them.
What it cannot do: enforce an expiry time. A CKB script can prove time has
passed (since is a lower bound) but never that it has not. Expiry is enforced
in the browser; the owner can revoke on-chain at any moment, and the session
cell's balance caps the total at risk.
Enforced where:
| Rule | Browser (checkRequest) | Session lock (on-chain) |
|---|---|---|
| expiresAt | yes | no (cannot be) |
| maxPerTx | yes | yes, fees included |
| recipient | yes | yes, if scope.recipients has one entry (openSession reads it from the scope) |
| rate limit | — | yes, per cell (min_interval blocks) |
| revocation | — | only by an owner transaction (closeSession / recoverSessions) |
If a session key leaks
The key cannot be revoked by expiry: after expiresAt it still satisfies the lock
until the owner acts. Until then it can move at most max_per_tx per transaction,
only to the recipient if one is set, and, with a rate limit, once per
min_interval blocks per session cell. The owner should sweep at once:
- with the key still at hand:
closeSession(wallet, session, deployment, binding); - without it (device lost or wiped):
recoverSessions(wallet, deployment)finds every session the wallet opened and sweeps them, without the key.
An owner sweep is accepted at any time and ignores the scope. It is not certain to beat a spend already racing it: the key holder can replace a pending sweep by paying a higher fee, so sweep early, and resweep what is left. Keep budgets small: the budget is the most a session can ever lose.
Owner and session locks must need a signature. The lock trusts that an input under the owner's lock means the owner signed. Plain secp256k1, JoyID and Omnilock are fine; an anyone-can-pay owner lock is not, since anyone may spend into it and would get owner mode.
A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance and its change must stay above that.
Enforced by the real network
Deployed on testnet: code hash 0xab419466…4c08ce (data2, deploy tx),
see deployment/testnet.json. The code is immutable.
Earlier deployments (8 Oct, before errors 16–18; 13 Oct, before token sessions)
are kept in TESTNET_LEGACY_DEPLOYMENTS, so recoverSessions still sweeps
sessions opened under them.
Verify the deployed lock yourself. The deployed binary was built with rustc
1.99.0 on the x86_64-pc-windows-gnu host (pinned in
rust-toolchain.toml), and a
rebuild there is byte-identical to the code on chain. On Windows, from the
repository root:
rustup toolchain install 1.99.0-x86_64-pc-windows-gnu --profile minimal --target riscv64imac-unknown-none-elf
(cd contracts/session-lock && cargo +1.99.0-x86_64-pc-windows-gnu build --release --locked)
npm ci && npm run check:lock-hashThe last command compares the build's data hash with codeHash in
deployment/testnet.json and fails on any difference; CI's lock-hash job runs
the same three steps. Another host or rustc builds a working lock with other
bytes, so its hash will not match.
npm run smoke:lock runs every rule against public testnet. Run on 14 Oct 2026, on the current lock:
| Step | Network said |
|---|---|
| Owner funds three session cells and a key cell | accepted |
| Session key spends exactly max_per_tx (100 CKB), no owner signature | accepted |
| Session key spends 100 CKB + 1 shannon | rejected, error 12 (outflow exceeds max_per_tx) |
| Rate-limited cell spent with no since | rejected, error 14 (rate limited) |
| Same cell with since = 3 relative blocks | accepted once mature |
| Change split into two cells | rejected, error 16 (too many change cells) |
| Recipient-scoped cell, 50 CKB moved into the key cell | rejected, error 17 (key cell may not grow) |
| Change given a type script (a valid Type ID) | rejected, error 18 (no type scripts) |
| Owner sweeps everything back | accepted |
Cycles, measured with ckb-cycle-tools on the accepted session spend: the session lock 15,651, the key cell's secp256k1 lock 1,652,327, matching the node's 1,667,978 exactly. On a token payment from the /tokens page's flow: the session lock 29,031, xUDT 18,795, the creator's anyone-can-pay lock 34,106, the key 1,642,571 (1,724,503 in all).
v0.1 browser flow, verified on public testnet
npm run smoke:testnet funds a fresh session, spends inside its scope, and checks
that an over-limit spend is refused. Run on 8 Oct 2026:
| Step | Result | |---|---| | Fund the session (300 CKB) | 0x2acf…c45f | | Spend 100 CKB, signed by the session key | 0x52a1…da50 | | Spend 150 CKB (limit 100) | refused before signing |
Limits
What this does not do, in one place. Each is stated where it applies above.
- Testnet only, not audited. The lock has had community and adversarial reviews, each finding fixed with a regression test, but no external audit. Do not put mainnet funds behind it.
- Expiry is not on-chain. A CKB script cannot prove that time has not
passed, so
expiresAtis checked in the browser. A leaked key stays valid until the owner sweeps; the session's balance is the most it can lose. - The rate limit is per cell.
openSessionmakes one cell and the lock forbids splitting, but anyone adding more cells under the same session lock gives the key more cells to spend from (with their own money, never the owner's). - The key cell is not recoverable without the key.
recoverSessionssweeps every session cell with the wallet alone; the 62–64 CKB key cell needs the lost key. - An owner sweep can be raced. A key holder can replace a pending sweep by paying a higher fee. Sweep early and sweep again.
- Paid access is checked at zero confirmations.
verifyAccessaccepts a payment once it is committed in a block; a reorg could undo it after the content was released. Fine for articles and pixels, not for anything costly. - An access proof does not expire. It is a bearer token for one article: whoever holds the signed proof can fetch that article again. It cannot be used for another article or by another payment.
- Owner locks must need a signature. An anyone-can-pay owner lock would give
anyone owner mode;
openSessionrefuses one. - Subscription periods are in blocks, and charges need the creator online.
The lock's rate limit is a block count, so a period is an estimate of time
(
blocksForSeconds). Nothing charges by itself: the creator's server must send each charge, one transaction per subscriber. Cancelling ends access at once, and the current period's price is not refunded. - Sessions are not cheap to open. A session cell occupies 121 CKB (153 with a recipient), returned on close; small payments go to anyone-can-pay cells because a new cell needs 61 CKB.
Why it is a separate repository
The session problem has nothing to do with any one application. Anything that wants to act repeatedly on a user's behalf in a browser has it, so it should not be buried inside one application's source tree.
Related work
Other CKB projects work on nearby problems. This is what each one does, as described in its own public post or repository, and where this kit differs.
- CKAutomata (forum post,
CKBuilder review issue):
one-time, scheduled and recurring CKB payments, and Nervos DAO harvest. Each
automation is a cell holding its rules, schedule and funds; the user signs once
to set it up, and an executor submits each payment when it is due, without
being able to change amount, recipient or schedule. The overlap is with
subscriptions here: both let a payer sign once and be charged later while
offline. The differences: here the subscription is one use of a general
session lock, the creator's own server holds the key and pulls each charge,
and
subscriptionStatuslets that server gate content on-chain with no database. This kit does not schedule payments for third-party executors and does nothing with the Nervos DAO. - QuotaCell (forum post, repository): a shared treasury allowance for a team. Several delegates, each with their own key, a personal limit and a per-payment ceiling, draw from one cumulative allowance that only the owner can renew, enforced by an on-chain policy cell; agent spending is a use it is exploring. The session lock also enforces spend limits on-chain, but for a different shape: one user, one short-lived key generated and held in that user's browser, a fixed recipient and a rate limit, so an app can act without wallet popups. It has no notion of several delegates sharing one allowance.
- FiberLatch Access (forum post)
and Fiber's Encrypted Data Payment pattern
(docs): access to
content paid for over Fiber. FiberLatch issues and verifies signed access
receipts once an app has accepted a payment; in the Fiber pattern, the invoice
preimage is the key that decrypts the data. Pay-per-read here runs on CKB L1
instead: no channel to open, each payment is an ordinary transaction signed by
the session key inside the on-chain scope, and the server checks the payment on
chain (
verifyAccess). The trade-off is that every L1 payment waits for a block and pays a fee, where a Fiber payment settles off-chain once a channel is open. - Wallet and login projects: JoyID (passkey wallet), Corven Connect (Google and passkey sign-in with an embedded CKB wallet) and CKB KeyWay (email login with a recoverable CKB account, plus Fiber). These create or unlock the user's account. This kit does not: it starts from a wallet the user already has and removes the repeated signing after that, so it can sit on top of any of them. A JoyID owner works today (tested by hand); the other two have not been tested as session owners.
Licence
MIT. See LICENSE.
Author
Luong Thanh Tung — @luongthanhtung03
