<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>cat/forge</title>
<subtitle>a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases</subtitle>
<id>https://src.rest/cat/forge/</id>
<link href="https://src.rest/cat/forge/"/>
<updated>2026-11-03T22:06:49-07:00</updated><entry>
<id>350f662f8f446a2db9dd091c655f66a2a37abeb4</id>
<title>A file on the default branch shows its blame and history</title>
<link href="https://src.rest/cat/forge/commit/350f662f8f446a2db9dd091c655f66a2a37abeb4/"/>
<author><name>cat</name></author>
<updated>2026-11-03T22:06:49-07:00</updated>
<content type="text">Each file page on the default branch gets a blame.json beside it: the
commit that last changed each line, and the commits that changed the
file, each with the review it landed in. The page draws them on demand,
blame as a column before the line numbers, history as a panel. A build
runs git blame only for files commits touched since the last one.</content>
</entry>
<entry>
<id>a88eb729a2600eb60b38bf93007dc04680c4d996</id>
<title>Approval rules by path, and agent reviews signed like CI</title>
<link href="https://src.rest/cat/forge/commit/a88eb729a2600eb60b38bf93007dc04680c4d996/"/>
<author><name>cat</name></author>
<updated>2026-11-03T22:06:49-07:00</updated>
<content type="text">.forge/approvals on the default branch says how many approvals a change
to it needs, and from whom, by the files it touches; the rules are read
from the branch before the change, so a review can&#39;t loosen its own.
forge.requireapproval holds the gate to them, forge land checks the
same verdict, and a live review&#39;s page opens with what landing needs.

