<feed xmlns='http://www.w3.org/2005/Atom'>
<title>typerpunk/crates/server, branch main</title>
<subtitle>Rust + WASM typing game.
</subtitle>
<id>https://srdusr.com/git/typerpunk/atom?h=main</id>
<link rel='self' href='https://srdusr.com/git/typerpunk/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/'/>
<updated>2026-02-27T23:17:00+00:00</updated>
<entry>
<title>Sell merchandise, put the sprites on the track, and add the page margins</title>
<updated>2026-02-27T23:17:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-02-27T23:17:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=5af72f55bb7fb620af740c9da394e093d7f1280a'/>
<id>urn:sha1:5af72f55bb7fb620af740c9da394e093d7f1280a</id>
<content type='text'>
Merchandise. Shirts, mugs and deskmats, sold through the same hosted
checkout. Physical goods differ from cosmetics in three ways the schema had
to carry: they have a size to choose, they need an address, and payment
unlocks nothing. What a paid order produces is an obligation to pack and post
something.

The address is collected by Stripe on its own page and arrives here on the
webhook, so no postal detail is ever entered on this site. Postage is a
separate line item rather than folded into the price. A size is checked
against the sizes the item actually comes in, on the server as well as in the
browser, so a request naming anything else is refused rather than quietly
posted as a medium.

/api/admin/orders lists what has been paid for and not yet sent, and the
Contribute screen shows an administrator the same list with the address and a
button to mark each one posted. Without that the list of what is owed lives
only in the processor's dashboard. Shipping is limited to 21 countries, which
is a list of places somebody is willing to post to, not a technical limit.

The race view. The sprite sat still at the left of a 6px bar, which made the
one thing a player owns and can see the least visible part of the race. It
rides the track now, moving with progress, on a dashed line that reads as
road ahead with the trail behind it. The field is 1040px wide rather than
680, the sprites are 30px rather than 18, and each row carries its own
percentage as well as its speed.

Advertising. Two 160x600 rails in the page margins, shown only above 1424px,
which is the width at which they fit beside the widest content column without
crowding it. Below that they do not exist. They follow the same rules as the
banner: never while typing, never for a supporter.

--border-color was used in five places and defined in none. An undefined
custom property invalidates the whole declaration at computed-value time, so
every one of those borders fell back to currentColor: bundle cards, merch
cards, the size buttons and the settings checkbox all had borders that were
either invisible or faint text-coloured lines. It is defined now, derived
from each theme's neutral so it tracks the palette.

.settings-hint caps itself at 340px, which is right under a heading and wrong
for a line introducing a full-width grid.

Verified against a real database: the catalogue endpoint, a shirt refused
without a size, a shirt refused with a size it does not come in, a valid size
and a no-variant mug both reaching checkout, 404 on an unknown item, 401
unauthenticated, and 401 on the admin orders list without the role.

24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations
clean.
</content>
</entry>
<entry>
<title>Make every passage long enough to be worth timing, and credit all of them</title>
<updated>2026-01-14T20:23:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-14T20:23:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=4933f669f8338ca5e4c64cf0294818c5d15ed122'/>
<id>urn:sha1:4933f669f8338ca5e4c64cf0294818c5d15ed122</id>
<content type='text'>
The dataset's median passage was 69 characters, about twelve words, which is
over in twelve seconds at an ordinary speed. Some packs were worse: shell had
a median of 46 and a shortest entry of 17. That is the reason packs felt
small. It was not the number of entries, which was 15 to 37 per pack, but the
length of each one.

Multiplayer had already hit this and worked around it: load_race_texts
filters to 120 characters or more. That filter left only 61 of 350 passages
eligible, 38 of them from two packs, so races repeated constantly and four
packs could never come up at all.

Both halves are fixed.

Content: 104 longer passages added across every pack. The command packs get
whole pipelines rather than single flags, which is how the tools are actually
used and what the explanations were always for. Prose packs get passages that
run 150 to 320 characters.

