The importance of finishing personal projects pt2

Yes, so part 1 became a bit too long, and I was still busy working on the project that would be blog post part 2, so it was probably a good thing that I split them up, especially since they’re two completely different types of personal projects. I’m not going to copy the intro again about the “why” behind these projects, but I’d recommend reading part 1 first. No need to read the whole post, just the first part until I start talking about the project itself:

Part 1: https://brain.seppjm.com/the-importance-of-finishing-personal-projects-pt1/

The importance of finishing personal projects
Personal projects? I could drown in them. But the most important thing is actually finishing them, in one form or another. Why? Because if you keep starting without finishing them, it becomes easy to half-ass every project you do. It also raises the threshold for starting new projects in the

The origin of this project is also completely different. It’s actually based on the project that started my company; not because it was successful, but because I made it the backbone for my company and all my work. It was called "DiodeMatrix Workspace", and it was a stack made up of a custom backend, frontend applications, and a modified/forked Phabricator sidecar that I used for syncing with my custom backend, and also being the front webdoor, with all the extra development functionality it contained. I have to admit that it was a bit of a crazy setup, but I loved it.

But usage was really low, and reading the old archived web pages definitely made me giggle. Sixteen-year-old me had big dreams for sure.

But in 2021, I lost all my personal projects, and half of the work that wasn’t in DiodeMatrix Workspace's SVN. Why SVN and not Git? Don’t ask. Why no backup? i've learned. Trust me.

I also hadn’t maintained it for a long time, dependencies were stale, and some were even vulnerable. Then came the announcement that Phabricator would stop being maintained. So I pulled the plug in 2022 and left a small Git server running for users with projects that were still semi-using Workspace.

"Aanloop"

Forward to 2023, when I started working on some personal projects again, and I came across these two:

The first one was a joke project from 2021; when I was talking to a friend about the internet back when we were younger and we talked and remembered Hyves, Hyves. Yes, Hyves. That was an interesting and fun time in internet history. With all the old farts on there and all the quirky functions it had. So my friend said, “Bet you can’t remake that.” Naturally, that turned into a proper hold-my-beer project. With some geeked-out PHP code, a rip of the Web Archive's HTML, and a simple database setup, I turned it into a basic Hyves clone. You could create an account and “krabbel” on each others profile. Barely passable enough to win that argument haha. And definitely not something that should ever be hosted outside of an internal/private network; the thing had less security than just opening your database to the public and saying, “Please insert your post, don’t touch the others please.” Haha.

The second project was the custom Workspace backend I talked about above. I felt a bit sad that all that custom work was just sitting there doing nothing. Oh well, like all other projects, there’s always something useful in them for later. But still.

Then I got an interesting idea: what if I combined them and created a new project? And because I had also shut down community.diodematrix.com and discourse.diodematrix.com, it felt like the perfect opportunity to merge all of that together. So development begon on this unnamed project.

But that soon stalled as it was a little to ambitious, just like the RUK engine project in the previous post.

In 2025 I finally had some time again to work on personal projects, and I started a new fork this time as a proper remaster, with the goal of actually finishing it in a prototype-like way. However, I still had one burning question: was I going to build it under my company and use it commercially? which would mean not being able to use the classic style I had in mind or was I going to make it as a personal portfolio project, which would give me way more freedom and, more importantly, fun?

I decided that question would be answered once I finished it.

Found the initial commit, humble beginning

Skept.nl - Overview

I didn’t have a name for it yet, but I did have the rules set. And those were important, because I was going to use AI assistance for porting the old code to the new stack. The only way to keep that efficient was to have a big&strict ruleset focused on security and performance.

Why?

I’ve said it before, and I’m still a strong advocate for checklists. I’m sure we in IT and software development can learn a lot from aviation. Pilots do the same thing every day, yet they still go through their checklists every single time; for landing, for takeoff, for everything. And honestly, I don’t think it’s a bad habit to have when programming, implementing, or designing systems and servers either. It would prevent a lot of oopsies, that's for sure.