forge critique runs the reviewer the target branch names (.forge/review)
over a review on your machine. Its findings become comments marked
agent and blocking, and a statement of what it reviewed and what is
still open is signed into refs/notes/review; later runs review only
what changed and carry open findings until they are resolved. A rule&#39;s
&quot;+ agent&quot; wants one of the tip&#39;s tree with nothing blocking open.</content>
</entry>
<entry>
<id>242833e9b6329682e1969af0591105b196d08a39</id>
<title>CI reports metrics, and a repo&#39;s insights charts them by commit</title>
<link href="https://src.rest/cat/forge/commit/242833e9b6329682e1969af0591105b196d08a39/"/>
<author><name>cat</name></author>
<updated>2026-11-03T21:22:29-07:00</updated>
<content type="text">.forge/ci writes &quot;name number [unit]&quot; lines to $FORGE_METRICS; forge
attest signs them into the statement as metric lines. The lake keeps
them in ci_metrics, and commits gains the tree, so a repo&#39;s insights
page charts each metric on its default branch, a reworded commit
keeping its values. A lake made before a column existed gets it when
next opened to write.</content>
</entry>
<entry>
<id>c3443814e355e88b193b53919496daa1c04ac2fc</id>
<title>code-search-range reads its results without node&#39;s warnings</title>
<link href="https://src.rest/cat/forge/commit/c3443814e355e88b193b53919496daa1c04ac2fc/"/>
<author><name>cat</name></author>
<updated>2026-11-03T20:22:03-07:00</updated>
<content type="text">On node 26 sqlite-wasm&#39;s look at localStorage makes node print an ExperimentalWarning on stderr, which the scenario&#39;s log takes as its first line, so the check that the first result is many/zz.txt:1 failed though the search was right. The scenario&#39;s code-search-bench runs pass --no-warnings.</content>
</entry>
<entry>
<id>1cf16ec9e3d1aadfa35eac01e002be9be0871943</id>
<title>The activity grid fills its panel&#39;s width</title>
<link href="https://src.rest/cat/forge/commit/1cf16ec9e3d1aadfa35eac01e002be9be0871943/"/>
<author><name>cat</name></author>
<updated>2026-11-03T20:20:10-07:00</updated>
<content type="text">Cells were 13px at most and left in the panel, so on a wide page the 53 weeks took about 790px and left the rest empty. Now the cell is always the panel&#39;s width over 53 weeks, less the gaps, at least 10px: the grid spans the panel at any width, and narrower boxes shrink and then drop the oldest weeks as before. ui.njk&#39;s comment and DESIGN.md say so.</content>
</entry>
<entry>
<id>cf8d4d285de2caff71cbc44b6efaefcb16476dab</id>
<title>Insights says where its numbers come from once</title>
<link href="https://src.rest/cat/forge/commit/cf8d4d285de2caff71cbc44b6efaefcb16476dab/"/>
<author><name>cat</name></author>
<updated>2026-10-19T07:26:15-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>4ea47f8146d1f24cd1a2619e8226d7d68294ccd7</id>
<title>A repo&#39;s insights shows its activity grid as a chart, without the list</title>
<link href="https://src.rest/cat/forge/commit/4ea47f8146d1f24cd1a2619e8226d7d68294ccd7/"/>
<author><name>cat</name></author>
<updated>2026-10-19T07:19:42-06:00</updated>
<content type="text">The insights page had the activity grid and, under it, the latest events, with a picked day listing that day&#39;s. Now the grid is a chart like the others on the page: a figure panel whose head strip has its total and what it counts, and no list. ui.activity takes still, which makes each day a span named and titled with its count, with no tabindex, no aria-pressed and no hover or pointer in style.css; ui.ts repaints still grids from activity.json in the reader&#39;s days, and a click or a day&#39;s address picks nothing. The owner page&#39;s grid is as it was. activity.json now carries only each event&#39;s time, newest first, so repoActivity drops the kinds, texts, links and the title and subject subqueries, and lib/forge.ts drops recent. DESIGN.md and the style guide show the still grid. Scenario: the insights page has spans and no list, activity.json is days and times, and clicking a day or opening a day&#39;s address leaves the grid unpicked.</content>
</entry>
<entry>
<id>a2dcf07444e8f197ebbff8cb1ab0df6528830fa8</id>
<title>The demo&#39;s clock starts at midnight, so CI&#39;s render check holds all day</title>
<link href="https://src.rest/cat/forge/commit/a2dcf07444e8f197ebbff8cb1ab0df6528830fa8/"/>
<author><name>cat</name></author>
<updated>2026-10-19T07:19:42-06:00</updated>
<content type="text">BSD date -j -f %Y-%m-%d fills the hour it isn&#39;t given from the current time, so the demo started on 2026-09-01 at whatever time it was made and, about 35 clock hours later, finished on 09-02 or, made after about 12:40 UTC, on 09-03. The render check in .forge/ci renders at 2026-09-02, so after midday the insights pages&#39; activity grids ended a day early there and differed from 11ty&#39;s, on main too. make-demo.sh starts at 2026-09-01 00:00:00 UTC and the check renders at 2026-09-02 00:00:00, both spelled out for BSD and GNU date; the demo now ends on 09-02 around 11:00, whenever it is made.</content>
</entry>
<entry>
<id>abf4f2a085ee5307cc543cca20bfeede8e9077f9</id>
<title>A scenario: a CI note&#39;s runner is never account@hostname</title>
<link href="https://src.rest/cat/forge/commit/abf4f2a085ee5307cc543cca20bfeede8e9077f9/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">whoami_at already gives the forge user and a made-up machine name (the hostname only seeds its hash), and no other path writes the hostname. This holds it: with the forge user named as the local account, the runner isn&#39;t that account at this hostname, and the hostname is nowhere in the note.</content>
</entry>
<entry>
<id>1283f9e0f1748aa765dd47489ae9a95fe74249d1</id>
<title>A restored identity.db from litestream is the git user&#39;s alone</title>
<link href="https://src.rest/cat/forge/commit/1283f9e0f1748aa765dd47489ae9a95fe74249d1/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">forge restore moved litestream&#39;s copy of identity.db into place with whatever mode litestream gave it; forge identity restore made it 600 first, and now identity_pick does too. Scenario: a stand-in litestream writes the file readable by anyone, and the restored one is 600.</content>
</entry>
<entry>
<id>3e6ffab7b6c67950bc8c4dc702a6e0d275ddd489</id>
<title>The edge can send the mirror bucket a token</title>
<link href="https://src.rest/cat/forge/commit/3e6ffab7b6c67950bc8c4dc702a6e0d275ddd489/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">With the Fly secret FORGE_MIRROR_TOKEN set, the edge sends it as X-Forge-Mirror-Token on each request for the offline copy, so a Cloudflare WAF rule in front of the bucket can later refuse requests without it. Without the secret the header is empty. Scenario: the bucket sees the token, and the reply doesn&#39;t carry it.</content>
</entry>
<entry>
<id>835172a22647d43ff8752262a9148c26cf5b2e5e</id>
<title>Offline, the edge never redirects to another site&#39;s address</title>
<link href="https://src.rest/cat/forge/commit/835172a22647d43ff8752262a9148c26cf5b2e5e/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">The edge&#39;s offline copy added a folder&#39;s slash with redir {path}/, so //evil.example/x answered 308 to //evil.example/x/, another site. A path that starts // or /&#92; (decoded) isn&#39;t redirected now; it is asked of the copy as it is and gets its 404. Caddy&#39;s path matchers see the path with its slashes merged, so the check reads {path} itself. Scenario: a folder still gets its slash, and //, /%5C and /%2F paths get no Location.</content>
</entry>
<entry>
<id>43f0cccc60bb3c7c724c54b683f5b1a2feb998e8</id>
<title>Insights exports give a build&#39;s reason as push or other</title>
<link href="https://src.rest/cat/forge/commit/43f0cccc60bb3c7c724c54b683f5b1a2feb998e8/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">builds.parquet carried each build&#39;s reason, and the reasons private and removed said when a repo went private or away. The exports now say push, or other for everything else; the lake keeps the real reason. lib/insights.ts and the README also say what happens to a repo&#39;s facts when it goes public again: they are published with it, from while it was private too, as its whole history is then. Scenario: the lake has private and removed, the exports only push and other.</content>
</entry>
<entry>
<id>0573918c003ef590d27ecf8b263d254e423aa824</id>
<title>A repo gone private or removed leaves the index before the mirror syncs, and a removed one leaves the lake</title>
<link href="https://src.rest/cat/forge/commit/0573918c003ef590d27ecf8b263d254e423aa824/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">ssh private and repo rm started the index and owner build in the background and then the mirror&#39;s sync within 30 seconds, while that build could still be waiting on build.lock, so the listing could linger in the offline copy. They build the index before returning now (rebuild_index), unless builds are off. repo rm deleted only the repo&#39;s exports, so its rows stayed in the lake and came back in the exports of a new repo under the same name: lib/lake.ts forget takes them out of every table, with the lake&#39;s note of where it read the repo&#39;s sources, under the lake&#39;s lock. Scenarios: the listing is gone when the command returns, and a reused name starts with no history.</content>
</entry>
<entry>
<id>51330a11d4f4f0236a1ee7b446832936c7f0beaa</id>
<title>Only owners with a public repo have a page</title>
<link href="https://src.rest/cat/forge/commit/51330a11d4f4f0236a1ee7b446832936c7f0beaa/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">Every directory under the forge&#39;s root got an owner page, and forge user add makes one for each new user, so /&lt;name&gt;/ said who had an account. Owner pages are built only for owners with at least one public repo now, and a build of the index (or of one repo, or @owner) takes away the page of an owner who no longer has one, whose last repo went private or was removed. Scenario: no page for a user with no repos or only private ones, and one that goes when the last public repo does.</content>
</entry>
<entry>
<id>56690dd0e278f446809ace61f4728dce4b13c321</id>
<title>Event streams are capped, and the Node servers time out slow requests</title>
<link href="https://src.rest/cat/forge/commit/56690dd0e278f446809ace61f4728dce4b13c321/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:59-06:00</updated>
<content type="text">The events server took any number of open streams. Now at most FORGE_EVENTS_MAX (2,000) are open for people not signed in to a private repo&#39;s stream, and FORGE_EVENTS_PER_IP (16) from one address; past either it answers 503 with Retry-After. The private, events and pages servers give a request&#39;s headers 10 seconds and the whole request 30 to arrive (a response, such as the event stream, may run on). Caddy has no rate-limit module in the container&#39;s build, so per-address limits in Caddy are left for later. Scenario: the per-address and overall caps, and a closed stream freeing its place.</content>
</entry>
<entry>
<id>473a10f84180dbd2c0dc654aa8e72a2d525c9a7f</id>
<title>Invites check the code first, and count posts per address</title>
<link href="https://src.rest/cat/forge/commit/473a10f84180dbd2c0dc654aa8e72a2d525c9a7f/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:01-06:00</updated>
<content type="text">/login/invite/accept and forge user accept checked the key before the code, so anyone could ask whether a key had an account here (&quot;that key is already on this forge&quot;). The code is checked first now, on the web and in the shell, and without a live invite every post gets the same expired answer, whatever its key. The rate limit was one bucket for the whole forge that every post counted against, so junk posts could use up an invitee&#39;s turns: posts are counted per address now, 10 a minute, and only those with a live invite&#39;s code. Scenarios: a dead code answers the same for any key, and the per-address, live-only count.</content>
</entry>
<entry>
<id>2133113aac28d246e42c834ac8d1019d52b5d732</id>
<title>Sign-in challenges keep nothing on the server, belong to one browser, and are rate limited</title>
<link href="https://src.rest/cat/forge/commit/2133113aac28d246e42c834ac8d1019d52b5d732/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:42:01-06:00</updated>
<content type="text">/login/options kept challenges in a map of 1000 that dropped the oldest, so anyone could push out a sign-in in progress by asking for options in a loop. A challenge now carries its expiry and an HMAC (a key the server makes when it starts) over it, what it is for and the browser that asked, which gets a five-minute __Host-forge-challenge cookie with a random id; verify checks all of that and remembers used nonces until they expire. A challenge fetched by one client can&#39;t be completed by another. Each address may ask for options 20 times a minute (lib/limits.ts). The address is X-Forge-Client-IP, which Caddy sets from {client_ip}, replacing any a client sent; in the container Caddy trusts Fly-Client-IP from the loopback tunnel only. Scenarios: a flood of 1500 options requests doesn&#39;t break a sign-in, the per-address limit, the limit through Caddy despite a spoofed header, and a challenge refused from another browser.</content>
</entry>
<entry>
<id>fe26ac5eb0af68c410079aa849dc38bb8da598c4</id>
<title>The forge&#39;s own pages send HSTS, nosniff, a referrer policy and a CSP</title>
<link href="https://src.rest/cat/forge/commit/fe26ac5eb0af68c410079aa849dc38bb8da598c4/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:37:59-06:00</updated>
<content type="text">Caddy adds Strict-Transport-Security to every response on the forge&#39;s hostname, and to its pages (not /sites/ or /previews/, which are anyone&#39;s) X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin and a CSP of frame-ancestors &#39;self&#39;, base-uri &#39;none&#39; and object-src &#39;none&#39;. Each is set only if the response has none, so the sign-in and private pages keep their stricter ones. Nothing scripts, styles, fonts or wasm load is restricted.</content>
</entry>
<entry>
<id>ebe809d2761d6e5fae0ff25ad968cf28f64399a2</id>
<title>go get&#39;s reply answers only for name paths, and loads nothing</title>
<link href="https://src.rest/cat/forge/commit/ebe809d2761d6e5fae0ff25ad968cf28f64399a2/"/>
<author><name>cat</name></author>
<updated>2026-10-19T01:37:59-06:00</updated>
<content type="text">The ?go-get=1 reply echoed the owner and repo from the decoded path into an HTML page, so a link with markup in it reflected that markup on the forge&#39;s origin. The matcher now takes only name characters, so any other path falls through to the page it would get without go-get=1, and the reply says default-src &#39;none&#39; and nosniff. Scenarios: hostile segments get the plain 404, and real module paths still get their go-import tag.</content>
</entry>
<entry>
<id>a283b86ab9926aa599a4c6e2124c0a9875d1b381</id>
<title>Front matter for people and repos, bauhaus avatars, and a grid of days</title>
<link href="https://src.rest/cat/forge/commit/a283b86ab9926aa599a4c6e2124c0a9875d1b381/"/>
<author><name>cat</name></author>
<updated>2026-10-19T00:32:43-06:00</updated>
<content type="text">An owner&#39;s page opens with who they are: an avatar drawn from their
name (lib/avatar.ts, cat&#39;s bauhaus pattern generator cut down to a 3 by 3
grid in five new avatar tokens), their name, the bio and link they set
with ssh profile, and their repos, sites and the month they joined. Then
the repos they pinned, or their three most recently active, the shelf
tabs, their activity grid and the list. The avatars replace the home
page&#39;s letters.