Selection: a floor of 120 characters. Prose packs draw from their long
entries where a pack holds at least five, so no pack is reduced to the same
few passages. The command packs chain consecutive entries into one drill,
which is the natural shape for them, and the explanations are collected so
each line is still described.

Measured over 400 draws per pack, every category now runs a median of 146 to
218 characters with a shortest draw of 120. The race pool went from 61 of 350
to 130 of 454.

Attribution: 24 passages had none and displayed nothing at all under the
text. They now say Unknown, which is the honest answer for a fact written for
the pack, and the pangram is credited as one. The results and typing screens
both fall back to Unknown rather than rendering an empty line, and the pack
name is shown beside the source.

Multiplayer results, three fixes:

- PB never appeared. A race has no mode of its own, so modeKey was undefined,
  so recordResult never ran. Races share one key, because the passage is
  whatever the server dealt and a per-passage best would never be beaten.
- Play Again started a solo test. A race carries standings and a solo run does
  not, so the results screen can tell them apart and now queues for another
  race. The end screen's own cleanup leaves the old room first.
- CONSISTENCY was the longest label on the screen and made the accuracy
  column wider than the WPM column opposite it. It reads CON.

RaceText carries the pack name so the results can show it for a race the same
way single player does.

24 Rust tests and 3 browser tests pass.
</content>
</entry>
<entry>
<title>Fix the store showing every item as free, and six layout defects</title>
<updated>2026-01-13T19:14:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-13T19:14:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=695bf0df2ec48e67d716dad25711cdc2e96bf7f4'/>
<id>urn:sha1:695bf0df2ec48e67d716dad25711cdc2e96bf7f4</id>
<content type='text'>
The catalogue served every price as $0.00. The Cosmetic struct typed
price_cents as i64 against an INTEGER column, so sqlx refused the decode and
unwrap_or_default turned the failure into a zero. Nothing was logged. The
whole store read as free while the database held the real prices.

Decode errors are now returned rather than defaulted away, and create_session
refuses any amount at or below zero, so a price that fails to decode cannot
become a session that grants an item without charging.

Layout, all measured in a real browser at 1280, 820 and 390 pixels:

- The close button sat underneath the fixed top rail. The rail reaches into
  the content column on any viewport under about 1350px, so the overlap was
  there for nearly every visitor. The button starts below the rail now.
- The wordmark and the top rail overlapped by 75px at 390px. Both are fixed
  to the top of the viewport and neither knew about the other. Both give
  ground on narrow screens, and Sign Up drops out of the rail because Sign In
  reaches the same screen.
- Store rows were 93px tall for one line of content, because .menu-button
  carries a vertical margin meant for a stacked menu. Twenty six items came
  to 2400px of scrolling. Rows are 47px and the page is 2467px rather than
  3784px.
- .stats-screen centres its children, so any child without a declared width
  shrink-wraps. That left the sign-in box at 415px, the bundle grid at 488px
  (which collapsed it to a single column) and the leaderboard table at 498px,
  all inside a 636px column. Every screen was swept for the same defect.
  .account-panel is the one narrow child that is deliberate: it declares
  max-width 360px because a sign-in form should not be 636px wide.
- Leaderboard rows had a hard 461px minimum from fixed column widths, so at
  390px the row ran from x=-36 to x=426 and the date column was cut off the
  side of the screen. The date is hidden on narrow viewports.
- The profile links in the leaderboard were 21px tall, under the 24x24
  minimum target size, and they are the only route to a player's profile.

Sprites never showed as equipped: the store compared the equipped caret and
flair but not the sprite.

The store test bought items by clicking Buy, which used to grant them for
nothing. It now asserts that Buy does not grant, then grants the items the
way a signature-verified webhook would, and goes on to check that an equipped
caret colour reaches the typing screen.

All 21 screen and viewport combinations are clean for overlap, overflow,
clipped content and target size. 3/3 browser tests pass.
</content>
</entry>
<entry>
<title>Charge for store items, and price them individually</title>
<updated>2026-01-12T19:19:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-12T19:19:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7'/>
<id>urn:sha1:a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7</id>
<content type='text'>
The store had 26 items, a price on each and a working equip flow, but the
purchase endpoint granted ownership without taking any money. Anyone signed
in could take the whole catalogue for nothing. That endpoint is now gone.