I’ve also noticed that it works amazingly well with AI assistance, because there are still serious problems that come with programming using AI LLMs. In my personal experience, they often create functions with unnecessary helpers, overcomplicate code, change test code instead of fixing the actual issue, lose track of context, create parallel functions for things that already exist, leave dead code behind, and so on.

So for these production-style projects i always create a complete checklist and rulebook for performance and security, that in the case of AI assistance both my coding and audit LLMs have to read. One of the funniest and best hacks I came up with to check whether they actually followed/remembered the sets is the “ding indicator.” It’s basically a rule/guideline where, as a final check, the model has to execute ding/ding against the changed files and code. It’s surprisingly effective as an indicator to see whether the LLMs are still following their guidelines or just winging it.

That's basically how i work on projects with AI assistance now, pretty neat as i must say. So how does that look for the Skept.nl project? Let me give you a sneak peak into the root structure:

skept.nl/
├── ARCHITECTURE.md
├── SECURITY.md
├── PERFORMANCE.md
├── PRODUCTION.md
├── README.md 
├── LLMLOGS/
...
  • Architecture.md is a generated enginering contract based of the one i gave in text. I had to make changes but it gives the LLM clear indication of what im doing and whats placed where and why
  • Security.md consists of everything I know about building a secure backend setup that I’ve gathered over the years, and it’s a variant I use for every project that is public or publicly accessible (production). As of today, it contains 31 rules based on recommendations from security researchers, studies, and official documentation.
  • Performance.md consists of all the performance rules I’ve bumped my toes into over the years. It currently contains 23 rules and covers things like sidecar workflows, async processing, caching, queues, workers and more.
  • Production.md is my personal checklist for pushing/deploying to production, and changes i have to make when its not internal testing or development. So i don't accidentality miss a certificate design or ship debug functions etc.
  • LLMLOGS folder is part of the ding/ding indicator system. No written LLMLOG memory after an action? Means the LLM lost important context

Lets look at the

Skept.nl - Backend

Skept.nl will be publicly accessible, so for security reasons I have to omit some details. Due to the strong security-focused design, that’s only a tiny fraction of the project that I can’t show.

The backend is the most complex part, and that’s by design. I wanted Skept to be API-first, because that’s how I had designed Workspace, and that approach came in handy. I’m really, really bad at frontend design and programming JavaScript, so I already knew I was going to need AI assistance if I wanted it to have any chance of Skept looking decent. And honestly, that also limits the risk, there’s not much in the frontend that AI can completely mess up, i just had to keep track of some rendering and browser security policies.

I also have friends who are learning programming, and having an extensive backend system that is fully OpenAPI-specced is a huge plus for that. And for me personally, the last time I built a mobile app was in 2014. That’s way too long ago. Even with everything basically becoming a web app now, that’s still the second bird this stone has to kill. (and im currently giving kotlin development a try, wish me luck…)

Skept’s backend is completely written in Go, with the smallest amount of dependencies possible. I genuinely don’t understand why JavaScript full stacks became such a big thing. For me, it never really worked out. I just can’t wrap my head around one project having 1400 dependencies. That’s also why I added it as a joke on the third-party dependencies webpage.

Don't get me started on the crazy npm chain attacks we've seen lately...

If you (reader) develop JS stacks for a living could you message me? I would love to know a couple of things.

For the database i decided on using PostgreSQL, especially since i had some SQL sharpening to do, and lately i did too much farting around in Clickhouse.

For the frontend i went with two themes (classicmodern) sharing the same view models. Templates, assets, and locale bundles. Making sure i didn't have tech-debt there. And making way for more themes in the future.

Talking about tech debt, I made sure to implement everything without the usual “I’ll come back and do that better later” tag attached to it, even the stack runtimedesign itself. There are two main startup modes: DEBUG and PRODUCTION. I use the debug env variable for connecting testing and debugging functionality that I don’t want enabled in production. If none is set, it defaults to production mode for security reasons. Basic stuff

Then there are the submodes for horizontal scaling:

