Everything that has changed here, newest first. This page is the changelog from the
repository, so it is written when the work happens rather than afterwards. The site is on
v0.9.1 right now.
Italic Fraunces was downloaded twice on every public page. The preload used {{asset}}, which
appends ?v=<hash>, while tokens.css spells the same font as a plain relative path — a stylesheet
cannot call asset. Two URLs are two cache entries, so the browser preloaded 43 KB and then fetched it
again, and the preload bought nothing. The preloads are now character-for-character what the stylesheet
asks for. Found in a Lighthouse network trace while scoring the art pipeline.
Changed
A fresh passkey assertion is now required to take an image off the site, and to reject one that is
currently live — the house rule that step-up is for an action that changes what the public sees.
Approving does not, for the same reason publishing a post does not.
v0.9.0
Added
internal/art and kinkong artgen (PLAN.md Session 6, D6–D7): the artwork pipeline. A prompt built
from the published style guide, an ImageRouter call counted against the one shared $9 cap in
internal/ledger, a file on disk, and a row that nobody but a person at /admin/art can make public.
Nothing publishes itself, and it is the shape of the code rather than a rule to remember.Insert
is the only way a row is created and cannot create one in any status but pending; Approve takes a
user id and the CLI has no session to give it; the public file handler serves approved rows only. An
unreviewed image therefore has no URL on the public site at all, rather than an obscure one.
The approval queue at /admin/art, shaped like the news inbox: a machine fills it, a person empties
it. The review page shows the picture on the site's own background, the whole prompt as sent, and what
is currently in that slot, so approving is a choice between two pictures rather than a surprise.
Alt text is required before anything goes live, and it is written by the reviewer.
Slots. A picture goes to hero.home, category.image-generation, og.tool.comfyui and so on; the
part before the first dot decides the shape and the sizes cut from the original. A slot holds one live
image per rating, enforced by a partial unique index — approving a second one retires the first in the
same transaction rather than giving a page two heroes and letting ORDER BY pick.
Every asset carries a rating of sfw or after_dark (D6). Both go through the same approval, and a
slot with no After Dark variant falls back to the SFW picture rather than to a hole.
/art/{file}, served by the site rather than by Caddy — which is what makes "approved rows only"
enforceable. The name is the SHA-256 of the original, so it is immutable and cached for a year with no
purge step in the deploy, and the same bytes generated twice cost one file and leave one thing to review.
Artwork on the home page hero and the category cards, behind a <picture> with a real srcset.
45 new tests: the pipeline against a stub ImageRouter, the publication guarantee against the real route
table, the path allow-list, and 15 new end-to-end browser checks (133 in all) that ask the browser
what it painted rather than what the HTML says.
Changed
The derivative step is ImageRouter → Go → ImageMagick, and the WebP encoder was changed after it was
measured. The first implementation used nativewebp — pure Go, no cgo, no external binary — and on a
1200-pixel hero it produced 34.8 KB where ImageMagick's lossy WebP produced 3.4 KB, because it
encodes lossless only. A "modern format" two and a half times the size of the JPEG it replaces is worse
than not shipping one. So WebP and AVIF both go through ImageMagick (libwebp, libheif), nativewebp
stays as the floor under WebP when ImageMagick is absent, and a kind with transparency uses it by
choice. AVIF is ImageMagick or nothing: the pure-Go encoders embed a wasm runtime — the one that
works costs 7.5 MB of binary and wants W^X memory, which this site's own systemd unit forbids with
MemoryDenyWriteExecute=yes. Recording this is PLAN.md Session 6's "pick one and record it".
Each width is encoded from a lossless PNG held in memory, never from the JPEG fallback, so two sets of
artefacts are never stacked on material — flat neon gradients — that shows them immediately.
A srcset never names a width the original cannot give. A rung wider than the source is dropped
rather than filled with a copy: three identical files under three names is waste, and offering "2400w"
for a 1024-pixel image tells the browser something untrue, which on a wide screen it believes.
ledger.Record returns the row id, so an asset can point at the exact call that paid for it.
A dict template function, so a partial can be called with more than one argument.
v0.8.0
Added
Design tokens (static/css/tokens.css): every colour, size, radius, duration and effect knob the
site uses, named in one file. No other stylesheet holds a literal, which is the one property a skin
depends on — a skin that sets every token cannot re-colour a component that kept a hex code to itself.
A test enforces it. The palette's values are unchanged, so nothing looks different.
The skin engine (PLAN.md D6): a skin is a file of token values and nothing else. Holo is the
default and is the :root block, so the skin nobody picked costs no request and no bytes. The
specificity rules a skin has to follow are documented in docs/design/skins.md.
The effects kit (static/css/effects.css, static/js/fx.js): scanlines, holo shimmer, glitch and
the Matrix rain. Only transform, opacity and filter animate; every layer is out of flow, so the kit
costs nothing in layout shift. The rain starts on idle, caps at 30 fps, pauses off-screen and in a
hidden tab, follows the theme, and under prefers-reduced-motion never starts at all.
just budget, a new gate: PLAN.md D8's 50 KB CSS and 60 KB JS per public page, gzipped. It fails
on an asset that is in no bundle, so nothing ships unmeasured. The worst page is 11.1 KB of CSS.
The 18+ interstitial at /go/{slug} (D7), in front of an outbound link to a tool that allows adult
content. The destination is never in the URL — it is read from the tool's own row — so there is no
parameter that could carry an open redirect. Agreeing is a POST that sets kk_18 for a year; the
cookie holds "1" and nothing else.
Sixteen end-to-end browser checks for the tokens and the kit, and four Go tests for the interstitial.
Changed
The legal pages are the real copy, not the skeleton: every claim checked against the code first.
The privacy page now lists all three cookies by name and lifetime, what inbound mail is stored, what
each of the three third parties is sent, and the retention horizons. The content policy carries the art
style guide D7 asks for.
--on-accent replaces six hardcoded #fff across four stylesheets, which would have been white text
on a pale accent the moment a skin picked one.
Fixed
The scanline effect parsed and drew nothing.--fx-scanline-color holds a comma-separated list, so
rgb(var(--fx-scanline-color) / var(--fx-scanline-opacity)) substituted to rgb(0, 0, 0 / 0.07) —
comma and slash syntax mixed, invalid, and an invalid value drops the whole declaration. Found by
asking a real browser what it had computed, which is now what the e2e checks do.
The privacy page promised a session list on /account/passkeys that does not exist, and the DMCA
page named a designated agent that has not been registered. Both corrected to what is true.
v0.7.1
Added
Inbound mail (internal/mailer, /admin/mail): Postmark holds the MX records for the domain,
accepts anything addressed to *@kink.ong and posts it to a webhook at
/hooks/postmark/inbound/{token}. The token in the path is the whole authentication and is compared
in constant time; a wrong one gets the same 404 a made-up path gets. What is stored is less than what
arrives: plain text only, no attachments, a capped body, and one row per provider MessageID so a
retry is not a second copy. The site never replies to anything — /admin/mail is a queue a person
reads and marks done.
The inbound webhook URL is handed over the way an invite link is: minted if it does not exist,
shown once, behind step-up, never on a plain GET of /admin/vault.
Provider balances (balances job, every 6 h): what OpenRouter and ImageRouter say is left on the
account, stored in micro-dollars on the secret's own row and shown on its vault card with its age.
Nothing is ever fetched while a page is being rendered; there is a button for when you want it now.
It is the ledger's second opinion — ai_ledger says what we believe we spent, this says what the
provider believes we have left.
Changed
The ledger moved to internal/ledger, since the art pipeline spends from the same $9 as the
news summaries. Store.Ledger is now Store.Totals. No behaviour change.
Fixed
The ImageRouter probe rejected a working key. Every probe read at most 32 KB and the model list
is 78 KB, so the JSON ended mid-object. That endpoint now streams under a cap of its own.
The OpenRouter probe wrote part of the key into the database. Its note quoted the provider's
label, whose default value is a truncation of the key itself, which Scrub could not match. The note
now reports what the key can do rather than what it is called, and Scrub also redacts anything
shaped like a key.
v0.7.0
Added
The news inbox (internal/news, /admin/news, /admin/feeds, /admin/posts): curated feeds are
pulled into a queue, a person decides what is worth writing up, and a person publishes it. The flow
runs one way and never skips a stage, and two of its arrows are people:
feeds → news_inbox → (a person) → news_posts → (a person) → published. No code path in the jobs
package can write a post or publish one, and a test scans the package to keep it that way.
Ten news sources, each one fetched and verified before it was added. Three of the addresses
PLAN.md D5 suggested do not exist and the working address is used instead; ImageRouter publishes no
feed at all. They ship in data/seed/news/feeds.json and load with kinkong seed, which never
re-enables a source somebody turned off.
feeds (every 2 h) pulls every enabled source with a conditional request, so a quiet feed costs
one 304. An item is identified by the publisher's own guid where there is one, and by its canonical
URL otherwise — tracking parameters and fragments stripped.
summarize (every 30 min) asks a model for a title, a summary, one line on why it matters and
some tags, in batches of five. It writes into the item's own AI columns, next to the source's words
rather than over them, and the review page shows both.
The AI ledger (ai_ledger): every call, successful or not, with what it cost in micro-dollars.
The $9 cap is shared with Session 6's art generation and is enforced by summing the table before a
call, not by a counter anybody has to remember to increment.
Public /news, /news/{slug} and /news.atom, plus the three newest posts on the home page. A
post whose first draft came from a model carries "AI-summarized, human-reviewed" on the listing,
the page and every entry of the feed; the label comes from a database column set when the draft was
made, and editing the words afterwards does not clear it.
release_watch now also drafts an inbox item for a new release, as PLAN.md D5 asks. The notice stays:
they are two queues asking two questions — "did you see this" and "is this worth a post".
fetch.PostJSON, the first outbound call this site makes that is not a read.
Changed
A feed's text is stored as plain text with the markup stripped, never as HTML. A post made from it
still goes through goldmark, so this is belt and braces — but there is then no copy of a stranger's
markup anywhere to be rendered by mistake later.
A draft post's URL is a 404, the same answer a slug that never existed gets.
The admin dashboard gained two tiles: what is waiting in the news inbox, and what the AI has cost.
v0.6.0
Added
The freshness engine (internal/jobs, /admin/jobs): an in-process scheduler with a
sixty-second tick that runs six jobs and records every run. It keeps no state of its own — "is this
due" is answered by the newest row in job_runs against the wall clock — so a deploy in the middle
of the afternoon neither re-runs the morning's work nor skips the evening's.
alive (daily) asks each published tool's homepage whether it still answers.
github_sync (6 h) reads stars, forks, last commit, licence and the archived flag.
hf_sync (6 h) reads downloads, likes and last-modified for models with a Hugging Face repo.
release_watch (6 h) notices a new release on a tracked repo.
wayback (weekly) finds each tool's newest archive snapshot, and requests a fresh one when
archive.org credentials are in the vault.
housekeeping (hourly) expires sessions, ceremonies and invite tokens, thins the metrics history
and trims the job log. It replaces the ten-minute session sweep, which now leaves a record.
One outbound client (internal/fetch) with the site's manners written down once: it says who it
is, makes at most one request a second to any host, obeys Retry-After for the whole host, and stops
at ten seconds, two megabytes and five redirects.
It will not dial a private address. A tool's homepage is a URL a human typed into a form, and this
process can reach the database and the admin panel on loopback. Every connection is checked after DNS
resolves and again on each redirect hop, and loopback, private, link-local, CGNAT and multicast
addresses are refused by name. A source scan fails the build if anything outside a test can switch
that off.
Admin notices (/admin/notices): a tool that stopped answering, a repository its maintainer
archived, a release nobody has written up, a job that has failed three times running. The same
problem seen four times is one notice with a count of four, and the engine withdraws its own notice
when the thing it complained about starts working again.
Signs of life on a tool page: when it last answered, its star history as a server-rendered
sparkline, the latest release, and a link to the archived copy. The section says in as many words
that these are the only figures on the page a machine wrote.
kinkong jobs list|run NAME|probe URL. probe fetches one URL through the real client, which is how
deploy/rehearse-sandbox.sh asks whether the sandbox allows egress — a question about the unit file
that can only be answered from inside a unit.
Changed
The engine may raise exactly one status by itself: possibly_dead, after three consecutive silent
checks, cleared when the tool answers again. It follows GitHub's archived flag too, because that is
the maintainer's own statement rather than a guess. A status a person wrote — abandoned, archived —
is never overwritten, and a licence somebody typed is never replaced by a forge's SPDX guess.
Drafts are not fetched. A page nobody can read is not worth anyone's bandwidth.
The systemd unit raises MemoryMax to 512M with GOMEMLIMIT=384MiB, and its egress comment now
explains where the real rules live (PLAN.md D5). Rehearsed under a transient unit before deploying.
The end-to-end browser run grew from 60 checks to 71: the jobs page, starting a job from the admin
panel and watching the history pick it up, and the dashboard agreeing that the engine has run.
v0.5.0
Added
The catalog editor (/admin/tools, /admin/categories, /admin/models, /admin/hardware):
tools, categories, models and hardware profiles are created and edited in the admin panel, with
markdown bodies. Until now the catalog was only editable by hand in data/seed/*.json.
Nothing publishes itself. A new tool saves as a draft and stays invisible — absent from the
listings, from search, from the sitemap and from the far end of another tool's relation — until
someone presses Publish. Taking a page back down, and deleting anything, needs a fresh passkey
assertion, and deleting asks first.
The list fields — modalities, interfaces, operating systems, features, limitations, languages, links
and relations — are textareas, one item per line, value | note | source: the same shape as the seed
files, so a catalog entry can be pasted between them. A line the parser cannot read is refused with
the line number and the word that confused it, and the form comes back with everything still typed
in it.
Catalog changes are recorded in the audit log, so "who took that page down" has an answer.
Fixed
An edge to a draft is no longer lost when the other tool is saved. A relation whose far end was
unpublished was correctly hidden from the public page — and was also hidden from the editor, which
rewrites the relations it owns on every save. The next save would have deleted it silently. The
editor now loads both ends regardless of state; the public pages still hide drafts, and the test
that says so is in cmd/kinkong/admin_catalog_test.go.
An edge stored on the other tool is listed read-only rather than offered for editing, because
posting it back would have moved it.
Changed
The end-to-end browser run grew from 48 checks to 60: a tool is created in the real form, published,
taken down through a real passkey ceremony and deleted, and seen appearing and disappearing from the
public site in between.
v0.4.0
Added
The catalog: 44 tools, 10 models, 10 categories and 7 hardware profiles, browsable at /tools,
/categories and /models, with a page each. Every tool records the three things that are usually
hardest to find out — where it runs, what it allows, and what it does with what you give it — and says
unknown where we have not found a clear answer, because a guess dressed as a fact is the failure
this whole directory exists to avoid.
"What can I run on…": pick your machine and the list narrows to what will actually run on it, with
each card saying whether it fits comfortably, fits tightly, or has no published requirements at all.
A tool nobody has written a VRAM figure for is kept and labelled rather than quietly dropped.
Search at /search, full-text over tools and models (SQLite FTS5), ranked so a tool's own name
beats a passing mention of it, with the matched words marked in the result.
Permanent URLs. Renaming anything records the slug it used to have, and the old URL answers a
permanent redirect for ever. A link posted anywhere keeps working.
Limitations with sources: every tool lists what it does not do, or does badly, with a citation
where there is one. An entry with nothing bad to say is an advert, and a test refuses to let one ship.
kinkong seed fills the catalog from JSON files embedded in the binary, and re-running it updates
rather than duplicating. kinkong reindex rebuilds the search index.
/sitemap.xml is now generated from the database, so a newly published tool is in it immediately.
Drafts never are — an unpublished tool is absent from the listings, the search index and the sitemap.
Changed
The header and footer carry the catalog: Tools, Categories, Models and Search.
Filtered listings are noindex and canonical to /tools, rather than a few hundred near-duplicates.
Lighthouse now scores the catalog routes too; all fourteen public routes stay at 95+.
The end-to-end browser run grew from 38 checks to 48, covering the filter form, search and a tool page.
v0.3.0
Added
Encrypted vault (/admin/vault, superadmin only, every action behind a fresh passkey assertion):
API keys sealed with AES-256-GCM under a master key kept in /var/keys and handed to the service by
systemd, never in the database, an environment variable, a log or a backup. The secret's own name is the
additional authenticated data, so a row cannot be moved under another name and still open. The page is
write-only: it shows what a secret is for, where it came from and how its last check went, and never the
value, its length, its prefix or a hash of it.
A test message you send yourself from /admin/vault. A key check proves Postmark accepts the token;
only a message that arrives proves DKIM, the Return-Path CNAME and DMARC are right too.
Live key checks: a Test button per secret that asks the provider the cheapest authenticated question
it has — Postmark, OpenRouter, ImageRouter, GitHub, Hugging Face, Cloudflare — and records the answer,
scrubbed of the value in case the provider quotes it back.
kinkong vault init|status|rekey: init writes the master key, prints it once and points the systemd
unit at it; status runs as the service user and needs no key at all; rekey re-seals every secret
under a new key, staging the old one first so an interrupted run still opens everything.
Transactional email through Postmark, with the token read from the vault at the moment it is needed.
Mail is built to be off: with no token the attempt is recorded and the feature simply does not run.
A send governor (5 per recipient a day, 60 an hour site-wide, and a breaker after three provider
failures) sits in front of every message. No tracking pixel, no link rewriting, and a text part that
stands on its own.
Passkey recovery at /recover: an emailed link that can only register a new passkey. It never signs
anyone in, it leaves existing passkeys alone, the token rides in the URL fragment, and every reply is
identical whatever happened — so it cannot be used to ask whether an address has an account here. Staff
accounts are refused: an admin's access must not hang on a mailbox.
/account/passkeys: add a passkey (after confirming with one you already have), rename it, or remove
it — never the last one, because with no passwords that removal is the one action that can end an
account.
/updates and /updates.atom: the changelog you are reading, rendered from the repository.
/roadmap: the checkbox roadmap with progress bars, from the same file the build works from.
/api/version, and the running version in the footer of every page, linked to the changelog.
Legal skeletons at /terms, /privacy, /content-policy, /dmca, /disclosure and /contact,
describing what the site does today. The real copy comes before launch.
/admin/changelog, showing the git history embedded in the running build.
Changed
The service may now make outbound HTTPS connections, which mail and the key checks need. systemd can
only filter by address prefix and no provider publishes a stable one, so the limits that matter are in
the code: a fixed host per call, a ten-second timeout and a capped response body.
The end-to-end browser run grew from 17 checks to 37, now covering the vault, passkey management and
recovery.
Fixed
A master key that exists but cannot be read by this process now means "the vault is sealed" rather than
a process that refuses to start. Only the service and root can read it, which is the point.
Adding a passkey on a device that already has one says so, instead of reporting a closed prompt.
v0.2.0
Added
Passkey authentication, and only passkeys: invite links that register a credential, one-tap
discoverable login with no username, and a step-up assertion (user verification required) before
inviting, changing a role or disabling an account.
Accounts with ordered roles (user < mod < admin < superadmin), database sessions stored as digests,
single-use invites and an audit log.
Admin panel at /admin: dashboard (build, box, account counts, recent activity), users (invite, role,
disable) and the audit log, with a Ctrl-K command palette rendered from one nav table.
SQLite via modernc (pure Go, no cgo) with numbered embedded migrations, WAL, a single-writer pool.
Security middleware: per-response CSP nonce, script-src-attr 'none', same-origin check on writes,
real client IP behind Cloudflare, named rate limiters, JSON request log.
Gates: admin-route guard (source and runtime), role-literal scan, migration, CSP nonce, secret scrub,
security headers on the wire, Lighthouse 95+, and an end-to-end passkey test in headless Chromium.
justfile with dev, check, gates, ship, backup, db, release, private-check.
Changed
Restructured into cmd/kinkong + internal/ with embedded templates and assets; the placeholder's
output stayed byte-identical through the move.
robots.txt disallows /admin/, /login and /invite (all three are also noindex).
The service runs as a static kinkong user with StateDirectory=kinkong, so the CLI and the service
share one uid on the database file.
Documentation
Build plan v2 (docs/PLAN.md): nine sessions, encrypted vault design, freshness engine, news pipeline,
versioning, After Dark skin rules.
docs/ROADMAP.md and this changelog as the public roadmap and updates sources.
VERSION file.
v0.1.0
Added
Placeholder site in Go with a live server-stats widget.
Full meta tags, Open Graph, JSON-LD, icons, manifest, robots, sitemap, security.txt, llms.txt.
Sandboxed systemd unit, Caddyfile, deploy script.
Cloudflare Web Analytics allowed in the CSP; plain-http visitors redirected via CF-Visitor.