Payment goes through Stripe Checkout, which is hosted by Stripe. The buyer is
redirected there and comes back, so no card details reach this server and it
stays outside PCI scope.

Three rules hold the money path together:

- The price comes from the server's own catalogue row. The client sends an
  item id and never an amount.
- Nothing is granted at checkout. The item appears only when a webhook
  arrives with a valid HMAC-SHA256 signature, checked in constant time
  against a 5 minute timestamp window.
- Fulfilment keys off the processor's session id, which is UNIQUE, so a
  webhook delivered twice cannot grant the same item twice.

Prices now vary by item. Every caret cost the same as every other because
they are the same thing in a different colour, which left nothing to save
for. Carets run 149 to 349, flair 129 to 299, and sprites 249 to 399, since a
sprite is the one cosmetic every other racer sees.

Four bundles sit above the catalogue, each priced below the sum of its parts:
Starter Kit, Neon Set, Racer Set and The Lot. The saving is computed from the
current item prices rather than asserted, so it cannot drift. The Lot is
defined as every cosmetic rather than a fixed list, so it stays complete as
items are added.

Three bugs found while wiring this up:

- Sprites never showed as equipped. The store compared the equipped caret and
  flair but not the sprite.
- Supporter status was a stored boolean that was set on payment and never
  cleared, so a 30 day subscription lasted forever. Both read paths now
  derive it from the expiry.
- sqlx::migrate! reads the migrations directory at compile time, but cargo
  watches source files only. Adding a migration did not trigger a rebuild, so
  the binary shipped the old migration set and the schema change never ran.
  A build.rs now declares the dependency.

Verified against a real database: bundle maths, the grant statement and its
replay, 401 unauthenticated, 404 on unknown ids, 501 with no Stripe keys, and
both already-owned refusals.
</content>
</entry>
<entry>
<title>Unify the control styling, add racer sprites, fill the store, reserve ad space</title>
<updated>2025-12-24T22:24:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-24T22:24:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=b97beebfd527a12b182a12a556381768a728d9dc'/>
<id>urn:sha1:b97beebfd527a12b182a12a556381768a728d9dc</id>
<content type='text'>
Design tokens
- The stylesheet had five corner radii in use with no rule for which applied
  where, so a button was square while the input beside it was rounded and the
  icon button next to that was something else again. Two tokens now: --radius
  for controls, --radius-panel for the surfaces they sit on.
- One border weight. Buttons were 2px and inputs 1px, which made a field and
  its own button read as different weights of the same idea. The two start
  buttons keep 2px, where it is doing work.
- Letter-spacing was 1px, 2px, 3px and 4px with no rule behind it. Now 1px
  for small caps labels and 2px for button text.
- Removed the .ghost modifier. After the hierarchy pass it rendered
  identically to .quiet, so the two were one control under two names. Its
  uses were toggles, which .quiet plus .active already expresses.

Racer sprites
- Six sprites in the same angular language as the rest of the icon set, one
  per racer in the lobby and the race. A race reads as characters moving
  rather than as coloured lines with names attached. Assigned from the
  server's player order, so every client draws the same person as the same
  character without needing to agree on anything.

Store
- Six items to twenty-six, across three slots. Race sprites join carets and
  flair as a third slot, with the equipped sprite stored per user. Nothing is
  required to race: an unequipped player keeps the sprite their position in
  the room assigns.

Advertising space
- Reserved, empty, and loading nothing. The slot exists so that adding a
  network later cannot push the page around when the tag loads. Shown to
  signed-out visitors and to accounts without the supporter flag, on the end
  screen only: interrupting somebody mid-test is the one placement that would
  cost more than it earns.