Mode Frontend API + DB ENV trigger
BootModeFull on on SKEPT_HEAD_MODE=false SKEPT_HEADLESS_MODE=false (or SKEPT_DEBUG=true which forces this)
BootModeHead on off SKEPT_HEAD_MODE=true
BootModeHeadless off on SKEPT_HEADLESS_MODE=true

Designing it this way meant that all my background workers needed leader election support, queues and cache had to be shared properly between instances, and state handling had to stay consistent across them too. You get the picture. Basic scaling design

With that even the simplest background task starts with the question “oh wait… what happens if 4 instances try this at the same time?”.

Preventing as much tech debt as possible is intensive, but completely worth it in the long run #mypersonalexperience ...

OpenApi design

The fun part about having a fully OpenAPI-specced project is that I can basically just point you to the OpenAPI/Swagger pages and say: have fun! Because every function I use for Skept.nl is also available through the API. So instead of explaining every endpoint one by one, we can jump straight into what the not so straight forward ones actually do behind the scenes:

Edit 19-05-2026:

Developer docs: https://skept.nl/developers

And swagger page: https://api.skept.nl/v1/docs

Auth & Sessions

Authentication is handled through an OAuth/openID provider, in this case DiodeMatrix-ID. Yep, that was decided before I even knew whether this was going to become a project under my company's name or a personal project. But honestly, I don’t care. The security benefits outweigh the branding awkwardness in this case.

I’d much rather rely on a proven and secure identity provider that already supports multiple social login methods than spend time implementing registration, login flows, email verification, MFA, password recovery, and all the other authentication headaches myself.

It also explains the bearer-token pickup design for third-party clients/API usage. Skept also supports API keys out of the box, but they’re intentionally restricted to only work with the post-posting APIs. So if a user accidentally pushes their script to GitHub and leaks a key, the damage is limited to posting abuse instead of a full account takeover.

After authentication, sessions are stored by their hash, and for the web frontend I make use of the classic double-submit cookie pattern with a constant-time comparison (CSRF), like pretty much everyone does.

That cookie is also required to pick up the bearer token for alternate clients. That’s how the /v1 endpoints can safely support both Skept.nl browser clients using cookies + CSRF protection, and alternate clients using bearer authentication without cookies, without compromising either model.

Richtext Pipeline

Skept is full of rich text, basically every page contains SkeptBBCode. I wanted the renderer to be backend-enforced and render everything on demand, so we don’t store raw rendered HTML that might need to be regenerated later when the renderer changes or gets updated. It also makes sanitization a lot easier, because the frontend only has to safely render the already-allowed HTML output.

So Skept stores the BBCode source, never raw user HTML. User text is HTML-escaped first, then BBCode tags are matched against an allowlist that only permits supported tags like mentions, hive/space tags, and the gadget tag. URLs are validated against an allowed scheme list to prevent jokes like injection attacks, alongside a bunch of additional sanitization and validation checks before the pipeline finally releases the content back to the function so it can continue its rendering journey.

Gadgets

This was important. Gadgets are user-authored “widgets“ that render inside sandboxed iframes in posts and profiles. The system itself is relatively small, but very security-sensitive, because it allows users to ship HTML/CSS/JS that other users will execute in their browsers.

Back in the old days, people didn’t really care that much about security. Now we do. So I had to design this very carefully and ended up coming up with three different types:

Kind Owner Surface
user_html any user, after review iframe-rendered; HTML/CSS/JS authored as definition, reviewed by Skept admin before deployment, sanitized on save
skept_gadget platform builtin iframe-rendered system gadgets with backend state (like poll votes, ratings, BBCode box, media etc.)
redacted specops only renders directly into the page DOM, no iframe

Yeah that last one might seem a little risky, but it makes sure that i can patch pages in runtime for special projects in the future. And is only available to developers and is heavily gated server-side: the gadget creator/editor checks if the actor is allowed to create/place and rejects otherwise

The embed gadget iframe is sandbox="allow-scripts allow-forms" no allow-same-origin. The iframe's document origin is opaque/null. Containment of the gadget surface is enforced by:

  • frame-ancestors 'self' in the embed CSP
  • X-Frame-Options: SAMEORIGIN from the global security middleware
  • Sandboxed origin (no same-origin DOM access)

