Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ discover-probe/
pool.sql
**/pool.sql
tools/kofi-worker/.wrangler/
tools/kofi-worker/.dev.vars
tools/kofi-worker/node_modules/
supporter-key.pem
supporter-codes*.txt
# The code admin's ledger: every code with who it went to and their email (scripts/code-admin.py).
supporter-ledger*.json
# The worker's address and admin token, for the same page.
supporter-worker*.json
**/pool-*.sql

# Jazzer's generated corpus (seeds live in market/src/test/resources)
.cifuzz-corpus/
Expand Down
25 changes: 22 additions & 3 deletions docs/releases/kofi.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,16 @@ becomes real with the first one, and *This week in Folio* is the shape suggested
Both must keep saying the same thing about what Folio *does*; only the emphasis changes. When one is edited, check
the other.

### One-off support: a month per $3
### One-off support: a month per donation

**Decided (McCal, 19 Sep 2026):** a one-off payment earns a dated code — **$3 → one month, $6 → two, $12 → four** —
matching the Coffee tier, so nobody can buy cheaper access than a member gets. Three ways to do it, cheapest first.
**Decided (McCal, 22 Sep 2026), replacing the 19 Sep rule below:** a one-off tip or donation earns **one month**,
whatever the amount, and each payment counts (two tips, two months). The tiers stay **Coffee $3, Backer $7, Builder
$15**; each Backer or Builder payment is one month, and Coffee carries no code. `scripts/code-admin.py` applies this
when it reads the Ko-fi CSV (see *The admin page* below).

*Earlier (19 Sep 2026):* a one-off payment earned a dated code, $3 → one month, $6 → two, $12 → four, matching the
Coffee tier. The shop, worker and pool notes below were written for that rule; the pools become one-month pools.
Three ways to do it, cheapest first.

1. **Shop items, one per length.** "Folio early access · 1 month", "· 2 months", "· 4 months", each with a months
code attached. Nothing to run, works while asleep, and the files never go stale.
Expand Down Expand Up @@ -368,6 +374,19 @@ done

`pool.sql` holds real codes, so it never gets committed.

### The admin page

`./scripts/code-admin.py`, run from the checkout that holds `supporter-key.pem`, opens a page on this Mac only
(127.0.0.1, a fresh token each launch). Drop in the Ko-fi CSV export and it lists who needs a code and for how many
months; one click hands out a ready code of that length or mints one, with the code, the `folio://redeem` link and a
draft message to copy. It also marks codes sent or withdrawn (and prints the serial for `BetaKeys.WITHDRAWN`), and
makes ready codes or a `tools/kofi-worker/pool-m<N>.sql` pool. Everything it knows is in `supporter-ledger.json`
(gitignored, 0600, real codes and emails); the first run takes in the codes already in `supporter-codes*.txt`.

Ko-fi has no API for reading transactions, so the CSV covers what already happened and the worker's webhook is the
only way codes go out on their own. The key never leaves the Mac: the worker and Folio Dev only ever hand out codes
minted here in advance.

### Two posts, in this order

The release first, the ask second — a day or two apart.
Expand Down
20 changes: 20 additions & 0 deletions scripts/Folio Codes.command
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/sh
# Double-click this to open the supporter code admin page (scripts/code-admin.py).
#
# Put a copy wherever it suits: the Desktop, or the Dock (drag it to the right-hand side, next to the Trash).
# A copy anywhere still runs the script from this checkout, so it stays up to date with the repository.
# ln -s ~/dev/duo-fold-launcher/scripts/"Folio Codes.command" ~/Desktop/
#
# macOS may ask once whether you're sure you want to open it, because it didn't come from the App Store.
cd "$(dirname "$0")/.." || exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the checkout before following the launcher symlink

When the launcher is invoked through the documented Desktop symlink, $0 is the Desktop path, so this changes into the user's home directory and python3 scripts/code-admin.py fails. Resolve the symlink target before deriving the checkout directory; a literal copied launcher likewise cannot satisfy the claim that it continues to run this checkout without storing its location.

Useful? React with 👍 / 👎.

