[{"data":1,"prerenderedAt":818},["ShallowReactive",2],{"profile":3,"projects:page:\u002Fprojects\u002Fforevermore\u002Farchitecture":22,"projects:docs:\u002Fprojects\u002Fforevermore":778,"structured-data:current-stack":804},{"id":4,"alias":5,"current":6,"email":10,"extension":11,"headline":12,"links":13,"location":17,"meta":18,"name":19,"stem":20,"__hash__":21},"profile\u002Fprofile.yml","readonly",{"role":7,"company":8,"companyUrl":9},"Fullstack Engineer — Tech Lead","DataCose","https:\u002F\u002Fdatacose.com","redonemini18@gmail.com","yml","Fullstack Engineer",{"github":14,"linkedin":15,"twitch":16},"https:\u002F\u002Fgithub.com\u002FRedoni18","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fredon-emini\u002F","https:\u002F\u002Fwww.twitch.tv\u002Fr3adonly","Pristina, Kosovo",{},"Redon Emini","profile","Fbb1IAp4nuUOOtgXdwpzcqSmG0CUKtZ4D0ifPqdK5QU",{"id":23,"title":24,"body":25,"description":763,"extension":764,"featured":765,"kind":766,"links":767,"meta":768,"navigation":769,"order":771,"path":772,"period":773,"preview":773,"seo":774,"stack":775,"stem":776,"year":773,"__hash__":777},"projects\u002Fprojects\u002Fforevermore\u002F1.architecture.md","Subdomains and permissions",{"type":26,"value":27,"toc":755},"minimark",[28,32,43,46,49,54,61,73,80,85,92,99,557,570,578,582,585,588,613,623,718,725,729,736,739,751],[29,30,31],"p",{},"Forevermore is one pnpm monorepo:",[33,34,39],"pre",{"className":35,"code":37,"language":38},[36],"language-text","apps\u002F\n  landing\u002F        marketing site\n  dashboard\u002F      the editor, where gifts are built and paid for\n  experience\u002F     the viewer that shows a finished gift\n  atlas\u002F          internal asset browser\n  builder\u002F        experimental, not deployed\npackages\u002F\n  templates\u002F      the world catalog and the data contract every world reads\n  template-kits\u002F  a library of reusable procedural assets\n","text",[40,41,37],"code",{"__ignoreMap":42},"",[29,44,45],{},"The split follows who's looking at the screen. The editor is a full app, with sign-in, uploads and checkout. The viewer is the opposite: a small, fast page that someone opens from a link on their phone, often without knowing what to expect. Keeping them as separate apps means the viewer doesn't carry any of the editor's weight, and a change to the editor can't break a gift that someone is opening right now.",[29,47,48],{},"This post covers the two architecture problems that shaped the rest of the system.",[50,51,53],"h2",{"id":52},"every-gift-gets-its-own-address","Every gift gets its own address",[29,55,56,57,60],{},"A finished gift lives on its own subdomain, like ",[40,58,59],{},"theirname.getforevermore.co",". It's a small product decision with big consequences. It reads like a real place instead of a link with an ID on the end, and it's what people actually see in a text message.",[29,62,63,64,68,69,72],{},"To serve that, you need a ",[65,66,67],"strong",{},"wildcard domain",": one setup that answers for ",[40,70,71],{},"*.getforevermore.co",", whatever comes before the dot. That single requirement is most of the reason the stack runs on Cloudflare rather than Vercel. On Cloudflare, a wildcard on your own domain is covered by the free certificate it already issues. On Vercel, the same setup needed a paid plan plus certificate automation for each host. Cloudflare also doesn't charge for bandwidth, which matters for a product whose whole point is being shared.",[29,74,75,76,79],{},"To be fair to Vercel, its developer experience is nicer, and it runs native Node dependencies without complaint. That's exactly why the editor was the hardest app to move. It used ",[40,77,78],{},"sharp",", an image library built on a native binary, which can't run on Cloudflare Workers, and it had to be replaced with Cloudflare's own image service before the editor could run there at all. Every platform choice sends you a bill like that somewhere. It's better to know where it is up front.",[81,82,84],"h3",{"id":83},"routing-a-wildcard-by-hand","Routing a wildcard by hand",[29,86,87,88,91],{},"There was one more catch. Cloudflare Pages, where the apps are hosted, doesn't allow a wildcard as a custom domain. Cloudflare ",[65,89,90],{},"Workers"," routes do. A Worker is a small function that runs on Cloudflare's network in front of your site, and it can rewrite a request before passing it along.",[29,93,94,95,98],{},"So a Worker sits on ",[40,96,97],{},"*.getforevermore.co\u002F*"," and does the routing itself. Simplified, it looks like this:",[33,100,105],{"className":101,"code":102,"filename":103,"language":104,"meta":42,"style":42},"language-js shiki shiki-themes vitesse-light vitesse-dark","const RESERVED = new Set(['www', 'app', 'api', 'assets' \u002F* … *\u002F])\n\nexport default {\n  async fetch(request) {\n    const url = new URL(request.url)\n    const label = url.hostname.split('.')[0]   \u002F\u002F \"theirname\"\n\n    if (label === 'www') {\n      url.hostname = 'getforevermore.co'\n      return Response.redirect(url.toString(), 301)\n    }\n    if (RESERVED.has(label)) {\n      return fetch(request)   \u002F\u002F infrastructure subdomains go to their own apps\n    }\n\n    \u002F\u002F Anything else is a gift: send it to the viewer app,\n    \u002F\u002F and remember which subdomain was asked for.\n    const originalHost = url.hostname\n    url.hostname = VIEWER_HOST\n    const proxied = new Request(url, request)\n    proxied.headers.set('X-Forwarded-Host', originalHost)\n    return fetch(proxied)\n  },\n}\n","infra\u002Fgift-subdomain-router\u002Fworker.js","js",[40,106,107,180,187,200,220,249,292,297,322,342,373,379,403,419,424,429,435,441,458,473,499,530,545,551],{"__ignoreMap":42},[108,109,112,116,120,124,127,131,134,138,142,144,147,150,153,155,157,159,162,164,166,168,171,173,177],"span",{"class":110,"line":111},"line",1,[108,113,115],{"class":114},"s5TCs","const",[108,117,119],{"class":118},"s9nN2"," RESERVED",[108,121,123],{"class":122},"si6no"," =",[108,125,126],{"class":114}," new",[108,128,130],{"class":129},"s_xSY"," Set",[108,132,133],{"class":122},"([",[108,135,137],{"class":136},"scnC2","'",[108,139,141],{"class":140},"spP0B","www",[108,143,137],{"class":136},[108,145,146],{"class":122},",",[108,148,149],{"class":136}," '",[108,151,152],{"class":140},"app",[108,154,137],{"class":136},[108,156,146],{"class":122},[108,158,149],{"class":136},[108,160,161],{"class":140},"api",[108,163,137],{"class":136},[108,165,146],{"class":122},[108,167,149],{"class":136},[108,169,170],{"class":140},"assets",[108,172,137],{"class":136},[108,174,176],{"class":175},"snYqZ"," \u002F* … *\u002F",[108,178,179],{"class":122},"])\n",[108,181,183],{"class":110,"line":182},2,[108,184,186],{"emptyLinePlaceholder":185},true,"\n",[108,188,190,194,197],{"class":110,"line":189},3,[108,191,193],{"class":192},"sTPum","export",[108,195,196],{"class":192}," default",[108,198,199],{"class":122}," {\n",[108,201,203,206,209,212,215,218],{"class":110,"line":202},4,[108,204,205],{"class":114},"  async",[108,207,208],{"class":129}," fetch",[108,210,211],{"class":122},"(",[108,213,214],{"class":118},"request",[108,216,217],{"class":122},")",[108,219,199],{"class":122},[108,221,223,226,229,231,233,236,238,240,243,246],{"class":110,"line":222},5,[108,224,225],{"class":114},"    const",[108,227,228],{"class":118}," url",[108,230,123],{"class":122},[108,232,126],{"class":114},[108,234,235],{"class":129}," URL",[108,237,211],{"class":122},[108,239,214],{"class":118},[108,241,242],{"class":122},".",[108,244,245],{"class":118},"url",[108,247,248],{"class":122},")\n",[108,250,252,254,257,259,261,263,266,268,271,273,275,277,279,282,286,289],{"class":110,"line":251},6,[108,253,225],{"class":114},[108,255,256],{"class":118}," label",[108,258,123],{"class":122},[108,260,228],{"class":118},[108,262,242],{"class":122},[108,264,265],{"class":118},"hostname",[108,267,242],{"class":122},[108,269,270],{"class":129},"split",[108,272,211],{"class":122},[108,274,137],{"class":136},[108,276,242],{"class":140},[108,278,137],{"class":136},[108,280,281],{"class":122},")[",[108,283,285],{"class":284},"sqbOQ","0",[108,287,288],{"class":122},"]",[108,290,291],{"class":175},"   \u002F\u002F \"theirname\"\n",[108,293,295],{"class":110,"line":294},7,[108,296,186],{"emptyLinePlaceholder":185},[108,298,300,303,306,309,312,314,316,318,320],{"class":110,"line":299},8,[108,301,302],{"class":192},"    if",[108,304,305],{"class":122}," (",[108,307,308],{"class":118},"label",[108,310,311],{"class":114}," ===",[108,313,149],{"class":136},[108,315,141],{"class":140},[108,317,137],{"class":136},[108,319,217],{"class":122},[108,321,199],{"class":122},[108,323,325,328,330,332,334,336,339],{"class":110,"line":324},9,[108,326,327],{"class":118},"      url",[108,329,242],{"class":122},[108,331,265],{"class":118},[108,333,123],{"class":122},[108,335,149],{"class":136},[108,337,338],{"class":140},"getforevermore.co",[108,340,341],{"class":136},"'\n",[108,343,345,348,351,353,356,358,360,362,365,368,371],{"class":110,"line":344},10,[108,346,347],{"class":192},"      return",[108,349,350],{"class":118}," Response",[108,352,242],{"class":122},[108,354,355],{"class":129},"redirect",[108,357,211],{"class":122},[108,359,245],{"class":118},[108,361,242],{"class":122},[108,363,364],{"class":129},"toString",[108,366,367],{"class":122},"(),",[108,369,370],{"class":284}," 301",[108,372,248],{"class":122},[108,374,376],{"class":110,"line":375},11,[108,377,378],{"class":122},"    }\n",[108,380,382,384,386,389,391,394,396,398,401],{"class":110,"line":381},12,[108,383,302],{"class":192},[108,385,305],{"class":122},[108,387,388],{"class":118},"RESERVED",[108,390,242],{"class":122},[108,392,393],{"class":129},"has",[108,395,211],{"class":122},[108,397,308],{"class":118},[108,399,400],{"class":122},"))",[108,402,199],{"class":122},[108,404,406,408,410,412,414,416],{"class":110,"line":405},13,[108,407,347],{"class":192},[108,409,208],{"class":129},[108,411,211],{"class":122},[108,413,214],{"class":118},[108,415,217],{"class":122},[108,417,418],{"class":175},"   \u002F\u002F infrastructure subdomains go to their own apps\n",[108,420,422],{"class":110,"line":421},14,[108,423,378],{"class":122},[108,425,427],{"class":110,"line":426},15,[108,428,186],{"emptyLinePlaceholder":185},[108,430,432],{"class":110,"line":431},16,[108,433,434],{"class":175},"    \u002F\u002F Anything else is a gift: send it to the viewer app,\n",[108,436,438],{"class":110,"line":437},17,[108,439,440],{"class":175},"    \u002F\u002F and remember which subdomain was asked for.\n",[108,442,444,446,449,451,453,455],{"class":110,"line":443},18,[108,445,225],{"class":114},[108,447,448],{"class":118}," originalHost",[108,450,123],{"class":122},[108,452,228],{"class":118},[108,454,242],{"class":122},[108,456,457],{"class":118},"hostname\n",[108,459,461,464,466,468,470],{"class":110,"line":460},19,[108,462,463],{"class":118},"    url",[108,465,242],{"class":122},[108,467,265],{"class":118},[108,469,123],{"class":122},[108,471,472],{"class":118}," VIEWER_HOST\n",[108,474,476,478,481,483,485,488,490,492,494,497],{"class":110,"line":475},20,[108,477,225],{"class":114},[108,479,480],{"class":118}," proxied",[108,482,123],{"class":122},[108,484,126],{"class":114},[108,486,487],{"class":129}," Request",[108,489,211],{"class":122},[108,491,245],{"class":118},[108,493,146],{"class":122},[108,495,496],{"class":118}," request",[108,498,248],{"class":122},[108,500,502,505,507,510,512,515,517,519,522,524,526,528],{"class":110,"line":501},21,[108,503,504],{"class":118},"    proxied",[108,506,242],{"class":122},[108,508,509],{"class":118},"headers",[108,511,242],{"class":122},[108,513,514],{"class":129},"set",[108,516,211],{"class":122},[108,518,137],{"class":136},[108,520,521],{"class":140},"X-Forwarded-Host",[108,523,137],{"class":136},[108,525,146],{"class":122},[108,527,448],{"class":118},[108,529,248],{"class":122},[108,531,533,536,538,540,543],{"class":110,"line":532},22,[108,534,535],{"class":192},"    return",[108,537,208],{"class":129},[108,539,211],{"class":122},[108,541,542],{"class":118},"proxied",[108,544,248],{"class":122},[108,546,548],{"class":110,"line":547},23,[108,549,550],{"class":122},"  },\n",[108,552,554],{"class":110,"line":553},24,[108,555,556],{"class":122},"}\n",[29,558,559,560,562,563,565,566,569],{},"The viewer reads ",[40,561,521],{}," to work out which gift to show. That header is the key detail. Once a request has been proxied, the viewer sees its own hostname, not the one the recipient typed. Passing the original along in a header is the standard way proxies deal with this, and it's why ",[40,564,521],{}," and ",[40,567,568],{},"X-Forwarded-For"," show up all over web infrastructure.",[29,571,572,573,575,576,242],{},"It's a few dozen lines, and the whole product depends on them. The same reserved list lives in the shared package too, so nobody can give their gift a name like ",[40,574,152],{}," or ",[40,577,161],{},[50,579,581],{"id":580},"the-browser-doesnt-get-to-decide-whats-paid","The browser doesn't get to decide what's paid",[29,583,584],{},"Forevermore uses Supabase, which means the browser talks to Postgres more or less directly for anything the user is allowed to change. The editor saves a gift's title, photos and letter by updating the row itself, without a custom API endpoint for every field. That's fast to build with, but it moves the security boundary. If the browser can write to the database, the database's own permissions are the only thing standing between a user and any column they can name.",[29,586,587],{},"Postgres gives you two layers for this, and it helps to keep them separate in your head:",[589,590,591,603],"ul",{},[592,593,594,597,598,602],"li",{},[65,595,596],{},"Row Level Security (RLS)"," decides ",[599,600,601],"em",{},"which rows"," a user can touch. For gifts, the policy boils down to \"only your own\".",[592,604,605,608,609,612],{},[65,606,607],{},"Column privileges"," decide ",[599,610,611],{},"which columns"," in those rows a user can write.",[29,614,615,616,619,620,622],{},"A lot of Supabase tutorials stop at RLS. But RLS alone would still let someone update the ",[40,617,618],{},"status"," of their own gift, and ",[40,621,618],{}," is the column that says whether a gift has been paid for and published. So the signed-in role is granted write access to a specific list of columns and nothing else. In spirit, it looks like this:",[33,624,628],{"className":625,"code":626,"language":627,"meta":42,"style":42},"language-sql shiki shiki-themes vitesse-light vitesse-dark","-- Start from nothing...\nrevoke insert, update on public.projects from authenticated;\n\n-- ...then allow only what the editor needs.\ngrant update (title, recipient_name, dedication, metadata, template_slug)\n  on public.projects to authenticated;\n\n-- No `status`, no publish timestamps.\n-- Only server code using the service role can change those.\n","sql",[40,629,630,635,668,672,677,688,704,708,713],{"__ignoreMap":42},[108,631,632],{"class":110,"line":111},[108,633,634],{"class":175},"-- Start from nothing...\n",[108,636,637,640,643,647,650,653,657,659,662,665],{"class":110,"line":182},[108,638,639],{"class":192},"revoke",[108,641,642],{"class":192}," insert",[108,644,646],{"class":645},"s8w-G",", ",[108,648,649],{"class":192},"update",[108,651,652],{"class":192}," on",[108,654,656],{"class":655},"sfsYZ"," public",[108,658,242],{"class":645},[108,660,661],{"class":655},"projects",[108,663,664],{"class":192}," from",[108,666,667],{"class":645}," authenticated;\n",[108,669,670],{"class":110,"line":189},[108,671,186],{"emptyLinePlaceholder":185},[108,673,674],{"class":110,"line":202},[108,675,676],{"class":175},"-- ...then allow only what the editor needs.\n",[108,678,679,682,685],{"class":110,"line":222},[108,680,681],{"class":192},"grant",[108,683,684],{"class":192}," update",[108,686,687],{"class":645}," (title, recipient_name, dedication, metadata, template_slug)\n",[108,689,690,693,695,697,699,702],{"class":110,"line":251},[108,691,692],{"class":192},"  on",[108,694,656],{"class":655},[108,696,242],{"class":645},[108,698,661],{"class":655},[108,700,701],{"class":192}," to",[108,703,667],{"class":645},[108,705,706],{"class":110,"line":294},[108,707,186],{"emptyLinePlaceholder":185},[108,709,710],{"class":110,"line":299},[108,711,712],{"class":175},"-- No `status`, no publish timestamps.\n",[108,714,715],{"class":110,"line":324},[108,716,717],{"class":175},"-- Only server code using the service role can change those.\n",[29,719,720,721,724],{},"The real list is longer, but the principle is the same. The first line matters more than it looks. In Postgres, a table-level ",[40,722,723],{},"UPDATE"," grant covers every column, and revoking one column afterwards doesn't carve it out. Revoking at the table level and granting back an explicit list is the reliable way to do it, and it has a nice side effect: a column added later is locked by default instead of open by default.",[81,726,728],{"id":727},"publishing-happens-on-the-server-after-payment","Publishing happens on the server, after payment",[29,730,731,732,735],{},"So who ",[599,733,734],{},"does"," change a gift's status? The payment webhook. When Paddle confirms a charge, it calls an endpoint on the server, which verifies the request and publishes the gift using the service role. The browser never sends a \"mark as paid\" request, because there's nothing it could send that the database would accept.",[29,737,738],{},"That also covers a case that's easy to forget: someone pays, then closes the tab before the success page loads. If publishing depended on the browser reaching that page, they'd have paid for a gift that's stuck in the editor. Because the webhook goes from Paddle's server to ours, the gift gets published whether or not the browser is still around.",[740,741,744],"callout",{"title":742,"type":743},"Why not just validate harder in the frontend?","note",[29,745,746,747,750],{},"Frontend validation is guidance for honest users, not protection. Anyone can open dev tools and send whatever request they like. Locking down what the database ",[599,748,749],{},"accepts"," means a bug in the UI, or a curious user, can't publish something that wasn't paid for. And when a grant is wrong, you find out through a loud permission error, not a quiet data problem three weeks later.",[752,753,754],"style",{},"html pre.shiki code .s5TCs, html code.shiki .s5TCs{--shiki-default:#AB5959;--shiki-dark:#CB7676}html pre.shiki code .s9nN2, html code.shiki .s9nN2{--shiki-default:#B07D48;--shiki-dark:#BD976A}html pre.shiki code .si6no, html code.shiki .si6no{--shiki-default:#999999;--shiki-dark:#666666}html pre.shiki code .s_xSY, html code.shiki .s_xSY{--shiki-default:#59873A;--shiki-dark:#80A665}html pre.shiki code .scnC2, html code.shiki .scnC2{--shiki-default:#B5695977;--shiki-dark:#C98A7D77}html pre.shiki code .spP0B, html code.shiki .spP0B{--shiki-default:#B56959;--shiki-dark:#C98A7D}html pre.shiki code .snYqZ, html code.shiki .snYqZ{--shiki-default:#A0ADA0;--shiki-dark:#758575DD}html pre.shiki code .sTPum, html code.shiki .sTPum{--shiki-default:#1E754F;--shiki-dark:#4D9375}html pre.shiki code .sqbOQ, html code.shiki .sqbOQ{--shiki-default:#2F798A;--shiki-dark:#4C9A91}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);}html pre.shiki code .s8w-G, html code.shiki .s8w-G{--shiki-default:#393A34;--shiki-dark:#DBD7CAEE}html pre.shiki code .sfsYZ, html code.shiki .sfsYZ{--shiki-default:#A65E2B;--shiki-dark:#C99076}",{"title":42,"searchDepth":182,"depth":189,"links":756},[757,760],{"id":52,"depth":182,"text":53,"children":758},[759],{"id":83,"depth":189,"text":84},{"id":580,"depth":182,"text":581,"children":761},[762],{"id":727,"depth":189,"text":728},"How every gift gets its own address on a host that doesn't support wildcard domains, and how Postgres grants keep payment state out of the browser's reach.","md",false,"personal",{},{},{"title":770},"Architecture",999,"\u002Fprojects\u002Fforevermore\u002Farchitecture",null,{"title":24,"description":763},[],"projects\u002Fforevermore\u002F1.architecture","1YEdiK0sabuOFFzFPeypQMVXYjOSRcwKZASEI3yqezg",[779,788,790,797],{"path":780,"title":781,"stem":782,"navigation":185,"description":783,"preview":784},"\u002Fprojects\u002Fforevermore","Forevermore","projects\u002Fforevermore\u002Findex","A platform for personalized animated gift worlds. Pick a scene, fill it with photos and a song, and send it like a message.",{"src":785,"alt":786,"og":787},"\u002Fprojects\u002Fforevermore\u002Fpreview.webp","Forevermore: 'Create a world for someone you love.' beside three animated gift worlds fanned out like postcards","\u002Fprojects\u002Fforevermore\u002Fog.png",{"path":772,"title":24,"stem":776,"navigation":789,"description":763,"preview":773},{"title":770},{"path":791,"title":792,"stem":793,"navigation":794,"description":796,"preview":773},"\u002Fprojects\u002Fforevermore\u002Frendering-pipeline","One payload, many worlds","projects\u002Fforevermore\u002F2.rendering-pipeline",{"title":795},"Rendering pipeline","How dozens of very different worlds read one data contract, why a video is secretly a photo, and why most of the 3D assets are code instead of files.",{"path":798,"title":799,"stem":800,"navigation":801,"description":803,"preview":773},"\u002Fprojects\u002Fforevermore\u002Fautopilot","A marketing pipeline built as a state machine","projects\u002Fforevermore\u002F3.autopilot",{"title":802},"Autopilot","Autopilot plans, writes, renders and checks a week of social posts on its own. How a state machine, compare-and-swap updates and injected dependencies make it safe to run unattended.",[805,806,807,808,809,810,811,812,813,814,815,816,817],"TypeScript","Vue 3","Nuxt","Pinia","Tailwind CSS","Python","FastAPI","SQLAlchemy","Pydantic","PostgreSQL","Supabase","GCP","Firebase",1790471332097]