gadgetEmbedContentSecurityPolicy(kind, scriptNonce) constructs a stricter policy than the main site CSP:

default-src 'none';
img-src 'self' https: http: data:;
media-src 'self' https: http: data:;
style-src 'self' 'unsafe-inline' {cdn};
script-src 'self' 'nonce-{base64-16-bytes}';   # system kinds
script-src 'self' 'unsafe-inline';             # user_html only
font-src 'self' data: {cdn};
connect-src 'none';
frame-src 'none';
frame-ancestors 'self';
object-src 'none';
base-uri 'none';
form-action 'self' or 'none';

Each response generates a fresh 16-byte random nonce. First-party inline <script> tags inside the embed page (the size-resize IIFE, the SKEPT_GADGET_CONFIG bootstrap, the parent-postMessage ready signal) carry nonce="...". Only user_html gadgets retain 'unsafe-inline'

SkeptGadgets can't directly call the APIs. They post messages back to the parent page:

parent.postMessage({ type: "skept:gadget:vote", instanceID, logicURL, optionKey }, "*");

The parent page validates the message origin/instance ID and then calls the first-party logic endpoint (/v1/gadgets/instances/{id}/logic). targetOrigin = "*" is intentional and documented inline: a sandboxed iframe with no allow-same-origin has an opaque origin, so there is no useful origin string the parent could match. Containment is provided by frame-ancestors.

Storage (usercontent)

The user content system is based on an existing project I had made before, which is why it probably feels a bit more detailed and slightly out of place. It was called DiodeMatrixS3Wrapper, and it basically acted as a detailed authorization matrix on top of S3-compatible storage.

Because of that, Skept treats user content as application-controlled data, not as raw object storage paths.

DS3W Extension Purpose
storage_boxes one per user/hive, quota bucket, owner_user_id or owner_hive_id
storage_containers named collections inside a box (private/friends/public/profile_public/posts/krabbels) with visibility, encryption_mode, allowed_mime_family
storage_blobs dedup'd binary by SHA-256 + size; wrapped_dek when server-encryption is on
storage_objects the user-facing item (filename, title, mime, owner, container, state); privacy_source records which layer the visibility came from
storage_object_post_links attaches an object to a post (post-inherited visibility)
storage_object_mailbox_grants grants a specific inbox-thread participant access
media_collections, media_collection_items named galleries

Visibility is computed at request time from this chain: container default -> object override -> linked post privacy (if any) -> grants. The application always answers the access question based on the chain.

For restricted (private/friends) and mixed-visibility (post-attachment) containers, Skept uses application-managed envelope encryption. A per-blob DEK is generated at write time, the data is AES-GCM encrypted, and the DEK is wrapped with the master key. The S3 bytes are then useless without the key.

Public-only containers are intentionally not encrypted, they are public by design and benefit from raw delivery. Keeping load light, making that an accepted risk design.

Key notes:

  • All supported user content media goes through a thorough sanitization pipeline that removes metadata and validates the actual object against the claimed MIME type.
  • Public attachments that get switched to private go through a one-way encryption flow and remain encrypted from that point on.
  • The same applies the other way around: when private content becomes public again, it stays in its existing state instead of being rewritten or transformed again.

Outbound HTTP (SSRF)

Skept has many outbound connections, most of them go through a single guarded client and the only way the server initiates HTTP. The constructor newGuardedHTTPClient(opts) returns an *http.Client with:

  • tls.Config.MinVersion = tls.VersionTLS12 (TLS 1.3 negotiated when available)
  • MaxConnsPerHostIdleConnTimeout, explicit connect/read/total deadlines
  • a custom DialContext that resolves the hostname then validates each candidate IP via outboundIPAllowed() ; RFC1918 (10.0.0.0/8172.16.0.0/12192.168.0.0/16), loopback, link-local (169.254.0.0/16), multicast, and unspecified addresses are rejected. This is post-DNS, so DNS rebinding cannot bypass it.
  • CheckRedirect callback that caps redirect count (5 for OIDC, 3 for RSS/link-card/image) and re-validates each hop with the same scheme + host + IP checks.