echo "Folio supporter codes"
echo "Ctrl-C, or close this window, to stop the page."
echo
python3 scripts/code-admin.py "$@"
status=$?
# A window that vanishes takes the reason with it: on a failure, hold it open until it's been read.
if [ $status -ne 0 ] && [ $status -ne 130 ]; then
echo
echo "The page stopped (exit $status). Press Return to close this window."
read -r _
fi
59 changes: 47 additions & 12 deletions scripts/beta-code.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,17 @@ def base32(data):
return "-".join(text[i:i + 5] for i in range(0, len(text), 5))


def mint(key, scopes, tier, expires, count, sql_pool=None, months=0):
# Asked once, not once per code: minting two hundred shouldn't mean typing the passphrase two hundred times.
signing = passin(key)
def scope_bits(scopes):
bits = 0
for scope in scopes:
if scope not in SCOPES:
sys.exit(f"unknown scope {scope}; pick from {', '.join(SCOPES)}")
bits |= 1 << SCOPES.index(scope)
return bits


def shape(tier, expires, months):
"""The version, tier byte and day a code is minted with, checked the way BetaCodes reads them."""
if expires:
day = (datetime.date.fromisoformat(expires) - EPOCH).days
if not 1 <= day <= 0xFFFF:
Expand All @@ -162,16 +165,48 @@ def mint(key, scopes, tier, expires, count, sql_pool=None, months=0):
sys.exit("--months takes 1 to 15; for longer, use --expires")
if not 0 <= tier <= 15:
sys.exit("--tier must be 0-15 when --months is used, since they share a byte")
version, tier_byte = VERSION_MONTHS, months << 4 | tier
else:
if not 0 <= tier <= 255:
sys.exit("--tier must be 0-255")
version, tier_byte = VERSION, tier
return VERSION_MONTHS, months << 4 | tier, day
if not 0 <= tier <= 255:
sys.exit("--tier must be 0-255")
return VERSION, tier, day


def sign(key, signing, version, bits, tier_byte, day):
"""One code with a fresh random serial. `signing` is what passin() returned, so the passphrase is asked once."""
serial = secrets.randbits(32)
payload = bytes([version, bits, tier_byte, day >> 8 & 0xFF, day & 0xFF]) + serial.to_bytes(4, "big")
der = run(["openssl", "dgst", "-sha256", "-sign", key] + signing, stdin=payload)
return base32(payload + raw_signature(der))


def describe(code):
"""What a code carries, read without checking its signature: serial, scopes, months or last day, tier."""
value = 0
count = 0
for ch in code.replace("-", "").upper():
value = value << 5 | ALPHABET.index(ch)
count += 5
data = (value >> (count % 8)).to_bytes(count // 8, "big")
version, bits, tier_byte = data[0], data[1], data[2]
day = data[3] << 8 | data[4]
months = tier_byte >> 4 if version == VERSION_MONTHS else 0
return {
"version": version,
"serial": int.from_bytes(data[5:9], "big"),
"scopes": [s for i, s in enumerate(SCOPES) if bits >> i & 1],
"tier": tier_byte & 0x0F if version == VERSION_MONTHS else tier_byte,
"months": months,
"expires": (EPOCH + datetime.timedelta(days=day)).isoformat() if day else None,
}


def mint(key, scopes, tier, expires, count, sql_pool=None, months=0):
# Asked once, not once per code: minting two hundred shouldn't mean typing the passphrase two hundred times.
signing = passin(key)
bits = scope_bits(scopes)
version, tier_byte, day = shape(tier, expires, months)
for _ in range(count):
serial = secrets.randbits(32)
payload = bytes([version, bits, tier_byte, day >> 8 & 0xFF, day & 0xFF]) + serial.to_bytes(4, "big")
der = run(["openssl", "dgst", "-sha256", "-sign", key] + signing, stdin=payload)
code = base32(payload + raw_signature(der))
code = sign(key, signing, version, bits, tier_byte, day)
if sql_pool:
print(f"INSERT OR IGNORE INTO codes (code, pool) VALUES ('{code}', '{sql_pool}');")
else:
Expand Down
Loading
Loading