Updates

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.

Atom feed What's next

v0.9.1

Fixed

  • 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.
  • kinkong CLI: serve, migrate, admin invite, version.
  • 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.