Every external fetch routes through this client, including:

  • APNS, FCM, generic relay push
  • Web-push wakeup delivery

Internal skept operations system (ISOS)

I can’t share too much about this part, but think of it as the system I use to monitor and manage everything at runtime: errors, reports, gadgets, runtime metrics, database pools, workers, latencies, requests, audits, user/hive management, maintenance jobs, ratelimits, abuse and so on.

Workers

Skept has a lot of background workers that keep the root execution flow clean. Like I said before, this required a lot of work to make sure they could do their jobs safely, smoothly, and in a way that’s suitable for horizontal scaling.

Worker Trigger Purpose
errorReporter channel-driven Async insert app errors and hands out trackers so 500 handlers don't block on DB
notificationDispatcher channel-driven, bounded queue Fan-out web push + APNS/FCM + generic relay via the guarded outbound client
profileViewTracker channel-driven + 12h prune ticker Records unique profile views, retention-prunes old rows
postViewTracker channel-driven + 12h prune ticker Same for posts
skadminUserExportWorker channel-driven Async user data export builder; produces a redacted snapshot of all user-linked tables (GDPR Compliant)
pendingProfileSetupWorker 1h ticker (configurable) Cleans up users who never completed first-login setup
purgeSweepWorker 5min ticker (configurable) Executes due account/Hive deletions
newsRSSRefreshWorker 15min ticker (configurable) Fetches all RSS News sources via the SSRF-safe client, imports new items as rss_news
footerStatsRefreshWorker 5min ticker Refreshes the day-counter cache used by the footer
apiKeyUsagePruneWorker 12h ticker, 365-day retention Prunes old api key usage events
agendaRetentionPruneWorker 24h ticker Deletes cancelled agenda events past 90 days and active events with a definite past end past 730 days
storageMaintenanceWorker 6h ticker Marks stale unlinked post-attachment drafts deleted, prunes expired deleted storage objects, and claims orphan blob rows before backend byte cleanup

and many more smaller workers, but you get the idea.

These are probably the most interesting parts of the backend. I could keep going on and on, but that would become a bit too gortig, as we say in Dutch.

If you have any questions about how or why something was implemented, feel free to reach out. And if you spot something that I maybe didn’t do very smartly, definitely reach out as well. It might earn you a free beer ;)

Skept.nl - Frontend

Yeah, im not proud of this one, like i said multiple times before: Im not a frontend developer, i have no creative design feeling. So this was 90% “vibe coded”. And thats no secret. See for yourself. I just searched for the frontend dependancies i wanted for the webapp and gave it the goahead to design and implement. Some cool features:

Skept text editor | SCEditor

SCEditor is an amazing project that I’ve used before. It makes it really easy to have a visual BBCode editor, and combined with the vibed Skept editor wrapper, it works and looks pretty cool.

Within Skept, users get a lot of freedom when it comes to designing their text, and with the question mark helper they can quickly see what the backend allows:

Smileys come from our internal smiley catalog and are also available through the V1 API :)

GIFs are a funny implementation built around GifCities by Internet Archive. It runs through a relay layer to avoid hammering their servers too much, while also caching the GIFs locally.

Skept Image editor | Konva & modern-gif

The client side image editor makes it easy to crop and pimp your image without needing special tools. And with the modern-gif library it can even be converted to gif with layerd gifs. Works great but needs some bugfixing.

I’ll make Skept.nl publicly accessible soon, so you can check it out for yourself. Just use an existing OAuth account and explore, and you can always request your data and or delete your account afterwards ;)

Skept.nl

And now what? Right now I’m writing this blog while implementing the final touches. And in the end, I decided to keep it exactly what it became: a fun personal portfolio project.

Keep an eye out and expect Skept.nl to be online soon, but for now some sneak peaks from the internal goofy development server:

Proost,