- No ad code is included, and none can be added without three deliberate
  changes recorded in adSlot.js: the CSP forbids third-party script, frame
  and image sources; the privacy page states there is no advertising and no
  third-party script; and an ad network brings consent requirements that this
  app has no mechanism for. Verified after this change that the only external
  request the page makes is still fonts.googleapis.com.
</content>
</entry>
<entry>
<title>Fix the end screen layout, make the app responsive, document secrets</title>
<updated>2025-12-18T17:21:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-18T17:21:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=b371f21989bfca13fe8cc9bd59e95f5614f63b0d'/>
<id>urn:sha1:b371f21989bfca13fe8cc9bd59e95f5614f63b0d</id>
<content type='text'>
End screen
- There was an auto-fit routine that forced this screen into one viewport: it
  shrank the graph to a 120px floor, trimmed the Play Again margin, then
  capped the passage box at 80px with its own scrollbar. On a 650px window
  that left the passage 80px tall and clipped, and the graph 120px, which are
  the two things the screen exists to show. Removed. The page scrolls
  instead, which is the right trade for a screen that is read rather than
  acted on under time pressure.
- The screen is a flex column, so its children also shrank by default once
  the content was taller than the viewport. The passage, graph, stat row and
  standings no longer shrink, and the chart has a floor below which it stops
  carrying information.

Bottom chrome
- The keyboard hint and the footer links were both fixed at bottom centre and
  overlapped at every window size. The hint now sits above the footer.
- Normal-flow content could end up underneath the fixed footer and the corner
  rails. One --bottom-chrome variable reserves that space on every screen.
- On a narrow screen the footer grows to the full width once its links wrap,
  so at 375px it ran through both corner rails and covered Play Again, which
  made the button unclickable. The chrome stacks there instead: rails on the
  bottom line, footer above them, hint above that.

Mode picker
- It ran to the last pixel of the window at every size. It now keeps clear of
  the bottom edge, and opens upward when a short window leaves more room
  above than below.

Responsive
- Checked at nine viewports from 1920x1080 down to 375x667: no horizontal
  overflow and nothing off-screen on the menu, the typing screen or the end
  screen.

Configuration
- dotenvy searches upward from the working directory, so crates/server/.env
  was only found when starting the server from that directory. The repository
  root is tried as well, which is where it is usually started from.
- .env.example and the README explain where secrets belong: the environment,
  a gitignored .env for local work, and EnvironmentFile or a platform secret
  store in production. Also what to do if one is exposed.
- TYPERPUNK_ADMIN_USERNAME and TYPERPUNK_ENV are documented rather than left
  to be discovered in the source.
</content>
</entry>
<entry>
<title>Harden for production: dependencies, headers, admin roles, docs</title>
<updated>2025-12-15T18:44:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-15T18:44:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=1740327557074df0c8b99635ee949e2540ac94d0'/>
<id>urn:sha1:1740327557074df0c8b99635ee949e2540ac94d0</id>
<content type='text'>
Dependencies
- The server build carried 37 known advisories, including RUSTSEC-2024-0363
  in sqlx 0.7, which is the database layer. sqlx moved to 0.8 with
  default-features off, which also drops the MySQL and SQLite drivers and
  with them rsa and RUSTSEC-2023-0071. reqwest moved to 0.12, which brings
  hyper 1.x and was the sole source of every remaining advisory: h2 0.3,
  rustls-webpki 0.101, rustls-pemfile 1.0 and idna 0.3.
- The server build now reports no known vulnerabilities against OSV. cargo
  audit itself would not compile, so the check queries OSV with the crate
  versions cargo tree reports for the server binary.
- Cargo.lock is committed. This workspace produces binaries, so the lockfile
  is what makes a deployed build reproducible and the audit above meaningful.

Headers
- The application sent no security headers at all. The static server now
  sends a Content-Security-Policy, nosniff, frame options, a referrer policy
  and a permissions policy; the API sends a policy of its own, since it
  serves JSON and should load and frame nothing.
- The one inline script in index.html moved to a file so script-src needs no
  unsafe-inline. WebAssembly needs wasm-unsafe-eval, without which nothing
  types at all, so that is present and explained.
