[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"docs-navigation":3,"docs-page-rate-limiting":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":1573,"linksToKit":311},{"path":124,"title":125,"description":126,"navTitle":125,"group":116,"seo":168,"body":170},{"title":169,"description":126},"Rate limiting Nuxt routes on Cloudflare Workers",{"type":171,"value":172,"toc":1561},"minimark",[173,177,185,190,196,232,236,239,394,407,463,476,487,491,497,672,675,699,712,716,723,851,865,869,1216,1238,1242,1252,1283,1309,1313,1316,1386,1389,1397,1400,1404,1411,1417,1426,1429,1512,1515,1519,1557],[174,175,125],"h1",{"id":176},"rate-limiting",[178,179,180,184],"p",{},[181,182,183],"code",{},"requireRateLimit"," caps how often a caller can hit a route, counted per scope\nand key, and locks the key out once the cap is reached. Auth, invitations,\ncheckout, the contact page and staff support actions already use it. Add it to\nany route that checks a credential or costs money per call.",[186,187,189],"h2",{"id":188},"how-it-works","How it works",[178,191,192,193,195],{},"Each call to ",[181,194,183],{}," passes two layers, and a third can sit on your\nCloudflare zone:",[197,198,199,218,229],"ol",{},[200,201,202,203,206,207,217],"li",{},"Cloudflare's rate-limiting binding, ",[181,204,205],{},"AUTH_RATE_LIMIT"," unless you name\nanother, counts the request twice: once by scope and key, once by scope and\nsource IP. It sheds floods before any storage is touched. Its limit is set\nin ",[208,209,214],"a",{"href":210,"rel":211,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fwrangler.jsonc",[212],"nofollow","Licensees only",[181,215,216],{},"wrangler.jsonc"," and counted per Cloudflare\nlocation.",[200,219,220,221,228],{},"A Durable Object per scope and key,\n",[208,222,225],{"href":223,"rel":224,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fdurable\u002Fauth-throttle.ts",[212],[181,226,227],{},"AuthThrottle",", reserves the attempt\natomically against the policy you pass. This is the real limit.",[200,230,231],{},"WAF rate-limiting rules on the zone stop mass reloads before the Worker\nruns. You set those in the Cloudflare dashboard.",[186,233,235],{"id":234},"limit-a-route","Limit a route",[178,237,238],{},"A report route that allows each account 60 calls a minute:",[240,241,246],"pre",{"className":242,"code":243,"language":244,"meta":245,"style":245},"language-ts shiki shiki-themes github-light github-light github-dark","export default defineEventHandler(async (event) => {\n  const session = await requireVerifiedSession(event)\n\n  await requireRateLimit(event, {\n    scope: 'report',\n    key: String(session.user.id),\n    policy: { max: 60, windowMs: 60_000, lockoutMs: 60_000 },\n  })\n\n  return buildReport(event, session.user.id)\n})\n","ts","",[181,247,248,286,307,313,324,336,347,369,374,378,389],{"__ignoreMap":245},[249,250,252,256,259,263,267,270,273,277,280,283],"span",{"class":251,"line":12},"line",[249,253,255],{"class":254},"s8jYJ","export",[249,257,258],{"class":254}," default",[249,260,262],{"class":261},"snPdu"," defineEventHandler",[249,264,266],{"class":265},"sxrX7","(",[249,268,269],{"class":254},"async",[249,271,272],{"class":265}," (",[249,274,276],{"class":275},"sP4rz","event",[249,278,279],{"class":265},") ",[249,281,282],{"class":254},"=>",[249,284,285],{"class":265}," {\n",[249,287,288,291,295,298,301,304],{"class":251,"line":17},[249,289,290],{"class":254},"  const",[249,292,294],{"class":293},"sBjJW"," session",[249,296,297],{"class":254}," =",[249,299,300],{"class":254}," await",[249,302,303],{"class":261}," requireVerifiedSession",[249,305,306],{"class":265},"(event)\n",[249,308,309],{"class":251,"line":22},[249,310,312],{"emptyLinePlaceholder":311},true,"\n",[249,314,315,318,321],{"class":251,"line":27},[249,316,317],{"class":254},"  await",[249,319,320],{"class":261}," requireRateLimit",[249,322,323],{"class":265},"(event, {\n",[249,325,326,329,333],{"class":251,"line":32},[249,327,328],{"class":265},"    scope: ",[249,330,332],{"class":331},"sIIMD","'report'",[249,334,335],{"class":265},",\n",[249,337,338,341,344],{"class":251,"line":37},[249,339,340],{"class":265},"    key: ",[249,342,343],{"class":261},"String",[249,345,346],{"class":265},"(session.user.id),\n",[249,348,349,352,355,358,361,364,366],{"class":251,"line":46},[249,350,351],{"class":265},"    policy: { max: ",[249,353,354],{"class":293},"60",[249,356,357],{"class":265},", windowMs: ",[249,359,360],{"class":293},"60_000",[249,362,363],{"class":265},", lockoutMs: ",[249,365,360],{"class":293},[249,367,368],{"class":265}," },\n",[249,370,371],{"class":251,"line":51},[249,372,373],{"class":265},"  })\n",[249,375,376],{"class":251,"line":56},[249,377,312],{"emptyLinePlaceholder":311},[249,379,380,383,386],{"class":251,"line":64},[249,381,382],{"class":254},"  return",[249,384,385],{"class":261}," buildReport",[249,387,388],{"class":265},"(event, session.user.id)\n",[249,390,391],{"class":251,"line":73},[249,392,393],{"class":265},"})\n",[178,395,396,398,399,406],{},[181,397,183],{}," lives in\n",[208,400,403],{"href":401,"rel":402,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Futils\u002Frate-limit.ts",[212],[181,404,405],{},"server\u002Futils\u002Frate-limit.ts"," and is\nauto-imported. Its options:",[408,409,410,416,426,444,457],"ul",{},[200,411,412,415],{},[181,413,414],{},"scope"," names the counter, so two scopes never spend each other's budget.",[200,417,418,421,422,425],{},[181,419,420],{},"key"," is who is limited: a user id, a lowercased email, or\n",[181,423,424],{},"requestIp(event)",".",[200,427,428,431,432,435,436,439,440,443],{},[181,429,430],{},"policy.max"," attempts are allowed per ",[181,433,434],{},"policy.windowMs",". The ",[181,437,438],{},"max","-th still\ngoes through and locks the key. The next is refused until ",[181,441,442],{},"lockoutMs"," has\npassed, or until the window ends if that is later.",[200,445,446,449,450,453,454,425],{},[181,447,448],{},"binding"," picks the per-location limiter, ",[181,451,452],{},"'AUTH_RATE_LIMIT'"," or\n",[181,455,456],{},"'ANALYTICS_RATE_LIMIT'",[200,458,459,462],{},[181,460,461],{},"unavailableMessage"," is the message of the 503 sent when the Durable Object\nnamespace is missing.",[178,464,465,466,469,470,473,474,425],{},"Over the limit it throws a 429 with a ",[181,467,468],{},"Retry-After"," header,\n",[181,471,472],{},"data.retryAfterSeconds",", and the message \"Too many attempts. Try again in N\nminute(s).\" The per-location limiter refuses with \"Too many attempts. Try again\nshortly.\" and a 60-second ",[181,475,468],{},[178,477,478,479,486],{},"Call it before doing the work, and after the session and permission checks:\nthose decide whether the caller may, the limit decides how often.\n",[208,480,483],{"href":481,"rel":482,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fapi\u002Fexample-paid.get.ts",[212],[181,484,485],{},"server\u002Fapi\u002Fexample-paid.get.ts"," limits\nafter its plan checks, because a paying customer can still run up your bill on\na route that calls a model.",[186,488,490],{"id":489},"count-only-failures","Count only failures",[178,492,493,494,496],{},"On a credential check, call ",[181,495,183],{}," before you check the credential,\nso a caller who is already refused learns nothing from the answer. On success,\nhand the attempt back:",[240,498,500],{"className":242,"code":499,"language":244,"meta":245,"style":245},"import type { RateLimitOptions } from '#server\u002Futils\u002Frate-limit'\n\nconst limit: RateLimitOptions = {\n  scope: 'pin',\n  key: String(session.user.id),\n  policy: { max: 5, windowMs: 15 * 60_000, lockoutMs: 15 * 60_000 },\n}\n\nawait requireRateLimit(event, limit)\n\nif (!(await pinMatches(event, session.user.id, body.pin))) {\n  throw createError({ statusCode: 400, statusMessage: 'Wrong PIN.' })\n}\n\nawait clearRateLimitFailures(event, limit)\n",[181,501,502,519,523,541,551,560,589,594,598,608,612,632,655,659,663],{"__ignoreMap":245},[249,503,504,507,510,513,516],{"class":251,"line":12},[249,505,506],{"class":254},"import",[249,508,509],{"class":254}," type",[249,511,512],{"class":265}," { RateLimitOptions } ",[249,514,515],{"class":254},"from",[249,517,518],{"class":331}," '#server\u002Futils\u002Frate-limit'\n",[249,520,521],{"class":251,"line":17},[249,522,312],{"emptyLinePlaceholder":311},[249,524,525,528,531,534,537,539],{"class":251,"line":22},[249,526,527],{"class":254},"const",[249,529,530],{"class":293}," limit",[249,532,533],{"class":254},":",[249,535,536],{"class":261}," RateLimitOptions",[249,538,297],{"class":254},[249,540,285],{"class":265},[249,542,543,546,549],{"class":251,"line":27},[249,544,545],{"class":265},"  scope: ",[249,547,548],{"class":331},"'pin'",[249,550,335],{"class":265},[249,552,553,556,558],{"class":251,"line":32},[249,554,555],{"class":265},"  key: ",[249,557,343],{"class":261},[249,559,346],{"class":265},[249,561,562,565,568,570,573,576,579,581,583,585,587],{"class":251,"line":37},[249,563,564],{"class":265},"  policy: { max: ",[249,566,567],{"class":293},"5",[249,569,357],{"class":265},[249,571,572],{"class":293},"15",[249,574,575],{"class":254}," *",[249,577,578],{"class":293}," 60_000",[249,580,363],{"class":265},[249,582,572],{"class":293},[249,584,575],{"class":254},[249,586,578],{"class":293},[249,588,368],{"class":265},[249,590,591],{"class":251,"line":46},[249,592,593],{"class":265},"}\n",[249,595,596],{"class":251,"line":51},[249,597,312],{"emptyLinePlaceholder":311},[249,599,600,603,605],{"class":251,"line":56},[249,601,602],{"class":254},"await",[249,604,320],{"class":261},[249,606,607],{"class":265},"(event, limit)\n",[249,609,610],{"class":251,"line":64},[249,611,312],{"emptyLinePlaceholder":311},[249,613,614,617,619,622,624,626,629],{"class":251,"line":73},[249,615,616],{"class":254},"if",[249,618,272],{"class":265},[249,620,621],{"class":254},"!",[249,623,266],{"class":265},[249,625,602],{"class":254},[249,627,628],{"class":261}," pinMatches",[249,630,631],{"class":265},"(event, session.user.id, body.pin))) {\n",[249,633,634,637,640,643,646,649,652],{"class":251,"line":78},[249,635,636],{"class":254},"  throw",[249,638,639],{"class":261}," createError",[249,641,642],{"class":265},"({ statusCode: ",[249,644,645],{"class":293},"400",[249,647,648],{"class":265},", statusMessage: ",[249,650,651],{"class":331},"'Wrong PIN.'",[249,653,654],{"class":265}," })\n",[249,656,657],{"class":251,"line":83},[249,658,593],{"class":265},[249,660,661],{"class":251,"line":88},[249,662,312],{"emptyLinePlaceholder":311},[249,664,665,667,670],{"class":251,"line":93},[249,666,602],{"class":254},[249,668,669],{"class":261}," clearRateLimitFailures",[249,671,607],{"class":265},[178,673,674],{},"A failure needs no call, because the attempt was counted when it was reserved.",[408,676,677,683,693],{},[200,678,679,682],{},[181,680,681],{},"clearRateLimitFailures(event, options)"," resets the key's count and lifts\nits lockout. Use it when the key names one subject.",[200,684,685,688,689,692],{},[181,686,687],{},"releaseRateLimitAttempts(event, options, n)"," hands back only ",[181,690,691],{},"n","\nreservations. Use it on a key strangers share, such as an IP, where a reset\nwould wipe everyone else's failures.",[200,694,695,698],{},[181,696,697],{},"resetRateLimit(event, options)"," forgets the count, the lockout and the last\nsend. It is for an operator unlocking someone else. A route that lets its\ncaller reset their own counter has no limit.",[178,700,701,702,705,706,272,709,711],{},"For a public form, pair the limit with a Turnstile check,\n",[181,703,704],{},"assertHuman(event, token)"," from ",[181,707,708],{},"#server\u002Fauth\u002Fturnstile",[208,710,44],{"href":43},").",[186,713,715],{"id":714},"space-out-sends","Space out sends",[178,717,718,719,722],{},"A route that mails whoever the caller names is abused by succeeding over and\nover. Add ",[181,720,721],{},"minIntervalMs"," to the policy:",[240,724,726],{"className":242,"code":725,"language":244,"meta":245,"style":245},"const limit: RateLimitOptions = {\n  scope: 'share-link',\n  key: body.email.toLowerCase(),\n  policy: {\n    max: 10,\n    windowMs: 60 * 60_000,\n    lockoutMs: 15 * 60_000,\n    minIntervalMs: 60_000,\n  },\n}\n\nawait requireRateLimit(event, limit)\nawait sendShareLink(event, body.email)\nawait clearRateLimitFailures(event, limit)\n",[181,727,728,742,751,762,767,777,790,803,812,817,821,825,833,843],{"__ignoreMap":245},[249,729,730,732,734,736,738,740],{"class":251,"line":12},[249,731,527],{"class":254},[249,733,530],{"class":293},[249,735,533],{"class":254},[249,737,536],{"class":261},[249,739,297],{"class":254},[249,741,285],{"class":265},[249,743,744,746,749],{"class":251,"line":17},[249,745,545],{"class":265},[249,747,748],{"class":331},"'share-link'",[249,750,335],{"class":265},[249,752,753,756,759],{"class":251,"line":22},[249,754,755],{"class":265},"  key: body.email.",[249,757,758],{"class":261},"toLowerCase",[249,760,761],{"class":265},"(),\n",[249,763,764],{"class":251,"line":27},[249,765,766],{"class":265},"  policy: {\n",[249,768,769,772,775],{"class":251,"line":32},[249,770,771],{"class":265},"    max: ",[249,773,774],{"class":293},"10",[249,776,335],{"class":265},[249,778,779,782,784,786,788],{"class":251,"line":37},[249,780,781],{"class":265},"    windowMs: ",[249,783,354],{"class":293},[249,785,575],{"class":254},[249,787,578],{"class":293},[249,789,335],{"class":265},[249,791,792,795,797,799,801],{"class":251,"line":46},[249,793,794],{"class":265},"    lockoutMs: ",[249,796,572],{"class":293},[249,798,575],{"class":254},[249,800,578],{"class":293},[249,802,335],{"class":265},[249,804,805,808,810],{"class":251,"line":51},[249,806,807],{"class":265},"    minIntervalMs: ",[249,809,360],{"class":293},[249,811,335],{"class":265},[249,813,814],{"class":251,"line":56},[249,815,816],{"class":265},"  },\n",[249,818,819],{"class":251,"line":64},[249,820,593],{"class":265},[249,822,823],{"class":251,"line":73},[249,824,312],{"emptyLinePlaceholder":311},[249,826,827,829,831],{"class":251,"line":78},[249,828,602],{"class":254},[249,830,320],{"class":261},[249,832,607],{"class":265},[249,834,835,837,840],{"class":251,"line":83},[249,836,602],{"class":254},[249,838,839],{"class":261}," sendShareLink",[249,841,842],{"class":265},"(event, body.email)\n",[249,844,845,847,849],{"class":251,"line":88},[249,846,602],{"class":254},[249,848,669],{"class":261},[249,850,607],{"class":265},[178,852,853,854,857,858,860,861,864],{},"With an interval, ",[181,855,856],{},"clearRateLimitFailures"," keeps the count as a send count and\nstamps the time. A call within ",[181,859,721],{}," of the last success is refused\nwith ",[181,862,863],{},"data.reason: 'too-soon'"," and \"That was just sent. Try again in N\nsecond(s).\"",[186,866,868],{"id":867},"shipped-limits","Shipped limits",[870,871,872,891],"table",{},[873,874,875],"thead",{},[876,877,878,882,885,888],"tr",{},[879,880,881],"th",{},"Scope",[879,883,884],{},"Keyed by",[879,886,887],{},"Limit",[879,889,890],{},"Defined in",[892,893,894,924,939,954,969,984,1004,1018,1032,1053,1068,1096,1118,1133,1154,1179,1197],"tbody",{},[876,895,896,909,912,915],{},[897,898,899,902,903,902,906],"td",{},[181,900,901],{},"totp",", ",[181,904,905],{},"totp-recovery",[181,907,908],{},"reauth",[897,910,911],{},"account",[897,913,914],{},"5 per 15 min",[897,916,917],{},[208,918,921],{"href":919,"rel":920,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fauth\u002Fthrottle.ts",[212],[181,922,923],{},"server\u002Fauth\u002Fthrottle.ts",[876,925,926,931,933,936],{},[897,927,928],{},[181,929,930],{},"totp-daily",[897,932,911],{},[897,934,935],{},"20 per day",[897,937,938],{},"same",[876,940,941,946,949,952],{},[897,942,943],{},[181,944,945],{},"password",[897,947,948],{},"IP and address",[897,950,951],{},"10 per 15 min",[897,953,938],{},[876,955,956,961,964,967],{},[897,957,958],{},[181,959,960],{},"passkey",[897,962,963],{},"IP",[897,965,966],{},"30 per 15 min",[897,968,938],{},[876,970,971,976,979,982],{},[897,972,973],{},[181,974,975],{},"register",[897,977,978],{},"address",[897,980,981],{},"10 per hour",[897,983,938],{},[876,985,986,997,999,1002],{},[897,987,988,902,991,902,994],{},[181,989,990],{},"magic-link",[181,992,993],{},"password-reset",[181,995,996],{},"email-verify",[897,998,978],{},[897,1000,1001],{},"10 per hour, 1 min apart",[897,1003,938],{},[876,1005,1006,1011,1013,1016],{},[897,1007,1008],{},[181,1009,1010],{},"magic-confirm",[897,1012,911],{},[897,1014,1015],{},"3 per hour, 1 min apart",[897,1017,938],{},[876,1019,1020,1025,1028,1030],{},[897,1021,1022],{},[181,1023,1024],{},"email-change",[897,1026,1027],{},"new address",[897,1029,1001],{},[897,1031,938],{},[876,1033,1034,1039,1042,1044],{},[897,1035,1036],{},[181,1037,1038],{},"org-invite",[897,1040,1041],{},"invited address",[897,1043,1001],{},[897,1045,1046],{},[208,1047,1050],{"href":1048,"rel":1049,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Ftenancy\u002Finvitations.ts",[212],[181,1051,1052],{},"server\u002Ftenancy\u002Finvitations.ts",[876,1054,1055,1060,1063,1066],{},[897,1056,1057],{},[181,1058,1059],{},"org-invite-sender",[897,1061,1062],{},"inviter",[897,1064,1065],{},"50 per hour",[897,1067,938],{},[876,1069,1070,1081,1084,1087],{},[897,1071,1072,902,1075,902,1078],{},[181,1073,1074],{},"admin-password-reset",[181,1076,1077],{},"admin-verification-email",[181,1079,1080],{},"admin-unlock",[897,1082,1083],{},"staff member",[897,1085,1086],{},"30 per hour each",[897,1088,1089],{},[208,1090,1093],{"href":1091,"rel":1092,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fauth\u002Fsupport.ts",[212],[181,1094,1095],{},"server\u002Fauth\u002Fsupport.ts",[876,1097,1098,1103,1106,1109],{},[897,1099,1100],{},[181,1101,1102],{},"checkout:guest",[897,1104,1105],{},"email",[897,1107,1108],{},"5 per hour",[897,1110,1111],{},[208,1112,1115],{"href":1113,"rel":1114,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fapi\u002Fpayments\u002Fcheckout.post.ts",[212],[181,1116,1117],{},"server\u002Fapi\u002Fpayments\u002Fcheckout.post.ts",[876,1119,1120,1125,1128,1131],{},[897,1121,1122],{},[181,1123,1124],{},"checkout:coupon",[897,1126,1127],{},"account, or IP for a guest",[897,1129,1130],{},"20 per hour",[897,1132,938],{},[876,1134,1135,1140,1142,1145],{},[897,1136,1137],{},[181,1138,1139],{},"checkout:resolve",[897,1141,911],{},[897,1143,1144],{},"10 per 10 min",[897,1146,1147],{},[208,1148,1151],{"href":1149,"rel":1150,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fapi\u002Fpayments\u002Fpending.post.ts",[212],[181,1152,1153],{},"server\u002Fapi\u002Fpayments\u002Fpending.post.ts",[876,1155,1156,1164,1167,1170],{},[897,1157,1158,902,1161],{},[181,1159,1160],{},"contact:message",[181,1162,1163],{},"contact:address",[897,1165,1166],{},"IP, IPv6 by \u002F64",[897,1168,1169],{},"5 per 10 min",[897,1171,1172],{},[208,1173,1176],{"href":1174,"rel":1175,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fcontact\u002Flimits.ts",[212],[181,1177,1178],{},"server\u002Fcontact\u002Flimits.ts",[876,1180,1181,1189,1192,1195],{},[897,1182,1183,1185,1186],{},[181,1184,1160],{},", key ",[181,1187,1188],{},"site",[897,1190,1191],{},"the whole site",[897,1193,1194],{},"30 per hour",[897,1196,938],{},[876,1198,1199,1204,1206,1209],{},[897,1200,1201],{},[181,1202,1203],{},"example-paid",[897,1205,911],{},[897,1207,1208],{},"60 per minute",[897,1210,1211],{},[208,1212,1214],{"href":481,"rel":1213,"title":213},[212],[181,1215,485],{},[178,1217,1218,1219,1221,1222,1224,1225,1227,1228,1231,1232,1237],{},"The auth and ",[181,1220,1038],{}," lockouts last at least 15 minutes, and\n",[181,1223,1010],{},"'s an hour. The other scopes lock for their whole window, and\nthe site-wide ",[181,1226,1160],{}," count until its hour is up. From auth code,\ncall ",[181,1229,1230],{},"assertNotThrottled(event, 'totp', subject)"," from\n",[208,1233,1235],{"href":919,"rel":1234,"title":213},[212],[181,1236,923],{}," instead of restating a\npolicy.",[186,1239,1241],{"id":1240},"change-the-per-location-limit","Change the per-location limit",[178,1243,1244,1245,1248,1249,1251],{},"The limiters are ",[181,1246,1247],{},"ratelimits"," entries in ",[181,1250,216],{},". Their limit is set\nper binding, not per call, and applies to every route that uses the binding.\nTo give a route its own, add an entry:",[240,1253,1257],{"className":1254,"code":1255,"language":1256,"meta":245,"style":245},"language-jsonc shiki shiki-themes github-light github-light github-dark","{\n  \"name\": \"REPORTS_RATE_LIMIT\",\n  \"namespace_id\": \"1003\",\n  \"simple\": { \"limit\": 60, \"period\": 60 },\n}\n","jsonc",[181,1258,1259,1264,1269,1274,1279],{"__ignoreMap":245},[249,1260,1261],{"class":251,"line":12},[249,1262,1263],{},"{\n",[249,1265,1266],{"class":251,"line":17},[249,1267,1268],{},"  \"name\": \"REPORTS_RATE_LIMIT\",\n",[249,1270,1271],{"class":251,"line":22},[249,1272,1273],{},"  \"namespace_id\": \"1003\",\n",[249,1275,1276],{"class":251,"line":27},[249,1277,1278],{},"  \"simple\": { \"limit\": 60, \"period\": 60 },\n",[249,1280,1281],{"class":251,"line":32},[249,1282,593],{},[178,1284,1285,1286,1289,1290,1293,1294,1297,1298,1305,1306,425],{},"Cloudflare accepts a ",[181,1287,1288],{},"period"," of 10 or 60 seconds. Run ",[181,1291,1292],{},"pnpm cf-typegen",", add\nthe name to ",[181,1295,1296],{},"RateLimitBinding"," in\n",[208,1299,1302],{"href":1300,"rel":1301,"title":213},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Ftypes\u002Fplatform-rate-limit.ts",[212],[181,1303,1304],{},"server\u002Ftypes\u002Fplatform-rate-limit.ts",",\nand pass ",[181,1307,1308],{},"binding: 'REPORTS_RATE_LIMIT'",[186,1310,1312],{"id":1311},"protect-the-zone","Protect the zone",[178,1314,1315],{},"A flood of page loads costs a Worker invocation each, even when the answer is\ncached. Stop it on the zone instead: Security > Security rules > Create rule >\nRate limiting rules.",[870,1317,1318,1337],{},[873,1319,1320],{},[876,1321,1322,1325,1328,1331,1334],{},[879,1323,1324],{},"Plan",[879,1326,1327],{},"Rules",[879,1329,1330],{},"Counting period",[879,1332,1333],{},"Block for",[879,1335,1336],{},"Counts by",[892,1338,1339,1354,1370],{},[876,1340,1341,1344,1347,1350,1352],{},[897,1342,1343],{},"Free",[897,1345,1346],{},"1",[897,1348,1349],{},"10 s",[897,1351,1349],{},[897,1353,963],{},[876,1355,1356,1359,1362,1365,1368],{},[897,1357,1358],{},"Pro",[897,1360,1361],{},"2",[897,1363,1364],{},"up to 1 min",[897,1366,1367],{},"up to 1 hour",[897,1369,963],{},[876,1371,1372,1375,1377,1380,1383],{},[897,1373,1374],{},"Business",[897,1376,567],{},[897,1378,1379],{},"up to 10 min",[897,1381,1382],{},"up to 1 day",[897,1384,1385],{},"IP, IP + NAT",[178,1387,1388],{},"The free rule can match only on the URI path and Verified Bot fields. Spend it\non the paths that reach the Worker, counted per IP at 50 requests per 10\nseconds, action Block:",[240,1390,1395],{"className":1391,"code":1393,"language":1394,"meta":245},[1392],"language-text","(http.request.uri.path contains \"\u002Fapi\u002F\") or (http.request.uri.path contains \"\u002Fblog\") or (http.request.uri.path contains \"\u002Flegal\u002F\")\n","text",[181,1396,1393],{"__ignoreMap":245},[178,1398,1399],{},"Static assets never reach the Worker, so they need no rule.",[186,1401,1403],{"id":1402},"locally","Locally",[178,1405,1406,1407,1410],{},"Both layers are off under ",[181,1408,1409],{},"pnpm dev",". Each call passes and logs:",[240,1412,1415],{"className":1413,"code":1414,"language":1394,"meta":245},[1392],"[rate-limit] Durable Objects are unavailable under `nuxt dev`; report is unlimited here. Use `pnpm preview` to exercise the real limit.\n",[181,1416,1414],{"__ignoreMap":245},[178,1418,1419,1422,1423,711],{},[181,1420,1421],{},"pnpm preview"," runs the real limits. It needs a real Postgres\n(",[208,1424,71],{"href":1425},"\u002Fdocs\u002Fdatabase#locally",[186,1427,20],{"id":1428},"configuration",[870,1430,1431,1447],{},[873,1432,1433],{},[876,1434,1435,1438,1441,1444],{},[879,1436,1437],{},"Key",[879,1439,1440],{},"Where",[879,1442,1443],{},"Default",[879,1445,1446],{},"What it does",[892,1448,1449,1471,1490],{},[876,1450,1451,1458,1462,1465],{},[897,1452,1453,1455,1456],{},[181,1454,1247],{}," entry ",[181,1457,205],{},[897,1459,1460],{},[181,1461,216],{},[897,1463,1464],{},"20 per 60 s",[897,1466,1467,1468,1470],{},"The per-location layer ",[181,1469,183],{}," uses.",[876,1472,1473,1480,1484,1487],{},[897,1474,1475,1455,1477],{},[181,1476,1247],{},[181,1478,1479],{},"ANALYTICS_RATE_LIMIT",[897,1481,1482],{},[181,1483,216],{},[897,1485,1486],{},"60 per 60 s",[897,1488,1489],{},"Per-IP limit on the analytics beacon route.",[876,1491,1492,1501,1505,1509],{},[897,1493,1494,1497,1498],{},[181,1495,1496],{},"durable_objects"," binding ",[181,1499,1500],{},"AUTH_THROTTLE",[897,1502,1503],{},[181,1504,216],{},[897,1506,1507],{},[181,1508,227],{},[897,1510,1511],{},"The counters. Missing outside dev, every limited route answers 503.",[178,1513,1514],{},"There are no environment variables. Policies live in code, next to the route\nthat spends them.",[186,1516,1518],{"id":1517},"good-to-know","Good to know",[408,1520,1521,1524,1527,1534,1551],{},[200,1522,1523],{},"Every call also spends the per-location limiter: as shipped, 20 a minute for\nthe same scope and key, and 20 for the same scope and IP. A policy above\nthat, like the 60 a minute above, is held to 20 from one location unless the\nroute passes a binding of its own.",[200,1525,1526],{},"The per-location limiter resets in each Cloudflare location, so a caller\nspread across locations gets its limit from each. The Durable Object is the\nbound.",[200,1528,1529,1530,1533],{},"Counter state is deleted a day after its window started, or when its lockout\nends if that is later. A count in a ",[181,1531,1532],{},"windowMs"," longer than 24 hours resets\nafter 24 hours unless it locked.",[200,1535,1536,1537,1539,1540,1543,1544,1547,1548,425],{},"The source IP comes from ",[181,1538,424],{},", which reads ",[181,1541,1542],{},"cf-connecting-ip","\nand never ",[181,1545,1546],{},"X-Forwarded-For",", so a client behind Cloudflare can't forge it.\nCallers with no known IP share the source ",[181,1549,1550],{},"unknown",[200,1552,1553,1554,1556],{},"This is abuse control. A quota a customer paid for belongs in their plan\n(",[208,1555,59],{"href":62},"), where going over is a 402, not a 429.",[1558,1559,1560],"style",{},"html pre.shiki code .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sP4rz, html code.shiki .sP4rz{--shiki-light:#E36209;--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sBjJW, html code.shiki .sBjJW{--shiki-light:#005CC5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sIIMD, html code.shiki .sIIMD{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#9ECBFF}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);}",{"title":245,"searchDepth":17,"depth":17,"links":1562},[1563,1564,1565,1566,1567,1568,1569,1570,1571,1572],{"id":188,"depth":17,"text":189},{"id":234,"depth":17,"text":235},{"id":489,"depth":17,"text":490},{"id":714,"depth":17,"text":715},{"id":867,"depth":17,"text":868},{"id":1240,"depth":17,"text":1241},{"id":1311,"depth":17,"text":1312},{"id":1402,"depth":17,"text":1403},{"id":1428,"depth":17,"text":20},{"id":1517,"depth":17,"text":1518},[1574,1575],{"path":119,"title":120,"description":121,"navTitle":120,"group":116,"order":122},{"path":129,"title":130,"description":131,"navTitle":130,"group":116,"order":132}]