<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rafal Manka</title>
    <description>The latest articles on DEV Community by Rafal Manka (@rafalmanka).</description>
    <link>https://dev.to/rafalmanka</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4051873%2F1b6580b0-5bb2-4b29-8ca1-93b006a79bad.jpg</url>
      <title>DEV Community: Rafal Manka</title>
      <link>https://dev.to/rafalmanka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rafalmanka"/>
    <language>en</language>
    <item>
      <title>Turning Featherboard into a multi-tenant application: GitHub OAuth and path-based routing</title>
      <dc:creator>Rafal Manka</dc:creator>
      <pubDate>Tue, 01 Sep 2026 17:56:14 +0000</pubDate>
      <link>https://dev.to/rafalmanka/turning-featherboard-into-a-multi-tenant-application-github-oauth-and-path-based-routing-48m4</link>
      <guid>https://dev.to/rafalmanka/turning-featherboard-into-a-multi-tenant-application-github-oauth-and-path-based-routing-48m4</guid>
      <description>&lt;p&gt;The next phase of development is done. At this stage, Featherboard starts to become something you can actually go ahead and host on your own server, one that can support multiple teams and multiple products. User authentication so far is implemented via GitHub OAuth login, but in future phases other providers will be added, as well as regular email login. At this point I've removed the temporary hardcoded token authentication. Also, multi-tenant routing is implemented via the &lt;code&gt;app.featherboard.net/{slug}/...&lt;/code&gt; path, where &lt;code&gt;slug&lt;/code&gt; is the name of the organization. This part of the implementation required the most debugging on my side, because all links had to be modified and the slug had to be added dynamically to all paths. Fortunately, Axum provides an extractor that allows intercepting requests and extracting organization data from the path very elegantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goodbye to the shared-token hack
&lt;/h2&gt;

&lt;p&gt;So far, as a placeholder, the &lt;code&gt;admin&lt;/code&gt; role was granted when a request contained a header with a token that was hardcoded and stored in an environment variable, &lt;code&gt;FEATHERBOARD_ADMIN_TOKEN&lt;/code&gt;. Naturally, this is not a sustainable solution and had to be replaced by actual authentication logic. This was replaced by a &lt;code&gt;users&lt;/code&gt; table, GitHub OAuth login, and an &lt;code&gt;is_admin&lt;/code&gt; flag on the users table. Even though that implementation sounds simple, getting there wasn't as straightforward as I would have liked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single binary with embedded static files
&lt;/h2&gt;

&lt;p&gt;In Featherboard I embed all the static files I use throughout the project, so that everything is contained within a single binary file. That includes sqlx migration files as well as HTML templates. The main reason for that decision was that I wanted the hoster not to have to worry about whether they have all the dependencies in the right relative folders. One notable particularity of sqlx migrations is that they are addition-only - if you want to change something in the schema you need to create an additional migration file, because otherwise the migration will be rejected and the app will crash on startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting &lt;code&gt;UNIQUE&lt;/code&gt; indexes right
&lt;/h2&gt;

&lt;p&gt;I added a &lt;code&gt;UNIQUE&lt;/code&gt; index on the GitHub account so the user does not register twice. This makes total sense - until you realize that it also prevents a user from creating an account in multiple organizations: if you log in to product A, you won't be able to log in to product B. I realized this during a debugging session, and I was right to try creating an account in another organization - the login failed immediately, as expected. That's not what we wanted, so instead I had to create a compound index on GitHub user id and organization slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub does not guarantee to give you an email
&lt;/h2&gt;

&lt;p&gt;In order to create an account within Featherboard, you need an email and an OAuth token. But GitHub allows you to make your email address private, so you need to fall back on the &lt;code&gt;/user/emails&lt;/code&gt; endpoint to retrieve the email address if it isn't provided in the original request. The &lt;code&gt;/user/emails&lt;/code&gt; endpoint returns a list of emails, and I had to get the primary one from the list. This wasn't really documented properly in GitHub's documentation, and I had to figure it out during debugging as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token upsert as a single operation
&lt;/h2&gt;