- Five style attributes moved to the CSSOM rather than adding unsafe-inline
  for styles. A style attribute in markup is refused by the policy; the same
  property set through element.style is not.

Production configuration
- With TYPERPUNK_ENV=production the server refuses to start if COOKIE_SECURE
  is off, if DATABASE_URL is still the development default, or if
  FRONTEND_ORIGIN is http on a non-local host. These were warnings, and a
  warning in a log nobody reads is not a safeguard.

Administration
- Moderators were appointed with psql. There is now an admin role,
  bootstrapped from TYPERPUNK_ADMIN_USERNAME at startup, and a UI to appoint
  and remove moderators. An administrator's own role cannot be changed
  through the API, so a mistake cannot lock everyone out of moderation.

Corpus
- scripts/export_approved.js writes approved submissions back into
  data/packs/community-*.json. Approved passages are served from the database
  and merged at startup, so without this the repository dataset and the live
  corpus drift apart, and a fresh checkout or the TUI sees only what shipped.

Documentation
- README rewritten for the repository: what it does, how to run it, the pack
  format, the server variables, deployment, and what the security posture
  actually is. Plain English, no em dashes, no emoji.

Checked and found already correct: every private endpoint refuses anonymous
callers, session cookies are HttpOnly and SameSite=Lax, CORS names a single
origin, internal errors are logged rather than returned, and every query is
parameterised.
</content>
</entry>
<entry>
<title>Add community text submissions, and make custom text usable for study</title>
<updated>2025-12-14T07:24:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-14T07:24:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=3dbebfbc9345d2603908f32c0dabebc0ff21feb3'/>
<id>urn:sha1:3dbebfbc9345d2603908f32c0dabebc0ff21feb3</id>
<content type='text'>
Submissions
- POST /api/texts proposes a passage; nothing reaches players until a
  moderator approves it. GET /api/texts serves the approved set, which the
  client merges on top of its bundled packs at startup.
- Validation the server enforces rather than trusts: category from a fixed
  list, 40 to 600 characters, no control characters (a newline makes a
  passage untypeable in a single-line input), attribution length, and a
  unique index on md5(lower(btrim(content))) so the same passage cannot be
  submitted twice under different whitespace or casing.
- Moderation is a flag on users. The queue and the review endpoint both
  refuse a non-moderator, and reviewing an already-reviewed submission is a
  404 rather than a silent second write.
- Submissions are rate limited per user: enough for a real contributor, not
  enough to fill the queue from a script.
- A Contribute screen carries the form, your own submissions with their
  status, and - for moderators only - the review queue.

This is the half of TypeRacer's model the packs could not reach by authoring:
their corpus is roughly twelve thousand passages, grown by submission.

Custom text as a study tool
- Imported documents are kept between visits, with how far through each one
  you are. Custom text lived only in memory, so importing a set of notes and
  reloading the page lost them - fine for pasting a paragraph to race,
  useless for working through a file over several sittings.
- Position is recorded when a segment is finished, not when the next is
  started, so closing the tab after a segment does not lose it.
- Markdown is chunked as markdown: fenced code blocks are kept whole and
  typed line by line, and the decoration - hashes, asterisks, backticks,
  link brackets, table pipes - is stripped so what you retype is the
  material rather than the punctuation around it.
- Everything stays on the device. Notes are not uploaded anywhere.

Fixed while doing it: a chunk could contain a newline, which cannot be typed
in a single-line input at all. Any paragraph with a line break inside it --
ordinary in notes and in wrapped prose - produced an unfinishable segment.
Whitespace inside a chunk is now flattened.
</content>
</entry>
<entry>
<title>Rebuild the generic packs, add shell and sysadmin, fix short race passages</title>
<updated>2025-12-07T19:58:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-07T19:58:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc'/>
<id>urn:sha1:8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc</id>
<content type='text'>
The packs were not proper. An audit found 86 of 253 items (33%) carrying an
attribution that just restated the category - prose *about* a topic with an
invented source, which is the same fault the movies pack had. Six of thirteen
packs were mostly that: technology had 15 items and one distinct attribution.