ssh profile prints what a page says; profile bio, link and pin set it,
--clear clears one. Anyone sets their own; lib/profile.ts checks it (one
line of 120 characters, one https:// link, up to three of their own
public repos) and keeps it in .forge/profiles, which backups carry. A
change rebuilds that page and the home page only (forge build @owner).

A repo&#39;s code page opens with its name, what it is about, its topics
(its shelf and its .forge/topics) and a column of facts: its site, latest
release, main&#39;s CI, open reviews, the license (an SPDX id from the text,
else a link to the file) and its languages by bytes in the default
branch, without vendored, generated or lock files.

The activity grid (ui.activity, lib/activity.ts, ui.ts) is a year of
days, a button each, on an owner&#39;s page (commits on their public repos&#39;
default branches, following the picked tab) and a repo&#39;s insights (its
pushes, landed reviews, releases, site deploys and CI results). Picking a
day narrows the list under it; the address keeps it as #&lt;day&gt; or
#&lt;shelf&gt;/&lt;day&gt;. Cells are 13px at most, left in the panel; narrower, they
shrink to 10px and then the oldest weeks go. forge lake now keeps the
default branches&#39; commits and exports each owner&#39;s commit times and each
repo&#39;s events as activity.json, which the build puts in the page and
beside it, and which the page fetches for fresher counts. With JS the
days are the reader&#39;s, without they are UTC. A private repo counts on no
public page.

