[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"docs-navigation":3,"docs-page-storage":166},[4,38,57,65,114,138,157],{"id":5,"group":6,"pages":7},"get-started","Get started",[8,13,18,23,28,33],{"path":9,"title":10,"description":11,"navTitle":10,"group":6,"order":12},"\u002Fdocs\u002Fgetting-started","Getting started","Create your app from the kit, run it locally with no credentials, make yourself superadmin and find where your own code goes.",1,{"path":14,"title":15,"description":16,"navTitle":15,"group":6,"order":17},"\u002Fdocs\u002Flayout","Project structure","Where code lives by where it runs, and which server\u002F directories turn a new file into a route, a job or a provider.",2,{"path":19,"title":20,"description":21,"navTitle":20,"group":6,"order":22},"\u002Fdocs\u002Fconfiguration","Configuration","starter.config.ts switches each part of the kit and its providers, environment variables hold the secrets: every key and what it changes.",3,{"path":24,"title":25,"description":26,"navTitle":25,"group":6,"order":27},"\u002Fdocs\u002Ftemplate","Updating from the kit","Keep the kit as a git remote and take each update as one commit you review, with pnpm template:update, then resolve what conflicts.",4,{"path":29,"title":30,"description":31,"navTitle":30,"group":6,"order":32},"\u002Fdocs\u002Fdeployment","Deployment","Ship the app as one Cloudflare Worker: create the resources, set the secrets, deploy main from GitHub Actions or your machine.",5,{"path":34,"title":35,"description":36,"navTitle":35,"group":6,"order":37},"\u002Fdocs\u002Fmcp","MCP server","Connect Claude Code, Cursor or VS Code to https:\u002F\u002Fnuxter.app\u002Fmcp, so your agent searches and reads these docs and their design notes.",6,{"id":39,"group":40,"pages":41},"accounts","Accounts",[42,47,52],{"path":43,"title":44,"description":45,"navTitle":44,"group":40,"order":46},"\u002Fdocs\u002Fauth","Auth","Pick the sign-in methods, add an OAuth provider, protect a route, and handle sessions, second factors and account deletion.",7,{"path":48,"title":49,"description":50,"navTitle":49,"group":40,"order":51},"\u002Fdocs\u002Ftenancy","Organizations","Scope routes and pages to an organization, and work with its members, roles, seats, invitations and a second level such as branches.",8,{"path":53,"title":54,"description":55,"navTitle":54,"group":40,"order":56},"\u002Fdocs\u002Fpermissions","Roles and permissions","Routes check permissions that an account’s role or its organization’s plan grants: checking one, adding a role, and the staff tools.",9,{"id":58,"group":59,"pages":60},"payments","Payments",[61],{"path":62,"title":59,"description":63,"navTitle":59,"group":59,"order":64},"\u002Fdocs\u002Fpayments","Sell plans through Stripe, Polar, Lemon Squeezy or Creem: define plans, connect a provider, gate features, trials, coupons and refunds.",10,{"id":66,"group":67,"pages":68},"building-blocks","Building blocks",[69,74,79,84,89,94,99,104,109],{"path":70,"title":71,"description":72,"navTitle":71,"group":67,"order":73},"\u002Fdocs\u002Fdatabase","Database","Postgres through Drizzle, on Hyperdrive in production and PGlite locally: add a table, query, transactions, migrations and seed data.",11,{"path":75,"title":76,"description":77,"navTitle":76,"group":67,"order":78},"\u002Fdocs\u002Fbackground-jobs","Background jobs","Run work after the response on Cloudflare Queues with retries: add and enqueue a job, run jobs locally, and schedule cron tasks.",12,{"path":80,"title":81,"description":82,"navTitle":81,"group":67,"order":83},"\u002Fdocs\u002Fevents","Events","React to what the kit does by adding a file to server\u002Flisteners\u002F, fire events of your own, and look up every event’s payload.",13,{"path":85,"title":86,"description":87,"navTitle":86,"group":67,"order":88},"\u002Fdocs\u002Femail","Email","Set up sending, send your own mail with deliverEmail, change the templates and wording, handle unsubscribes and add a provider.",14,{"path":90,"title":91,"description":92,"navTitle":91,"group":67,"order":93},"\u002Fdocs\u002Fcontact","Contact","A \u002Fcontact page that mails you a message or shows your address after a Turnstile check: choose the channels, set the address, know the limits.",15,{"path":95,"title":96,"description":97,"navTitle":96,"group":67,"order":98},"\u002Fdocs\u002Fnotifications","Notifications","The dashboard bell: add a notification type, send one with notify(), and how categories, opt-outs and retention apply.",16,{"path":100,"title":101,"description":102,"navTitle":101,"group":67,"order":103},"\u002Fdocs\u002Fstorage","Storage","Uploads in R2 with one policy per purpose: add a purpose, accept an upload, replace or delete a file, and serve it to its owner.",17,{"path":105,"title":106,"description":107,"navTitle":106,"group":67,"order":108},"\u002Fdocs\u002Ffeature-flags","Feature flags","Declare typed flags in starter.config.ts, read them with flag() or useFlag(), roll them out by rule or percentage, and add a provider.",18,{"path":110,"title":111,"description":112,"navTitle":111,"group":67,"order":113},"\u002Fdocs\u002Fai","AI","Call models from server code by role: text, images, speech, transcription, embeddings and video, with provider fallbacks.",19,{"id":115,"group":116,"pages":117},"web","Web",[118,123,128,133],{"path":119,"title":120,"description":121,"navTitle":120,"group":116,"order":122},"\u002Fdocs\u002Fsecurity-headers","Security headers","The Content-Security-Policy and hardening headers on every response: allow another origin, keep tokens out of Referer, accept cross-site posts.",20,{"path":124,"title":125,"description":126,"navTitle":125,"group":116,"order":127},"\u002Fdocs\u002Frate-limiting","Rate limiting","Cap how often a caller hits a route with requireRateLimit, count only failures, space out sends, and protect the zone.",21,{"path":129,"title":130,"description":131,"navTitle":130,"group":116,"order":132},"\u002Fdocs\u002Fanalytics","Analytics","Product analytics and ad conversions sent from the server with no browser script: add an event, connect a provider, and handle consent.",22,{"path":134,"title":135,"description":136,"navTitle":135,"group":116,"order":137},"\u002Fdocs\u002Fobservability","Observability","Find a failed request by its id, send faults to Sentry or Better Stack, report an error you caught, and change the error page.",23,{"id":139,"group":140,"pages":141},"content-and-look","Content and look",[142,147,152],{"path":143,"title":144,"description":145,"navTitle":144,"group":140,"order":146},"\u002Fdocs\u002Fblog","Blog","Markdown posts with an RSS feed per language, and workflows that draft posts with an AI model and open each one as a pull request.",24,{"path":148,"title":149,"description":150,"navTitle":149,"group":140,"order":151},"\u002Fdocs\u002Flanguages","Languages","Add or remove a language, write copy in the locale files, and write code, refusals and links that follow the reader’s language.",25,{"path":153,"title":154,"description":155,"navTitle":154,"group":140,"order":156},"\u002Fdocs\u002Ftheme","Theme","Change the brand colour and font, and build dashboard sections, list pages, loading placeholders and charts that match the kit.",26,{"id":158,"group":159,"pages":160},"quality","Quality",[161],{"path":162,"title":163,"description":164,"navTitle":163,"group":159,"order":165},"\u002Fdocs\u002Ftesting","Testing","Unit tests in Node, component tests in a real Nuxt app and Playwright end to end: test a route or a component, and what CI runs.",27,{"page":167,"surround":1682,"linksToKit":574},{"path":100,"title":101,"description":102,"navTitle":101,"group":67,"seo":168,"body":170},{"title":169,"description":102},"File uploads to Cloudflare R2 in Nuxt",{"type":171,"value":172,"toc":1671},"minimark",[173,177,181,186,254,268,272,286,413,424,450,460,497,514,518,528,598,604,882,895,900,989,1004,1015,1019,1037,1048,1216,1219,1223,1226,1249,1258,1284,1287,1304,1409,1428,1432,1450,1486,1489,1494,1620,1623,1627,1667],[174,175,101],"h1",{"id":176},"storage",[178,179,180],"p",{},"Uploaded files live in R2, under one policy per purpose: user avatars,\norganization photos, and whatever you add. You work here when you accept a new\nkind of upload, change a size limit or a format, or serve files only to their\nowner.",[182,183,185],"h2",{"id":184},"how-it-works","How it works",[187,188,189,202,217,247],"ol",{},[190,191,192,193,197,198,201],"li",{},"A purpose in ",[194,195,196],"code",{},"storage.purposes"," names the R2 binding and bucket, the\nformats it accepts, its size cap, who may read it, and its\n",[194,199,200],{},"Cache-Control",".",[190,203,204,205,208,209,212,213,216],{},"Your upload route calls ",[194,206,207],{},"readUpload",", which checks the size and sniffs the\nfile's first bytes, then ",[194,210,211],{},"newObjectKey"," and ",[194,214,215],{},"putObject",", then writes the key\nto your row.",[190,218,219,220,223,224,234,235,238,239,242,243,246],{},"A key is ",[194,221,222],{},"\u003Cpurpose>\u002F\u003Cowner>\u002F\u003C32 hex>.\u003Cext>",".\n",[225,226,231],"a",{"href":227,"rel":228,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fmodules\u002Fstorage-routes.ts",[229],"nofollow","Licensees only",[194,232,233],{},"modules\u002Fstorage-routes.ts"," registers\n",[194,236,237],{},"GET \u002Fapi\u002F\u003Cpurpose>\u002F:owner\u002F:file"," for every purpose, so\n",[194,240,241],{},"objectSrc(key)"," is just ",[194,244,245],{},"\u002Fapi\u002F"," plus the key.",[190,248,249,250,253],{},"To replace a file, store the new one, point the row at it, then\n",[194,251,252],{},"deleteObject"," the old one.",[178,255,256,257,212,260,263,264,267],{},"The shipped purposes are ",[194,258,259],{},"avatars",[194,261,262],{},"org-avatars",". Both use the ",[194,265,266],{},"AVATARS","\nbinding and bucket, and both are public.",[182,269,271],{"id":270},"add-a-purpose","Add a purpose",[178,273,274,275,277,278,285],{},"Add an entry to ",[194,276,196],{}," in\n",[225,279,282],{"href":280,"rel":281,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fstarter.config.ts",[229],[194,283,284],{},"starter.config.ts",". An invoice store that only the\nuploader can read:",[287,288,293],"pre",{"className":289,"code":290,"language":291,"meta":292,"style":292},"language-ts shiki shiki-themes github-light github-light github-dark","invoices: {\n  binding: 'INVOICES',\n  bucket: 'my-app-invoices',\n  formats: ['pdf'],\n  maxBytes: 10 * 1024 * 1024,\n  noun: 'invoice',\n  access: 'owner',\n  cacheControl: 'private, no-store',\n},\n","ts","",[194,294,295,307,322,334,348,372,384,396,408],{"__ignoreMap":292},[296,297,299,303],"span",{"class":298,"line":12},"line",[296,300,302],{"class":301},"snPdu","invoices",[296,304,306],{"class":305},"sxrX7",": {\n",[296,308,309,312,315,319],{"class":298,"line":17},[296,310,311],{"class":301},"  binding",[296,313,314],{"class":305},": ",[296,316,318],{"class":317},"sIIMD","'INVOICES'",[296,320,321],{"class":305},",\n",[296,323,324,327,329,332],{"class":298,"line":22},[296,325,326],{"class":301},"  bucket",[296,328,314],{"class":305},[296,330,331],{"class":317},"'my-app-invoices'",[296,333,321],{"class":305},[296,335,336,339,342,345],{"class":298,"line":27},[296,337,338],{"class":301},"  formats",[296,340,341],{"class":305},": [",[296,343,344],{"class":317},"'pdf'",[296,346,347],{"class":305},"],\n",[296,349,350,353,355,359,363,366,368,370],{"class":298,"line":32},[296,351,352],{"class":301},"  maxBytes",[296,354,314],{"class":305},[296,356,358],{"class":357},"sBjJW","10",[296,360,362],{"class":361},"s8jYJ"," *",[296,364,365],{"class":357}," 1024",[296,367,362],{"class":361},[296,369,365],{"class":357},[296,371,321],{"class":305},[296,373,374,377,379,382],{"class":298,"line":37},[296,375,376],{"class":301},"  noun",[296,378,314],{"class":305},[296,380,381],{"class":317},"'invoice'",[296,383,321],{"class":305},[296,385,386,389,391,394],{"class":298,"line":46},[296,387,388],{"class":301},"  access",[296,390,314],{"class":305},[296,392,393],{"class":317},"'owner'",[296,395,321],{"class":305},[296,397,398,401,403,406],{"class":298,"line":51},[296,399,400],{"class":301},"  cacheControl",[296,402,314],{"class":305},[296,404,405],{"class":317},"'private, no-store'",[296,407,321],{"class":305},[296,409,410],{"class":298,"line":56},[296,411,412],{"class":305},"},\n",[178,414,415,416,423],{},"Bind the bucket in ",[225,417,420],{"href":418,"rel":419,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fwrangler.jsonc",[229],[194,421,422],{},"wrangler.jsonc",":",[287,425,429],{"className":426,"code":427,"language":428,"meta":292,"style":292},"language-jsonc shiki shiki-themes github-light github-light github-dark","\"r2_buckets\": [\n  { \"binding\": \"AVATARS\", \"bucket_name\": \"nuxter-avatars\" },\n  { \"binding\": \"INVOICES\", \"bucket_name\": \"my-app-invoices\" },\n],\n","jsonc",[194,430,431,436,441,446],{"__ignoreMap":292},[296,432,433],{"class":298,"line":12},[296,434,435],{},"\"r2_buckets\": [\n",[296,437,438],{"class":298,"line":17},[296,439,440],{},"  { \"binding\": \"AVATARS\", \"bucket_name\": \"nuxter-avatars\" },\n",[296,442,443],{"class":298,"line":22},[296,444,445],{},"  { \"binding\": \"INVOICES\", \"bucket_name\": \"my-app-invoices\" },\n",[296,447,448],{"class":298,"line":27},[296,449,347],{},[178,451,452,453,456,457,423],{},"Then regenerate the ",[194,454,455],{},"Env"," types, create the bucket before you deploy, and\nrestart ",[194,458,459],{},"pnpm dev",[287,461,465],{"className":462,"code":463,"language":464,"meta":292,"style":292},"language-bash shiki shiki-themes github-light github-light github-dark","pnpm cf-typegen\npnpm exec wrangler r2 bucket create my-app-invoices\n","bash",[194,466,467,475],{"__ignoreMap":292},[296,468,469,472],{"class":298,"line":12},[296,470,471],{"class":301},"pnpm",[296,473,474],{"class":317}," cf-typegen\n",[296,476,477,479,482,485,488,491,494],{"class":298,"line":17},[296,478,471],{"class":301},[296,480,481],{"class":317}," exec",[296,483,484],{"class":317}," wrangler",[296,486,487],{"class":317}," r2",[296,489,490],{"class":317}," bucket",[296,492,493],{"class":317}," create",[296,495,496],{"class":317}," my-app-invoices\n",[178,498,499,500,503,504,506,507,509,510,513],{},"The purpose id is the key prefix, the URL segment and the config key at once,\nso it must match ",[194,501,502],{},"\u002F^[a-z][a-z0-9-]*$\u002F",". ",[194,505,459],{}," and the build refuse a\npurpose whose binding ",[194,508,422],{}," doesn't declare, or whose ",[194,511,512],{},"bucket","\ndiffers from the one that binding points at. Locally, R2 runs in Wrangler's\nsimulator and needs no bucket.",[182,515,517],{"id":516},"accept-an-upload","Accept an upload",[178,519,520,521,524,525,423],{},"The client posts ",[194,522,523],{},"multipart\u002Fform-data"," with the file in a field named ",[194,526,527],{},"file",[287,529,531],{"className":289,"code":530,"language":291,"meta":292,"style":292},"const body = new FormData()\nbody.append('file', file)\n\nawait $fetch('\u002Fapi\u002Finvoices', { method: 'POST', body })\n",[194,532,533,553,570,576],{"__ignoreMap":292},[296,534,535,538,541,544,547,550],{"class":298,"line":12},[296,536,537],{"class":361},"const",[296,539,540],{"class":357}," body",[296,542,543],{"class":361}," =",[296,545,546],{"class":361}," new",[296,548,549],{"class":301}," FormData",[296,551,552],{"class":305},"()\n",[296,554,555,558,561,564,567],{"class":298,"line":17},[296,556,557],{"class":305},"body.",[296,559,560],{"class":301},"append",[296,562,563],{"class":305},"(",[296,565,566],{"class":317},"'file'",[296,568,569],{"class":305},", file)\n",[296,571,572],{"class":298,"line":22},[296,573,575],{"emptyLinePlaceholder":574},true,"\n",[296,577,578,581,584,586,589,592,595],{"class":298,"line":27},[296,579,580],{"class":361},"await",[296,582,583],{"class":301}," $fetch",[296,585,563],{"class":305},[296,587,588],{"class":317},"'\u002Fapi\u002Finvoices'",[296,590,591],{"class":305},", { method: ",[296,593,594],{"class":317},"'POST'",[296,596,597],{"class":305},", body })\n",[178,599,600,601,423],{},"The route, ",[194,602,603],{},"server\u002Fapi\u002Finvoices.post.ts",[287,605,607],{"className":289,"code":606,"language":291,"meta":292,"style":292},"import {\n  deleteObject,\n  newObjectKey,\n  putObject,\n  readUpload,\n} from '#server\u002Fstorage'\nimport { objectSrc } from '#shared\u002Futils\u002Fstorage'\n\nexport default defineEventHandler(async (event) => {\n  const session = await requireVerifiedSession(event)\n\n  const upload = await readUpload(event, 'invoices')\n  const key = newObjectKey('invoices', session.user.id, upload.format)\n\n  await putObject(event, 'invoices', key, upload)\n\n  try {\n    await useDrizzle(event)\n      .insert(tables.invoices)\n      .values({ userId: session.user.id, key })\n  } catch (error) {\n    await deleteObject(event, 'invoices', key)\n    throw error\n  }\n\n  return { src: objectSrc(key) }\n})\n",[194,608,609,617,622,627,632,637,648,660,664,695,714,718,741,760,764,779,783,790,800,811,821,832,846,854,859,863,877],{"__ignoreMap":292},[296,610,611,614],{"class":298,"line":12},[296,612,613],{"class":361},"import",[296,615,616],{"class":305}," {\n",[296,618,619],{"class":298,"line":17},[296,620,621],{"class":305},"  deleteObject,\n",[296,623,624],{"class":298,"line":22},[296,625,626],{"class":305},"  newObjectKey,\n",[296,628,629],{"class":298,"line":27},[296,630,631],{"class":305},"  putObject,\n",[296,633,634],{"class":298,"line":32},[296,635,636],{"class":305},"  readUpload,\n",[296,638,639,642,645],{"class":298,"line":37},[296,640,641],{"class":305},"} ",[296,643,644],{"class":361},"from",[296,646,647],{"class":317}," '#server\u002Fstorage'\n",[296,649,650,652,655,657],{"class":298,"line":46},[296,651,613],{"class":361},[296,653,654],{"class":305}," { objectSrc } ",[296,656,644],{"class":361},[296,658,659],{"class":317}," '#shared\u002Futils\u002Fstorage'\n",[296,661,662],{"class":298,"line":51},[296,663,575],{"emptyLinePlaceholder":574},[296,665,666,669,672,675,677,680,683,687,690,693],{"class":298,"line":56},[296,667,668],{"class":361},"export",[296,670,671],{"class":361}," default",[296,673,674],{"class":301}," defineEventHandler",[296,676,563],{"class":305},[296,678,679],{"class":361},"async",[296,681,682],{"class":305}," (",[296,684,686],{"class":685},"sP4rz","event",[296,688,689],{"class":305},") ",[296,691,692],{"class":361},"=>",[296,694,616],{"class":305},[296,696,697,700,703,705,708,711],{"class":298,"line":64},[296,698,699],{"class":361},"  const",[296,701,702],{"class":357}," session",[296,704,543],{"class":361},[296,706,707],{"class":361}," await",[296,709,710],{"class":301}," requireVerifiedSession",[296,712,713],{"class":305},"(event)\n",[296,715,716],{"class":298,"line":73},[296,717,575],{"emptyLinePlaceholder":574},[296,719,720,722,725,727,729,732,735,738],{"class":298,"line":78},[296,721,699],{"class":361},[296,723,724],{"class":357}," upload",[296,726,543],{"class":361},[296,728,707],{"class":361},[296,730,731],{"class":301}," readUpload",[296,733,734],{"class":305},"(event, ",[296,736,737],{"class":317},"'invoices'",[296,739,740],{"class":305},")\n",[296,742,743,745,748,750,753,755,757],{"class":298,"line":83},[296,744,699],{"class":361},[296,746,747],{"class":357}," key",[296,749,543],{"class":361},[296,751,752],{"class":301}," newObjectKey",[296,754,563],{"class":305},[296,756,737],{"class":317},[296,758,759],{"class":305},", session.user.id, upload.format)\n",[296,761,762],{"class":298,"line":88},[296,763,575],{"emptyLinePlaceholder":574},[296,765,766,769,772,774,776],{"class":298,"line":93},[296,767,768],{"class":361},"  await",[296,770,771],{"class":301}," putObject",[296,773,734],{"class":305},[296,775,737],{"class":317},[296,777,778],{"class":305},", key, upload)\n",[296,780,781],{"class":298,"line":98},[296,782,575],{"emptyLinePlaceholder":574},[296,784,785,788],{"class":298,"line":103},[296,786,787],{"class":361},"  try",[296,789,616],{"class":305},[296,791,792,795,798],{"class":298,"line":108},[296,793,794],{"class":361},"    await",[296,796,797],{"class":301}," useDrizzle",[296,799,713],{"class":305},[296,801,802,805,808],{"class":298,"line":113},[296,803,804],{"class":305},"      .",[296,806,807],{"class":301},"insert",[296,809,810],{"class":305},"(tables.invoices)\n",[296,812,813,815,818],{"class":298,"line":122},[296,814,804],{"class":305},[296,816,817],{"class":301},"values",[296,819,820],{"class":305},"({ userId: session.user.id, key })\n",[296,822,823,826,829],{"class":298,"line":127},[296,824,825],{"class":305},"  } ",[296,827,828],{"class":361},"catch",[296,830,831],{"class":305}," (error) {\n",[296,833,834,836,839,841,843],{"class":298,"line":132},[296,835,794],{"class":361},[296,837,838],{"class":301}," deleteObject",[296,840,734],{"class":305},[296,842,737],{"class":317},[296,844,845],{"class":305},", key)\n",[296,847,848,851],{"class":298,"line":137},[296,849,850],{"class":361},"    throw",[296,852,853],{"class":305}," error\n",[296,855,856],{"class":298,"line":146},[296,857,858],{"class":305},"  }\n",[296,860,861],{"class":298,"line":151},[296,862,575],{"emptyLinePlaceholder":574},[296,864,865,868,871,874],{"class":298,"line":156},[296,866,867],{"class":361},"  return",[296,869,870],{"class":305}," { src: ",[296,872,873],{"class":301},"objectSrc",[296,875,876],{"class":305},"(key) }\n",[296,878,879],{"class":298,"line":165},[296,880,881],{"class":305},"})\n",[178,883,884,887,888,891,892,894],{},[194,885,886],{},"tables.invoices"," is your own table in ",[194,889,890],{},"server\u002Fdatabase\u002Fcustom\u002Fschema.ts","\n(",[225,893,71],{"href":70},").",[178,896,897,899],{},[194,898,207],{}," throws before anything is stored:",[901,902,903,924],"table",{},[904,905,906],"thead",{},[907,908,909,913,918,921],"tr",{},[910,911,912],"th",{},"Status",[910,914,915],{},[194,916,917],{},"data.reason",[910,919,920],{},"When",[910,922,923],{},"Extra data",[925,926,927,946,969],"tbody",{},[907,928,929,933,938,944],{},[930,931,932],"td",{},"400",[930,934,935],{},[194,936,937],{},"upload-missing",[930,939,940,941,943],{},"No non-empty ",[194,942,527],{}," part",[930,945],{},[907,947,948,951,956,965],{},[930,949,950],{},"413",[930,952,953],{},[194,954,955],{},"upload-too-large",[930,957,958,961,962],{},[194,959,960],{},"Content-Length"," or the file is over ",[194,963,964],{},"maxBytes",[930,966,967],{},[194,968,964],{},[907,970,971,974,979,985],{},[930,972,973],{},"415",[930,975,976],{},[194,977,978],{},"upload-format",[930,980,981,982],{},"The first bytes match none of the purpose's ",[194,983,984],{},"formats",[930,986,987],{},[194,988,984],{},[178,990,991,992,995,996,999,1000,1003],{},"Word the refusal from ",[194,993,994],{},"data"," in the reader's language. The English\n",[194,997,998],{},"statusMessage"," is built from the policy for logs: ",[194,1001,1002],{},"noun: 'invoice'"," gives\n\"Choose an invoice up to 10 MB.\" and \"Use a PDF invoice.\"",[178,1005,1006,1008,1009,682,1012,1014],{},[194,1007,215],{}," stores the object with the sniffed format's Content-Type and fires\n",[194,1010,1011],{},"storage:uploaded",[225,1013,81],{"href":80},"). A failed put throws, so your row\nis never written for an object that isn't there.",[182,1016,1018],{"id":1017},"replace-or-delete-a-file","Replace or delete a file",[178,1020,1021,1024,1025,1028,1029,1032,1033,1036],{},[194,1022,1023],{},"deleteObject(event, purpose, key, replacing?)"," never throws. A failed delete\nis swallowed and leaves the object in the bucket; only a delete that happened\nfires ",[194,1026,1027],{},"storage:deleted",". It does nothing when ",[194,1030,1031],{},"key"," is empty or equals\n",[194,1034,1035],{},"replacing",", which protects the object the row points at now.",[178,1038,1039,1040,1047],{},"When two uploads can race, make the row write a compare-and-set on the key you\nread first, as ",[225,1041,1044],{"href":1042,"rel":1043,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fauth\u002Froutes\u002Favatar.put.ts",[229],[194,1045,1046],{},"server\u002Fauth\u002Froutes\u002Favatar.put.ts","\ndoes:",[287,1049,1051],{"className":289,"code":1050,"language":291,"meta":292,"style":292},"const swapped = await useDrizzle(event)\n  .update(tables.invoices)\n  .set({ key })\n  .where(\n    and(eq(tables.invoices.id, invoiceId), eq(tables.invoices.key, previous)),\n  )\n  .returning({ id: tables.invoices.id })\n\nif (swapped.length === 0) {\n  await deleteObject(event, 'invoices', key)\n\n  throw createError({ statusCode: 409, statusMessage: 'Reload and try again.' })\n}\n\nawait deleteObject(event, 'invoices', previous, key)\n",[194,1052,1053,1068,1078,1088,1098,1116,1121,1131,1135,1155,1167,1171,1194,1199,1203],{"__ignoreMap":292},[296,1054,1055,1057,1060,1062,1064,1066],{"class":298,"line":12},[296,1056,537],{"class":361},[296,1058,1059],{"class":357}," swapped",[296,1061,543],{"class":361},[296,1063,707],{"class":361},[296,1065,797],{"class":301},[296,1067,713],{"class":305},[296,1069,1070,1073,1076],{"class":298,"line":17},[296,1071,1072],{"class":305},"  .",[296,1074,1075],{"class":301},"update",[296,1077,810],{"class":305},[296,1079,1080,1082,1085],{"class":298,"line":22},[296,1081,1072],{"class":305},[296,1083,1084],{"class":301},"set",[296,1086,1087],{"class":305},"({ key })\n",[296,1089,1090,1092,1095],{"class":298,"line":27},[296,1091,1072],{"class":305},[296,1093,1094],{"class":301},"where",[296,1096,1097],{"class":305},"(\n",[296,1099,1100,1103,1105,1108,1111,1113],{"class":298,"line":32},[296,1101,1102],{"class":301},"    and",[296,1104,563],{"class":305},[296,1106,1107],{"class":301},"eq",[296,1109,1110],{"class":305},"(tables.invoices.id, invoiceId), ",[296,1112,1107],{"class":301},[296,1114,1115],{"class":305},"(tables.invoices.key, previous)),\n",[296,1117,1118],{"class":298,"line":37},[296,1119,1120],{"class":305},"  )\n",[296,1122,1123,1125,1128],{"class":298,"line":46},[296,1124,1072],{"class":305},[296,1126,1127],{"class":301},"returning",[296,1129,1130],{"class":305},"({ id: tables.invoices.id })\n",[296,1132,1133],{"class":298,"line":51},[296,1134,575],{"emptyLinePlaceholder":574},[296,1136,1137,1140,1143,1146,1149,1152],{"class":298,"line":56},[296,1138,1139],{"class":361},"if",[296,1141,1142],{"class":305}," (swapped.",[296,1144,1145],{"class":357},"length",[296,1147,1148],{"class":361}," ===",[296,1150,1151],{"class":357}," 0",[296,1153,1154],{"class":305},") {\n",[296,1156,1157,1159,1161,1163,1165],{"class":298,"line":64},[296,1158,768],{"class":361},[296,1160,838],{"class":301},[296,1162,734],{"class":305},[296,1164,737],{"class":317},[296,1166,845],{"class":305},[296,1168,1169],{"class":298,"line":73},[296,1170,575],{"emptyLinePlaceholder":574},[296,1172,1173,1176,1179,1182,1185,1188,1191],{"class":298,"line":78},[296,1174,1175],{"class":361},"  throw",[296,1177,1178],{"class":301}," createError",[296,1180,1181],{"class":305},"({ statusCode: ",[296,1183,1184],{"class":357},"409",[296,1186,1187],{"class":305},", statusMessage: ",[296,1189,1190],{"class":317},"'Reload and try again.'",[296,1192,1193],{"class":305}," })\n",[296,1195,1196],{"class":298,"line":83},[296,1197,1198],{"class":305},"}\n",[296,1200,1201],{"class":298,"line":88},[296,1202,575],{"emptyLinePlaceholder":574},[296,1204,1205,1207,1209,1211,1213],{"class":298,"line":93},[296,1206,580],{"class":361},[296,1208,838],{"class":301},[296,1210,734],{"class":305},[296,1212,737],{"class":317},[296,1214,1215],{"class":305},", previous, key)\n",[178,1217,1218],{},"With a plain update, the losing upload's object is referenced by nothing and\nnever cleaned up.",[182,1220,1222],{"id":1221},"serve-files","Serve files",[178,1224,1225],{},"The read route checks the path first: the owner segment must be digits and the\nfile 32 hex characters plus one of the purpose's own extensions. Anything else\nanswers 404.",[1227,1228,1229,1235],"ul",{},[190,1230,1231,1234],{},[194,1232,1233],{},"access: 'public'"," serves the object to anyone who has the URL, with no\ndatabase read. The 32 random hex characters are what keep it unguessable.",[190,1236,1237,1240,1241,1244,1245,1248],{},[194,1238,1239],{},"access: 'owner'"," needs a verified session whose user id equals the key's\nowner segment. A signed-out request gets 401 and another user gets 404, so\nnobody learns the object exists. The response is always ",[194,1242,1243],{},"private, no-store",",\nwhatever ",[194,1246,1247],{},"cacheControl"," says.",[178,1250,1251,1254,1255,1257],{},[194,1252,1253],{},"owner"," reads the owner segment as a user id, so it only works for purposes\nwhose keys are minted with a user id. ",[194,1256,262],{}," keys carry an\norganization id, which is why that purpose stays public.",[178,1259,1260,1261,1264,1265,1267,1268,1271,1272,1275,1276,1283],{},"Responses carry a ",[194,1262,1263],{},"Content-Type"," from the file's extension and a\n",[194,1266,960],{}," from R2. The route ignores ",[194,1269,1270],{},"Range"," and always answers 200 with\nthe whole object. Chrome and Firefox play audio and video from it but can't\nseek without refetching. Safari may refuse media without a 206, so add range\nsupport to ",[194,1273,1274],{},"serveObject"," in ",[225,1277,1280],{"href":1278,"rel":1279,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fstorage\u002Fserve.ts",[229],[194,1281,1282],{},"server\u002Fstorage\u002Fserve.ts","\nbefore serving media to it.",[182,1285,1286],{"id":984},"Formats",[178,1288,1289,1290,1292,1293,277,1296,1303],{},"A purpose's ",[194,1291,984],{}," come from ",[194,1294,1295],{},"STORAGE_FORMATS",[225,1297,1300],{"href":1298,"rel":1299,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fshared\u002Fstorage\u002Fformats.ts",[229],[194,1301,1302],{},"shared\u002Fstorage\u002Fformats.ts",". Each entry has\nthe Content-Type, the label refusals use, and the byte check:",[901,1305,1306,1318],{},[904,1307,1308],{},[907,1309,1310,1313,1315],{},[910,1311,1312],{},"Format",[910,1314,1263],{},[910,1316,1317],{},"Recognized by",[925,1319,1320,1337,1352,1374,1391],{},[907,1321,1322,1327,1332],{},[930,1323,1324],{},[194,1325,1326],{},"jpg",[930,1328,1329],{},[194,1330,1331],{},"image\u002Fjpeg",[930,1333,1334],{},[194,1335,1336],{},"FF D8 FF",[907,1338,1339,1344,1349],{},[930,1340,1341],{},[194,1342,1343],{},"png",[930,1345,1346],{},[194,1347,1348],{},"image\u002Fpng",[930,1350,1351],{},"The 8-byte PNG signature",[907,1353,1354,1359,1364],{},[930,1355,1356],{},[194,1357,1358],{},"webp",[930,1360,1361],{},[194,1362,1363],{},"image\u002Fwebp",[930,1365,1366,1369,1370,1373],{},[194,1367,1368],{},"RIFF"," then ",[194,1371,1372],{},"WEBP"," at byte 8",[907,1375,1376,1381,1386],{},[930,1377,1378],{},[194,1379,1380],{},"pdf",[930,1382,1383],{},[194,1384,1385],{},"application\u002Fpdf",[930,1387,1388],{},[194,1389,1390],{},"%PDF-",[907,1392,1393,1398,1403],{},[930,1394,1395],{},[194,1396,1397],{},"mp3",[930,1399,1400],{},[194,1401,1402],{},"audio\u002Fmpeg",[930,1404,1405,1408],{},[194,1406,1407],{},"ID3",", or an MPEG frame sync",[178,1410,1411,1412,1415,1416,1419,1420,1423,1424,1427],{},"To accept another format, add an entry with its ",[194,1413,1414],{},"mime",", ",[194,1417,1418],{},"label"," and a\n",[194,1421,1422],{},"matches"," function. ",[194,1425,1426],{},"UNSUPPORTED_BY_DESIGN"," lists the ones left out: SVG\ncarries script that would run on your origin, GIF isn't needed by anything\nshipped (adding it is one entry), DOCX and XLSX start with the same bytes as\nany ZIP file, and plain text has no signature to sniff.",[182,1429,1431],{"id":1430},"avatars-in-the-browser","Avatars in the browser",[178,1433,1434,1437,1438,1441,1442,1449],{},[194,1435,1436],{},"AvatarDropzone.vue"," checks a picked file with ",[194,1439,1440],{},"imageFileSchema(purpose)"," from\n",[225,1443,1446],{"href":1444,"rel":1445,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fshared\u002Fschemas\u002Fprofile.ts",[229],[194,1447,1448],{},"shared\u002Fschemas\u002Fprofile.ts",", which reads the\nsize and types from the same purpose. The server checks both again and sniffs\nthe bytes.",[178,1451,1452,1453,1455,1456,1463,1464,1467,1468,1471,1472,1475,1476,1479,1480,1482,1483,201],{},"The ",[194,1454,259],{}," block in ",[225,1457,1460],{"href":1458,"rel":1459,"title":230},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fapp\u002Fapp.config.ts",[229],[194,1461,1462],{},"app\u002Fapp.config.ts"," makes\n",[194,1465,1466],{},"UAvatar"," render a plain ",[194,1469,1470],{},"\u003Cimg>",". Through ",[194,1473,1474],{},"@nuxt\u002Fimage"," the URL would go to an\noptimizer that can't read ",[194,1477,1478],{},"\u002Fapi\u002Favatars\u002F**",", and every avatar would fall back\nto initials, which ",[194,1481,1466],{}," builds from ",[194,1484,1485],{},"alt",[182,1487,20],{"id":1488},"configuration",[178,1490,1491,1492,423],{},"Each entry in ",[194,1493,196],{},[901,1495,1496,1509],{},[904,1497,1498],{},[907,1499,1500,1503,1506],{},[910,1501,1502],{},"Field",[910,1504,1505],{},"Shipped for both purposes",[910,1507,1508],{},"What it does",[925,1510,1511,1528,1542,1556,1570,1585,1604],{},[907,1512,1513,1518,1523],{},[930,1514,1515],{},[194,1516,1517],{},"binding",[930,1519,1520],{},[194,1521,1522],{},"'AVATARS'",[930,1524,1525,1526,201],{},"The R2 binding in ",[194,1527,422],{},[907,1529,1530,1534,1539],{},[930,1531,1532],{},[194,1533,512],{},[930,1535,1536],{},[194,1537,1538],{},"'nuxter-avatars'",[930,1540,1541],{},"The bucket that binding points at. Errors print the create command.",[907,1543,1544,1548,1553],{},[930,1545,1546],{},[194,1547,984],{},[930,1549,1550],{},[194,1551,1552],{},"['jpg', 'png', 'webp']",[930,1554,1555],{},"Accepted formats, checked against the first bytes.",[907,1557,1558,1562,1567],{},[930,1559,1560],{},[194,1561,964],{},[930,1563,1564],{},[194,1565,1566],{},"2 * 1024 * 1024",[930,1568,1569],{},"Largest file accepted.",[907,1571,1572,1577,1582],{},[930,1573,1574],{},[194,1575,1576],{},"noun",[930,1578,1579],{},[194,1580,1581],{},"'image'",[930,1583,1584],{},"What English refusal messages call the file.",[907,1586,1587,1592,1597],{},[930,1588,1589],{},[194,1590,1591],{},"access",[930,1593,1594],{},[194,1595,1596],{},"'public'",[930,1598,1599,1601,1602,201],{},[194,1600,1596],{}," or ",[194,1603,393],{},[907,1605,1606,1610,1615],{},[930,1607,1608],{},[194,1609,1247],{},[930,1611,1612],{},[194,1613,1614],{},"'public, max-age=31536000, immutable'",[930,1616,1617,1618,201],{},"Sent on public reads. Ignored for ",[194,1619,1253],{},[178,1621,1622],{},"There are no environment variables. The binding is the whole credential.",[182,1624,1626],{"id":1625},"good-to-know","Good to know",[1227,1628,1629,1637,1640,1643,1655],{},[190,1630,1631,1275,1634,1636],{},[194,1632,1633],{},"immutable",[194,1635,1247],{}," is safe only because every upload gets a fresh\nkey. Never write to an existing key under that header.",[190,1638,1639],{},"Uploads are read into memory whole. That suits files of a few megabytes, not\nhundreds; a large-file purpose needs its own streaming route.",[190,1641,1642],{},"Nothing sweeps orphaned objects. A failed delete leaks the object, and on a\npublic purpose it stays reachable at its URL.",[190,1644,1645,1646,1648,1649,1652,1653,201],{},"The read route serves only keys shaped the way ",[194,1647,211],{}," builds them,\nand a key whose owner segment isn't a number reports ",[194,1650,1651],{},"ownerId: 0"," in the\nstorage events. Build keys with ",[194,1654,211],{},[190,1656,1657,1660,1661,1664,1665,894],{},[194,1658,1659],{},"flags.purpose"," names the purpose whose bucket the ",[194,1662,1663],{},"r2"," feature-flag driver\nreads (",[225,1666,106],{"href":105},[1668,1669,1670],"style",{},"html pre.shiki code .snPdu, html code.shiki .snPdu{--shiki-light:#6F42C1;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sxrX7, html code.shiki .sxrX7{--shiki-light:#24292E;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sIIMD, html code.shiki .sIIMD{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sBjJW, html code.shiki .sBjJW{--shiki-light:#005CC5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .sP4rz, html code.shiki .sP4rz{--shiki-light:#E36209;--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":292,"searchDepth":17,"depth":17,"links":1672},[1673,1674,1675,1676,1677,1678,1679,1680,1681],{"id":184,"depth":17,"text":185},{"id":270,"depth":17,"text":271},{"id":516,"depth":17,"text":517},{"id":1017,"depth":17,"text":1018},{"id":1221,"depth":17,"text":1222},{"id":984,"depth":17,"text":1286},{"id":1430,"depth":17,"text":1431},{"id":1488,"depth":17,"text":20},{"id":1625,"depth":17,"text":1626},[1683,1684],{"path":95,"title":96,"description":97,"navTitle":96,"group":67,"order":98},{"path":105,"title":106,"description":107,"navTitle":106,"group":67,"order":108}]