<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Björn Friedrichs&apos; Blog</title><description>A mere stream of thoughts</description><link>https://bjoernf.com</link><item><title>PDF Interpolator: Replacing text in templated PDFs with spreadsheet data</title><description>I built a desktop app for a relative&apos;s business that needed to generate individual pricing PDFs for clients while keeping their designs intact and their data entirely offline. Here&apos;s the story and some of the technical hurdles along the way.</description><pubDate>Wed, 25 Feb 2026 19:35:00 GMT</pubDate><content:encoded>&lt;p&gt;I recently built &lt;a href=&quot;https://github.com/itsbjoern/pdf-interpolator&quot; target=&quot;_blank&quot;&gt; PDF Interpolator  &lt;/a&gt;, a desktop application that replaces text in PDF files using data from spreadsheets. It started with a simple idea, if you have a templated PDF (a pricing list or invoice) you should be able to automatically fill it using a spreadsheet. Map the columns, hit process, and you get a new PDF with the numbers swapped in. All of it runs locally on your machine. I purposefully wanted to avoid cloud processing to ensure that no data leaves your computer, but more on that in a bit.&lt;/p&gt;
&lt;p&gt;My previous work on &lt;a href=&quot;https://pdfblaze.com&quot; target=&quot;_blank&quot;&gt; PDFBlaze  &lt;/a&gt; was invaluable to understanding the requirements and issues for this project. PDFs are a notoriously complex format, so it helped a lot that for this project as I already had a good understanding of the underlying structure and how to manipulate it. Just for reference the &lt;a href=&quot;https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf&quot; target=&quot;_blank&quot;&gt; PDF version 1.7 specification  &lt;/a&gt; is over 500 pages long and was released almost 20 years ago, the more recent &lt;a href=&quot;https://developer.adobe.com/document-services/docs/assets/5b15559b96303194340b99820d3a70fa/PDF_ISO_32000-2.pdf&quot; target=&quot;_blank&quot;&gt; version 2.0  &lt;/a&gt; (2017) clocks in at almost 1000 pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The why: PDF design and data sensitivity&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I built this for a relative who needed it for their business, and their use case turned out to be quite interesting. Because of current market forces they had to recalculate their pricing sheets many times in relatively short intervals. Unfortunately this forced them to spend a lot of time painstakingly updating hundreds of individual prices to create catered PDFs for different clients. The manual work involved in updating prices, regenerating documents, and making sure everything looked right was eating into their time. They aren’t tech-illiterate at all, but it’s not their primary job to sit at a computer and it felt like a large burden to spend their energy on this extra task. Additionally, the industry or at least their target audience, comes from a pool of clientele that happens to care a lot about the details. Now, this is mostly concerning the product, but in such a sector you often require to be very conscious about the image, brand and design you project. As such my relative wanted to use their own design tools (e.g. InDesign, Illustrator, whatever they were comfortable with) to create beautifully laid-out PDFs. Then they just wanted to sub in the prices. A boring or generic template that looks like a generated document was not really an option.&lt;/p&gt;
&lt;p&gt;Generally this kind of tool is hard to come by, because it’s a niche use case, and also it actually involves some pretty complex technical hurdles to overcome. There are some alternatives floating around online, but all of questionable quality, pricing structures, or data security. A web-based PDF generator or a service that processes documents in the cloud was somewhat problematic anyway, for a lot of businesses their pricing data is the anchor of their business. Sending it anywhere, even to a quote-unquote trusted third party, was out of the question. To me a primary requirement was that everything had to run locally and no data to ever leave their computer.&lt;/p&gt;
&lt;p&gt;That’s what PDF Interpolator does. You pick a spreadsheet, map the source column (the placeholder text in the PDF) to the target column (the value from your sheet), select your PDF, choose an output location, and process. The app preserves the original layout, fonts, and formatting. It just swaps the text. Fully offline, no account required, no telemetry.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;The nitty gritty: what I had to deal with under the hood&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As mentioned earlier, PDFs are notoriously messy to work with. I already built &lt;a href=&quot;https://pdfblaze.com&quot; target=&quot;_blank&quot;&gt; PDFBlaze  &lt;/a&gt; before as a online-service for templated PDFs. This meant I had at least a rough idea of what I was getting into. PDFBlaze was sadly not suited in this case as since it is run by me the data protection issue was less of a concern, but the templating engine requires the user to create the whole thing in my online editor, which as mentioned before wasn’t really an option. Thus, the technical challenges to overcome for this app were the following:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fonts and encodings&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;PDFs don’t store text as plain strings. They store sequences of bytes that map to glyphs via font encodings. And it gets worse: different fonts use different encodings, sometimes the same “base font” can be encoded as multiple fonts with different encodings. WinAnsiEncoding, MacRomanEncoding, custom encodings defined in the font dictionary — you have to handle all of them. When you replace text, you need to encode your replacement string back into the same byte representation the PDF expects, otherwise the glyphs won’t render correctly. I had to build a font registry that maps Unicode characters to the correct byte codes for each font, and when a character doesn’t exist in the current font (e.g. a particular case is the euro sign in a font that only has ASCII), I fall back to other fonts in the same family.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content streams: strings, arrays and split bytes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;PDF content streams are essentially sequences of drawing operations. Text is rendered inside &lt;code&gt;BT&lt;/code&gt; … &lt;code&gt;ET&lt;/code&gt; blocks. The tricky part is that text can be specified in multiple ways: as a single string with &lt;code&gt;Tj&lt;/code&gt;, or as an array with &lt;code&gt;TJ&lt;/code&gt; where each element can be a string or a spacing adjustment. Sometimes that array is split across multiple operations. Sometimes strings are hex-encoded &lt;code&gt;&amp;lt;414243&amp;gt;&lt;/code&gt;, sometimes they’re encoded in octal notation &lt;code&gt;\041\042\043&lt;/code&gt; and sometimes they’re literal &lt;code&gt;(ABC)&lt;/code&gt; with escape sequences.&lt;/p&gt;
&lt;p&gt;I had to build a tokenizer that tracks byte positions so I could surgically replace only the text blocks that changed, leaving all the graphics, paths, and images untouched. One wrong splice and the whole page corrupts.&lt;/p&gt;
&lt;p&gt;A particularly challenging session was when I discovered that Adobe Acrobat Reader does not like certain representations of text. A hex-encoded string like &lt;code&gt;&amp;lt;414243&amp;gt;&lt;/code&gt; should be printed with the &lt;code&gt;Tj&lt;/code&gt; operator, since if &lt;code&gt;TJ&lt;/code&gt; is used a reader expects the text to be a sequence of strings and spacing adjustments. Interestingly, every reader I tested did not mind the missing spacings except for Acrobat Reader.&lt;/p&gt;
&lt;p&gt;And because PDF is a binary-ish format, content streams can be split at what feel like random byte boundaries. This means that I had to ensure that re-writing the content stream does not break the PDF structure and that the resulting PDF is still valid. I also didn’t want to unnecessarily bloat the PDF, so I had to be careful about removing unused content after the replacement and re-writing content streams with the appropriate compression filters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Number formatting: German vs English&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Spreadsheets, at least when read through libraries like xlsx, unfortunately seem to return numbers in English format without internationalisation: comma as thousands separator, period as decimal. So &lt;code&gt;1,234.56&lt;/code&gt; means one thousand and change. In German locale it’s the opposite: &lt;code&gt;1.234,56&lt;/code&gt;. My relative’s business runs in Germany. Just passing through the values, a price like &lt;code&gt;1,234.56&lt;/code&gt; is basically nonsensical to a German reader. I had to add locale-aware number formatting that detects the system language and converts between formats when loading from the spreadsheet. It’s one of those things you don’t think about until you’re debugging why all the prices are wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Putting it together&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The pipeline works like this: load the PDF, parse each page’s content stream with position tracking, extract font dictionaries and build character mappings, decode text blocks to Unicode, perform replacements (with font fallback when needed), re-encode to bytes, and patch only the modified &lt;code&gt;BT&lt;/code&gt; … &lt;code&gt;ET&lt;/code&gt; blocks back into the stream. Everything else stays byte-for-byte identical. The result is a PDF that renders the same in Adobe Reader and other finicky viewers, with all the original structure preserved.&lt;/p&gt;
&lt;p&gt;It was a satisfying project to work on. Solving real problems for someone you know, and learning a lot about a format that most people happily treat as a black box. If you have a similar use case for templated PDFs, local data, design-sensitive output, &lt;a href=&quot;https://github.com/itsbjoern/pdf-interpolator&quot; target=&quot;_blank&quot;&gt; PDF Interpolator  &lt;/a&gt; might be worth a look. It’s available for Windows, macOS, and Linux.&lt;/p&gt;</content:encoded></item><item><title>AI is amazing, AI is scary</title><description>Reflections, expectations and foresight on how AI has and will transform the software engineering landscape</description><pubDate>Sun, 04 Jan 2026 11:51:00 GMT</pubDate><content:encoded>&lt;p&gt;… and AI has fundamentally changed the way I think about what it means to be a software engineer.&lt;/p&gt;
&lt;p&gt;The last few years, but 2025 in particular, has been a whirlwind when it comes to the capabilities and skills AIs can offer. Ranging from almost instantly answering complex questions, all the way to making full plans and executing them autonomously. Where at first it was easy to discard their nature as being simple predictive parrots, we are now starting to realise that when designed correctly AI agents are much more capable. Increasing context windows, reiterating plan structures and having feedback loops within tasks, dare I say it, suddenly AI feels like it’s „thinking“ much more so than ever before.&lt;/p&gt;
&lt;p&gt;From a software perspective, tools like Claude Code or Cursor are capable of implementing, modifying and debugging reasonably complex issues, if provided with enough context. They have become much better at understanding surrounding code structures, plus adhering to workspace code guidelines and patterns.&lt;/p&gt;
&lt;p&gt;Automated tooling is now wide scale available, where some GitHub repositories launch automatic agents to create PRs when an issue is created, then a separate automated agent will review the PR and add notes. Sometimes this can go back and forth multiple times before a human even looks at the PR.&lt;/p&gt;
&lt;p&gt;There is many examples of these AI guided development workflows, two I find very inspiring is &lt;a href=&quot;https://x.com/charliermarsh&quot; target=&quot;_blank&quot;&gt; Charlie Marsh‘s  &lt;/a&gt; &lt;a href=&quot;https://github.com/astral-sh/uv&quot; target=&quot;_blank&quot;&gt; „uv“  &lt;/a&gt; and another  &lt;a href=&quot;https://x.com/jarredsumner&quot; target=&quot;_blank&quot;&gt; Jarred Sumner‘s  &lt;/a&gt; &lt;a href=&quot;https://bun.sh/&quot; target=&quot;_blank&quot;&gt; „Bun“  &lt;/a&gt; (now owned by Anthropic). These are not small hobby projects, they are massively popular tools which have to be carefully worked on and these people are succeeding by doing so with AI.&lt;/p&gt;
&lt;p&gt;After over a decade as a software engineer, nothing has shifted my view on how development works so drastically as these recent advancements of AI. In the past, new tools (e.g. for debugging, observability, code structuring, etc.) were released all the time and were worth investing the time to learn, but nothing compares to the effect that AI has and will have. In my opinion AI is a tool a software engineer will have to learn to use. You will not be able to avoid it. It is able, unequivocally, to speed up a lot of tasks for me already. If you haven’t yet, my plea is, go and try it out. Learn how to use it effectively, learn what it is able to do, and crucially what it isn’t. Find workflows that speed you up in the right places.&lt;/p&gt;
&lt;p&gt;To non-technical and leading staff: Make sure your engineers have the resources available to learn and use these tools. This doesn’t mean you have to burn through thousands of pounds and millions of tokens, even just baseline access to something like Claude or Cursor are a massive deal. Most engineers I know are naturally inclined to improve their skillset, they want to ride the wave of new technologies and advancements. Locking them out of these tools not only means that you’re likely slowing down development, but also realistically are frustrating your staff with a feeling of being left behind by the industry.&lt;/p&gt;
&lt;p&gt;Now, the big question is: Where does that leave the role of a software engineer? The most important part at play here is the human factor. Someone needs to understand the domain, someone needs to understand the product requirements, someone needs to translate them to machine requirements and someone needs to validate and manually correct issues. The real skill of an engineer isn’t writing the code itself, it’s being able to deeply understand a problem and reflecting it in a performant, secure and available system. AI doesn’t really replace any core capabilities here, it just massively enhances the speed by which they can be delivered. A good engineer will be able to leverage these tools and continue to deliver high-quality code at a faster rate, allowing them to focus on more or multiple issues at a time. A bad engineer will use it as a crutch, without reviewing the automated work. Which inevitably will start to muddle the carefully crafted systems, creating tech debt and incrementally adding unsolveable riddles.&lt;/p&gt;
&lt;p&gt;It really wouldn’t surprise me if by the end of 2026 the core competencies of skilled developers in high velocity teams will have shifted. Where previously their time was mostly spent writing code by themselves, it will be mostly consumed by planning, reviewing, and correcting code written by AI.&lt;/p&gt;
&lt;p&gt;I find this thought equally unnerving and exciting. I spent years learning and improving my craft, seeing it upended like this leaves me with a very strange feeling. That being said, I choose to view this change positively. After all, progress is exciting, and learning is exciting too (I promise!). Being part of the first wave of engineers that get to explore this new way of building on technology feels empowering, I just need to make sure it’s not leaving me behind. At the end of the day, I would see it as a failure of myself if a machine was able to replace me, instead of me being able to drive the machine to do what I want it to do.&lt;/p&gt;</content:encoded></item><item><title>Multi-row, dynamically truncated text width with ellipsis in pure CSS</title><description>Creating a two-row layout with text where both rows have a variable length (or width). One of the rows should dictate the width of the container, while the other row truncates to that size with ellipses.</description><pubDate>Wed, 27 Aug 2025 21:36:00 GMT</pubDate><content:encoded>&lt;p&gt;I ran into this annoying CSS problem that neither AI nor the internet seemed to be able to produce a sensible solution for. I don’t think it’s a hard problem nor but it did take me a little while to wrap my head around it. I’m now just documenting the issue for prosperity.&lt;/p&gt;
&lt;p&gt;The basic issue boils down to wanting a two-row layout with text where both rows have a variable length (or width). In the layout one of the rows should dictate the width of the container, while the other row truncates to it’s size with ellipses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Title: An overly long but truncated title&lt;/li&gt;
&lt;li&gt;Content: Any length content&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Any length content &lt;/div&gt; &lt;/div&gt;
&lt;p&gt;Code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt; &amp;lt;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  style&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    display: &lt;/span&gt;&lt;span&gt;&quot;inline-flex&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    flexDirection: &lt;/span&gt;&lt;span&gt;&quot;column&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; style&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{{ display: &lt;/span&gt;&lt;span&gt;&quot;flex&quot;&lt;/span&gt;&lt;span&gt; }}&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      style&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        width: &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        flexGrow: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        overflow: &lt;/span&gt;&lt;span&gt;&quot;hidden&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        textOverflow: &lt;/span&gt;&lt;span&gt;&quot;ellipsis&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        whiteSpace: &lt;/span&gt;&lt;span&gt;&quot;nowrap&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      A long but truncated title&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    Any length content&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explanation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The important components of this setup are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Using &lt;code&gt;inline-flex&lt;/code&gt; on the outer container to make it shrink to fit the content of the row that controls the size. This means that whichever row is controlling the size will determine the overall width of the container and thus truncate the other row.&lt;/li&gt;
&lt;li&gt;Wrapping the truncated view in a &lt;code&gt;flex&lt;/code&gt; container that the truncated text can grow into. This will allow the text to take up the available space without exceeding the container’s width.&lt;/li&gt;
&lt;li&gt;Setting the truncated text to &lt;code&gt;width: 0&lt;/code&gt; and &lt;code&gt;flex-grow: 1&lt;/code&gt; to make it grow to the size of the interim flex container, but it can never exceed further than the overall container.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It creates a rather elegant solution where there is no need to calculate any sizes, and it’s adaptable to any content size. It also automatically animates rather nicely if the width-controlling row has a transition.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The playground&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In the following example the content is editable, give it a go and see how it behaves:&lt;/p&gt;
&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Edit me &lt;/div&gt; &lt;/div&gt;
&lt;hr /&gt;
&lt;p&gt;Additionally, if the width of the lower row is constrained, it will also work nicely:&lt;/p&gt;
&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Content that is much longer and will break because the container is restricted in width &lt;/div&gt; &lt;/div&gt;
&lt;hr /&gt;
&lt;p&gt;As mentioned before, if the width-controlling row is animated, it will also animate the truncation of the other row:&lt;/p&gt;
&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Any length content &lt;/div&gt; &lt;/div&gt;
&lt;br /&gt;
Toggle width

