[{"data":1,"prerenderedAt":1011},["ShallowReactive",2],{"profile":3,"experience":70,"work":182},{"title":4,"description":5,"name":6,"role":7,"tagline":8,"location":9,"country":10,"workEligibility":10,"remote":11,"timezone":12,"email":13,"availability":14,"links":15,"skills":22,"education":40,"languages":52,"slug":54,"body":55},"","I build interfaces for products people use every day — mostly in Vue and Nuxt, and mostly at the\npoint where a design system meets real production constraints.","Jake Armour","Senior Frontend Developer","Vue 3 · Nuxt · TypeScript","Telford, United Kingdom","UK",true,"UTC−2 to UTC+2","jobs@jakearmour.com","Open to senior frontend roles",[16,19],{"label":17,"href":18},"GitHub","https:\u002F\u002Fgithub.com\u002Fjake-armour",{"label":20,"href":21},"LinkedIn","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fjakearmour",{"core":23,"other":31},[24,25,26,27,28,29,30],"Vue 3","Nuxt","TypeScript","JavaScript","HTML\u002FCSS","Tailwind CSS","Pinia",[32,33,34,35,36,37,38,39],"Design systems","Component libraries","Headless CMS","Core Web Vitals","Unit testing","CI\u002FCD","Data visualisation","Git",[41,47],{"institution":42,"qualification":43,"grade":44,"start":45,"end":46},"The University of Huddersfield","MEng Computer Science","Pass with Distinction",2014,2019,{"institution":48,"qualification":49,"grade":50,"start":51,"end":45},"Telford College of Arts and Technology","BTEC Information Technology","D*D*D*",2012,[53],"English (fluent)","profile",{"type":56,"children":57},"root",[58,65],{"type":59,"tag":60,"props":61,"children":62},"element","p",{},[63],{"type":64,"value":5},"text",{"type":59,"tag":60,"props":66,"children":67},{},[68],{"type":64,"value":69},"I'm currently a senior frontend developer at Spamhaus, where I've led two company rebrands and\nbuilt the Vue 3 component library the rest of the team ships on. Outside work I build tools I\nactually need: a component workshop for Vue, a zero-knowledge password manager, and a monitor\nthat watches the containers running in my house.",[71,128,142,155,168],{"title":4,"description":4,"company":72,"role":7,"start":73,"end":74,"detail":75,"order":76,"stack":77,"slug":80,"body":81},"Spamhaus","2023-09",null,"full",1,[24,25,26,29,78,79],"Directus","PayloadCMS","spamhaus",{"type":56,"children":82},[83],{"type":59,"tag":84,"props":85,"children":86},"ul",{},[87,93,98,103,108,113,118,123],{"type":59,"tag":88,"props":89,"children":90},"li",{},[91],{"type":64,"value":92},"Led two full company rebrands from a frontend perspective, translating third-party UI\u002FUX\ndesigns into scalable, production-ready code in Vue 3, Nuxt and Tailwind CSS.",{"type":59,"tag":88,"props":94,"children":95},{},[96],{"type":64,"value":97},"Architected and maintained a reusable Vue 3 \u002F TypeScript component library, establishing\ndesign consistency across applications and significantly increasing team development velocity.",{"type":59,"tag":88,"props":99,"children":100},{},[101],{"type":64,"value":102},"Gave the non-technical marketing team a hands-on playground for components they had otherwise\nonly seen as Figma frames, so they could try the real thing with real props rather than signing\noff from a design file.",{"type":59,"tag":88,"props":104,"children":105},{},[106],{"type":64,"value":107},"Served as the primary frontend developer on multiple major customer-facing launches, taking\nfeatures from concept to production in a high-autonomy environment.",{"type":59,"tag":88,"props":109,"children":110},{},[111],{"type":64,"value":112},"Optimised web performance and Core Web Vitals, improving load times, accessibility and overall\nuser experience across the primary platforms.",{"type":59,"tag":88,"props":114,"children":115},{},[116],{"type":64,"value":117},"Integrated headless CMS solutions (Directus, PayloadCMS) with Nuxt, developing custom schemas\nand dynamic UI components so marketing could build and update content independently.",{"type":59,"tag":88,"props":119,"children":120},{},[121],{"type":64,"value":122},"Partnered with marketing to build flexible, CMS-driven page templates, streamlining campaign\nlaunches while holding brand consistency across core platforms.",{"type":59,"tag":88,"props":124,"children":125},{},[126],{"type":64,"value":127},"Worked with backend engineers and DevOps to integrate REST APIs and streamline automated CI\u002FCD\ndeployment pipelines.",{"title":4,"description":129,"company":130,"role":131,"start":132,"end":73,"detail":133,"order":134,"slug":135,"body":136},"I contributed to the rewrite of the user platform from Vue 2 to Vue 3, including a custom\ncomponent pack, then redesigned and rewrote the admin interface to align with the new v2\ninterface. I introduced a code coverage tool and conventional commits to make the change history\neasier to track, and worked with client services on reported bugs, feeding progress back through\nour ticketing system.","Awin","Frontend Developer","2022-05","compact",2,"awin",{"type":56,"children":137},[138],{"type":59,"tag":60,"props":139,"children":140},{},[141],{"type":64,"value":129},{"title":4,"description":143,"company":144,"role":145,"start":146,"end":132,"detail":133,"order":147,"slug":148,"body":149},"One of a three-person frontend team building Kahu, a desk and room booking system. I implemented\na full redesign of the user application to streamline the booking journey, and helped raise test\ncoverage — maintaining both the e2e and unit suites, and reviewing as much code as I submitted.","Vanti","Web Application Developer","2021-03",3,"vanti",{"type":56,"children":150},[151],{"type":59,"tag":60,"props":152,"children":153},{},[154],{"type":64,"value":143},{"title":4,"description":156,"company":157,"role":158,"start":159,"end":146,"detail":133,"order":160,"slug":161,"body":162},"My graduate role, on an internal web development team spread across Singapore, Crawley and Texas.\nI maintained the intranet the whole company used daily, and helped research and launch a new wiki\nto centralise the company's information in one place.","CGG","Web Software Developer","2019-06",4,"cgg",{"type":56,"children":163},[164],{"type":59,"tag":60,"props":165,"children":166},{},[167],{"type":64,"value":156},{"title":4,"description":169,"company":170,"role":171,"start":172,"end":173,"detail":133,"order":174,"slug":175,"body":176},"A placement year from my degree, maintaining the school's management information system (iSAMS),\nsupporting staff and students day to day, and imaging teacher and student laptops ready for\nrollout. I also built a security application for the campus gate team, letting them check the\nperson at the main gate against the registration plates parents had registered.","The British School of Brussels","Web Technician","2016-07","2017-08",5,"british-school-of-brussels",{"type":56,"children":177},[178],{"type":59,"tag":60,"props":179,"children":180},{},[181],{"type":64,"value":169},[183,331,737,845],{"title":184,"description":185,"tagline":186,"year":187,"status":188,"role":189,"featured":11,"order":76,"stack":190,"links":196,"highlights":200,"slug":204,"body":205},"Jungleland","Jungleland is a children's play centre in Telford. Until 2021 it had no booking system at all —\nfamilies simply turned up. Social distancing ended that: entry had to be capped per session, so\nthe whole thing had to be built from nothing. Timed slots with hard capacity limits, a deposit\ntaken at the point of booking, and a way for staff to open and close dates themselves without\ncalling me.","Timed-slot booking and deposits for a children's play centre",2021,"live","Sole developer — frontend, payments, backend, deployment",[191,30,192,193,194,195,29],"Nuxt 4","Stripe","AWS Amplify","AppSync \u002F DynamoDB","AWS SES",[197],{"label":198,"href":199,"primary":11},"junglelandbooking.com","https:\u002F\u002Fjunglelandbooking.com",[201,202,203],"Real customers, real payments","Multi-step flow that survives a refresh","Staff-managed dates and capacity","jungleland",{"type":56,"children":206},[207,211,216,223,228,240,260,266,286,292,309,315,320,326],{"type":59,"tag":60,"props":208,"children":209},{},[210],{"type":64,"value":185},{"type":59,"tag":60,"props":212,"children":213},{},[214],{"type":64,"value":215},"It has been running and taking bookings ever since.",{"type":59,"tag":217,"props":218,"children":220},"h2",{"id":219},"the-booking-flow",[221],{"type":64,"value":222},"The booking flow",{"type":59,"tag":60,"props":224,"children":225},{},[226],{"type":64,"value":227},"A parent booking a party is usually doing it one-handed. The flow is deliberately linear — pick a\ndate, pick a slot, pay a deposit, get a confirmation — and each step is its own route:",{"type":59,"tag":229,"props":230,"children":234},"pre",{"className":231,"code":233,"language":64},[232],"language-text","\u002Fbook\n  → \u002Fbook\u002F[date]\n    → \u002Fbook\u002F[date]\u002F[timeslot]\n      → \u002Fdeposit\u002F[id]\n        → \u002Fconfirmation\u002F[id]\n",[235],{"type":59,"tag":236,"props":237,"children":238},"code",{"__ignoreMap":4},[239],{"type":64,"value":233},{"type":59,"tag":60,"props":241,"children":242},{},[243,245,250,252,258],{"type":64,"value":244},"Booking state lives in a ",{"type":59,"tag":246,"props":247,"children":248},"strong",{},[249],{"type":64,"value":30},{"type":64,"value":251}," store persisted to ",{"type":59,"tag":236,"props":253,"children":255},{"className":254},[],[256],{"type":64,"value":257},"localStorage",{"type":64,"value":259},", so a half-finished booking\nsurvives a refresh, a backgrounded tab, or a phone that decides to lock mid-payment. That single\ndecision removed the most common support complaint from the first version.",{"type":59,"tag":217,"props":261,"children":263},{"id":262},"capacity-is-the-hard-part",[264],{"type":64,"value":265},"Capacity is the hard part",{"type":59,"tag":60,"props":267,"children":268},{},[269,271,277,279,284],{"type":64,"value":270},"Everything else follows from not overselling a slot. The data model is deliberately narrow —\n",{"type":59,"tag":236,"props":272,"children":274},{"className":273},[],[275],{"type":64,"value":276},"Date → TimeSlot → Booking",{"type":64,"value":278}," in ",{"type":59,"tag":246,"props":280,"children":281},{},[282],{"type":64,"value":283},"AppSync\u002FGraphQL over DynamoDB",{"type":64,"value":285}," — with capacity held on the\ntimeslot rather than derived at read time. Abandoned bookings expire on their own through\nDynamoDB TTLs rather than needing a cleanup job, so a parent who bails at the payment step\nreleases their slot without anyone intervening.",{"type":59,"tag":217,"props":287,"children":289},{"id":288},"payments-and-mail",[290],{"type":64,"value":291},"Payments and mail",{"type":59,"tag":60,"props":293,"children":294},{},[295,297,301,303,307],{"type":64,"value":296},"Deposits go through ",{"type":59,"tag":246,"props":298,"children":299},{},[300],{"type":64,"value":192},{"type":64,"value":302},", with the checkout session created server-side in a Nitro route so\nno key is ever exposed to the client. Confirmations are sent through ",{"type":59,"tag":246,"props":304,"children":305},{},[306],{"type":64,"value":195},{"type":64,"value":308},". Both are\nboring, on purpose — this is somebody's actual revenue.",{"type":59,"tag":217,"props":310,"children":312},{"id":311},"admin",[313],{"type":64,"value":314},"Admin",{"type":59,"tag":60,"props":316,"children":317},{},[318],{"type":64,"value":319},"Staff get a JWT-authenticated admin area to create bookable dates, set slot capacity, and see\nwhat has been booked. It is intentionally plain: the people using it are running a play centre,\nnot learning software.",{"type":59,"tag":217,"props":321,"children":323},{"id":322},"what-id-change",[324],{"type":64,"value":325},"What I'd change",{"type":59,"tag":60,"props":327,"children":328},{},[329],{"type":64,"value":330},"The repo has no automated tests and no linting — it grew from something built quickly under a\nreopening deadline and never had that debt paid down. If I picked it up again today, capacity\nlogic is the first thing I'd put under test, because it is the part that costs real money when\nit is wrong.",{"title":332,"description":333,"tagline":334,"year":335,"status":336,"role":337,"featured":11,"order":134,"stack":338,"highlights":342,"slug":346,"body":347},"Vitro","Vitro is a component workshop for Vue 3 — the thing you open to build a component in isolation,\nbefore it has a page to live on.","A Vite-native component workshop for Vue 3",2026,"building","Creator",[24,339,26,340,341],"Vite","vue-component-meta","Shiki",[343,344,345],"Stories are real Vue templates","Controls derived from your props","A playground non-developers can use","vitro",{"type":56,"children":348},[349,353,358,370,376,381,394,400,413,598,611,617,645,651,682,687,693,720,726,731],{"type":59,"tag":60,"props":350,"children":351},{},[352],{"type":64,"value":333},{"type":59,"tag":60,"props":354,"children":355},{},[356],{"type":64,"value":357},"It exists because writing Storybook stories was always the part of the job I put off. Authoring\nfriction was the problem I actually wanted to solve; format churn came a close second, because a\nstory format that keeps changing underneath you turns migrations into routine work. Histoire, the\nobvious alternative, had stalled.",{"type":59,"tag":60,"props":359,"children":360},{},[361,363,368],{"type":64,"value":362},"The CLI is ",{"type":59,"tag":236,"props":364,"children":366},{"className":365},[],[367],{"type":64,"value":346},{"type":64,"value":369},". Vite is the builder — not wrapped, not shimmed, just Vite.",{"type":59,"tag":217,"props":371,"children":373},{"id":372},"who-it-turned-out-to-be-for",[374],{"type":64,"value":375},"Who it turned out to be for",{"type":59,"tag":60,"props":377,"children":378},{},[379],{"type":64,"value":380},"It has earned its keep in a way I did not plan for. A lot of my work at Spamhaus is building\ncomponents that the marketing team have only ever seen as a Figma frame — approved in a design\nfile, never touched. Giving non-technical stakeholders somewhere hands-on to poke at instead\n(change the props yourself, with the Figma frame sitting next to the real rendered component)\nhas been worth more than any amount of written documentation.",{"type":59,"tag":60,"props":382,"children":383},{},[384,386,392],{"type":64,"value":385},"That is what makes the shareable-URL state and the ",{"type":59,"tag":236,"props":387,"children":389},{"className":388},[],[390],{"type":64,"value":391},"figmaUrl",{"type":64,"value":393}," embed the two features I would\nkeep above all the others: they turn \"here is the component\" from a meeting into a link.",{"type":59,"tag":217,"props":395,"children":397},{"id":396},"stories-are-just-vue",[398],{"type":64,"value":399},"Stories are just Vue",{"type":59,"tag":60,"props":401,"children":402},{},[403,405,411],{"type":64,"value":404},"The format is a ",{"type":59,"tag":236,"props":406,"children":408},{"className":407},[],[409],{"type":64,"value":410},".story.vue",{"type":64,"value":412}," file containing ordinary Vue markup:",{"type":59,"tag":229,"props":414,"children":418},{"className":415,"code":416,"language":417,"meta":4,"style":4},"language-vue shiki shiki-themes github-light github-dark","\u003Ctemplate>\n  \u003CStory title=\"Button\">\n    \u003CVariant title=\"Primary\">\n      \u003CAppButton variant=\"primary\">Save changes\u003C\u002FAppButton>\n    \u003C\u002FVariant>\n  \u003C\u002FStory>\n\u003C\u002Ftemplate>\n","vue",[419],{"type":59,"tag":236,"props":420,"children":421},{"__ignoreMap":4},[422,444,478,508,548,564,581],{"type":59,"tag":423,"props":424,"children":426},"span",{"class":425,"line":76},"line",[427,433,439],{"type":59,"tag":423,"props":428,"children":430},{"style":429},"--shiki-default:#24292E;--shiki-dark:#E1E4E8",[431],{"type":64,"value":432},"\u003C",{"type":59,"tag":423,"props":434,"children":436},{"style":435},"--shiki-default:#22863A;--shiki-dark:#85E89D",[437],{"type":64,"value":438},"template",{"type":59,"tag":423,"props":440,"children":441},{"style":429},[442],{"type":64,"value":443},">\n",{"type":59,"tag":423,"props":445,"children":446},{"class":425,"line":134},[447,452,457,463,468,474],{"type":59,"tag":423,"props":448,"children":449},{"style":429},[450],{"type":64,"value":451},"  \u003C",{"type":59,"tag":423,"props":453,"children":454},{"style":435},[455],{"type":64,"value":456},"Story",{"type":59,"tag":423,"props":458,"children":460},{"style":459},"--shiki-default:#6F42C1;--shiki-dark:#B392F0",[461],{"type":64,"value":462}," title",{"type":59,"tag":423,"props":464,"children":465},{"style":429},[466],{"type":64,"value":467},"=",{"type":59,"tag":423,"props":469,"children":471},{"style":470},"--shiki-default:#032F62;--shiki-dark:#9ECBFF",[472],{"type":64,"value":473},"\"Button\"",{"type":59,"tag":423,"props":475,"children":476},{"style":429},[477],{"type":64,"value":443},{"type":59,"tag":423,"props":479,"children":480},{"class":425,"line":147},[481,486,491,495,499,504],{"type":59,"tag":423,"props":482,"children":483},{"style":429},[484],{"type":64,"value":485},"    \u003C",{"type":59,"tag":423,"props":487,"children":488},{"style":435},[489],{"type":64,"value":490},"Variant",{"type":59,"tag":423,"props":492,"children":493},{"style":459},[494],{"type":64,"value":462},{"type":59,"tag":423,"props":496,"children":497},{"style":429},[498],{"type":64,"value":467},{"type":59,"tag":423,"props":500,"children":501},{"style":470},[502],{"type":64,"value":503},"\"Primary\"",{"type":59,"tag":423,"props":505,"children":506},{"style":429},[507],{"type":64,"value":443},{"type":59,"tag":423,"props":509,"children":510},{"class":425,"line":160},[511,516,521,526,530,535,540,544],{"type":59,"tag":423,"props":512,"children":513},{"style":429},[514],{"type":64,"value":515},"      \u003C",{"type":59,"tag":423,"props":517,"children":518},{"style":435},[519],{"type":64,"value":520},"AppButton",{"type":59,"tag":423,"props":522,"children":523},{"style":459},[524],{"type":64,"value":525}," variant",{"type":59,"tag":423,"props":527,"children":528},{"style":429},[529],{"type":64,"value":467},{"type":59,"tag":423,"props":531,"children":532},{"style":470},[533],{"type":64,"value":534},"\"primary\"",{"type":59,"tag":423,"props":536,"children":537},{"style":429},[538],{"type":64,"value":539},">Save changes\u003C\u002F",{"type":59,"tag":423,"props":541,"children":542},{"style":435},[543],{"type":64,"value":520},{"type":59,"tag":423,"props":545,"children":546},{"style":429},[547],{"type":64,"value":443},{"type":59,"tag":423,"props":549,"children":550},{"class":425,"line":174},[551,556,560],{"type":59,"tag":423,"props":552,"children":553},{"style":429},[554],{"type":64,"value":555},"    \u003C\u002F",{"type":59,"tag":423,"props":557,"children":558},{"style":435},[559],{"type":64,"value":490},{"type":59,"tag":423,"props":561,"children":562},{"style":429},[563],{"type":64,"value":443},{"type":59,"tag":423,"props":565,"children":567},{"class":425,"line":566},6,[568,573,577],{"type":59,"tag":423,"props":569,"children":570},{"style":429},[571],{"type":64,"value":572},"  \u003C\u002F",{"type":59,"tag":423,"props":574,"children":575},{"style":435},[576],{"type":64,"value":456},{"type":59,"tag":423,"props":578,"children":579},{"style":429},[580],{"type":64,"value":443},{"type":59,"tag":423,"props":582,"children":584},{"class":425,"line":583},7,[585,590,594],{"type":59,"tag":423,"props":586,"children":587},{"style":429},[588],{"type":64,"value":589},"\u003C\u002F",{"type":59,"tag":423,"props":591,"children":592},{"style":435},[593],{"type":64,"value":438},{"type":59,"tag":423,"props":595,"children":596},{"style":429},[597],{"type":64,"value":443},{"type":59,"tag":60,"props":599,"children":600},{},[601,603,609],{"type":64,"value":602},"No abstract ",{"type":59,"tag":236,"props":604,"children":606},{"className":605},[],[607],{"type":64,"value":608},"args",{"type":64,"value":610}," object standing in for a template. You write the markup you would actually\nwrite, which means a story is readable by anyone on the team and stays honest about how the\ncomponent is really used. The format is a semver-protected, additive-only contract — the\nmigration churn is the thing I was trying to escape, so committing to never causing it was the\npoint.",{"type":59,"tag":217,"props":612,"children":614},{"id":613},"controls-you-didnt-have-to-write",[615],{"type":64,"value":616},"Controls you didn't have to write",{"type":59,"tag":60,"props":618,"children":619},{},[620,622,627,629,635,637,643],{"type":64,"value":621},"Controls are derived from the component's own props via ",{"type":59,"tag":236,"props":623,"children":625},{"className":624},[],[626],{"type":64,"value":340},{"type":64,"value":628},". A variant with no\nslot content auto-renders ",{"type":59,"tag":236,"props":630,"children":632},{"className":631},[],[633],{"type":64,"value":634},"\u003CComponent v-bind=\"state\" \u002F>",{"type":64,"value":636},", so a component you have just written\ngets a working playground with zero configuration. Manual ",{"type":59,"tag":236,"props":638,"children":640},{"className":639},[],[641],{"type":64,"value":642},"Hst*",{"type":64,"value":644}," controls are there when you want\nto override, and control changes update the preview live without remounting — state survives, so\nyou can tweak a prop mid-interaction.",{"type":59,"tag":217,"props":646,"children":648},{"id":647},"three-views",[649],{"type":64,"value":650},"Three views",{"type":59,"tag":60,"props":652,"children":653},{},[654,659,661,666,668,673,675,680],{"type":59,"tag":246,"props":655,"children":656},{},[657],{"type":64,"value":658},"Canvas",{"type":64,"value":660}," for the component, ",{"type":59,"tag":246,"props":662,"children":663},{},[664],{"type":64,"value":665},"Source",{"type":64,"value":667}," for Shiki-highlighted markup, ",{"type":59,"tag":246,"props":669,"children":670},{},[671],{"type":64,"value":672},"Docs",{"type":64,"value":674}," for autodocs —\nprop, event and slot tables pulled from the type signatures, including JSDoc descriptions. If a\n",{"type":59,"tag":236,"props":676,"children":678},{"className":677},[],[679],{"type":64,"value":391},{"type":64,"value":681}," is set on the story, the Figma frame embeds alongside it.",{"type":59,"tag":60,"props":683,"children":684},{},[685],{"type":64,"value":686},"The full UI state — story, variant, every control value, viewport, background, theme — is encoded\nin the URL. Any view you are looking at is a link you can paste to a designer.",{"type":59,"tag":217,"props":688,"children":690},{"id":689},"built-output",[691],{"type":64,"value":692},"Built output",{"type":59,"tag":60,"props":694,"children":695},{},[696,702,704,710,712,718],{"type":59,"tag":236,"props":697,"children":699},{"className":698},[],[700],{"type":64,"value":701},"vitro build",{"type":64,"value":703}," produces a static, relative-path ",{"type":59,"tag":236,"props":705,"children":707},{"className":706},[],[708],{"type":64,"value":709},"dist\u002F",{"type":64,"value":711}," with per-story chunks that will serve from\nany subpath. It also emits a Storybook-compatible ",{"type":59,"tag":236,"props":713,"children":715},{"className":714},[],[716],{"type":64,"value":717},"\u002Findex.json",{"type":64,"value":719},", so existing visual-regression\ntooling — Lost Pixel, Playwright — works against it without adaptation. Adopting a new workshop\nshould not mean rewriting your VR pipeline.",{"type":59,"tag":217,"props":721,"children":723},{"id":722},"status",[724],{"type":64,"value":725},"Status",{"type":59,"tag":60,"props":727,"children":728},{},[729],{"type":64,"value":730},"M1–M6 complete: the full v1 workshop is working. Next is hosted publishing.",{"type":59,"tag":732,"props":733,"children":734},"style",{},[735],{"type":64,"value":736},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":738,"description":739,"tagline":740,"year":335,"status":336,"role":337,"featured":11,"order":147,"stack":741,"highlights":746,"slug":750,"body":751},"Cubby","Cubby is a self-hosted password manager — a Bitwarden replacement with a web dashboard and a\nFirefox extension. The constraint that shapes every decision in it: the server never sees\nanything it could decrypt. Your master password and vault keys do not leave the client.","A zero-knowledge password manager",[191,26,742,743,744,745],"Web Crypto","Postgres","Docker","WXT",[747,748,749],"Server only ever holds ciphertext","Shared vaults without breaking the model","Proven by test, not by claim","cubby",{"type":56,"children":752},[753,765,770,776,796,802,807,813,818,824],{"type":59,"tag":60,"props":754,"children":755},{},[756,758,763],{"type":64,"value":757},"Cubby is a self-hosted password manager — a Bitwarden replacement with a web dashboard and a\nFirefox extension. The constraint that shapes every decision in it: ",{"type":59,"tag":246,"props":759,"children":760},{},[761],{"type":64,"value":762},"the server never sees\nanything it could decrypt.",{"type":64,"value":764}," Your master password and vault keys do not leave the client.",{"type":59,"tag":60,"props":766,"children":767},{},[768],{"type":64,"value":769},"That is easy to claim and easy to get subtly wrong, so it is asserted by a test that talks to a\nlive Postgres, registers a user, creates an item, and fails if any row holds anything but\nciphertext. The property is enforced by CI rather than by my good intentions.",{"type":59,"tag":217,"props":771,"children":773},{"id":772},"where-the-crypto-lives",[774],{"type":64,"value":775},"Where the crypto lives",{"type":59,"tag":60,"props":777,"children":778},{},[779,781,787,789,794],{"type":64,"value":780},"Key derivation happens in the browser. The symmetric key stays in memory only — reload the page\nand you get the unlock screen back, master password required. The API deals exclusively in opaque\nblobs; it can tell you ",{"type":59,"tag":782,"props":783,"children":784},"em",{},[785],{"type":64,"value":786},"that",{"type":64,"value":788}," an item changed and ",{"type":59,"tag":782,"props":790,"children":791},{},[792],{"type":64,"value":793},"when",{"type":64,"value":795},", never what it says.",{"type":59,"tag":217,"props":797,"children":799},{"id":798},"sharing-without-cheating",[800],{"type":64,"value":801},"Sharing without cheating",{"type":59,"tag":60,"props":803,"children":804},{},[805],{"type":64,"value":806},"Shared vaults are where zero-knowledge designs usually quietly compromise. Cubby gives every user\nan RSA key-pair, with the private key wrapped under their own symmetric key. A shared vault has\nits own key, delivered to each member encrypted to that member's public key. Invite by email,\naccept, and everyone can read and edit — while the server still holds nothing but ciphertext and\na set of per-member wrapped keys it cannot open.",{"type":59,"tag":217,"props":808,"children":810},{"id":809},"the-rest-of-it",[811],{"type":64,"value":812},"The rest of it",{"type":59,"tag":60,"props":814,"children":815},{},[816],{"type":64,"value":817},"Revision-based delta sync against an encrypted local cache, so a client fetches what changed\nrather than the whole vault. Folders with encrypted names. Live TOTP codes. A configurable\ngenerator with look-alike exclusion. Client-side Bitwarden JSON import — decrypted, re-encrypted\nand uploaded without the plaintext touching the wire. Account recovery via a one-time key issued\nat signup, because \"we can reset it for you\" is precisely what a zero-knowledge system cannot\noffer.",{"type":59,"tag":217,"props":819,"children":821},{"id":820},"running-it",[822],{"type":64,"value":823},"Running it",{"type":59,"tag":60,"props":825,"children":826},{},[827,829,835,837,843],{"type":64,"value":828},"One ",{"type":59,"tag":236,"props":830,"children":832},{"className":831},[],[833],{"type":64,"value":834},"docker compose up",{"type":64,"value":836}," brings up Postgres, the API, and a Caddy proxy handling auto-TLS and\nrouting, plus a scheduled ",{"type":59,"tag":236,"props":838,"children":840},{"className":839},[],[841],{"type":64,"value":842},"pg_dump",{"type":64,"value":844}," backup with retention. The Firefox extension is built with\nWXT and is the piece still in progress.",{"title":846,"description":847,"tagline":848,"year":335,"status":336,"role":337,"featured":11,"order":160,"stack":849,"highlights":852,"slug":856,"body":857},"Beacon","I run a handful of Docker containers at home that are reachable from the internet. Beacon exists\nbecause I kept finding out one of them had fallen over by trying to use it.","Uptime monitoring for the containers I self-host",[191,850,743,744,851],"Drizzle","Cloudflare Tunnel",[853,854,855],"Watches from outside the box","No inbound ports, no manual DNS","One docker compose up","beacon",{"type":56,"children":858},[859,863,869,879,885,896,902,940,946,1006],{"type":59,"tag":60,"props":860,"children":861},{},[862],{"type":64,"value":847},{"type":59,"tag":217,"props":864,"children":866},{"id":865},"the-one-rule",[867],{"type":64,"value":868},"The one rule",{"type":59,"tag":60,"props":870,"children":871},{},[872,877],{"type":59,"tag":246,"props":873,"children":874},{},[875],{"type":64,"value":876},"The monitor must not run on the same host as the things it watches.",{"type":64,"value":878}," Its entire job is to\nnotice when that host dies, which it cannot do from inside it. Obvious once stated, easy to get\nwrong when you are the person who owns both boxes and has a spare slot on one of them. Beacon\nruns somewhere else, deliberately, and everything about its packaging assumes that.",{"type":59,"tag":217,"props":880,"children":882},{"id":881},"getting-out-of-the-house-safely",[883],{"type":64,"value":884},"Getting out of the house safely",{"type":59,"tag":60,"props":886,"children":887},{},[888,890,894],{"type":64,"value":889},"The services it watches reach the internet through a ",{"type":59,"tag":246,"props":891,"children":892},{},[893],{"type":64,"value":851},{"type":64,"value":895}," — no inbound ports\nopen on the home network, no port forwarding, no DNS records maintained by hand, no certificates\nto renew. The tunnel dials out; nothing dials in.",{"type":59,"tag":217,"props":897,"children":899},{"id":898},"what-it-is",[900],{"type":64,"value":901},"What it is",{"type":59,"tag":60,"props":903,"children":904},{},[905,907,911,913,917,919,923,925,931,933,938],{"type":64,"value":906},"A ",{"type":59,"tag":246,"props":908,"children":909},{},[910],{"type":64,"value":191},{"type":64,"value":912}," app with an auth-protected dashboard: what is up, what is down, how long it has been\nthat way, and history per service. Checks and results live in ",{"type":59,"tag":246,"props":914,"children":915},{},[916],{"type":64,"value":743},{"type":64,"value":918}," via ",{"type":59,"tag":246,"props":920,"children":921},{},[922],{"type":64,"value":850},{"type":64,"value":924},", with\nmigrations applied on startup so a deploy is a restart. Auth is ",{"type":59,"tag":236,"props":926,"children":928},{"className":927},[],[929],{"type":64,"value":930},"nuxt-auth-utils",{"type":64,"value":932},". The whole\nthing is a single ",{"type":59,"tag":236,"props":934,"children":936},{"className":935},[],[937],{"type":64,"value":834},{"type":64,"value":939}," on a host that needs Docker and nothing else — no Node, no\npackage manager; dependencies are installed and the app built inside the image.",{"type":59,"tag":217,"props":941,"children":943},{"id":942},"configuration-is-load-bearing",[944],{"type":64,"value":945},"Configuration is load-bearing",{"type":59,"tag":60,"props":947,"children":948},{},[949,951,957,959,965,967,972,974,980,982,988,990,996,998,1004],{"type":64,"value":950},"A note I left in the deployment guide for my future self, after it bit me twice: every\napplication variable must be ",{"type":59,"tag":236,"props":952,"children":954},{"className":953},[],[955],{"type":64,"value":956},"NUXT_",{"type":64,"value":958},"-prefixed. Nitro bakes ",{"type":59,"tag":236,"props":960,"children":962},{"className":961},[],[963],{"type":64,"value":964},"runtimeConfig",{"type":64,"value":966}," at build time and\nre-reads it at runtime ",{"type":59,"tag":782,"props":968,"children":969},{},[970],{"type":64,"value":971},"only",{"type":64,"value":973}," from ",{"type":59,"tag":236,"props":975,"children":977},{"className":976},[],[978],{"type":64,"value":979},"NUXT_*",{"type":64,"value":981},". Set ",{"type":59,"tag":236,"props":983,"children":985},{"className":984},[],[986],{"type":64,"value":987},"ADMIN_PASSWORD_HASH",{"type":64,"value":989}," instead of\n",{"type":59,"tag":236,"props":991,"children":993},{"className":992},[],[994],{"type":64,"value":995},"NUXT_ADMIN_PASSWORD_HASH",{"type":64,"value":997}," and sign-in returns a 503 while ",{"type":59,"tag":236,"props":999,"children":1001},{"className":1000},[],[1002],{"type":64,"value":1003},"docker compose exec",{"type":64,"value":1005}," shows you a\nperfectly correct-looking value in the environment. The container starts, the dashboard loads,\nand the one thing you need it to do fails silently.",{"type":59,"tag":60,"props":1007,"children":1008},{},[1009],{"type":64,"value":1010},"Small operational details like that are most of what running your own infrastructure actually is.",1785686103272]