&lt;p&gt;GitHub login needs to update the users table, specifically the &lt;code&gt;provider&lt;/code&gt; and &lt;code&gt;provider_user_id&lt;/code&gt; columns; next, it needs to decide whether to create a new entry or update an existing one based on what's already there. Fortunately, SQLite supports exactly the tool I need for this, specifically the syntax: &lt;code&gt;INSERT ... ON CONFLICT (provider, provider_user_id) DO UPDATE ... RETURNING id;&lt;/code&gt; - a single statement that creates or updates a record and returns its id. I could have just used a &lt;code&gt;SELECT&lt;/code&gt; to check if a record already exists, and then &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; respectively, but that wouldn't be as elegant as this single statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenant routing: the &lt;code&gt;{org-slug}&lt;/code&gt; quagmire
&lt;/h2&gt;

&lt;p&gt;Routing was complex and required a lot of changes, because the root of every URL changes in a way that requires me to dynamically inject a path parameter, and also extract that path parameter for every request. Fortunately, Rust's Axum supports extractors, which allowed me to write a simple &lt;code&gt;impl&lt;/code&gt; for an extractor that parses the path and creates a &lt;code&gt;CurrentOrg&lt;/code&gt; struct from it. Then, in each function handler, I just add that parameter to the handler signature for the extractor to work automatically. It was a very clean and pleasant experience.&lt;/p&gt;

&lt;p&gt;None of the bugs I experienced were unusual, and it was quite easy to figure out what I was doing wrong. But even with such a simple-sounding implementation, the amount of work I had to put in was surprisingly large, and at each of those steps I could easily have shot myself in the foot.&lt;/p&gt;

&lt;p&gt;Here's the code if you're interested, ready to grab or follow along: &lt;a href="https://github.com/RafalManka/Featherboard" rel="noopener noreferrer"&gt;https://github.com/RafalManka/Featherboard&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>axum</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Featherboard is AGPL-3.0, single, self-contained binary</title>
      <dc:creator>Rafal Manka</dc:creator>
      <pubDate>Tue, 28 Jul 2026 18:11:15 +0000</pubDate>
      <link>https://dev.to/rafalmanka/why-featherboard-is-agpl-30-single-self-contained-binary-3cmh</link>
      <guid>https://dev.to/rafalmanka/why-featherboard-is-agpl-30-single-self-contained-binary-3cmh</guid>
      <description>&lt;p&gt;Phase 1 is now complete. The submit -&amp;gt; vote -&amp;gt; comment -&amp;gt; status-tag -&amp;gt; filter path is done. Before moving to Phase 2, I'd like to reflect on a big fundamental decision I've made and the reasoning behind it. I never really contemplated what the real-world consequences of choosing the right license are, but now I get it.&lt;/p&gt;

&lt;p&gt;Why I chose AGPL-3.0 and not MIT or Apache-2.0. The business model is open-core: anyone can host a copy of the app and run it on their own infrastructure. But if you don't want to run it yourself, you can always pay me to do it. Everyone wins.&lt;/p&gt;

&lt;p&gt;If I'd chosen MIT or Apache 2.0, anyone could just fork my repository, run it as a hosted SaaS with their own extensions on top, and never have to share their changes with anyone. Not even plain GPL closes this gap, since its copyleft only kicks in when you &lt;em&gt;distribute&lt;/em&gt; the software, and running a modified version as a web service isn't distribution. That is what they call the "SaaS loophole" and it's the reason why the AGPL license was created.&lt;/p&gt;

&lt;p&gt;Why a single, self-contained binary? Because the target audience is someone who wants this to work fast with minimal setup and little infrastructure - not someone who wants to run their own Postgres database, Docker infrastructure, and expensive AWS hosting solutions, then resolve a whole bunch of dependencies just to load the page after hours of struggling. I chose SQLite and &lt;code&gt;rust-embed&lt;/code&gt; for static assets and Askama baked-in templates. That allowed me to package the entire web server into one 6.8MB executable file.&lt;/p&gt;

&lt;p&gt;Additionally, htmx is bundled with the binary so no CDN dependency is needed - one less thing to worry about, and you don't even need internet access. Self-hosting is supposed to be all about removing external dependencies, so &lt;code&gt;htmx.min.js&lt;/code&gt; is baked into the binary and VENDOR.md is attached to respect licensing requirements.&lt;/p&gt;

&lt;p&gt;The effect? All my decisions point in the same direction - minimize hassle for the hoster and make the install as easy as possible.&lt;/p&gt;

&lt;p&gt;Code's here if you want to follow along or try it yourself ;-)  &lt;a href="https://github.com/RafalManka/Featherboard" rel="noopener noreferrer"&gt;https://github.com/RafalManka/Featherboard&lt;/a&gt;   &lt;/p&gt;

</description>
      <category>opensource</category>
      <category>rust</category>
      <category>showdev</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