&lt;hr /&gt;
&lt;p&gt;Lastly, it also works great if you have multiple components of varying widths that need to sitting next to each other in one row (this was my use case)&lt;/p&gt;
&lt;div&gt;&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; First title &lt;/div&gt; &lt;/div&gt;&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Second title &lt;/div&gt; &lt;/div&gt;&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Third title &lt;/div&gt; &lt;/div&gt;&lt;div&gt; &lt;div&gt; &lt;div&gt; An overly long but truncated title &lt;/div&gt; &lt;/div&gt; &lt;div&gt; Fourth title &lt;/div&gt; &lt;/div&gt;&lt;/div&gt;</content:encoded></item><item><title>Microsoft Teams silent login flow in 2025</title><description>Implementing a silent login flow for Microsoft Teams using the Teams SDK and Microsoft Graph API. Load user information without requiring an additional login prompt.</description><pubDate>Fri, 01 Aug 2025 17:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This post outlines the implementation of a silent login flow for Microsoft Teams. At SimplyDo we had previously implemented a solution for this and it worked, after some changes from Microsoft there is now a slightly more elegant solution available.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sidenote: in all fairnesss, most of our prior issues stemmed from the proactive messages required by Teams applications, but this is also much more straightforward nowadays. If you’re interested in (or struggling with) implementing proactive notifications feel free to send me a message on one of my socials.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;You can find our process for the previous solution in our &lt;a href=&quot;https://devblog.simplydo.co.uk/posts/2023-11-22-silent_auth/&quot; target=&quot;_blank&quot;&gt; team blog post  &lt;/a&gt;. You will find that the process is actually still extremely similar, especially in regards to Steps 1 &amp;amp; 2. However, since then Microsoft has made some changes to their recommended Teams packages, so this updated approach should be more in line with the setup you can expect when creating a new Teams app in 2025.&lt;/p&gt;
&lt;p&gt;Either way, the following should guide you through an example of how to implement a silent login flow in Microsoft Teams using the Teams SDK and the Microsoft Graph API. The example will cover both the frontend and backend parts of the flow.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The MS Teams frontend&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The flow begins in the Teams environment itself. What we are looking for is a way to utilise the users authentication context of Teams itself to silently authenticate the user in our app.&lt;/p&gt;
&lt;p&gt;For this we use the following versions of the Teams SDK, as of writing these are the latest versions available.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;  &quot;@microsoft/teams-js&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;^2.36.0&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;@microsoft/teamsfx&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;^4.0.0&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;@microsoft/teamsfx-react&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;^4.0.2&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Technically only &lt;code&gt;@microsoft/teams-js&lt;/code&gt; is required for this, but I assume most people will look at this in context of a React app. However, as you can see in their code the teamsfx packages actually just a wrapper around an authentication request around to make the ssoToken available, so this should always be available as long as &lt;code&gt;app.initialize()&lt;/code&gt; has been called.
References: &lt;a href=&quot;https://github.com/OfficeDev/microsoft-365-agents-toolkit/blob/29b60835ab3a9c6ca0d1e57e2a910650437ceb21/packages/sdk/src/credential/teamsUserCredential.browser.ts#L277C33-L277C44&quot; target=&quot;_blank&quot;&gt; Wrapping the internal call  &lt;/a&gt; and &lt;a href=&quot;https://github.com/OfficeDev/microsoft-365-agents-toolkit/blob/29b60835ab3a9c6ca0d1e57e2a910650437ceb21/packages/sdk/src/credential/teamsUserCredential.browser.ts#L329&quot; target=&quot;_blank&quot;&gt; calling the underlying authentication  &lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you open a Teams app in the Desktop or web client you will have the &lt;code&gt;useTeamsUserCredential&lt;/code&gt; hook available to you. This hook will allow you to access the Teams user credentials and initiate the login flow. Crucially, you have access to the &lt;code&gt;ssoToken&lt;/code&gt; which is the auth token that validates the user in Teams. This token is what we will use to authenticate the user in our app.&lt;/p&gt;
&lt;p&gt;The setup should look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { useTeamsUserCredential } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@microsoft/teamsfx-react&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; Component&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; () &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; teamsCredentials&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; useTeamsUserCredential&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    initiateLoginEndpoint: &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;meta&lt;/span&gt;&lt;span&gt;.env.&lt;/span&gt;&lt;span&gt;VITE_START_LOGIN_PAGE_URL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    clientId: &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;meta&lt;/span&gt;&lt;span&gt;.env.&lt;/span&gt;&lt;span&gt;VITE_CLIENT_ID&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;currentUser&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;setCurrentUser&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; useState&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;userLoginStatus&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;setUserLoginStatus&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; useState&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;loading&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;loading&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;teamsUserCredential&lt;/span&gt;&lt;span&gt; } &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; teamsCredentials&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  if&lt;/span&gt;&lt;span&gt; (loading) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; &amp;lt;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;Loading&lt;/span&gt;&lt;span&gt;...&amp;lt;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  useEffect&lt;/span&gt;&lt;span&gt;(() &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (userLoginStatus &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; &quot;loading&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      return&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    setUserLoginStatus&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;initialising&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // If we already have a token, we can skip the login flow&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; apiToken&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; localStorage.&lt;/span&gt;&lt;span&gt;getItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;token&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (apiToken) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      api.&lt;/span&gt;&lt;span&gt;registerToken&lt;/span&gt;&lt;span&gt;(apiToken)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      const&lt;/span&gt;&lt;span&gt; user&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; api.&lt;/span&gt;&lt;span&gt;getUser&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      setCurrentUser&lt;/span&gt;&lt;span&gt;(user)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      setUserLoginStatus&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;loggedIn&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      return&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; initialiseLogin&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; async&lt;/span&gt;&lt;span&gt; () &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      // Ensure the ssoToken is available&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      const&lt;/span&gt;&lt;span&gt; userInfo&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; teamsUserCredential?.&lt;/span&gt;&lt;span&gt;getUserInfo&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;userInfo) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        setUserLoginStatus&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;loginFailed&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      // Read the silent id token from the TeamsFx context&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      const&lt;/span&gt;&lt;span&gt; ssoToken&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; teamsUserCredential?.ssoToken?.token&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      const&lt;/span&gt;&lt;span&gt; response&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; api.&lt;/span&gt;&lt;span&gt;loginWithTeams&lt;/span&gt;&lt;span&gt;({ ssoToken })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      api.&lt;/span&gt;&lt;span&gt;registerToken&lt;/span&gt;&lt;span&gt;(response.token)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      localStorage.&lt;/span&gt;&lt;span&gt;setItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;token&quot;&lt;/span&gt;&lt;span&gt;, response.token)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      setCurrentUser&lt;/span&gt;&lt;span&gt;(response.user)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      setUserLoginStatus&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;loggedIn&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    initialiseLogin&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }, [teamsUserCredential])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here we first call the &lt;code&gt;.getUserInfo()&lt;/code&gt; method to ensure that the user is logged in and we have access to the &lt;code&gt;ssoToken&lt;/code&gt;. This token is then passed to our backend API to authenticate the user. Once the request succeeds we can use this information set a session token in our api which allows us to further communicate with our backend without having to re-authenticate through the &lt;code&gt;ssoToken&lt;/code&gt; in the future.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The backend&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In the backend we will verify the &lt;code&gt;ssoToken&lt;/code&gt; and use it to authenticate the user. Before we do so we need to check what our audience for the token is. This can be done by checking the &lt;code&gt;aud&lt;/code&gt; claim in the token. You should never parse this directly from the token, but it will be stable across requests. If you are not deployed yet you can also check your Azure AD app registration to see what the audience is set to.&lt;/p&gt;
&lt;p&gt;The following image shows where you would find the App audience in the App registration.  Let’s assume it’s set to &lt;code&gt;api://&amp;lt;your-app-id&amp;gt;&lt;/code&gt; for now.&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/app_id.BLDs4P6__ZrOCcO.webp&quot; alt=&quot;Example of App ID in Azure&quot; loading=&quot;lazy&quot; width=&quot;1580&quot; height=&quot;808&quot; /&gt;
&lt;p&gt;Now that we know the audience, we can verify the token and extract the user information from it. The token is signed by Microsoft and we can verify it using the public keys provided by Microsoft.&lt;/p&gt;
&lt;p&gt;Please be aware that in this example we will request user information from the Microsoft Graph API using the &lt;code&gt;https://graph.microsoft.com/.default&lt;/code&gt; scope. This scope grants access to any delegated permissions configured for the application, but fails if there isn’t any. You will need to ensure that the app registration has the necessary permissions granted to resolve the fields of the following Graph API call. The configured permissions can be found in the app registration under “API permissions”.&lt;/p&gt;
&lt;p&gt;In these examples I will use a few dummy functions to illustrate how you could use the data in this flow, but you will need to implement your own logic to handle the user data and authentication flow in your app.&lt;/p&gt;
&lt;div&gt; &lt;div&gt;  Python  TypeScript  &lt;/div&gt; &lt;div&gt; &lt;div&gt; &lt;p&gt;For Python we use the &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;pyjwt&lt;/code&gt; libraries to verify the token.
The &lt;code&gt;msal&lt;/code&gt; library is used to acquire an access token on behalf of the user if the user is not found in our database.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; os&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; requests&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; jwt&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  from&lt;/span&gt;&lt;span&gt; msal &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; ConfidentialClientApplication&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  # Microsoft maintains a rotating set of public keys to verify the JWT signature.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  # Reference:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  # https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#validate-the-signature&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  config_response &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; requests.get(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      timeout&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  config_data &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; config_response.json()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  jwks_url &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; config_data.get(&lt;/span&gt;&lt;span&gt;&quot;jwks_uri&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  jwks_response &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; requests.get(jwks_url, &lt;/span&gt;&lt;span&gt;timeout&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  public_keys &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      key[&lt;/span&gt;&lt;span&gt;&quot;kid&quot;&lt;/span&gt;&lt;span&gt;]: jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      for&lt;/span&gt;&lt;span&gt; key &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; jwks_response.json().get(&lt;/span&gt;&lt;span&gt;&quot;keys&quot;&lt;/span&gt;&lt;span&gt;, [])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  # The api handler as invoked by api.loginWithTeams on the frontend&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  def&lt;/span&gt;&lt;span&gt; login_from_teams_token&lt;/span&gt;&lt;span&gt;(data):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      teams_token &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; data[&lt;/span&gt;&lt;span&gt;&quot;ssoToken&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      token_headers &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; jwt.get_unverified_header(teams_token)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      public_key &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; public_keys[token_headers[&lt;/span&gt;&lt;span&gt;&quot;kid&quot;&lt;/span&gt;&lt;span&gt;]]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      decoded_token &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; jwt.decode(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          teams_token,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          key&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;public_key,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          algorithms&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;RS256&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          audience&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;api://&amp;lt;your-app-id&amp;gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      tid &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; decoded_token[&lt;/span&gt;&lt;span&gt;&quot;tid&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      # The oid is the users unique identifier in Azure AD&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      # This can be None or empty in certain scenarios, so handle it accordingly&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      oid &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; decoded_token.get(&lt;/span&gt;&lt;span&gt;&quot;oid&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; oid:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          # Find the user and return it, otherwise we have to provision a new user&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          user &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; get_user_by_oid(oid)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          if&lt;/span&gt;&lt;span&gt; user:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              return&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;token&quot;&lt;/span&gt;&lt;span&gt;: create_api_token(user),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;user&quot;&lt;/span&gt;&lt;span&gt;: user,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      # MICROSOFT_APP_ID and MICROSOFT_APP_SECRET are the app registration credentials&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      msal_client &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ConfidentialClientApplication(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          client_id&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;os.environ[&lt;/span&gt;&lt;span&gt;&quot;MICROSOFT_APP_ID&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          client_credential&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;os.environ[&lt;/span&gt;&lt;span&gt;&quot;MICROSOFT_APP_SECRET&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          authority&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;https://login.microsoftonline.com/&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;tid&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      access_data &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; msal_client.acquire_token_on_behalf_of(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          user_assertion&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;teams_token,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          scopes&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;https://graph.microsoft.com/.default&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; &quot;access_token&quot;&lt;/span&gt;&lt;span&gt; not&lt;/span&gt;&lt;span&gt; in&lt;/span&gt;&lt;span&gt; access_data:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          raise&lt;/span&gt;&lt;span&gt; Exception&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Failed to acquire access token&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      # Example of how the token can be used to create a new user&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      resp &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; requests.get(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          f&lt;/span&gt;&lt;span&gt;&quot;https://graph.microsoft.com/v1.0/me?$select=id,givenName,mail&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          headers&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              &quot;Authorization&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;Bearer &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;access_data[&lt;/span&gt;&lt;span&gt;&apos;access_token&apos;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      user_data &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; resp.json()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      user &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; create_user(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          oid&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;oid,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          email&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;user_data.get(&lt;/span&gt;&lt;span&gt;&quot;mail&quot;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          first_name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;user_data.get(&lt;/span&gt;&lt;span&gt;&quot;givenName&quot;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      return&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &quot;token&quot;&lt;/span&gt;&lt;span&gt;: create_api_token(user),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &quot;user&quot;&lt;/span&gt;&lt;span&gt;: user,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt; &lt;/div&gt;&lt;div&gt; &lt;p&gt;&lt;em&gt;Disclaimer: I chucked the Python example into AI to generate a TypeScript version. It may not be perfect (or working for that matter), but it should give you an idea.&lt;/em&gt;&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; axios &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;axios&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; jwt, { JwtHeader, JwtPayload } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;jsonwebtoken&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; jwkToPem &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;jwk-to-pem&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  import&lt;/span&gt;&lt;span&gt; { ConfidentialClientApplication } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@azure/msal-node&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; configUrl&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; &quot;https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  let&lt;/span&gt;&lt;span&gt; publicKeys&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Record&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&amp;gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; {};&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  async&lt;/span&gt;&lt;span&gt; function&lt;/span&gt;&lt;span&gt; fetchPublicKeys&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Promise&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; configResponse&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; axios.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(configUrl, { timeout: &lt;/span&gt;&lt;span&gt;5000&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; jwksUri&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; configResponse.data.jwks_uri;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; jwksResponse&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; axios.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(jwksUri, { timeout: &lt;/span&gt;&lt;span&gt;5000&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; keys&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; jwksResponse.data.keys &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    publicKeys &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; keys.&lt;/span&gt;&lt;span&gt;reduce&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;acc&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Record&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&amp;gt;, &lt;/span&gt;&lt;span&gt;key&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; any&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      acc[key.kid] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; jwkToPem&lt;/span&gt;&lt;span&gt;(key);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      return&lt;/span&gt;&lt;span&gt; acc;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }, {});&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  interface&lt;/span&gt;&lt;span&gt; TeamsLoginData&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ssoToken&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  // The api handler as invoked by api.loginWithTeams on the frontend&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  export&lt;/span&gt;&lt;span&gt; async&lt;/span&gt;&lt;span&gt; function&lt;/span&gt;&lt;span&gt; loginFromTeamsToken&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; TeamsLoginData&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Promise&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;any&lt;/span&gt;&lt;span&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; teamsToken&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; data.ssoToken;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;Object.&lt;/span&gt;&lt;span&gt;keys&lt;/span&gt;&lt;span&gt;(publicKeys).&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      await&lt;/span&gt;&lt;span&gt; fetchPublicKeys&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; tokenHeaders&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; jwt.&lt;/span&gt;&lt;span&gt;decode&lt;/span&gt;&lt;span&gt;(teamsToken, { complete: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; })?.header &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; JwtHeader&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; kid&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; tokenHeaders?.kid;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;kid &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;publicKeys[kid]) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      throw&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Invalid token: Key ID not found&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; publicKey&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; publicKeys[kid];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; decodedToken&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; jwt.&lt;/span&gt;&lt;span&gt;verify&lt;/span&gt;&lt;span&gt;(teamsToken, publicKey, {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      algorithms: [&lt;/span&gt;&lt;span&gt;&quot;RS256&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      audience: &lt;/span&gt;&lt;span&gt;&quot;api://&amp;lt;your-app-id&amp;gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }) &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; JwtPayload&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; tid&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; decodedToken[&lt;/span&gt;&lt;span&gt;&quot;tid&quot;&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; oid&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; decodedToken[&lt;/span&gt;&lt;span&gt;&quot;oid&quot;&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // The oid is the users unique identifier in Azure AD&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // This can be null or empty in certain scenarios, so handle it accordingly&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (oid) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      const&lt;/span&gt;&lt;span&gt; user&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; getUserByOid&lt;/span&gt;&lt;span&gt;(oid);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; (user) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; { user, token: &lt;/span&gt;&lt;span&gt;createApiToken&lt;/span&gt;&lt;span&gt;(user)};&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // MICROSOFT_APP_ID and MICROSOFT_APP_SECRET are the app registration credentials&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; msalClient&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; ConfidentialClientApplication&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      auth: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        clientId: process.env.&lt;/span&gt;&lt;span&gt;MICROSOFT_APP_ID&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        clientSecret: process.env.&lt;/span&gt;&lt;span&gt;MICROSOFT_APP_SECRET&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        authority: &lt;/span&gt;&lt;span&gt;`https://login.microsoftonline.com/${&lt;/span&gt;&lt;span&gt;tid&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; result&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; msalClient.&lt;/span&gt;&lt;span&gt;acquireTokenOnBehalfOf&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      oboAssertion: teamsToken,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      scopes: [&lt;/span&gt;&lt;span&gt;&quot;https://graph.microsoft.com/.default&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;result &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;result.accessToken) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      throw&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Failed to acquire access token&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // Example of how the token can be used to create a new user&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; graphResponse&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; axios.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;https://graph.microsoft.com/v1.0/me?$select=id,givenName,mail&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      headers: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        Authorization: &lt;/span&gt;&lt;span&gt;`Bearer ${&lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;accessToken&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; userData&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; graphResponse.data;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; user&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; createUser&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      oid,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      email: userData.mail,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      first_name: userData.givenName,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; { user, token: &lt;/span&gt;&lt;span&gt;createApiToken&lt;/span&gt;&lt;span&gt;(user)};&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; 
&lt;p&gt;Nothing here is terribly complicated, but it does require some understanding of how the Teams SDK works and how to verify the JWT tokens. The key takeaway is that you can use the Teams user credentials to silently authenticate the user in your app without requiring them to log in again.&lt;/p&gt;</content:encoded></item><item><title>Using UUID v4 in Expo (native + web)</title><description>A workable UUID v4 generator for Expo apps that works on both native and web platforms.</description><pubDate>Fri, 25 Jul 2025 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Today I ran into a problem with generating v4 UUIDs in an Expo app. The &lt;code&gt;uuid&lt;/code&gt; package is a popular choice for generating UUIDs, but it doesn’t work out of the box in Expo apps because it relies on Node.js’s &lt;code&gt;crypto&lt;/code&gt; module. Specifically the &lt;code&gt;getRandomValues&lt;/code&gt; function, which is not available in the Expo environment.&lt;/p&gt;
&lt;p&gt;There seem to be workaround such as the &lt;a href=&quot;https://www.npmjs.com/package/react-native-uuid&quot; target=&quot;_blank&quot;&gt; react-native-uuid  &lt;/a&gt; package which &lt;a href=&quot;https://github.com/eugenehp/react-native-uuid/blob/master/src/rng.ts&quot; target=&quot;_blank&quot;&gt; re-implements the random number generator  &lt;/a&gt; using &lt;code&gt;Math.random()&lt;/code&gt; which I’m not a huge fan of.&lt;/p&gt;
&lt;p&gt;There’s also &lt;a href=&quot;https://www.npmjs.com/package/react-native-get-random-values&quot; target=&quot;_blank&quot;&gt; react-native-get-random-values  &lt;/a&gt; which is a polyfill for the &lt;code&gt;crypto.getRandomValues&lt;/code&gt; function, but it has not been updated in months (years) and users seem to have multiple issues with it. I didn’t want to outsource a core functionality to a potentially flakey dependency (which in fairness it may not be, but I didn’t want to deal with the headache if it was).&lt;/p&gt;
&lt;p&gt;Instead I decided to rely on the &lt;code&gt;expo-crypto&lt;/code&gt; package which is a part of the Expo SDK and provides a reliable way to generate random values. &lt;code&gt;uuid&lt;/code&gt;’s &lt;code&gt;v4&lt;/code&gt; generation implementation allows you to pass in a custom random number generator, so we can use &lt;code&gt;expo-crypto&lt;/code&gt;’s &lt;code&gt;getRandomValues&lt;/code&gt; function to generate the random bytes needed for the UUID.&lt;/p&gt;
&lt;p&gt;It may have been possible to somehow polyfill the &lt;code&gt;crypto.getRandomValues&lt;/code&gt; function in a way that would work with the &lt;code&gt;uuid&lt;/code&gt; package, but I didn’t want to spend time on that. Especially not because from prior experience I know how much of a pain it is when Expo, metro or something else in the process changes down the line. Instead, I opted for a straightforward solution that simply re-exports it’s own &lt;code&gt;v4&lt;/code&gt; function.&lt;/p&gt;
&lt;p&gt;It’s a simple and elegant solution that works on both native and web platforms. Here’s how you can implement it in your Expo app, just remember to import &lt;code&gt;v4&lt;/code&gt; from your own file instead of the &lt;code&gt;uuid&lt;/code&gt; package directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// uuid.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { getRandomValues } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;expo-crypto&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { v4 &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; uuidv4 } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;uuid&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; v4&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; () &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; random&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Uint8Array&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;16&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  getRandomValues&lt;/span&gt;&lt;span&gt;(random)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; uuidv4&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    random,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;</content:encoded></item><item><title>Multiple services in Tailscale with Traefik + Docker</title><description>Ensuring proper routing of multiple services in Tailscale using Traefik and Docker wasn&apos;t quite as straightforward as I expected. The following setup is a pretty solid workaround to Tailscales subdomain restriction.</description><pubDate>Thu, 19 Jun 2025 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Preamble&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I finally went through the process of upgrading my home server from a Raspberry Pi to a more powerful machine. This was mainly motivated by me wanting to use &lt;a href=&quot;https://immich.app/&quot; target=&quot;_blank&quot;&gt; Immich  &lt;/a&gt; but my Raspberry Pi 4 was not powerful enough to run it alongside the other services (such as &lt;a href=&quot;https://github.com/dani-garcia/vaultwarden&quot; target=&quot;_blank&quot;&gt; Vaultwarden  &lt;/a&gt; and &lt;a href=&quot;https://www.home-assistant.io/&quot; target=&quot;_blank&quot;&gt; Home Assistant  &lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I had already been using Tailscale to access my Vaultwarden and Home Assistant instances, but I had just been running them on different ports on the same machine, Vaultwarden in particular also required a &lt;a href=&quot;https://caddyserver.com/&quot; target=&quot;_blank&quot;&gt; Caddy  &lt;/a&gt; reverse proxy to handle the TLS certificates. As part of this upgrade I aimed to make my setup a bit neater and instead of routing everything through different ports, I wanted to use subdomains for each service.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://traefik.io/&quot; target=&quot;_blank&quot;&gt; Traefik  &lt;/a&gt; was not strictly necessary as a choice for this, as Caddy should be able to handle this as well, but I was curious about it and wanted to try it out. It also seemed to have good integration with &lt;a href=&quot;https://www.docker.com/&quot; target=&quot;_blank&quot;&gt; Docker  &lt;/a&gt; and Tailscale built in.&lt;/p&gt;
&lt;p&gt;I ended up upgrading to a &lt;a href=&quot;https://www.bee-link.com/products/beelink-mini-s12-pro-n100&quot; target=&quot;_blank&quot;&gt; Beelink S12  &lt;/a&gt; as I wanted something small and quiet, but powerful enough to run multiple services. It didn’t need to be a complete powerhouse, but I just didn’t want to have to worry about it for a bit at least. And the price was just right to not break the bank. I ended up ditching the default Windows install and just went with &lt;a href=&quot;https://ubuntu.com/download/server&quot; target=&quot;_blank&quot;&gt; Ubuntu Server  &lt;/a&gt; as this is what I was most comfortable with.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;The nitty gritty&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In theory the setup is quite simple, install Tailscale, Traefik and Docker, then configure Traefik to route requests to the correct Docker containers based on the subdomain. Then use the built-in &lt;a href=&quot;https://doc.traefik.io/traefik/https/tailscale/&quot; target=&quot;_blank&quot;&gt; Tailscale resolver  &lt;/a&gt; in Traefik to handle the certificates for me automagically.&lt;/p&gt;
&lt;p&gt;In practice however this is not quite as simple. I assume most people who have used Tailscale are aware of this but Tailscale does not support multiple subdomains per device. This means assigning uniquely routable subdomains to each service is not possible. One option would be to use a single subdomain and then route requests based on the path, but while this is more recently possible &lt;a href=&quot;https://github.com/dani-garcia/vaultwarden/wiki/Using-an-alternate-base-dir&quot; target=&quot;_blank&quot;&gt; with Vaultwarden  &lt;/a&gt; it is not possible &lt;a href=&quot;https://github.com/home-assistant/core/issues/21113&quot; target=&quot;_blank&quot;&gt; with Home Assistant  &lt;/a&gt;. It also apparently can cause all kinds of issues with WebSockets and other parts of the services that expect them to be on their own subdomain.&lt;/p&gt;
&lt;p&gt;Instead after some searching around and reading through the Traefik documentation I realised I could just route DNS requests to subdomains of my choosing to my Tailscale network, then sign those subdomains using &lt;a href=&quot;https://letsencrypt.org/&quot; target=&quot;_blank&quot;&gt; Let&apos;s Encrypt  &lt;/a&gt;. I will be using AWS Route53 for this, but this video shows how it can be achieve with Cloudflare as well: &lt;a href=&quot;https://www.youtube.com/watch?v=Vt4PDUXB_fg&quot; target=&quot;_blank&quot;&gt; https://www.youtube.com/watch?v=Vt4PDUXB_fg  &lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I will redact my actual setup in this example, let’s just assume:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Your tailnet is &lt;code&gt;tail-net.ts.net&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Your internal Tailscale device IP is &lt;code&gt;100.XXX.YYY.ZZZ&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;You have a domain &lt;code&gt;example.com&lt;/code&gt; that you own and can configure DNS for.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first step is to set up your desired DNS subdomains in your DNS provider to point at your tailnet device address. To allow all services to be forwarded to Tailscale I set up a wildcard A record for &lt;code&gt;*.athome.example.com&lt;/code&gt; pointing to &lt;code&gt;100.XXX.YYY.ZZZ&lt;/code&gt;. This way any subdomain request will be routed to the Tailscale device. Any subdomain works here, I just chose the namespace &lt;code&gt;athome&lt;/code&gt; to free up the other subdomains for other purposes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;EDIT:&lt;/strong&gt; This section previously referred to using a CNAME record to point the subdomains to the Tailscale device instead of it’s IP. However, it turns out there is an &lt;a href=&quot;https://github.com/tailscale/tailscale/issues/7650&quot; target=&quot;_blank&quot;&gt; issue with routing on Windows and Android devices  &lt;/a&gt; when doing so. Therefore, this is amended to using an A record now.&lt;/p&gt;
&lt;p&gt;Then Traefik can be configured to handle the requests for these subdomains and route them to the correct Docker containers based on the subdomain. The configuration is quite straightforward, you just need to set up the entry points, providers and the certificates resolvers.&lt;/p&gt;
&lt;div&gt; &lt;div&gt;  Traefik - Config  Traefik - Docker Compose  Route53 IAM Policy  &lt;/div&gt; &lt;div&gt; &lt;div&gt; &lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# traefik.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;entryPoints&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  http&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    address&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;:80&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;https&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  address&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;:443&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;providers&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  docker&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    endpoint&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;unix:///var/run/docker.sock&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    exposedByDefault&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;certificatesResolvers&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  myresolver&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    acme&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      storage&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;/letsencrypt/acme.json&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      email&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;email@example.com&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      dnsChallenge&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        provider&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;route53&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        resolvers&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - &lt;/span&gt;&lt;span&gt;&quot;8.8.8.8&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;br /&gt;&lt;p&gt;The Traefik configuration is quite standard, a couple things to note:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;resolvers&lt;/code&gt; are set to use Google’s DNS servers, this is required to properly resolve the DNS challenges while having configured the CNAME redirect.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;provider&lt;/code&gt; is set to &lt;code&gt;route53&lt;/code&gt;, this is the AWS Route53 DNS provider. You can use any &lt;a href=&quot;https://doc.traefik.io/traefik/https/acme/#providers&quot; target=&quot;_blank&quot;&gt; supported DNS provider  &lt;/a&gt; here, but I chose Route53 as I already use it for my domain.&lt;/li&gt;
&lt;li&gt;Using the &lt;code&gt;docker&lt;/code&gt; provider to automatically route Docker containers that are started within the Traefik network.&lt;/li&gt;
&lt;/ul&gt; &lt;/div&gt;&lt;div&gt; &lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# docker-compose.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  traefik&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;traefik:latest&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    restart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;unless-stopped&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    container_name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;traefik&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ports&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;80:80&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;443:443&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;8080:8080&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    volumes&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;./traefik.yml:/etc/traefik/traefik.yml:ro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;/var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock:ro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;./letsencrypt:/letsencrypt&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;traefik&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    environment&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;AWS_ACCESS_KEY_ID=XXXX&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;AWS_SECRET_ACCESS_KEY=XXXX&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;AWS_HOSTED_ZONE_ID=XXXX&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;AWS_REGION=XXXX&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;LEGO_DISABLE_CNAME_SUPPORT=true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  traefik&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    external&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;br /&gt;&lt;p&gt;The Docker Compose configuration is also quite standard, a couple things to note:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;The AWS specific credentials (&lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;, &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, &lt;code&gt;AWS_HOSTED_ZONE_ID&lt;/code&gt;, &lt;code&gt;AWS_REGION&lt;/code&gt;) only need to be supplied if you are using Route53&lt;/li&gt;
&lt;li&gt;&lt;s&gt;The &lt;code&gt;LEGO_DISABLE_CNAME_SUPPORT&lt;/code&gt; environment variable is set to &lt;code&gt;true&lt;/code&gt;. Basically, because we are forwarding all subdomains to the Tailscale device, by default Traefik (or rather it’s dependency LEGO) will start following the CNAME records to set up the certificates. However, this will cause it to follow the CNAME to the Tailscale device and then try to set up the certificate there, which will fail as your tailnet address is not routable from the public internet.&lt;/s&gt; This is no longer necessary when using an A instead of a CNAME record for the subdomains.&lt;/li&gt;
&lt;li&gt;I use an external Docker network called &lt;code&gt;traefik&lt;/code&gt; to allow Traefik to communicate with the other containers. You can create this network with the following command: &lt;code&gt;docker network create traefik&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt; &lt;/div&gt;&lt;div&gt; &lt;p&gt;&lt;strong&gt;This policy is only relevant if you are using AWS Route53 as your DNS provider.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;It allows Traefik to manage the DNS records for the subdomains you want to use. You will need to create an IAM user with this policy and provide the credentials in the Docker Compose file.&lt;/p&gt;&lt;p&gt;Make sure to replace &lt;code&gt;&amp;lt;YOUR_HOSTED_ZONE_ID&amp;gt;&lt;/code&gt; with your actual Route53 hosted zone ID.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;Version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;2012-10-17&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;Statement&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Sid&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;VisualEditor0&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Effect&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;Allow&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Action&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;route53:GetChange&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;route53:ListHostedZonesByName&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            ],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Resource&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;*&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Sid&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;VisualEditor1&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Effect&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;Allow&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Action&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;route53:ChangeResourceRecordSets&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &quot;route53:ListResourceRecordSets&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            ],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &quot;Resource&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;arn:aws:route53:::hostedzone/&amp;lt;YOUR_HOSTED_ZONE_ID&amp;gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; 
&lt;p&gt;With this configuration in place, Traefik will automatically handle the DNS challenges for the subdomains you have set up in your DNS provider. It will request certificates from Let’s Encrypt and store them in the &lt;code&gt;/letsencrypt/acme.json&lt;/code&gt; file. This file should be mounted as a volume in the Traefik container to persist the certificates across restarts, otherwise you may have issues with Let’s Encrypt rate limits.&lt;/p&gt;
&lt;p&gt;Below are example Docker Compose files that I use on my instances. Personally, I prefer to disable Traefik’s automatic service detection by setting &lt;code&gt;exposedByDefault: false&lt;/code&gt;, which means I need to explicitly add &lt;code&gt;traefik.enable=true&lt;/code&gt; to the labels of any service I want to expose.&lt;/p&gt;
&lt;p&gt;Since Traefik can route traffic to containers within its network, it’s not necessary to expose service ports to the host machine. However, if a service runs on a port other than &lt;code&gt;:80&lt;/code&gt;, you must specify the port in the Traefik labels, as Traefik cannot detect it automatically. For example, Home Assistant runs on port &lt;code&gt;8123&lt;/code&gt; by default, so you need to add the label &lt;code&gt;traefik.http.services.homeassistant.loadbalancer.server.port=8123&lt;/code&gt; (see the full config below). If you bind multiple networks to a service – e.g, if you have other services in the same compose file that you don’t want to expose to the Traefik network – you can use the &lt;code&gt;traefik.docker.network&lt;/code&gt; label to specify which network Traefik should use to route traffic to the service. So, in my case I would add &lt;code&gt;traefik.docker.network=traefik&lt;/code&gt; to every service that I specify more than one network for.&lt;/p&gt;
&lt;div&gt; &lt;div&gt;  Vaultwarden  Home Assistant  &lt;/div&gt; &lt;div&gt; &lt;div&gt; &lt;p&gt;Setting up a service is straightforward:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;Configure a rule for the domain you want to use for Vaultwarden, e.g. &lt;code&gt;vaultwarden.athome.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set the TLS resolver to the one you configured in Traefik, e.g. &lt;code&gt;myresolver&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set the &lt;code&gt;DOMAIN&lt;/code&gt; environment variable in Vaultwarden to match the domain in your Traefik rule.&lt;/li&gt;
&lt;/ol&gt;&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# docker-compose.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  vaultwarden&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;vaultwarden/server:latest&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    restart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;always&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    container_name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;vaultwarden&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    volumes&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;./vw-data:/data&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    environment&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;DOMAIN=https://vaultwarden.athome.example.com&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;ADMIN_TOKEN=XXXXXXXX&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;WEBSOCKET_ENABLED=true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;INVITATIONS_ALLOWED=false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;SIGNUPS_ALLOWED=false&lt;/span&gt;&lt;span&gt; # Set to true if resetting&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    labels&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.enable=true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.http.routers.vaultwarden.rule=Host(`vaultwarden.athome.example.com`)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.http.routers.vaultwarden.tls.certresolver=myresolver&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;traefik&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  traefik&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    external&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt; &lt;/div&gt;&lt;div&gt; &lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# docker-compose.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  homeassistant&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    container_name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;homeassistant&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;ghcr.io/home-assistant/home-assistant:stable&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    volumes&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;./ha-data/config:/config&lt;/span&gt;&lt;span&gt; # Retain home assistant config&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    restart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;unless-stopped&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    privileged&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;traefik&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    labels&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.enable=true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.http.routers.homeassistant.rule=Host(`homeassistant.athome.example.com`)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.http.routers.homeassistant.tls.certresolver=myresolver&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;&quot;traefik.http.services.homeassistant.loadbalancer.server.port=8123&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  traefik&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    external&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; 
&lt;p&gt;Once the services are ready and started, Traefik will automatically pick up any changes using the docker provider. You can now visit your services using the subdomains you configured, e.g. &lt;code&gt;https://vaultwarden.athome.example.com&lt;/code&gt; and &lt;code&gt;https://homeassistant.athome.example.com&lt;/code&gt;. Just as a hint, it can take a few minutes sometimes for the DNS challenge to complete and for the certificates to be issued, so don’t panic if it doesn’t work immediately.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This setup now allows me to finally separate my personal services into their own subdomains. I wish it was easier to achieve using &lt;em&gt;just&lt;/em&gt; Tailscale but I am fine with the relatively small compromise of having to route the requests through a DNS provider.&lt;/p&gt;
&lt;p&gt;Maybe this helps someone else who is trying to achieve a similar setup. If you have any questions or suggestions, feel free to reach out to me on &lt;a href=&quot;https://fosstodon.org/@itsbjoern&quot; target=&quot;_blank&quot;&gt; Mastodon  &lt;/a&gt; or &lt;a href=&quot;https://bsky.app/profile/bjoernf&quot; target=&quot;_blank&quot;&gt; Bluesky  &lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Building my own (virtual) bookshelf</title><description>The Kindle is great it presents me with one small but annoying issue. Unlike with collecting real, physical books you never get the satisfaction of seeing your collection grow.</description><pubDate>Sun, 09 Mar 2025 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A couple of years ago I made a snap decision to buy a Kindle and it is now the main reason for me getting back into reading regularly. I think it is a fantastic device that lets me read any book I want, be it at home with the light turned off or on the go. I genuinely cannot recommend it enough.&lt;/p&gt;
&lt;p&gt;All that praise out of the way, while the Kindle is great it presents me with one small but annoying issue. Unlike with collecting real, physical books you never get the satisfaction of seeing your collection grow. All your books are solely contained on your small electronic screen. To offset this a little bit I already like logging books I have read (or plan to read) on &lt;a href=&quot;https://www.goodreads.com/&quot; target=&quot;_blank&quot;&gt; Goodreads  &lt;/a&gt;. For that purpose, I think it is a great platform and it lets me keep track of what I have read and what I want to read next. Comparatively though that is really not the same as having a physical bookshelf.&lt;/p&gt;
&lt;p&gt;For a while now I had this idea that it would be cool to somehow pass this list of books into a 3D renderer to at least get some visual sense of what my collection would look like. Recently I finally sat down and actually considered what I would like this to look like.&lt;/p&gt;
&lt;p&gt;My goals were:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A 3D representation of the books I had read&lt;/li&gt;
&lt;li&gt;Ideally it should just be able to load a list of books from a JSON/XML file&lt;/li&gt;
&lt;li&gt;It should be interactive, so I can click on a book and get more information about it&lt;/li&gt;
&lt;li&gt;I wanted it to be embeddable so that other people may be able to also use it in the future.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Upon starting the investigation for this project I was immediately disheartened when I saw that Goodreads does &lt;a href=&quot;https://help.goodreads.com/s/article/Does-Goodreads-support-the-use-of-APIs&quot; target=&quot;_blank&quot;&gt; not offer a public API (anymore)  &lt;/a&gt;. I browsed their website for a bit and considered scraping for a while, but I really did want this project to be mostly standalone, without regular needs to configure a scraping service. I noticed that, almost hidden on the bottom of the page, every list actually has an RSS feed.&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/goodreads_rss.CREVT8gs_1a1DGf.webp&quot; alt=&quot;Goodreads RSS feed&quot; loading=&quot;lazy&quot; width=&quot;150&quot; height=&quot;137&quot; /&gt;
&lt;p&gt;This was perfect, the RSS feed not only included all the necessary data such as titles and links, it also has links to nice thumbnails of the book covers. That meant I was able to just parse the list of books from that RSS feed without any need for scraping or otherwise.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;To render the bookshelf and books themselves I am using &lt;a href=&quot;https://threejs.org/&quot; target=&quot;_blank&quot;&gt; Three.js  &lt;/a&gt;. Using Three.js allowed me to relatively quickly iterate on a simple 3D scene that served me well. It also comes with some nice drag and zoom controls out of the box.&lt;/p&gt;
&lt;p&gt;So below you can see the result: A virtual bookshelf filled with the books I have read since I started using Goodreads. You can move the scene via drag and zoom in and out with the mouse wheel. Holding shift or control while dragging will also move the whole scene to get a better view. Hovering over a book shows a bit of extra information in the sidebar, and by clicking it you get a little highlight on the current book.&lt;/p&gt;
&lt;div&gt;&lt;div&gt;&lt;/div&gt; &lt;div&gt; &lt;div&gt; Field of view &lt;br /&gt;  &lt;/div&gt; &lt;div&gt; Books per shelf &lt;br /&gt;  &lt;/div&gt; &lt;div&gt; Divider every &lt;br /&gt;  &lt;/div&gt; &lt;div&gt; Horizontal rotation &lt;br /&gt;  &lt;/div&gt; &lt;div&gt; Vertical rotation &lt;br /&gt;  &lt;/div&gt; &lt;div&gt; Only Read &lt;br /&gt;  &lt;/div&gt; &lt;/div&gt; &lt;div&gt; &lt;div&gt;&lt;/div&gt; &lt;/div&gt; &lt;/div&gt;
&lt;p&gt;You can find a full-screen version here &lt;a href=&quot;/fun/bookshelf&quot;&gt; here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are also a few tweaks that can be configured within the canvas itself, such as the number of books per shelf, the count of dividers, or the initial angle when viewing the scene.&lt;/p&gt;
&lt;p&gt;These tweaks are also configurable via the URL, so when embedding it yourself (see below) you can pass in your own parameters to adjust the scene to your liking.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;booksPerShelf&lt;/code&gt; - The amount of books per shelf&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dividerEvery&lt;/code&gt; - How many books should be separated by a divider&lt;/li&gt;
&lt;li&gt;&lt;code&gt;horiRotation&lt;/code&gt; - The initial horizontal rotation of the scene (in degrees)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vertRotation&lt;/code&gt; - The initial vertical rotation of the scene (in degrees)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;onlyRead&lt;/code&gt; - Only show books that have been marked read, 1 for true, 0 for false  (default: 1)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; - The URL to the RSS feed (default: the one from my Goodreads)&lt;/li&gt;
&lt;/ul&gt;
&lt;img src=&quot;https://bjoernf.com/static/editable.DXrPaiL0_XByfP.webp&quot; alt=&quot;Editable version&quot; loading=&quot;lazy&quot; width=&quot;2092&quot; height=&quot;1636&quot; /&gt;
&lt;a href=&quot;/fun/bookshelf?edit&quot;&gt; You can check out the editable version here&lt;/a&gt;
&lt;p&gt;Overall, I am quite happy with the result. It was a fun little project that I could finish in a few days and it was nice to see the books I have read in a different light. I hope you enjoy it too.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;P.S: The site is set up so that you can embed it as an iframe with your set of books. To do so you will have to host your own Goodreads &lt;code&gt;.xml&lt;/code&gt; because the iframe will have to fetch the data. Goodreads does obviously not allow wildcard CORS requests, so you will need to host (or forward) the file yourself and configure a CORS origin for my site &lt;code&gt;Access-Control-Allow-Origin: &quot;bjoernf.com&quot;&lt;/code&gt;. The iframe will then load and parse your own list.&lt;/p&gt;
&lt;p&gt;An example embed would be:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;iframe&lt;/span&gt;&lt;span&gt; src&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;https://bjoernf.com/fun/bookshelf?url=https://your-own-xml-url.com&quot;&lt;/span&gt;&lt;span&gt; width&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;100%&quot;&lt;/span&gt;&lt;span&gt; height&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;500px&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;iframe&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;</content:encoded></item><item><title>Pdf Mini: Images with transparency</title><description>I have released a first version of my new project https://pdfblaze.com. It’s an ultrafast, convenient way to generate PDFs by using custom templates and dynamic values.</description><pubDate>Tue, 19 Nov 2024 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I have released a first version of my new project &lt;a href=&quot;https://pdfblaze.com&quot; target=&quot;_blank&quot;&gt; https://pdfblaze.com  &lt;/a&gt;. It’s an ultrafast, convenient way to generate PDFs by using custom templates and dynamic values.&lt;/p&gt;
&lt;p&gt;As part of creating this application I had to learn a lot about the PDF and TrueType font specifications. Some of the information I needed was quite arduous to find, to say the least.. So, I want to make a mini series of blog posts where I intend to tackle one specific issue I had encountered in each post.&lt;/p&gt;
&lt;p&gt;Without further ado, let’s get into it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Images with transparency in PDFs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The long and short of it: the main issue with transparency in images is that the PDF specification does not really consider the alpha channel part of the pixel data itself. Rather we need to separate the RGB pixel data from the alpha data and then overlay them using an &lt;code&gt;SMask&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To explain this in more detail the best I can do is show you an example of how to specify an image with such an alpha mask. A regular image will usually be described by an &lt;code&gt;XObject&lt;/code&gt; dictionary something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;1 0 obj &amp;lt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Type /XObject&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Subtype /Image&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Width 3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Height 3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/ColorSpace /DeviceRGB&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/BitsPerComponent 8&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Filter [ /ASCIIHexDecode ]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;stream&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ff0000 00ff00 0000ff 0000ff ff0000 00ff00 ff0000 0000ff ff0000&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;endstream&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(note: I leave out some properties like &lt;code&gt;\Length&lt;/code&gt; in these examples. While PDF viewers are usually able with recovering without them, I’d not recommend omitting them and stick to adding any fields the specification requires)&lt;/p&gt;
&lt;p&gt;This example shows rendering a simple 3x3 pixel image where we defined that every RGB value in the data stream has 8 bits of freedom, as indicated by the &lt;code&gt;DeviceRGB&lt;/code&gt; colour space and 8 &lt;code&gt;BitsPerComponent&lt;/code&gt;. This means that the trailing data stream of the object is simply the literal pixel values encoded as a hex string (depending on the filter but here &lt;code&gt;ASCIIHexDecode&lt;/code&gt; means just hex). In this case we have 3x3 = 9 pixel values, the resulting colour matrix is encoded left-to-right top-to-bottom as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;red    green  blue&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;blue   red    green&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;red    blue   red&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting image when printed onto a PDF looks like this:&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/1.DBZv804E_ZDHhpM.webp&quot; alt=&quot;Image without transparency&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;199&quot; /&gt;
&lt;p&gt;Note: I rendered the image larger than a 3x3 grid so the colours interpolate into each other during upscaling by default, but this wouldn’t normally happen for regular images rendered at their actual size. In most case this would of course be a much larger image, usually encoded with a filter that offers way more compression.&lt;/p&gt;
&lt;p&gt;Now, if we want to add transparency to this image all we actually need to do is define a separate &lt;code&gt;XObject&lt;/code&gt; as an &lt;code&gt;SMask&lt;/code&gt; for our original image.&lt;/p&gt;
&lt;p&gt;This &lt;code&gt;SMask&lt;/code&gt; should follow the same structure (i.e., the same height and width definitions) but since we are only defining an alpha mask we only need the &lt;code&gt;DeviceGray&lt;/code&gt; colour space instead of &lt;code&gt;DeviceRGB&lt;/code&gt;. This is because we only need a single byte per alpha value instead of 3. According to this, such an &lt;code&gt;XObject&lt;/code&gt; would be defined like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;2 0 obj&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Type /XObject&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Subtype /Image&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Width 3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Height 3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/ColorSpace /DeviceGray&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/BitsPerComponent 8&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Filter [ /ASCIIHexDecode ]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;stream&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ff ff ff 66 66 66 00 00 00&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;endstream&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;endobj&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And of course don’t forget to amend the previous object to include the SMask&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;1 0 obj&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Type /XObject&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/Subtype /Image&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/SMask 2 0 R&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[...]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting image will have combined the mask and the pixels for this resulting image:&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/2._EMNLtco_2ika4r.webp&quot; alt=&quot;Image with transparency&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;200&quot; /&gt;
&lt;p&gt;As you can see there isn’t actually much to it, all you need to do is separate your RGB colour channel from the alpha channel and then define them separately in another XObject. However, if you (like me) have jumped into the full PDF specification you may know it’s sometimes difficult to parse these details from it.&lt;/p&gt;
&lt;p&gt;And that is all for this post, enjoy your images with transparency!&lt;/p&gt;</content:encoded></item><item><title>TanStack Router SPA co-located in Astro</title><description>I was setting up a site that primarily serves a front-page plus documentation, where most parts are static and I wanted to fully generate them ahead of time to serve from a bucket. Astro makes this very easy, and also enables me to create and iterate</description><pubDate>Sat, 17 Aug 2024 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was setting up a site that primarily serves a front-page plus documentation, where most parts are static and I wanted to fully generate them ahead of time to serve from a bucket. Astro makes this very easy, and also enables me to create and iterate on content for documentation very quickly. Furthermore, it also has the nice benefit being able to weave in interactive components on the fly if needed.&lt;/p&gt;
&lt;p&gt;The docs in question are fully generated from markdown, but I like to have a few interactive examples so that users can build a better understanding of features. My use case is basically just a complex JSON configuration in a single object, not multi-file setups so users. It is very useful to have some visual feedback to play around with, but having to jump into a code sandbox for testing any change seemed overkill.&lt;/p&gt;
&lt;p&gt;To create this, Astro reads my &lt;a href=&quot;https://docs.astro.build/en/guides/integrations-guide/mdx/&quot; target=&quot;_blank&quot;&gt; .mdx templates  &lt;/a&gt; which includes embedded React components. Via &lt;a href=&quot;https://docs.astro.build/en/concepts/islands/&quot; target=&quot;_blank&quot;&gt; Astro Islands  &lt;/a&gt; I can serve this very efficiently and only have to load the components when needed.&lt;/p&gt;
&lt;p&gt;However, to actually use the platform I have a fully interactive part which acts as a dashboard to the user. Here they can configure their account specific settings, upload files and do whatever needs to be done. There is a lot of moving parts, so I opted to build it as an SPA instead of relying on Astro. This comes with so many nice ease-of-use features and reactivity plus the package ecosystem of React. It just made sense to me for building such a dashboard.&lt;/p&gt;
&lt;p&gt;I wanted to use &lt;a href=&quot;https://tanstack.com/router/latest&quot; target=&quot;_blank&quot;&gt; TanStack Router  &lt;/a&gt; as the navigation for this portion and while their documentation is extremely well written and easy to follow it is more meant for a “classic” Vite setup. There is no official integration setup, but it was still fairly easy to configure in the end.&lt;/p&gt;
&lt;p&gt;My file tree layout is as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;./&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|- astro.config.mjs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|- src/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |- layout/                  &amp;lt;  These are the regular Astro folders,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |- content/                 &amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |- pages/                   &amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- index.astro           &amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- dashboard&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |  |- [...page].astro    &amp;lt; This Astro page imports the React dashboard from&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |                             from src/dashboard/main&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |- components/              &amp;lt;  Folders for shared (react) components,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- astro/                &amp;lt;  they can be imported in Astro or the Dashboard&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- react/                &amp;lt;  code.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |- dashboard/               &amp;lt;  Dashboard code, here I can set up&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- main.tsx              &amp;lt;  @tanstack/react-router files as&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- routes/               &amp;lt;  in a regular project.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |  |- __root.tsx         &amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |  |- dashboard/         &amp;lt;  Matching the route `/dashboard` of the Astro page&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |  |  |- index.lazy.tsx  &amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|  |  |- routeTree.gen.ts      &amp;lt;  Auto-generated by @tanstack/router-plugin/vite&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This layout lets me to co-locate Astro layout and components with the React dashboard. It is super useful because it allows me to import shared React components in both areas.&lt;/p&gt;
&lt;p&gt;To generate the &lt;code&gt;routeTree.gen.ts&lt;/code&gt; that is required for TanStack Router, the internal Vite bundler of Astro needs to be made aware of the generator plugin. I use the following Astro config:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// astro.config.mjs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineConfig } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &apos;astro/config&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; react &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@astrojs/react&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; mdx &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@astrojs/mdx&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { TanStackRouterVite } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &apos;@tanstack/router-plugin/vite&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// https://astro.build/config&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; default&lt;/span&gt;&lt;span&gt; defineConfig&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  integrations: [&lt;/span&gt;&lt;span&gt;react&lt;/span&gt;&lt;span&gt;(), &lt;/span&gt;&lt;span&gt;mdx&lt;/span&gt;&lt;span&gt;()],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  vite: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    plugins: [&lt;/span&gt;&lt;span&gt;TanStackRouterVite&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      routesDirectory: &lt;/span&gt;&lt;span&gt;&quot;./src/dashboard/routes&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      generatedRouteTree: &lt;/span&gt;&lt;span&gt;&quot;./src/dashboard/routeTree.gen.ts&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      routeFileIgnorePrefix: &lt;/span&gt;&lt;span&gt;&quot;-&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      quoteStyle: &lt;/span&gt;&lt;span&gt;&quot;double&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    })]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Astro makes it possible to append to Vite plugins by exposing the vite key in its configuration. By setting the &lt;code&gt;routerDirectory&lt;/code&gt; and &lt;code&gt;generatedRouteTree&lt;/code&gt; params in the &lt;code&gt;TanStackRouterVite&lt;/code&gt; options it is possible to point the route generation at the correct folder. I had no luck using the &lt;code&gt;tsr.config.json&lt;/code&gt; as referenced in the documentation, but passing the options directly like so seems to work fine. Just make sure to &lt;code&gt;npm install @tanstack/router-plugin/vite&lt;/code&gt; for this to work.&lt;/p&gt;
&lt;p&gt;The following two files are then practically the glue to import the actual dashboard into Astro and render it into the page layout. Astro needs to know which paths exist to generate the proper SSG routes. This can be accomplished by parsing the &lt;code&gt;routeTree&lt;/code&gt; as generated by TanStack Router and populating &lt;code&gt;getStaticPaths&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// src/pages/dashboard/[...page].astro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Layout &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;../layouts/Layout.astro&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { Dashboard &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; ReactDashboard } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;../../dashboard/main.tsx&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt; Route } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@tanstack/react-router&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { routeTree } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;../../dashboard/routeTree.gen.ts&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; async&lt;/span&gt;&lt;span&gt; function&lt;/span&gt;&lt;span&gt; getStaticPaths&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; mapChildren&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;route&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Route&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; any&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;route?.options) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      return&lt;/span&gt;&lt;span&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      route.options,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ...&lt;/span&gt;&lt;span&gt;Object.&lt;/span&gt;&lt;span&gt;values&lt;/span&gt;&lt;span&gt;(route?.children &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; []).&lt;/span&gt;&lt;span&gt;flatMap&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;child&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        mapChildren&lt;/span&gt;&lt;span&gt;(child)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; asMapped&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; mapChildren&lt;/span&gt;&lt;span&gt;(routeTree &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; unknown&lt;/span&gt;&lt;span&gt; as&lt;/span&gt;&lt;span&gt; Route&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; asMapped.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;route&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;path&lt;/span&gt;&lt;span&gt;?:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt; }) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    params: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      // splice the prefixed `/dashboard` by the generator&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      path: route.path?.&lt;/span&gt;&lt;span&gt;split&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/&quot;&lt;/span&gt;&lt;span&gt;)?.&lt;/span&gt;&lt;span&gt;splice&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)?.&lt;/span&gt;&lt;span&gt;join&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/&quot;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;Layout&lt;/span&gt;&lt;span&gt; title&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span&gt;main&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;ReactDashboard&lt;/span&gt;&lt;span&gt; client:only&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;react&quot;&lt;/span&gt;&lt;span&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;/&lt;/span&gt;&lt;span&gt;main&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;Layout&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point I want to note that this strategy will generate a new file for EVERY route in your SPA router. In my case this is useful because it still allows me to dump the whole site into a bucket, but if you are running an &lt;a href=&quot;https://docs.astro.build/en/guides/server-side-rendering/#enable-on-demand-server-rendering&quot; target=&quot;_blank&quot;&gt; Astro adapter  &lt;/a&gt; to serve your site dynamically in &lt;a href=&quot;https://docs.astro.build/en/guides/server-side-rendering/#enable-on-demand-server-rendering&quot; target=&quot;_blank&quot;&gt; hybrid or server mode  &lt;/a&gt; then you might want to consider using &lt;code&gt;prefetch: false&lt;/code&gt; for just this page.&lt;/p&gt;
&lt;p&gt;Finally this is the React component which is the entry point for the dashboard:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// src/dashboard/main.tsx&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { RouterProvider, createRouter } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;@tanstack/react-router&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// Import the generated route tree&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { routeTree } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &quot;./routeTree.gen.ts&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// Create a new router instance&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; router&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; createRouter&lt;/span&gt;&lt;span&gt;({ routeTree })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// Register the router instance for type safety&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;declare&lt;/span&gt;&lt;span&gt; module&lt;/span&gt;&lt;span&gt; &quot;@tanstack/react-router&quot;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  interface&lt;/span&gt;&lt;span&gt; Register&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    router&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; typeof&lt;/span&gt;&lt;span&gt; router&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// Usually here we would render to the DOM, but since Astro will handle&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// this for us we are okay to just return the component&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; Dashboard&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; () &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; &amp;lt;&lt;/span&gt;&lt;span&gt;RouterProvider&lt;/span&gt;&lt;span&gt; router&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;router&lt;/span&gt;&lt;span&gt;} /&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As far as I can tell this setup is totally seamless and behaves exactly like you would expect. I am super impressed by both TanStack’s Router and Astro to just go hand-in-hand like this without any issues.&lt;/p&gt;</content:encoded></item><item><title>What’s a story point anyway</title><description>Recently I read an interesting post about story points in agile development titled „Story Point Revisited“ by Ron Jeffries. It made me think about how I feel about them and estimation processes for software development in general. A TL;DR recap</description><pubDate>Sun, 11 Aug 2024 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Recently I read an interesting post about story points in agile development titled &lt;a href=&quot;https://ronjeffries.com/articles/019-01ff/story-points/Index.html&quot; target=&quot;_blank&quot;&gt; „Story Point Revisited“  &lt;/a&gt; by Ron Jeffries. It made me think about how I feel about them and estimation processes for software development in general.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A TL;DR recap&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this article Jeffries critiques the use of story points as a misplaced proxy for just estimating time in the first place. According to him their original inception was driven by the fact that the „ideal“ time of implementation is often distorted by distractions and it led his team to the conclusion that the actual time of work needed often ended up being closer to three times of that ideal. By calling 3 days a „point“ they avoided confusion (what’s a „story day“?) and it was a reasonably straightforward solution when dealing with the problem of encoding difficulty, effort and complexity with the concept of time.&lt;/p&gt;
&lt;p&gt;If you are in any way involved with other developers you will have invariably have run into the crowd that absolutely deplores this kind of story point and anything vaguely connected to planning poker sessions. While I always generally accepted that some people have this position, I never quite understood it (or agreed with it for that matter). This post made me appreciate the potential issues with the system better and upon reflection it made me think of some of the key factors that in my opinion make or break story point estimations:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not measure time&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I know, I know. This one seemingly everyone talks about, and in actuality no one does. But I am being serious when I say, before you think of time, think about how difficult a change seems, how many components it touches, how much infrastructure or underlying systems need to change. What is the impact for the user? What are the risks? Are there any wait times, blockers, other team members involved? Any ticket may have dozens of such considerations and any of them may move the time needed to implement by hours or days. How many of those? Most of the time no one really knows and in my opinion this is where story points really shine.&lt;/p&gt;
&lt;p&gt;If you think of every single one of those decisions as a sliding scale, e.g. 0-5, and all your team members feel roughly the same about where a task lands on that scale for these decisions, then all of these complexities sum up into one number. I don’t want you to think about this too literally, don’t start pulling out a calculator in your scrum meetings, but rather think about it in a metaphorical sense. I never consider time directly as a factor when I estimate a ticket, the points I estimate will just naturally scale with the complexity of a ticket because of all the encoded decisions, and thus it will take more or less time to complete.&lt;/p&gt;
&lt;p&gt;After all, the estimation process is extremely useful as any initial estimation reveals a real insight into every developers thought process which ultimately determines an items difficulty. If there is large discrepancies in estimated points for a ticket here it’s a great opportunity to teach or learn about why a change may be more or less problematic than initially thought. In the future these gaps of estimation between team members will almost magically close by themselves, the team grows together and becomes way more “dialled-in” to all the potential factors a change might carry with it.&lt;/p&gt;
&lt;p&gt;There is a caveat to this, which is that it only works if quite literally time is not of the essence in terms of your development cycle. In particular I mean, the result of the work has to be more important than the time spent on it. It won’t work if your time worked is tracked against a paying client, instead of you developing a product for your own company where the feature or change is „just“ an inherent value add. If your project needs to be delivered by a certain time, or there is a particularly statistics driven culture around delivering workloads on time then this simply won’t work. If you need to measure time, measure time not story points.&lt;/p&gt;
&lt;p&gt;This leads into my next point:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No comparisons, EVER&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Given my previous philosophy on how I think about story point estimates, I think this one will come at no shock. It simply does not make sense to compare items when they have their story points estimated for vastly different reasons.&lt;/p&gt;
&lt;p&gt;Two tickets might have vaguely similar points, but one might be a deeply complex change in a core system, while the other might require long wait times because of trial and error runs in our CI. One or the other may be done considerably faster or slower than the other for should-have-known and/or completely unforeseeable reasons.&lt;/p&gt;
&lt;p&gt;Additionally, these points we estimate and assign are invariably a compression over a range. In my opinion this range can by definition not be well defined, the same amount of points can mean very different things if they happen to be on opposite ends of the “acceptable” range for their point estimate. For example, currently we pretty much only estimate 1, 2, 3, 5, or 8 points - anything larger than 8 is practically too large to be considered one unit of work and will be split into smaller tickets. This correlates roughly with “no ticket should take longer than one or two days work”. To somewhat break it down:&lt;/p&gt;
&lt;p&gt;1 can be a one line fix or any very small change.&lt;/p&gt;
&lt;p&gt;3 would be probably the most common medium workload that touches multiple files and vaguely changes or adds to a system without requiring a total deep dive.&lt;/p&gt;
&lt;p&gt;8 would be a very large and substantial change, requiring multiple rounds of reviews and rigorous testing.&lt;/p&gt;
&lt;p&gt;Now how does it make sense if eight 1-point typo fixes can equal one such substantial change? That’s the neat thing, it doesn’t! The estimation merely suggests how much effort will be funneled into implementing that one change, as such it’s completely independent of any other ticket in your backlog. It’s an estimation for that one workload, and that one workload alone.&lt;/p&gt;
&lt;p&gt;You might think that this could not possibly work and that our amount of worked points has to fluctuate wildly. My counterpoint to that would be this graph of weekly points delivered over a period of 6 months, it represents how an average of how many points any single developer would have delivered per week, scaled against the available capacity in the team (to account for leave, hirings, etc.)&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/new_points.C_ZcGJqT_ZaIFu6.webp&quot; alt=&quot;Weekly points delivered&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;310&quot; /&gt;
&lt;p&gt;While we do have variation, we keep a fairly consistent level of delivered work week to week. Importantly, we don‘t care about the actual week to week performance because - once again - we don’t compare. It can sometimes be useful to reflect on a particularly „good“ (or bad) week to investigate which tickets have caused such stark deviation. In turn that in itself can act as a great self-fuelling function to get a better feel as a team for what constitutes an easy or hard ticket, and perhaps even more importantly, why it does! It teaches us more about the work we are doing day to day.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The horror&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With all that being said, this brings us to the seemingly biggest issue with story points that I luckily never had to experience myself. Even just the prospect fills me with dread and therefore I believe I understand some of the issues people have with a point based system.&lt;/p&gt;
&lt;p&gt;-&amp;gt; Abusing point estimates for cross-team reports and performance metrics.&lt;/p&gt;
&lt;p&gt;This one is a real head scratcher to me, as I had never even considered that there are project managers who may look at story points and then compare the amount of completed points across teams. The mere thought of this seems horrifying at best to me. I have always worked in small companies, the size of developer teams never exceeding the amount of people where you could comfortably have a meeting with everyone in the same room. Even then, the amount of times people have disagreed how hard a ticket would be for one reason or another, and how seemingly arbitrary these reasons sometimes are… I simply cannot fathom how anyone would attend such a meeting and feel like there is any merit in drawing useful comparisons from those metrics.&lt;/p&gt;
&lt;p&gt;Do not compare, please.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To wrap up, all of this only represents my personal perspective and I cannot assert that all I propose in this post is the only real way to use story point estimations. Software development usually is a highly dynamic process and I imagine that some teams have found ways to work around the exact issues I preach to avoid here. Nevertheless, everything I have learned about this process myself, and the opinions and comments I have read make me believe my conclusions about estimation are generally applicable.&lt;/p&gt;
&lt;p&gt;Finally, I truly do believe that utilising story points for agile development cycles can work, but you need a very cohesive team, that has enough agency to deliver work outside of strict deadlines. If accurately tracking or reporting on spent development time and/or increasing a sense of velocity based on completed estimates are important goals to you, I would suggest avoiding story point estimation altogether.&lt;/p&gt;
&lt;p&gt;For my current team and frankly, all the teams I have worked in so far, story point estimation never felt like a chore but rather a useful tool. However, I do appreciate the perspective this review has given me.&lt;/p&gt;</content:encoded></item><item><title>Puppeteer in GitHub Actions</title><description>I couldn&apos;t find a straightforward way on how to accomplish launching headless puppeteer in GitHub Actions. This turned out to be surprisingly simple and does not require any third party actions or containers at all. It took me a while to find this bit </description><pubDate>Thu, 01 Aug 2024 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I couldn’t find a straightforward way on how to accomplish launching headless puppeteer in GitHub Actions. This turned out to be surprisingly simple and does not require any third party actions or containers at all. It took me a while to find this bit of information though and therefore I’m just leaving it here too, perhaps it will be useful to someone (even me) in the future.&lt;/p&gt;
&lt;p&gt;The trick is to just point the executable path to the correct location in the action. You can install the puppeteer package as you normally would and all you need to do then is point &lt;code&gt;executablePath&lt;/code&gt; to &lt;code&gt;usr/bin/google-chrome-stable&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;For reference, these are the full launch argument that which are currently working:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; browser&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; puppeteer.&lt;/span&gt;&lt;span&gt;launch&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  headless: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  defaultViewport: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  args: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;--ignore-certificate-errors&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;--no-sandbox&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;--disable-setuid-sandbox&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;--disable-accelerated-2d-canvas&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;--disable-gpu&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  executablePath: &lt;/span&gt;&lt;span&gt;&quot;/usr/bin/google-chrome-stable&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is quite possible that you won’t need all of the arguments specified above for it to continue working. The current working puppeteer version in the project is &lt;code&gt;puppeteer&quot;: &quot;22.14.0&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;And that’s all …&lt;/p&gt;</content:encoded></item><item><title>Generating images with sharp in dockerised Bun</title><description>Preface: as of writing this bun&apos;s latest released version is 1.0.12 and for sharp it is 0.</description><pubDate>Fri, 17 Nov 2023 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Preface: as of writing this bun’s latest released version is &lt;code&gt;1.0.12&lt;/code&gt; and for sharp it is &lt;code&gt;0.33.0-alpha.12&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;As can be seen by some of my previous posts on this blog, I love using this website itself to play around and familiarise myself with new tech. Before, I wrote about my own homebrewed SSR approach plus hydration. This was super fun to build, but wasn’t the most stable or easiest to maintain. I have recently switched to Astro which is a more mature version of the same idea with many nice additions and quality of life features. It also does not require react but supports JSX-style templating, very neat.&lt;/p&gt;
&lt;p&gt;But because I can’t help myself I decided to not go with an actual stable, regular node server and instead started tinkering with &lt;a href=&quot;https://bun.sh/&quot; target=&quot;_blank&quot;&gt; Bun  &lt;/a&gt;. In case you haven’t heard, Bun is an incredible new runtime/dependency manager developed by the extremely talented team at Oven. However, while it is impressive, in it’s current state it isn’t entirely without flaws.&lt;/p&gt;
&lt;p&gt;Back to the matter at hand: I have been working on exposing a custom endpoint which dynamically generates a social card image based on the current route. To generate and compose these images I use the &lt;a href=&quot;https://github.com/lovell/sharp&quot; target=&quot;_blank&quot;&gt; sharp  &lt;/a&gt; package. This is a well maintained image manipulation library with a very intuitive API. This works and looks great, and I thought it would be as simple as just pushing it to get it to work. Here is an example of what my &lt;a href=&quot;/blog/making-exercise-convenient&quot;&gt; previous post&lt;/a&gt; looks like when, for example, pasting a link to Facebook:&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/fb-preview.WH3vLo6d_wtAWH.webp&quot; alt=&quot;Facebook preview image&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;254&quot; /&gt;
&lt;p&gt;Unfortunately this wasn’t quite as easy as just adding the image generation part. While in my development environment everything works fine, there are some issues when setting it up in a docker container (which I use to host this website). At the moment bun breaks post-install scripts of dependencies, which sharp unfortunately relies upon, at least in the current stable release version &lt;code&gt;0.32.x&lt;/code&gt;. I found a &lt;a href=&quot;https://github.com/lovell/sharp/issues/3750#issuecomment-1751172728&quot; target=&quot;_blank&quot;&gt; GitHub issue  &lt;/a&gt; which referenced an “alpha” version of sharp which removed the reliance on it. And in fact, with &lt;code&gt;sharp@0.33.0-alpha.9&lt;/code&gt; this works (I tried upgrading to the current latest &lt;code&gt;-alpha.12&lt;/code&gt; too but this also seemingly breaks).&lt;/p&gt;
&lt;p&gt;Once I had figured this out, I was sure it everything else would just fall into place… nope, no luck - apparently the Docker image &lt;code&gt;oven/bun:1.0.12&lt;/code&gt; (which as of right now is &lt;strong&gt;latest&lt;/strong&gt;) is broken when installing my dependencies. I don’t know if this is the case for everyone or just for the particular set of dependencies in my repo, what I do know is that I had to revert to the previous version &lt;code&gt;oven/bun:1.0.11&lt;/code&gt; to get it working again.&lt;/p&gt;
&lt;p&gt;I was very happy when my build finally succeeded, the container spun up and the image generation worked! Well, until I realised that the bun docker image ships without any fonts pre-installed. So what I got was a lovely selection of boxes: ”☐☐☐☐☐☐☐☐☐☐☐”, while this is almost entertaining enough to keep as-is, I decided to at least try and solve the issue first. Luckily, this wasn’t too much of a problem, I now simply install fontconfig as part of my Docker build and then copy the fonts into my release image. This is required because sharp uses the resulting fontconfig file to resolve available fonts.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; oven/bun:1.0.11 &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; base&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; base &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; fonts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;RUN&lt;/span&gt;&lt;span&gt; apt-get update&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;RUN&lt;/span&gt;&lt;span&gt; apt-get -y install fontconfig fonts-roboto&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;RUN&lt;/span&gt;&lt;span&gt; fc-cache -fv&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[...]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; base &lt;/span&gt;&lt;span&gt;AS&lt;/span&gt;&lt;span&gt; release&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;COPY&lt;/span&gt;&lt;span&gt; --from=fonts /usr/share/fonts /usr/share/fonts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;COPY&lt;/span&gt;&lt;span&gt; --from=fonts /etc/fonts /etc/fonts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[...]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now my images finally render correctly, including text and everything. Probably these setup issues will be resolved soon (either in sharp and/or bun) but until then this is a helpful workaround.&lt;/p&gt;</content:encoded></item><item><title>Making exercise convenient</title><description>Everyone has some sort of system to keep themselves in shape, but keeping yourself in the habit of regular exercise can be just as rewarding and fun as it can be difficult, annoying or demanding. One realisation I have had about working out is that there is one thing above anything else that I value. That thing is convenience, for one </description><pubDate>Mon, 13 Nov 2023 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone has some sort of system to keep themselves in shape, but keeping yourself in the habit of regular exercise can be just as rewarding and fun as it can be difficult, annoying or demanding.&lt;/p&gt;
&lt;p&gt;One realisation I have had about working out is that there is one thing above anything else that I value. That thing is convenience, for one I love living close to my gym. It wasn’t really a consideration when I chose this place but being able to walk to my gym within less than 5 minutes is a huge motivator. I have some weights at home and there is a lot of exercises you can do at home anyway, but actually going to the gym just puts me into a flow state that I can’t achieve at home. Previously I’d still often just accept not having this simply because I didn’t actually want to make the trek to the gym. Now, there is rarely a point for me to work out at home, popping over is so easy that I just always end up doing it.&lt;/p&gt;
&lt;p&gt;Secondly, I went to the gym for the first time when I was ~16 years old (over 10 years ago now.. jeez) and since then I have been very on and off with actually going. Sometimes I wouldn’t go at all for a few years, for example when moving out for my undergrad etc. My relationship and expectations with exercising is therefore a bit all over the place. I have had friends and gone to work out with people on all ends of the spectrum who either went the straight up body builder route or quit almost straight away. Especially in youth there probably was a lot of not-so-great advice.&lt;/p&gt;
&lt;p&gt;For a long time my assumption with working out was that you should pretty much always go as hard as possible, and exert yourself to get the maximum out of the exercise you are doing. Build as much muscle as you can, any way possible. This rings especially true if you have a long travel time to maximise that initial time investment.&lt;/p&gt;
&lt;p&gt;What I used to do is fairly long workouts in the range of 90-120+ minutes. But what that exertion did was that it didn’t really leave me satisfied to break myself down like that. Instead of feeling good about the work, I was super tired and sometimes even feeling unwell afterwards. Head spinning or slight feeling of sickness also wasn’t uncommon. And at some point I just realised I don’t have to do that, I can do short and intense workouts and then just enjoy the rest of my day. Nowadays I much much prefer shorter, more frequent workouts. I often go for just about 45 minute sessions now.&lt;/p&gt;
&lt;p&gt;This may not be optimal, to be honest, I don’t care. I use the gym as a means to stay relatively fit, and also as a way to check out of my day-to-day and just lift some weights. Some days this also really bolsters my mental health. I don’t chase records, I don’t ego lift, I do my best to stay in form and keep myself from getting injured. While some might find that boring, it really works for me. With that come other non-optimal things I do, I don’t drink protein shakes or take any supplements. Through my cooking I maintain a pretty balanced diet almost automatically and at that point whatever nutrients or protein I get from that is what I get.&lt;/p&gt;
&lt;p&gt;I think this is where my entire viewpoint on exercise weaves together. I want working out / the gym to be a positive, healthy addition to my life. At no point do I want to change my habits (wants or needs) to adjust for this. I live my life and the gym gets added on simply as an improvement, something that doesn’t require maintenance or thought. I don’t mean this to be advice, or preachy in any way. I fully understand and am happy for people who do choose to make it a bigger part of their lives. However, I often felt like I almost had to hide that I don‘t consider it like that. Like as if when you don’t take it seriously it isn’t a „valid“ way to exercise.&lt;/p&gt;
&lt;p&gt;It took me a long time to figure out that the best way to do it is the way that makes me personally happy, and which fits best with how I want to live my life. As I don’t see this sentiment out there a lot I wanted to share it as an anecdote.&lt;/p&gt;</content:encoded></item><item><title>Breaking the rules (sometimes)</title><description>A while ago I came across this idea in music production, this is paraphrased but the idea is: „It doesn’t matter if what you’re doing is wrong, if you like it“. I have seen this echoed multiple times and often it comes with the addendum „understand why it’s </description><pubDate>Sat, 07 Oct 2023 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A while ago I came across this idea in music production, this is paraphrased but the idea is: „It doesn’t matter if what you’re doing is wrong, if you like it“. I have seen this echoed multiple times and often it comes with the addendum „understand why it’s wrong and then do it anyways“. Only then will you distinguish yourself from the pack and make something truly special and interesting.&lt;/p&gt;
&lt;p&gt;I thought about this mindset a lot recently. Let me make you consider this: Everyone starts life with roughly the same ways in terms of education, from a young age, as kids, we get taught how things are done, how they are supposed to be done. As we grow older we realise that the „olden“ ways might not be perfect, that our teachers had flaws and so do their preachings. However, oftentimes within them there are valuable lessons, we understand why things are done a certain way or how something works and then we adapt it so that it works better for us.&lt;/p&gt;
&lt;p&gt;This also extends to later in life. I do think most of us hit this moment in our careers where we think we know exactly what’s going on. We have finally cracked the code, we may be aware that there is more to learn but generally we are sure we know the extent of what there is to know and only little is left to master. A certain image comes to mind.&lt;/p&gt;
&lt;img src=&quot;https://bjoernf.com/static/slope.Ds7FIrgy_ZDsaPs.webp&quot; alt=&quot;Slope of enlightenment&quot; loading=&quot;lazy&quot; width=&quot;1092&quot; height=&quot;727&quot; /&gt;
&lt;a href=&quot;https://www.gartner.com/en/research/methodologies/gartner-hype-cycle&quot; target=&quot;_blank&quot;&gt; Source  &lt;/a&gt;
&lt;p&gt;So when we think we have arrived, we almost certainly have not. And this isn’t about the classic, the „there is always more to learn“. This is more specifically about a certain point that will shift how you view problems and solutions within your domain. A point where you notice that a decision was made outside of the rules, but instead of disliking the decision you actually agree? Suddenly this unfolds an almost infinite amount of knowledge and experimentation beyond what you understood as the de facto rules of the subject matter. You will have this moment of „wait, you can do that?“ and it will completely warp your understanding of what you thought you knew so well.&lt;/p&gt;
&lt;p&gt;And while I think this immediately makes a lot of sense for artistic disciplines, I think it translates to many other areas. As a developer I formally learned about all these concepts that over decades were refined to make my craft as optimal as possible. Think ideas such as abstraction, normalisation, typing, etc..&lt;/p&gt;
&lt;p&gt;Once I started working with and not just studying these concepts, it felt like I got the hang of them relatively quickly. I thought I could refactor everything to follow the „perfect rules“ that I got taught previously. In my head they were sound and made sense. It surprised me when people with years and years of experience engineered solutions that felt incomplete or conceptually wrong to me. Even more so when I had to fight for the „correct“ way to implement whatever concept I was dealing with at the time. However, the more I built, the more I learned. One approach being technically more correct doesn’t always mean it is mandatory to do it that way.&lt;/p&gt;
&lt;p&gt;Sometimes what you do 99% of the time is not required in certain edge cases. Sometimes it’s fine to duplicate code or columns in your database. Sometimes it’s fine to use an „any“ type. Sometimes it’s fine to spend 5 instead of 30+ minutes to build a script to run it exactly once and then forget about it and touch it never again.&lt;/p&gt;
&lt;p&gt;What is important is that you understand why it is ok to break the rules in those cases. And for that you have to learn and understand the rules first. You have to internalise what they contribute and, perhaps, what their drawbacks are. You may even break the rules but get it wrong multiple times first, before it clicks and you get it right. Over time this will evolve and the boundaries of „technically correct“ and „accomplishes the job just right“ blur more and more. The part to remember is that this should not be done out of laziness or because of a lack of care. It needs to be an active, deliberate choice. However, when it is, allowing it to happen will enable you to be more considered and also more expressive. In software engineering this may not be as flexible as in music or other art forms, but in my experience it still applies to some extent. Plus, if nothing else it is nice to reconsider why you do the things you do, rather than just habitually follow what you picked up previously.&lt;/p&gt;
&lt;p&gt;To wrap up my thoughts on this: It is important to learn the rules. Learning the rules allows you to make happy accidents, rather than mistakes.&lt;/p&gt;</content:encoded></item><item><title>Utilising the co-occurrence of user interface interactions as a risk indicator for smartphone addiction</title><description>I just published a substantial paper on the viability of using smartphone interactions for addiction evaluation in Elsevier‘s Pervasive and Mobile Computing. The study highlights a novel methodology to transform and analyse large amounts of</description><pubDate>Tue, 16 Aug 2022 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I just published a substantial paper on the viability of using smartphone interactions for addiction evaluation in &lt;a href=&quot;https://www.sciencedirect.com/journal/pervasive-and-mobile-computing&quot; target=&quot;_blank&quot;&gt; Elsevier‘s Pervasive and Mobile Computing  &lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The study highlights a novel methodology to transform and analyse large amounts of interaction events to infer a user’s level of smartphone addiction. This is a step forward from using commonly used metrics such as pure screen on time which can misrepresent the cognitive complexities and dependencies of human behaviour.&lt;/p&gt;
&lt;p&gt;Additionally, we find that session to session behaviour shows a lot of variance. From this we conclude that, even with a stable user trait such as addiction, users display problematic use patterns in only a subset of all their sessions.&lt;/p&gt;
&lt;p&gt;While the paper is still in pre-press stage as of now, you can already find it here: &lt;a href=&quot;https://doi.org/10.1016/j.pmcj.2022.101677&quot; target=&quot;_blank&quot;&gt; https://doi.org/10.1016/j.pmcj.2022.101677  &lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Resolving 404 issues with S3, CloudFront and SPAs</title><description>Today I worked on an issue that led me down a rabbit hole to resolve an issue with AWS. We noticed that security headers were missing from some of our CloudFront responses. Weirdly, everything was fine at first glance.</description><pubDate>Mon, 01 Aug 2022 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Today I worked on an issue that led me down a rabbit hole to resolve an issue with AWS. We noticed that security headers were missing from some of our CloudFront responses. Weirdly, everything was fine at first glance. Navigating to the application at its base path would return all expected headers. The issue was reported specifically for our &lt;code&gt;/login&lt;/code&gt; path and as it turned out, using this path as an entry point would indeed not send the correct headers.&lt;/p&gt;
&lt;p&gt;Without going into too much detail, the application in question is served using a single index file, where any path then gets resolved by the application itself via &lt;a href=&quot;https://v5.reactrouter.com/&quot; target=&quot;_blank&quot;&gt; react-router  &lt;/a&gt;. A classic setup for a React SPA. On the AWS side, the static files are located in a S3 bucket with &lt;code&gt;Static website hosting&lt;/code&gt; enabled. This is used as an Origin domain in a CloudFront distribution. The headers get applied by a CloudFront Function in a Viewer Response.&lt;/p&gt;
&lt;p&gt;So far so good. The first issue I identified was that a page load on any path that was not the root (such as &lt;code&gt;/login&lt;/code&gt;) would result in a &lt;code&gt;404&lt;/code&gt; response. But importantly, the application would still load and render correctly. I initially assumed this was just a peculiar cache-miss problem on CloudFront’s end but it turned out to be more complicated than that.&lt;/p&gt;
&lt;p&gt;After a lot of back and forth of checking configurations I found a lead in S3’s &lt;code&gt;Static website hosting&lt;/code&gt;. It turns out the index file was specified for both, the &lt;code&gt;Index document&lt;/code&gt; but also the &lt;code&gt;Error document&lt;/code&gt;. This meant that even if the path to a resource was specified incorrectly (in this case, any path was added to the request at all) the index file would always be served anyway.&lt;/p&gt;
&lt;p&gt;It also appears that CloudFront will always forward the original request URL (e.g. &lt;code&gt;/login&lt;/code&gt;) directly to its Origin. In the case of S3, it will try to resolve the path as a file. Since we only host the index file and a static folder, the request fails with a &lt;code&gt;File Not Found&lt;/code&gt; response. &lt;strong&gt;Critically, in case of an Origin returning an error code CloudFront does not apply the Viewer Response function.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So how do you fix this? AWS does not have an easy way to deal with this. There is no in-built filtering functionality or any other way to create an automatic redirection to the correct resources. I found two solutions that did not work for me, and one that did.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution that does not work #1: Set up a custom error page in CloudFront&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;CloudFront distributions allow to set custom error pages based on responses received from their Origin. While this allows to remap the 404 error to a 200 status code, CloudFront will still not apply its Viewer Response function. Therefore, while the console network tab is happy, our fault is not fixed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution that does not work #2: Setting the Default root object&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As seen on StackOverflow, some people had success setting the Default root object in the Distributions General Settings (source) to their index file. However, this did not help at all in this case.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution that did work: Conditionally rewriting the path in the Viewer Request&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Remember how CloudFront forwards every URL directly to its Origins. Using the Viewer Request function of a Distribution we can update the path to be rerouted to the root element /. This will cause S3 to respond with the index file. Note, we still serve static files from the same domain so we want to avoid rewriting all requests.&lt;/p&gt;
&lt;p&gt;For reference, this is the function we added. Please forgive the ugly code, it was a long day - I intend to clean it up tomorrow. I also despise whatever environment AWS is running these in as it would not accept my slightly more elegant regex.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; handler&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  var&lt;/span&gt;&lt;span&gt; request &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; event.request;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  var&lt;/span&gt;&lt;span&gt; pathItems &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; request.uri.&lt;/span&gt;&lt;span&gt;split&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  var&lt;/span&gt;&lt;span&gt; lastItem &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; pathItems[pathItems.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; -&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  // Rewrite any paths that do not end in a file (e.g. .html, .css or .js)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  if&lt;/span&gt;&lt;span&gt; (lastItem.&lt;/span&gt;&lt;span&gt;indexOf&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;.&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; -&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    request.uri &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &quot;/&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; request;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This finally resolved our issue, as S3 now returns the index file for every request that is not a static file request which then made CloudFront add the headers using it edge functions.&lt;/p&gt;
&lt;p&gt;In conclusion, if you run into an issue where on first page load your non-root paths do not load and you use S3 as an origin for your CloudFront Distribution, try to rewrite the request URL in your &lt;code&gt;Viewer Request&lt;/code&gt; function to remove any unnecessary path before the request reaches S3.&lt;/p&gt;</content:encoded></item><item><title>Statistics are weird</title><description>As part of my PhD research I have had to interact with way more statistical evaluation than ever before. Even though I would have to go through the occasional ANOVA et al. for my under- and postgrad degrees, I never really needed to internalise how and why statistical testing was actually .</description><pubDate>Tue, 07 Dec 2021 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;As part of my PhD research I have had to interact with way more statistical evaluation than ever before. Even though I would have to go through the occasional ANOVA et al. for my under- and postgrad degrees, I never really needed to internalise how and why statistical testing was actually done. It is one of those topics where when you are just starting out, you accept a few things as fact.&lt;/p&gt;
&lt;p&gt;Just to be clear, I am far from understanding it all. I feel like that the more I’m learning about statistics the more it dawns on me how much further there is to go. That being said, I picked up a few bits that have helped me align my own understanding with a few of the basic rules.&lt;/p&gt;
&lt;p&gt;My biggest take away so far has been that there is never a 100% catch all test for every problem. Often the correct choice is to scan related literature for their reporting metrics or trying to find literature that relates to your core comparisons even if it does not match the exact topic.&lt;/p&gt;
&lt;p&gt;I have also learned to question my results. More often than not, going further than simply checking a p&amp;lt;0.05 has helped me for the better. Understanding and exploring how and why those relationships occur and what they mean has been a big help in comprehending my own research.&lt;/p&gt;
&lt;p&gt;Especially my discovery of reporting effect sizes has been eye opening. It is super interesting to see my own results transform completely just because I pay attention to the different magnitudes apart from just equalling them on terms of significance.&lt;/p&gt;</content:encoded></item><item><title>__name__ == &quot;__main__&quot;</title><description>or, humble beginnings. I will be looking to add more content here soon, especially about the inception of this website.</description><pubDate>Sun, 24 Oct 2021 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;or, humble beginnings.&lt;/p&gt;
&lt;p&gt;I will be looking to add more content here soon, especially about the inception of this website.&lt;/p&gt;</content:encoded></item></channel></rss>