1---
2meta:
3 title: clover's log
4---
5import './log.css';
6static const repo = "${repo}";
7
8
9[git]: https://git.paperclover.net/
10[home infra]: https://git.paperclover.net/clo/home-infra
11[name paint bot]: https://git.paperclover.net/clo/discord-name-painter
12[next.js blog post]: /blog/webdev/one-year-next-app-router
13[progress.ts]: https://jsr.io/@clo/lib/doc/progress
14[this site]: https://paperclover.net/
15[todo tracker]: https://git.paperclover.net/clo/todo-tracker
16[ts lie detector]: https://git.paperclover.net/clo/ts-lie-detector
17[evil inc]: https://evil.inc/
18[history of japan reanimated]: /history-of-japan-reanimated
19[react mutation]: http://jsr.io/@clo/react-mutation
20[react markdown]: http://jsr.io/@clo/react-markdown
21[HOTEWIG reanimated]: /hotewig-reanimated
22[clover creative control]: https://git.paperclover.net/clo/creative-control
23[markodown]: https://git.paperclover.net/clo/markodown
24
25<main>
26
27[go back to the home page](/)
28
29# clover's log
30
31these are like mini blog posts, but more in generally just what the heck i'm up to.
32
33## 2026-03-20
34
35tags: [react markdown]
36
37i published a new library named `@clo/react-markdown`. originally, i wanted to
38have my own version of the `streamdown` package from vercel. but i didn't
39realize how much of a loser company they all are. i wasn't even trying and i
40made a library like a hundred times better and simpler than theres. because of
41the awesome success here, instead of calling mine "memo markdown", i just said
42"yea, this covers every markdown use case for react" and called it React Markdown.
43
44you can install it from the JSR:
45
46```sh
47npx jsr add @clo/react-markdown
48pnpm add jsr:@clo/react-markdown
49```
50
51there are two main features that i deliver on:
52
53- predicting close tokens for sequences like `hello **world`, appending `**`
54 - streamdown has this too, but many many cases are not considered. while
55 theirs is extensible and mine isn't, i don't think you'll need to extend
56 my markdown predictor.
57- component memoization. all block and inline components will preserve their
58 state, even as adjacent content changes. this is done to preserve remounts
59 for things like custom `<a>` tags or other components. (for example, if a
60 custom `<a>` fetches previewing data and provides a hover card, that card
61 won't flicker).
62
63copying some architecture notes from the readme, the memoizer is performant
64from the following tricks:
65
66- Using proper `React.memo()` calls. Obviously.
67- Prediction is implemented in a stateful way that only parses the tail end of
68 the document, marking how much of the document is stable and where possible
69 incomplete syntax may live. Since prediction only applies at the end, changing
70 text midway through can invalidate the whole predictor.
71- Separate the parsed document into "blocks", noting the source location of
72 where each block lives.
73- Only start parsing after the first changed character, rounded to the nearest
74 block. In the append-only stream, this essentially means the last two blocks
75 are the only things being re-parsed.
76- Similarly, run AST transforms only on the changed data. This step has a couple
77 of slow paths for when reference link definitions are added or edited, since
78 it means any places that might have used a reference link may now have to
79 reflect it.
80- After all that, a special AST -> React node transform is used that diffs the
81 new ast with the last ast, reusing React nodes whenever possible. It supports
82 nested children as well as re-ordering top level blocks. This is what prevents
83 most rerenders and is the "secret sauce".
84
85## 2026-03-18
86
87tags: [progress.ts]
88
89laser hair removal is awesome btw. organizing things at home slowly. more work
90on the progress library, trying to handle every edge case possible for the log
91widget system.
92
93when it is done, a code snippet like this will work.
94
95```ts
96using node = progress.start("some action");
97for await (const token of stream) {
98 // correctly interweave progress TUI with partial log lines
99 process.stderr.write(token);
100}
101```
102
103the log system injects into `process` to ensure it plays nice, but there is
104only a fast path on stdout (since the main log messages uses stdout). stderr
105gets to use the crazy `getDrawLock` API i'm cooking up internally.
106
107after all of this works and is reliable, there are some more things i have to
108tidy, but then the progress blog post can be written and reviewed for realsies.
109
110## 2026-03-16
111
112just chilling. at work i replaced `bun install` with `pnpm`. pretty peak. in the
113night, i worked on [this page][this site], and worked a bit on [hexiflare]'s
114local llm system.
115
116## 2026-03-15
117
118tags: [clover creative control]
119
120this is sort of the new version of what i've called "creative toolkit." it's now
121a TypeScript library that controls REAPER and the DaVinci Resolve Speed Editor.
122the two layers are the library layer and the configuration layer. the config
123layer is meant to be trivial, editable and instantly-reloadable at a moments notice.
124
125```ts
126export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => {
127 const reaper = new Reaper();
128
129 // Sync state to LEDs
130 reaper.on("transport", (transport) => {
131 se.leds.cut = transport.recording;
132 se.leds.dissolve = transport.recording;
133 se.leds.smoothCut = transport.recording;
134 });
135
136 // Keyboard Actions
137 se.onPress("stopPlay", () => {
138 if (reaper.transport.recording) {
139 reaper.runAction("transport-stop-save-all-recorded-media");
140 } else {
141 reaper.runAction("transport-play-stop");
142 }
143 });
144 se.onPress("cut", () => {
145 if (reaper.transport.recording) {
146 reaper.runAction("transport-stop-delete-all-recorded-media");
147 reaper.runAction("transport-record");
148 } else {
149 reaper.runAction("transport-record");
150 }
151 });
152 se.onPress("dissolve", () => {
153 reaper.runAction("transport-stop-delete-all-recorded-media");
154 });
155 se.onPress("smoothCut", () => {
156 if (reaper.transport.recording) {
157 reaper.runAction("transport-stop-save-all-recorded-media");
158 reaper.runAction("transport-record");
159 }
160 });
161 se.onPress("videoOnly", () => {
162 if (mac.window?.title.startsWith("FX:") && mac.mainWindow) {
163 mac.focusMainWindow();
164 return;
165 }
166 reaper.runAction("track-view-fx-chain-for-current-last-touched-track");
167 });
168});
169```
170
171the winning workflow here for recording audio has been the bottom three keys being mapped to
172
173- start a recording. or if one is already going, delete and restart it
174- discard the currently recording take and stop
175- accept the currently recording and start a new one
176
177this is better than just having keys on the main keyboard, no modifiers! *and* i
178don't have to lose out on any existing keybinds.
179
180i used to have a setup like this with multiple external keyboards, but i am
181keeping that on hold until i run out of macro keys. what's also interesting to
182note is each app gets its own layer of bindings.
183
184## 2026-03-14
185
186tags: [album]
187
188i've made some beautiful progress on the album, i finished another song. it is
189one of my best creative works i've ever done. i'm personally very happy with my
190singing and vocal editing skills improving. will be holding it captive except in
191some small circles (who have all said it is amazing)
192
193## 2026-03-11
194
195tags: [react mutation]
196
197my coworker was working on something that needed a feature for react mutation. i
198also needed his approval on some code i was working on, but he wouldn't give me
199any attention. so as a bribe, i released a new patch of the library. my PR was
200reviewed, and everything was great. the changelog
201
202- tanstack query integration
203 - [feat: `arrayUpsert` and `objArrayUpsert`](https://git.paperclover.net/clo/react-mutation/commit/47304f56e98bf841b757d16e472fb64a01c99fd2)
204 - [fix: allow spreading `TanstackQueryOptimisticHelpers`](https://git.paperclover.net/clo/react-mutation/commit/4b09ff4dba5fad5b7e2b44a0bdc2789918f74561)
205- mutation buttons
206 - [pass `args={null}` to disable the button UI](https://git.paperclover.net/clo/react-mutation/commit/ec65067d04a72a51b71b63d3b4569843b8fd0dad)
207
208## 2026-03-10
209
210tags: [hexiflare]
211
212with the hexi bot with just two users eating my entire at-work anthropic
213subscription, i was messing with getting hexi to work as much on local models as
214possible. the results are very promising, but it is very much becoming a
215manually designed and written project, which is good (slop is bad). but that
216does mean i can't spent a lot of time on it, as i have other desires.
217
218anyways, the local model chat system works by having a qwen model with an insane
219"personality" prompt. and then it goes through another qwen model with an
220equally insane "review". the results are pretty good.
221
222```
223you> what u been up to?
224hexi> not much lol
225hexi> messing w/ some css animations rn breaking them is kinda fun though
226you> omg can i see?
227hexi> hold on lemme whip up a lil demo page real quick
228[triggers sandboxed agent task]
229```
230
231benefit of doing it this way instead of through a stupid wrapper, the latency
232can be brought down a ton. but the full system isn't wired up, and i frankly
233don't trust anyone but myself to do this task.
234
235## 2026-03-05
236
237tags: [work], [hexiflare]
238
239i wrote a beautiful dialog component for use at work and in the hexiflare
240template. the usage is like this:
241
242```ts
243showTextDialog({
244 title: "Hello hiii heelloooo.",
245 description: "Enter something important"
246 onConfirm: async (reason) => {
247 // do something with `reason`
248 },
249 onCancel: "close",
250});
251```
252
253it's certainly better than copying the Radix Dialog everywhere. that component
254is a great primitive but it isn't nice to spam it.
255
256
257## 2026-02-25
258
259tags: [hexiflare]
260
261i was starting to work on an ai project, a chatbot that is absurdly tuned to the
262format of texting. in addition to this, it has full access to a (sandboxed)
263computing environment where it can run any command, browser use, and also can
264deploy web apps.
265
266this whole thing is based on a friend's project, named "clungus", which was
267literally just a wrapper around claude code. his could deploy static websites,
268which was really cool since you could just ask it for some little thing and it
269would just make it. and you send your bug reports as text messages.
270
271in both bots, the interface is much more natural than using claude code,
272chatgpt, or another application. and with mine particularly, i was working on
273making the base app template as high quality as possible, so that the apps. so
274at a minimum, we now have one of the best tanstack start templates ever. at
275best, i might be able to automate some apps i never would've cared to write
276manually.
277
278## 2026-02-25
279
280## 2026-02-18
281
282tags: [this site], [markodown]
283
284i finally closed [issue #1](https://git.paperclover.net/clo/sitegen/issues/1) on
285my website, eliminating the MDX compiler in its entirety on my website.
286
287i don't think MDX is a great format to work with. and on top of that, it adds
288111 transitive dependencies to my website. and what, all for a medium markdown
289language. markdown is inherently concise, so pairing it with Marko felt like it
290would be a nicer format to work in. the answer: it is! i made "markodown", by
291using rust, a bit of llm slop, and used it a ton to ensure a good api. the
292result is beautiful.
293
294my favorite part of this language is automatic header tracking being built in.
295alongside Marko's id shorthand, i can type something like this:
296
297```ts
298<h2#technical-review>A Technical Review: What are Server Components?</>
299```
300
301and the compiler will track this properly, emitting it as
302`<Heading level=2 id="technical-review">` and including it in the generated
303table of contents. i can then include the table of contents with a
304`<table-of-contents />` element
305
306a little bit tough because i did spend a whole week on it. but it's done, and i
307love it. after this port, i also axed some other dependencies in favor of
308lighter alternatives:
309
310- `puppeteer` with `playwright`
311- `codemirror` with `<textarea />`
312- panning to replace `msgpackr` with a custom format that handles the types needed
313
314and the biggest of all, deprecating my HTML rendering framework, in favor of
315using Marko. this is technically a complexity increase, except for the part
316where i already supported Marko.
317
318## 2026-02-08
319
320tags: [HOTEWIG reanimated]
321
322i am on the team of people organizing "history of the entire world, i guess -
323reanimated." not much news yet, but it will be exciting. please fill out the
324form if you are interested, and please send it to anyone you may think would be
325interested.
326
327## 2026-02-06
328
329tags: [this site]
330
331spent yesterday and today catching up with this page and preparing to get
332writing heavy on the blog. i also fixed a bug on the video player where it
333wouldnt work on latest chrome on non-av1 accelerated hardware.
334
335there were two bugs:
336
337- chrome without av1 and version >=142 would try and play the hls stream
338 natively. they made `video.canPlayType("application/x-mpegURL")` return
339 "maybe", which is total propaganda because chrome does not actually support
340 this type of stream. they support some insane subset that isn't real world
341 use cases.
342- hls polyfill was never being used because of a bundling mistake, so those
343 users would get the unoptimized original file.
344
345since i was testing windows 7, decided to also fix some IE9 bugs. the page
346works besides the video player and the fonts, which is all things i believe i
347could easily fix, so i opened new issues for those tasks.
348
349## 2026-02-04
350
351tags: [this site]
352
353i did a ton of random stuff to [this site], including the RSS feed and random
354bug fixes on the domain. trying to reduce the issue count on my forgejo.
355
356## 2026-02-03
357
358tags: [history of japan reanimated]
359
360immediately after the youtube premiere, i worked on my webpage of it. the live
361credits was a really fun touch that really completes things. i'm very happy
362with the final result.
363
364i also had to do a ton of work fixing the video player to properly show
365thumbnails on the video player. the biggest one was adding even more hell to
366the dash-av1 player. a issue that `dash.js` has is providing the first frame of
367media before the user presses "play." this doesn't happen with the hls or
368native players. so my workaround... delay attachment of the view.
369
370```
371player.initialize(null, dashFile, false);
372player.updateSettings({
373 streaming: { cacheInitSegments: true },
374});
375player.preload();
376
377video.addEventListener("play", function play() {
378 video.removeEventListener("play", play);
379
380 enableSafariAirplay(); // <-- another important hack
381 player.attachView(video);
382 onCloverVideoInit?.(id, video);
383});
384video.controls = true;
385```
386
387but on chrome you get cooked because a video without a source is not playable.
388so a second hack used to attach a fake source.
389
390```
391const duration = new Promise<number>((resolve) => {
392 player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => {
393 const duration = e.data.mediaPresentationDuration;
394 resolve(duration); // seconds
395 });
396});
397mediaSource = new MediaSource();
398mediaSource.addEventListener("sourceopen", () => {
399 duration.then((duration) => {
400 mediaSource = mediaSource!; // typescript assertion
401 mediaSource.duration = duration;
402 const sb = mediaSource.addSourceBuffer('video/webm; codecs="vp8"');
403 const oneFrame = Uint8Array.from(
404 atob("GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAITEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggH97AEAAAAAAABZ" + "A".repeat(119) + "VSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAXgAAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDV7YG1KxA/CcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QCYloA4JCwgQG6gQGagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMuMTAwc3PWY8CLY8WIDV7YG1KxA/BnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjEyMDAwMDAwMAAfQ7Z11eeBAKOigQAAgBACAJ0BKgEAAQALxwiFhYiFhIg/ggAMDWAA/ua1AKOVgQAoALEBAC8R/AAYABhYL/QAJAAAo5WBAFAAsQEALxH8ABgAGFgv9AAkAAAcU7trkbuPs4EAt4r3gQHxggGj8IED"),
405 (x) => x.charCodeAt(0),
406 );
407 sb.appendBuffer(oneFrame);
408 });
409});
410objectUrl = URL.createObjectURL(mediaSource);
411
412mainSource = document.createElement("source");
413mainSource.src = objectUrl;
414video.appendChild(mainSource);
415```
416
417the payload within contains a single webm vp8 frame, one pixel by one pixel.
418this is enough to get chrome to shut up. now the experience is nearly perfect,
419with browser support all the way to the ancient firefox version on my old
420laptop, to modern Apple Silicon Macs.
421
422## 2026-01-31
423
424tags: [history of japan reanimated]
425
426main section of animation for the project was already done, but i wanted to
427make some needed adjustments to my scene. so i had been doing those for a few
428days, as well as animating an extra 25 seconds for the outro section. it was
429very fun doing that process, since i actually grabbed an old macbook i had in
430storage to take the textedit screenshot. more details on this entire process
431will be in the project page.
432
433## 2026-01-30
434
435tags: [react mutation]
436
437at work, one of my coworkers was complaining about my helper functions for
438TanStack Query's mutation system. the conclusion was that the mutation system
439we were building on was flawed and not enjoyable to use. so i spent a few days
440making an alternative library. it's on the JSR: [@clo/react-mutation](https://jsr.io/@clo/react-mutation).
441
442this project was really fun because of how much the API surface changed as i
443started staging the library into our actual code. the experience has shaped how
444i want to go about my upcoming blog post for the better.
445
446## 2026-01-25
447
448tags: [ts lie detector], [todo tracker]
449
450added a trivial binary to the lie detector, `tsld-node`, which is like `ts-node` or `tsx` but it runs with the lie detector.
451
452for todo tracker, it's been vibe coded to a point where the sitegen repo gets
453through all commits, but there are still some issues with missing TODOs. i haven't really had time to prioritize this, even with an ai agent writing most of it, since even then i have to review the progress of it, so it's just not worth my time until other projects pull through.
454
455## 2026-01-24
456
457tags: [git], [home infra]
458
459i moved forgejo off of sqlite and onto postgres. the only motiviation behind this was to easily backdate the repositories i had imported: [name paint bot] and a scripting language compiler i wrote. i was more comfortable doing this on a real database server than just editing the file.
460
461the migration took a bit for me to figure out, but last year someone named Sven [did the same thing](https://sven-seeberg.de/wp/?p=1213), and found this `pgloader` command with the critical `data only` clause.
462
463```
464echo "LOAD DATABASE
465FROM sqlite:///root/forgejo.db
466INTO postgresql://forgejo:$POSTGRES_PASSWORD_FORGEJO@postgres/forgejo
467WITH data only, reset sequences, prefetch rows = 10000
468SET work_mem TO '16MB', maintenance_work_mem TO '512MB';" > ./pgloader-command
469
470pgloader ./pgloader-command
471```
472
473## 2026-01-18
474
475tags: [git], [home infra]
476
477i finally setup system integrated ssh push, but with a twist.
478
479the first problem is having two ssh servers on the same machine, one for the
480host, and another for Forgejo. this means that one of them had to live on
481another port, so i chose to move the host. but it kind of just sucks to use
482this. the proper solution is to use a custom config to direct the `git` user to
483the right place.
484
485what made this harder is i actually had two git instances; the second one is
486for a temporary infrastructure i'm running for [evil inc] until the
487organization gets dedicated hardware (more about this in a future post). so
488even if i routed `git` specially, it still wouldnt know which git server to go
489to.
490
491**the solution**: write a custom "router" script that generates an
492`authorized_keys` file based on which git instances actually have a key, then
493the line within the `authorized_keys` forces a special wrapper which intercepts
494the targetted git repository, routing it to the git instance with it.
495
496the sshd config looks like
497
498```
499Match User git
500 AuthorizedKeysCommand /bin/python /mnt/storage1/apps/home-infra/config/forgejo/ssh/keys.py %u %t %k
501 AuthorizedKeysCommandUser git
502```
503
504it is convenient because `AuthorizedKeysCommand` is run on every connection. it produces a file with zero or one valid keys:
505
506```
507command="/mnt/storage1/apps/home-infra/config/forgejo/ssh/route.sh --clover 1 --evil 2",no-port-forwarding,...,restrict ssh-ed25519 AAAAC3NzaC...
508```
509
510and then the route script does this shit:
511
512```
513if [ -d "/mnt/storage1/apps/forgejo/git/repositories/${repo}" ]; then
514 [ -z "$clover_id" ] && echo "ssh key is not configured for repository on git.paperclover.net" >&2 && exit 1
515 exec sudo docker exec -i -u git forgejo /usr/bin/env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" /usr/local/bin/forgejo --config=/custom/conf/app.ini serv key-"$clover_id"
516elif [ -d "/mnt/storage1/apps/evil-infra/forgejo/git/repositories/${repo}" ]; then
517 [ -z "$evil_id" ] && echo "ssh key is not configured for repository on git.evil.inc" >&2 && exit 1
518 exec sudo docker exec -i -u git evil-forgejo /usr/bin/env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" /usr/local/bin/forgejo --config=/custom/conf/app.ini serv key-"$evil_id"
519else
520 echo "repo not found" >&2
521 exit 1
522fi
523```
524
525it works beautifully. [see the whole patch for more info](https://git.paperclover.net/clo/home-infra/commit/e402d6ef71dfc310caeeb4d3579bddd9d0710594)
526
527## 2026-01-14
528
529tags: [next.js blog post], [this site]
530
531general housekeeping. getting this activity page up. getting the spanish
532translation by my good friend trubiso up. things are looking really cozy.
533
534## 2026-01-12
535
536tags: [album]
537
538i started more music work. i've gotten better at lyric writing, phrasing this
539new song as a sort of "adventure". felt for one of the first times that i was
540doing worldbuilding in a song. the imagery is that good.
541
542## 2026-01-11
543
544tags: [progress.ts], [todo tracker]
545
546i finished streaming io on [progress.ts]. very proud of it. my git
547commits describe the tech better than me reiterating.
548
549> ## feat(lib/progress): implement streaming wire protocol
550> resolves `#47`
551>
552> `encodeByteStream` converts these events into a `ReadableStream`. by
553> batching events together, the stream contents remain small, that way the
554> code that constructs progress nodes do not have to worry about calling
555> many setters at once, it gets debounced be the serializer. stream
556> backpressure causes larger time-gaps to be batched (smaller). this
557> enables servers to respond with rich progress.
558>
559> ```
560> const root = new progress.Root();
561> doActionWithProgress(root).then(root.end, root.error);
562> // streaming clients indicate a header
563> if (req.headers.get("Accept")?.includes(progress.contentType))
564> return new Response(progress.encodeByteStream(root), {
565> headers: { 'Content-Type': progress.contentType },
566> });
567> // to support non-streaming clients
568> return Response.json(await root.asPromise());
569> ```
570>
571> and `decodeByteStream` on the client:
572>
573> ```
574> const output = document.getElementById("output");
575> const res = await fetch(...);
576> if (!res.ok) throw ...;
577> const root = new progress.Root();
578> root.on("change", (active) => {
579> output.innerText = ansi.strip(progress.formatAnsi(
580> performance.now(),
581> active,
582> ));
583> });
584> const result = await progress.decodeByteStream(res.body, root);
585> output.innerText = JSON.stringify(result);
586> ```
587>
588> there is currently no document bindings, but i plan to. additionally, a
589> React hook is very trivial to implement for this -- but that is
590> unplanned for this repository. for transports that require JSON or
591> UTF-8, there is `encodeEventStream` which returns a `ReadableStream` of
592> JSON objects which can be compressed at the developer's discretion.
593
594> ## feat(lib/progress): headless rendering + time estimation
595> node signaling is done by providing a `progress.Root` to every node,
596> dispatching events to it when the node changes. the root is connected to
597> an observer to construct a UI out of it. there are two apis planned:
598>
599> - `attachToScreen` binds a root to a TTY screen (via the log.Widget API).
600> the primary use of this is to implement the top level `progress.start`.
601>
602> - a serialization system that allows transmitting a `Root` over a wire.
603> this commit was going to include this but it is an unexpectedly large
604> component.
605>
606> - potentially a browser binding like `attachToDocument`. this will not
607> be added in this patch.
608>
609> additionally, resolves #33 by implementing `estimatedTime`
610
611i also did a large part of the work to create a "code todo tracking" tool. i
612would say it's about half done, since the second half is simply fixing all of
613the little bugs there are. most of this code is currently ai-generated, but
614with me manually coming in to write interfaces and the modular program
615architecture. then i synthesize the code and the tests. this was basically just
616going on ambiently while [progress.ts] was in progress.
617
618## 2026-01-09
619
620tags: [home infra]
621
622finished SSO sub-project. im happy with the setup i used to protect internal
623services, such as pgadmin and qbittorrent. it's a caddy snippet that i can
624re-use very easily.
625
626```
627(reverse_proxy_auth) {
628 handle /snow.oauth2/* {
629 reverse_proxy "http://forward-auth" {
630 header_up X-Real-IP {remote_host}
631 header_up X-Forwarded-Uri {uri}
632 }
633 }
634 handle {
635 forward_auth "http://forward-auth" {
636 uri /snow.oauth2/auth
637 header_up X-Real-IP {remote_host}
638 @error status 401
639 handle_response @error {
640 redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}
641 }
642 @valid_group header X-Auth-Request-Groups *role:{args[1]}*
643 handle_response @valid_group {
644 method {method}
645 rewrite {uri}
646 reverse_proxy {args[0]} {
647 header_up Cookie ([^;]*?)\s*_oauth2_proxy_\d=[^;]*(;?.*) "$1$2"
648 {block}
649 }
650 }
651 handle_response {
652 rewrite /403.html
653 file_server {
654 status 403
655 root /etc/caddy
656 }
657 }
658 }
659 }
660}
661
662# usage
663pg.{$HOME_DOMAIN} {
664 import reverse_proxy_auth "http://pgadmin" admin
665}
666qbt.{$HOME_DOMAIN} {
667 import reverse_proxy_auth "http://qbittorrent" media-manage
668}
669```
670
671
672## 2026-01-04
673
674tags: [home infra]
675
676working on SSO for my internal services. for context, i have about 12
677self-hosted services running, half of which i allow my friends to access.
678currently, this is done through manually creating an account on such service
679(jellyfin, forgejo), but many are done through a caddy rule. in the interest of
680making my password manager less confused (ip vs domain, subdomain etc), i'm
681slowly reducing this setup to a single sign in page.
682
683to do this, i am using https://keycloak.org, which supports openid connect
684(how i will configure forgejo and jellyfin), as well as a separate service to
685provide forward auth proxying (how i protect services like copyparty,
686syncthing, pgadmin, and many more). i tried authelia beforehand, but i really
687do not recommend them due to how hard it is to configure, passkeys being
688annoying to setup, and limited themes. i also dont recommend authentik, but i
689couldnt figure out how to even start using it after i installed it.
690
691keycloak is a bit stupid on config. as all the config lies in the postgres
692database, i can't use a config file to setup the primary realm. so instead, i
693have this huge python script to use the API to upsert the configuration in.
694this works pretty well, and means that for locally running the infrastructure
695for testing, i can get the config to be the same (useful if you brick
696keycloak, which is pretty easy to do).
697
698## 2026-01-02
699
700tags: [home infra], [git], [name paint bot] show
701
702i deleted all my github repositories except four: my "readme", a bug
703reproduction repo, the mirror for [ts lie detector], and a shared private repo
704with someone that is load bearing. in this process, i've moved all the projects
705to my [forgejo instance][git].
706
707with this, [name paint bot], one of my few remaining projects that is still
708active, moves to that forgejo instance using their github migrator. some of my
709private projects, like my pet scripting language, were migrated as well. it
710feels more alive on my site because of the theming and per-repo icons.
711
712after a year of forgejo, i am really happy with how it treats me.
713
714</main>