A shelf&#39;s list under its tabs no longer has a head strip saying the tab
again; the tabs&#39; counts follow the globe. DESIGN.md says so, and how
people and the grid are shown.</content>
</entry>
<entry>
<id>00f0dc723056bab0795a7c25a973c1d0dfc73f35</id>
<title>The README follows AP style, and DESIGN.md says docs do</title>
<link href="https://src.rest/cat/forge/commit/00f0dc723056bab0795a7c25a973c1d0dfc73f35/"/>
<author><name>cat</name></author>
<updated>2026-10-19T00:23:08-06:00</updated>
<content type="text">No serial comma in a simple series, numbers per AP, long sentences split. DESIGN.md gains Words in docs: docs prose is AP style, interface chrome keeps the lowercase rules.</content>
</entry>
<entry>
<id>719bd89270c4caf5f75ac373a87f7ef923289adf</id>
<title>cat and fox are the people in the README and /design/</title>
<link href="https://src.rest/cat/forge/commit/719bd89270c4caf5f75ac373a87f7ef923289adf/"/>
<author><name>cat</name></author>
<updated>2026-10-19T00:22:51-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>2938c4b214517a73b983cf0d2bdfe656b904a9eb</id>
<title>The guide says what forge is; how it works moves to internals</title>
<link href="https://src.rest/cat/forge/commit/2938c4b214517a73b983cf0d2bdfe656b904a9eb/"/>
<author><name>cat</name></author>
<updated>2026-10-19T00:22:51-06:00</updated>
<content type="text">The guide is now what you can do, a command or two each: browse, reviews, CI, sites and releases, people, the cheat sheet, and what isn&#39;t there yet. The push walkthrough, identity files, ref rules, deploy and release internals, hosting and the server&#39;s own commands are on the internals page, updated for the private server. Prior art is in the README only. The guide&#39;s tokens match the forge&#39;s, and its tables and cheat sheet sit in panels.</content>
</entry>
<entry>
<id>5f38414ad68b715997d0e89fc5ac31305f3b362c</id>
<title>The offline copy leaves out file pages, and syncs at most hourly</title>
<link href="https://src.rest/cat/forge/commit/5f38414ad68b715997d0e89fc5ac31305f3b362c/"/>
<author><name>cat</name></author>
<updated>2026-10-19T00:07:18-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>e86770c361a7be928ca7eeec361bb105ff8bd646</id>
<title>A shelf&#39;s link followed on its own owner page opens that shelf</title>
<link href="https://src.rest/cat/forge/commit/e86770c361a7be928ca7eeec361bb105ff8bd646/"/>
<author><name>cat</name></author>
<updated>2026-10-18T23:30:04-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>9bf849c7c2becd5b19a9b70b113d37d1e9a1d428</id>
<title>An owner&#39;s &quot;all&quot; is one list of every repo, newest first</title>
<link href="https://src.rest/cat/forge/commit/9bf849c7c2becd5b19a9b70b113d37d1e9a1d428/"/>
<author><name>cat</name></author>
<updated>2026-10-18T23:23:26-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>a3445fcbe17d2f9bce5a917b1a348b144c80d485</id>
<title>Times in a column are compact and line up; times in a sentence say ago</title>
<link href="https://src.rest/cat/forge/commit/a3445fcbe17d2f9bce5a917b1a348b144c80d485/"/>
<author><name>cat</name></author>
<updated>2026-10-18T23:11:53-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>f5a25f8a943708dc2fb436e252f2bb31c2625d16</id>
<title>A dead invite&#39;s refusal in its panel</title>
<link href="https://src.rest/cat/forge/commit/f5a25f8a943708dc2fb436e252f2bb31c2625d16/"/>
<author><name>cat</name></author>
<updated>2026-10-18T22:50:09-06:00</updated>
<content type="text">With the steps in panels, a dead invite link left the &quot;join forge&quot; head
strip with nothing under it and the refusal below the panel, which read
as a broken box. The refusal now takes the form&#39;s place in the panel.</content>
</entry>
<entry>
<id>81106ae94f4e75d932621ed91f53a611282893e3</id>
<title>/login/&#39;s steps in panels, named &quot;forge&quot; in lowercase</title>
<link href="https://src.rest/cat/forge/commit/81106ae94f4e75d932621ed91f53a611282893e3/"/>
<author><name>cat</name></author>
<updated>2026-10-18T22:42:15-06:00</updated>
<content type="text">The sign-in page&#39;s headings were sentence case and named the domain:
&quot;Join src.rest&quot;, &quot;Add a passkey&quot;, then &quot;You are fox on src.rest&quot; and &quot;For
fox on src.rest&quot; under it. Now each step is a ui.panel whose head strip
names it in lowercase chrome, with the forge&#39;s name: sign in, join forge,
add a passkey for fox, signed in as fox. The passkey step says who it is
for once, in its head strip. ssh commands on the page still show the
real host, to copy.</content>
</entry>
</feed>