- science, technology, history, nature and business are now sourced quotes
  with real attributions: Feynman, Curie, Hopper, Dijkstra, Lincoln, Carson,
  Drucker, Goodhart. 82 items, all attributed to a person or a work.
- general is original factual prose, so it now carries no attribution at all
  rather than claiming "General knowledge" as a source. merge_packs no longer
  invents one from the pack's filename.
- Four explanatory passages in philosophy lost their "Philosophy" attribution
  for the same reason.
- One duplicated passage removed.

Generic attributions: 86/253 before, 0/349 now.

New technical packs
- shell: 24 awk, sed and pipeline drills, each explaining what the line does
  - field splitting, associative arrays, !seen[$0]++, process substitution,
  xargs -0, strict mode.
- sysadmin: 24 operational one-liners across systemd, disk, processes,
  network, permissions, SSH, backup and containers.
- programming grew to 37 and hacking to 30, with git bisect, window
  functions, EXPLAIN ANALYZE, certificate transparency and capability audits.
- All 115 technical drills carry an explanation.

Race passage length
- Multiplayer drew from the same pool as single player, so a race could land
  on a 22-character quote and be over before anyone had their hands in
  position. Races now require 120 characters; the filter is applied when the
  pool is loaded, not to the packs, since a short quote is fine to type alone.

For reference, TypeRacer organises by difficulty and language rather than
topic: one default English pool of ~11,900 texts plus per-language universes
and specials (accuracy, repeat, easytexts, anime). Their scale comes from user
submission with moderation, which is still the feature this does not have.
</content>
</entry>
<entry>
<title>Move the server to PostgreSQL, harden the lyrics proxy, add a hacking mode</title>
<updated>2025-12-07T18:38:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-07T18:38:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=5b1ea38522dbf6bf60db5a2270463de0c12d9de3'/>
<id>urn:sha1:5b1ea38522dbf6bf60db5a2270463de0c12d9de3</id>
<content type='text'>
PostgreSQL
- sqlx switched from the sqlite feature to postgres; the server now runs on
  Postgres 18 and the SQLite file is gone.
- 95 placeholders renumbered from ? to $N.
- REAL widened to DOUBLE PRECISION: Postgres REAL is float4 and will not
  decode into the f64 the code reads.
- flagged and is_bot are real BOOLEANs rather than 0/1 integers, with the
  decode side reading bool.
- The leaderboard's derived table gained the alias Postgres requires, its
  flag comparisons became boolean predicates, and INSERT OR IGNORE became
  ON CONFLICT DO NOTHING.
- u32 binds cast to i64; Postgres has no unsigned integer types.
- Integration tests run against a real database - Postgres has no in-memory
  mode - each in a throwaway schema, with search_path set per connection
  because it is session state and the pool opens more than one.
- Timestamps stay TEXT for now and LISTEN/NOTIFY is still unused; both are
  recorded in TODO-postgres.md rather than left implied.

Custom text and lyrics, checked rather than assumed
- Custom files never reach the server: they are read in the browser through
  the File API, so there is no upload, no path handling and no remote file
  inclusion to have. Verified by driving a hostile file - markup in the body
  and in the filename - all the way onto the typing screen: it renders as
  literal characters, no nodes are created, nothing executes, and the
  filename is escaped in the attribution too.
- That test found a real regression: picking Custom from the new mode picker
  selected it without ever starting it, so the mode was unstartable.
- /api/lyrics fixes its upstream host, so it cannot be pointed elsewhere, but
  it was an unbounded relay: now rate limited per IP, with length caps on
  artist and track and a ceiling on the response body it will read.

Hacking mode
- 22 single-line drills across recon, web, memory safety, exploit
  development, crypto, post-exploitation and defence, each syntax
  highlighted and each explaining what the line actually does.

All 19 modes verified to start, render and be typable.
</content>
</entry>
</feed>
