[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"docs-navigation":3,"docs-page-permissions":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":1614,"linksToKit":262},{"path":53,"title":54,"description":55,"navTitle":54,"group":40,"seo":168,"body":170},{"title":169,"description":55},"Roles and permissions in a Nuxt SaaS",{"type":171,"value":172,"toc":1602},"minimark",[173,177,197,202,207,412,459,470,531,535,549,631,638,641,699,709,713,729,898,931,937,941,947,1087,1104,1107,1111,1133,1150,1159,1163,1170,1270,1276,1291,1295,1308,1319,1328,1331,1348,1394,1408,1413,1437,1440,1443,1540,1544,1598],[174,175,54],"h1",{"id":176},"roles-and-permissions",[178,179,180,181,185,186,196],"p",{},"Every account has one app role, and routes check permissions rather than\nroles. A permission comes from the account's role or from a plan its\norganization holds. You work here when you add a staff tool, a paid feature or\na new role. The whole model is the ",[182,183,184],"code",{},"access"," block in\n",[187,188,193],"a",{"href":189,"rel":190,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fstarter.config.ts",[191],"nofollow","Licensees only",[182,194,195],{},"starter.config.ts",".",[178,198,199,200,196],{},"Roles inside an organization (owner, admin, member) are a separate system,\ncovered in ",[187,201,49],{"href":48},[203,204,206],"h2",{"id":205},"how-it-works","How it works",[208,209,214],"pre",{"className":210,"code":211,"language":212,"meta":213,"style":213},"language-ts shiki shiki-themes github-light github-light github-dark","access: {\n  permissions: ['users:read', 'users:manage', 'blog:author', 'insights:read'],\n\n  defaultRole: 'user',\n\n  firstAccountRole: 'superadmin',\n\n  rolePermissions: {\n    user: [],\n    staff: ['users:read', 'blog:author'],\n    superadmin: ['users:read', 'users:manage', 'blog:author'],\n  },\n\n  planPermissions: {\n    free: [],\n    pro: ['insights:read'],\n    promax: ['insights:read'],\n    lifetime: ['insights:read'],\n  },\n},\n","ts","",[182,215,216,227,258,264,278,282,294,298,305,313,328,347,352,356,363,370,381,392,403,407],{"__ignoreMap":213},[217,218,220,223],"span",{"class":219,"line":12},"line",[217,221,184],{"class":222},"snPdu",[217,224,226],{"class":225},"sxrX7",": {\n",[217,228,229,232,235,239,242,245,247,250,252,255],{"class":219,"line":17},[217,230,231],{"class":222},"  permissions",[217,233,234],{"class":225},": [",[217,236,238],{"class":237},"sIIMD","'users:read'",[217,240,241],{"class":225},", ",[217,243,244],{"class":237},"'users:manage'",[217,246,241],{"class":225},[217,248,249],{"class":237},"'blog:author'",[217,251,241],{"class":225},[217,253,254],{"class":237},"'insights:read'",[217,256,257],{"class":225},"],\n",[217,259,260],{"class":219,"line":22},[217,261,263],{"emptyLinePlaceholder":262},true,"\n",[217,265,266,269,272,275],{"class":219,"line":27},[217,267,268],{"class":222},"  defaultRole",[217,270,271],{"class":225},": ",[217,273,274],{"class":237},"'user'",[217,276,277],{"class":225},",\n",[217,279,280],{"class":219,"line":32},[217,281,263],{"emptyLinePlaceholder":262},[217,283,284,287,289,292],{"class":219,"line":37},[217,285,286],{"class":222},"  firstAccountRole",[217,288,271],{"class":225},[217,290,291],{"class":237},"'superadmin'",[217,293,277],{"class":225},[217,295,296],{"class":219,"line":46},[217,297,263],{"emptyLinePlaceholder":262},[217,299,300,303],{"class":219,"line":51},[217,301,302],{"class":222},"  rolePermissions",[217,304,226],{"class":225},[217,306,307,310],{"class":219,"line":56},[217,308,309],{"class":222},"    user",[217,311,312],{"class":225},": [],\n",[217,314,315,318,320,322,324,326],{"class":219,"line":64},[217,316,317],{"class":222},"    staff",[217,319,234],{"class":225},[217,321,238],{"class":237},[217,323,241],{"class":225},[217,325,249],{"class":237},[217,327,257],{"class":225},[217,329,330,333,335,337,339,341,343,345],{"class":219,"line":73},[217,331,332],{"class":222},"    superadmin",[217,334,234],{"class":225},[217,336,238],{"class":237},[217,338,241],{"class":225},[217,340,244],{"class":237},[217,342,241],{"class":225},[217,344,249],{"class":237},[217,346,257],{"class":225},[217,348,349],{"class":219,"line":78},[217,350,351],{"class":225},"  },\n",[217,353,354],{"class":219,"line":83},[217,355,263],{"emptyLinePlaceholder":262},[217,357,358,361],{"class":219,"line":88},[217,359,360],{"class":222},"  planPermissions",[217,362,226],{"class":225},[217,364,365,368],{"class":219,"line":93},[217,366,367],{"class":222},"    free",[217,369,312],{"class":225},[217,371,372,375,377,379],{"class":219,"line":98},[217,373,374],{"class":222},"    pro",[217,376,234],{"class":225},[217,378,254],{"class":237},[217,380,257],{"class":225},[217,382,383,386,388,390],{"class":219,"line":103},[217,384,385],{"class":222},"    promax",[217,387,234],{"class":225},[217,389,254],{"class":237},[217,391,257],{"class":225},[217,393,394,397,399,401],{"class":219,"line":108},[217,395,396],{"class":222},"    lifetime",[217,398,234],{"class":225},[217,400,254],{"class":237},[217,402,257],{"class":225},[217,404,405],{"class":219,"line":113},[217,406,351],{"class":225},[217,408,409],{"class":219,"line":122},[217,410,411],{"class":225},"},\n",[413,414,415,430,444,450],"ol",{},[416,417,418,421,422,425,426,429],"li",{},[182,419,420],{},"permissions"," lists every permission. The ",[182,423,424],{},"Permission"," and ",[182,427,428],{},"UserRole"," types\ncome from this block, so a grant or a check naming anything else fails\ntypecheck.",[416,431,432,435,436,439,440,443],{},[182,433,434],{},"rolePermissions"," is what each role holds. ",[182,437,438],{},"planPermissions"," is what each\nplan sells, with ",[182,441,442],{},"free"," for an organization that holds no grant.",[416,445,446,449],{},[182,447,448],{},"requirePermission"," checks the role first. If the role doesn't grant it and\na plan sells it, it reads the live grants of the organization you name.",[416,451,452,455,456,196],{},[182,453,454],{},"users.role"," is a text column. Each request that checks the session reads\nit again, and a value the config doesn't declare reads as ",[182,457,458],{},"defaultRole",[178,460,461,462,465,466,469],{},"The two sources add up. A superadmin keeps staff powers on the free tier, and\nstaff status never opens a paid feature: to let ",[182,463,464],{},"superadmin"," read insights,\nlist ",[182,467,468],{},"insights:read"," under it.",[471,472,473,485],"table",{},[474,475,476],"thead",{},[477,478,479,482],"tr",{},[480,481,424],"th",{},[480,483,484],{},"Opens",[486,487,488,502,512,522],"tbody",{},[477,489,490,496],{},[491,492,493],"td",{},[182,494,495],{},"users:read",[491,497,498,501],{},[182,499,500],{},"\u002Fdashboard\u002Fcustomers",", the organization directory, account and billing details there",[477,503,504,509],{},[491,505,506],{},[182,507,508],{},"users:manage",[491,510,511],{},"Impersonating, suspending, editing accounts, support mails, granting plans, refunds",[477,513,514,519],{},[491,515,516],{},[182,517,518],{},"blog:author",[491,520,521],{},"Lets the account at the blog's author address appear as the byline",[477,523,524,528],{},[491,525,526],{},[182,527,468],{},[491,529,530],{},"The example paid feature, sold on every paid plan",[203,532,534],{"id":533},"check-a-permission-on-the-server","Check a permission on the server",[178,536,537,540,541,548],{},[182,538,539],{},"requirePermission(event, permission, orgId?)"," lives in\n",[187,542,545],{"href":543,"rel":544,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Futils\u002Fpermissions.ts",[191],[182,546,547],{},"server\u002Futils\u002Fpermissions.ts"," and returns the\nsession:",[208,550,552],{"className":210,"code":551,"language":212,"meta":213,"style":213},"export default defineEventHandler(async (event) => {\n  const session = await requirePermission(event, 'users:read')\n\n  return { viewer: session.user.email }\n})\n",[182,553,554,588,614,618,626],{"__ignoreMap":213},[217,555,556,560,563,566,569,572,575,579,582,585],{"class":219,"line":12},[217,557,559],{"class":558},"s8jYJ","export",[217,561,562],{"class":558}," default",[217,564,565],{"class":222}," defineEventHandler",[217,567,568],{"class":225},"(",[217,570,571],{"class":558},"async",[217,573,574],{"class":225}," (",[217,576,578],{"class":577},"sP4rz","event",[217,580,581],{"class":225},") ",[217,583,584],{"class":558},"=>",[217,586,587],{"class":225}," {\n",[217,589,590,593,597,600,603,606,609,611],{"class":219,"line":17},[217,591,592],{"class":558},"  const",[217,594,596],{"class":595},"sBjJW"," session",[217,598,599],{"class":558}," =",[217,601,602],{"class":558}," await",[217,604,605],{"class":222}," requirePermission",[217,607,608],{"class":225},"(event, ",[217,610,238],{"class":237},[217,612,613],{"class":225},")\n",[217,615,616],{"class":219,"line":22},[217,617,263],{"emptyLinePlaceholder":262},[217,619,620,623],{"class":219,"line":27},[217,621,622],{"class":558},"  return",[217,624,625],{"class":225}," { viewer: session.user.email }\n",[217,627,628],{"class":219,"line":32},[217,629,630],{"class":225},"})\n",[178,632,633,634,637],{},"It answers 401 without a verified session. When nothing grants the\npermission, it answers 402 with ",[182,635,636],{},"data.reason: 'upgrade-required'"," if some plan\nsells it and 403 if none does, so the client knows whether an upgrade helps.",[178,639,640],{},"A permission a plan sells is held by an organization, so name it. With\norganizations on, leaving the id out of a check that reaches the plans answers\n500:",[208,642,644],{"className":210,"code":643,"language":212,"meta":213,"style":213},"const scope = await requireOrgScope(event, getRouterParam(event, 'org') ?? '')\n\nawait requirePermission(event, 'insights:read', scope.rootOrgId)\n",[182,645,646,681,685],{"__ignoreMap":213},[217,647,648,651,654,656,658,661,663,666,668,671,673,676,679],{"class":219,"line":12},[217,649,650],{"class":558},"const",[217,652,653],{"class":595}," scope",[217,655,599],{"class":558},[217,657,602],{"class":558},[217,659,660],{"class":222}," requireOrgScope",[217,662,608],{"class":225},[217,664,665],{"class":222},"getRouterParam",[217,667,608],{"class":225},[217,669,670],{"class":237},"'org'",[217,672,581],{"class":225},[217,674,675],{"class":558},"??",[217,677,678],{"class":237}," ''",[217,680,613],{"class":225},[217,682,683],{"class":219,"line":17},[217,684,263],{"emptyLinePlaceholder":262},[217,686,687,690,692,694,696],{"class":219,"line":22},[217,688,689],{"class":558},"await",[217,691,605],{"class":222},[217,693,608],{"class":225},[217,695,254],{"class":237},[217,697,698],{"class":225},", scope.rootOrgId)\n",[178,700,701,702,704,705,708],{},"With payments off, every permission a plan sells is granted to every signed-in\naccount. ",[187,703,59],{"href":62}," has the paid side, including\n",[182,706,707],{},"requireEntitlement"," and the plan gate.",[203,710,712],{"id":711},"check-a-permission-in-the-browser","Check a permission in the browser",[178,714,715,718,719,241,722,425,725,728],{},[182,716,717],{},"usePermissions(org?)"," returns ",[182,720,721],{},"role",[182,723,724],{},"can(permission)",[182,726,727],{},"pending",":",[208,730,734],{"className":731,"code":732,"language":733,"meta":213,"style":213},"language-vue shiki shiki-themes github-light github-light github-dark","\u003Cscript setup lang=\"ts\">\nconst route = useRoute()\nconst { can, pending } = usePermissions(() => String(route.params.org ?? ''))\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CInsightsPanel v-if=\"can('insights:read')\" \u002F>\n  \u003CUpgradeCard v-else-if=\"!pending\" \u002F>\n\u003C\u002Ftemplate>\n","vue",[182,735,736,760,775,815,824,828,837,867,890],{"__ignoreMap":213},[217,737,738,741,745,748,751,754,757],{"class":219,"line":12},[217,739,740],{"class":225},"\u003C",[217,742,744],{"class":743},"sovSZ","script",[217,746,747],{"class":222}," setup",[217,749,750],{"class":222}," lang",[217,752,753],{"class":225},"=",[217,755,756],{"class":237},"\"ts\"",[217,758,759],{"class":225},">\n",[217,761,762,764,767,769,772],{"class":219,"line":17},[217,763,650],{"class":558},[217,765,766],{"class":595}," route",[217,768,599],{"class":558},[217,770,771],{"class":222}," useRoute",[217,773,774],{"class":225},"()\n",[217,776,777,779,782,785,787,789,792,794,797,800,802,805,808,810,812],{"class":219,"line":22},[217,778,650],{"class":558},[217,780,781],{"class":225}," { ",[217,783,784],{"class":595},"can",[217,786,241],{"class":225},[217,788,727],{"class":595},[217,790,791],{"class":225}," } ",[217,793,753],{"class":558},[217,795,796],{"class":222}," usePermissions",[217,798,799],{"class":225},"(() ",[217,801,584],{"class":558},[217,803,804],{"class":222}," String",[217,806,807],{"class":225},"(route.params.org ",[217,809,675],{"class":558},[217,811,678],{"class":237},[217,813,814],{"class":225},"))\n",[217,816,817,820,822],{"class":219,"line":27},[217,818,819],{"class":225},"\u003C\u002F",[217,821,744],{"class":743},[217,823,759],{"class":225},[217,825,826],{"class":219,"line":32},[217,827,263],{"emptyLinePlaceholder":262},[217,829,830,832,835],{"class":219,"line":37},[217,831,740],{"class":225},[217,833,834],{"class":743},"template",[217,836,759],{"class":225},[217,838,839,842,845,848,850,853,855,857,859,862,864],{"class":219,"line":46},[217,840,841],{"class":225},"  \u003C",[217,843,844],{"class":743},"InsightsPanel",[217,846,847],{"class":558}," v-if",[217,849,753],{"class":225},[217,851,852],{"class":237},"\"",[217,854,784],{"class":222},[217,856,568],{"class":225},[217,858,254],{"class":237},[217,860,861],{"class":225},")",[217,863,852],{"class":237},[217,865,866],{"class":225}," \u002F>\n",[217,868,869,871,874,877,879,881,884,886,888],{"class":219,"line":51},[217,870,841],{"class":225},[217,872,873],{"class":743},"UpgradeCard",[217,875,876],{"class":558}," v-else-if",[217,878,753],{"class":225},[217,880,852],{"class":237},[217,882,883],{"class":558},"!",[217,885,727],{"class":225},[217,887,852],{"class":237},[217,889,866],{"class":225},[217,891,892,894,896],{"class":219,"line":56},[217,893,819],{"class":225},[217,895,834],{"class":743},[217,897,759],{"class":225},[899,900,901,904,909],"ul",{},[416,902,903],{},"Pass the organization's public id when the permission may come from a plan.\nWith organizations on and none named, plans count for nothing and only the\nrole answers.",[416,905,906,908],{},[182,907,727],{}," is true while the plans load. Wait for it before showing an\nupsell, or paying customers see one flash on every load.",[416,910,911,912,915,916,919,920,927,928,930],{},"It subscribes to a query, so call it in component setup. Route middleware\ncalls ",[182,913,914],{},"can(permission, { role })"," from ",[182,917,918],{},"#shared\u002Futils\u002Fpermissions"," instead,\nas ",[187,921,924],{"href":922,"rel":923,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fapp\u002Fmiddleware\u002Fadmin.ts",[191],[182,925,926],{},"app\u002Fmiddleware\u002Fadmin.ts"," does to keep\naccounts without ",[182,929,495],{}," off the staff pages.",[178,932,933,934,936],{},"What the browser decides is for rendering. The route's ",[182,935,448],{}," is\nthe check that counts.",[203,938,940],{"id":939},"add-a-permission","Add a permission",[178,942,943,944,946],{},"List it in ",[182,945,420],{},", grant it from a role or a plan, and check it:",[208,948,950],{"className":210,"code":949,"language":212,"meta":213,"style":213},"access: {\n  permissions: [\n    'users:read',\n    'users:manage',\n    'blog:author',\n    'insights:read',\n    'reports:export',\n  ],\n\n  rolePermissions: {\n    user: [],\n    staff: ['users:read', 'blog:author', 'reports:export'],\n    superadmin: [\n      'users:read',\n      'users:manage',\n      'blog:author',\n      'reports:export',\n    ],\n  },\n},\n",[182,951,952,958,965,972,979,986,993,1000,1005,1009,1015,1021,1040,1046,1053,1060,1067,1074,1079,1083],{"__ignoreMap":213},[217,953,954,956],{"class":219,"line":12},[217,955,184],{"class":222},[217,957,226],{"class":225},[217,959,960,962],{"class":219,"line":17},[217,961,231],{"class":222},[217,963,964],{"class":225},": [\n",[217,966,967,970],{"class":219,"line":22},[217,968,969],{"class":237},"    'users:read'",[217,971,277],{"class":225},[217,973,974,977],{"class":219,"line":27},[217,975,976],{"class":237},"    'users:manage'",[217,978,277],{"class":225},[217,980,981,984],{"class":219,"line":32},[217,982,983],{"class":237},"    'blog:author'",[217,985,277],{"class":225},[217,987,988,991],{"class":219,"line":37},[217,989,990],{"class":237},"    'insights:read'",[217,992,277],{"class":225},[217,994,995,998],{"class":219,"line":46},[217,996,997],{"class":237},"    'reports:export'",[217,999,277],{"class":225},[217,1001,1002],{"class":219,"line":51},[217,1003,1004],{"class":225},"  ],\n",[217,1006,1007],{"class":219,"line":56},[217,1008,263],{"emptyLinePlaceholder":262},[217,1010,1011,1013],{"class":219,"line":64},[217,1012,302],{"class":222},[217,1014,226],{"class":225},[217,1016,1017,1019],{"class":219,"line":73},[217,1018,309],{"class":222},[217,1020,312],{"class":225},[217,1022,1023,1025,1027,1029,1031,1033,1035,1038],{"class":219,"line":78},[217,1024,317],{"class":222},[217,1026,234],{"class":225},[217,1028,238],{"class":237},[217,1030,241],{"class":225},[217,1032,249],{"class":237},[217,1034,241],{"class":225},[217,1036,1037],{"class":237},"'reports:export'",[217,1039,257],{"class":225},[217,1041,1042,1044],{"class":219,"line":83},[217,1043,332],{"class":222},[217,1045,964],{"class":225},[217,1047,1048,1051],{"class":219,"line":88},[217,1049,1050],{"class":237},"      'users:read'",[217,1052,277],{"class":225},[217,1054,1055,1058],{"class":219,"line":93},[217,1056,1057],{"class":237},"      'users:manage'",[217,1059,277],{"class":225},[217,1061,1062,1065],{"class":219,"line":98},[217,1063,1064],{"class":237},"      'blog:author'",[217,1066,277],{"class":225},[217,1068,1069,1072],{"class":219,"line":103},[217,1070,1071],{"class":237},"      'reports:export'",[217,1073,277],{"class":225},[217,1075,1076],{"class":219,"line":108},[217,1077,1078],{"class":225},"    ],\n",[217,1080,1081],{"class":219,"line":113},[217,1082,351],{"class":225},[217,1084,1085],{"class":219,"line":122},[217,1086,411],{"class":225},[208,1088,1090],{"className":210,"code":1089,"language":212,"meta":213,"style":213},"await requirePermission(event, 'reports:export')\n",[182,1091,1092],{"__ignoreMap":213},[217,1093,1094,1096,1098,1100,1102],{"class":219,"line":12},[217,1095,689],{"class":558},[217,1097,605],{"class":222},[217,1099,608],{"class":225},[217,1101,1037],{"class":237},[217,1103,613],{"class":225},[178,1105,1106],{},"A permission may be listed with no grant yet. A route gated on it then answers\n403 until a role or plan grants it.",[203,1108,1110],{"id":1109},"add-a-role","Add a role",[178,1112,1113,1114,1116,1117,1120,1121,1124,1125,1132],{},"Add a key to ",[182,1115,434],{}," and a label as ",[182,1118,1119],{},"dashboard.customers.roles.\u003Cid>","\nin every file in ",[182,1122,1123],{},"i18n\u002Flocales\u002F",".\n",[187,1126,1129],{"href":1127,"rel":1128,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Ftest\u002Funit\u002Frole-copy.test.ts",[191],[182,1130,1131],{},"test\u002Funit\u002Frole-copy.test.ts"," fails on a\nmissing label. Routes check permissions, never role names, so no other file\nneeds the new role.",[178,1134,1135,1136,1138,1139,1141,1142,1144,1145,1149],{},"Staff holding ",[182,1137,508],{}," assign roles from the Edit action on\n",[182,1140,500],{},". That dialog never offers a role that grants\n",[182,1143,508],{},", and the route refuses one with 403, so a new superadmin is\nmade by hand (",[187,1146,1148],{"href":1147},"#promote-an-account-by-hand","Promote an account by hand",").",[178,1151,1152,1155,1156,1158],{},[182,1153,1154],{},"access.defaultRole"," is written explicitly on signup, so changing it needs no\nmigration. The column's own SQL default of ",[182,1157,274],{}," only covers rows written\nby hand or by the seed.",[203,1160,1162],{"id":1161},"staff-tools","Staff tools",[178,1164,1135,1165,1167,1168,728],{},[182,1166,508],{}," get these actions on ",[182,1169,500],{},[471,1171,1172,1182],{},[474,1173,1174],{},[477,1175,1176,1179],{},[480,1177,1178],{},"Action",[480,1180,1181],{},"Route",[486,1183,1184,1194,1204,1217,1227,1237,1247,1260],{},[477,1185,1186,1189],{},[491,1187,1188],{},"Edit name, email, role",[491,1190,1191],{},[182,1192,1193],{},"PATCH \u002Fapi\u002Fadmin\u002Fusers\u002F:id",[477,1195,1196,1199],{},[491,1197,1198],{},"Suspend",[491,1200,1201],{},[182,1202,1203],{},"POST \u002Fapi\u002Fadmin\u002Fusers\u002Fban",[477,1205,1206,1209],{},[491,1207,1208],{},"Impersonate",[491,1210,1211,241,1214],{},[182,1212,1213],{},"POST \u002Fapi\u002Fadmin\u002Fimpersonate",[182,1215,1216],{},"POST \u002Fapi\u002Fadmin\u002Fimpersonate\u002Fstop",[477,1218,1219,1222],{},[491,1220,1221],{},"Mail a password reset",[491,1223,1224],{},[182,1225,1226],{},"POST \u002Fapi\u002Fadmin\u002Fusers\u002Fpassword-reset",[477,1228,1229,1232],{},[491,1230,1231],{},"Mail a verification link",[491,1233,1234],{},[182,1235,1236],{},"POST \u002Fapi\u002Fadmin\u002Fusers\u002Fverification-email",[477,1238,1239,1242],{},[491,1240,1241],{},"Clear sign-in lockouts",[491,1243,1244],{},[182,1245,1246],{},"POST \u002Fapi\u002Fadmin\u002Fusers\u002Funlock",[477,1248,1249,1252],{},[491,1250,1251],{},"Grant or take back a plan",[491,1253,1254,241,1257],{},[182,1255,1256],{},"POST \u002Fapi\u002Fadmin\u002Fbilling\u002Fgrant",[182,1258,1259],{},"\u002Frevoke",[477,1261,1262,1265],{},[491,1263,1264],{},"Refund a purchase",[491,1266,1267],{},[182,1268,1269],{},"POST \u002Fapi\u002Fadmin\u002Fbilling\u002Frefund",[178,1271,1272,1273,1275],{},"Each refuses the caller's own account with 400, and an account whose role\ngrants ",[182,1274,508],{}," with 403, so one administrator can't act on another.\nGranting a plan refuses the same way for an organization you or another\nadministrator belong to.\nThe password reset, verification mail and unlock each allow a staff member 30\nuses an hour, and staff never see a mailed link.",[178,1277,1278,1279,1286,1287,1290],{},"An impersonating session is read-only.\n",[187,1280,1283],{"href":1281,"rel":1282,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fmiddleware\u002Fimpersonation.ts",[191],[182,1284,1285],{},"server\u002Fmiddleware\u002Fimpersonation.ts","\nanswers every API write, anything but GET, HEAD and OPTIONS, with 403\n",[182,1288,1289],{},"impersonating",". Stopping the impersonation and signing out still work.",[203,1292,1294],{"id":1293},"the-first-superadmin","The first superadmin",[178,1296,1297,1298,1301,1302,1304,1305,196],{},"On a fresh deploy, the first account to sign up gets ",[182,1299,1300],{},"access.firstAccountRole","\nonce it proves its email address, as long as no account holds a role that\ngrants ",[182,1303,508],{},". An OAuth or magic-link signup proves it at once; a\npassword signup proves it by opening the verify link in a browser signed in\nas the account, or through a password reset. The promotion is logged in the\naccount's activity as ",[182,1306,1307],{},"admin.firstAccountPromoted",[178,1309,1310,1311,1318],{},"Register your own account before you share the URL, since the first signup\nwins. The account is not promoted if it came from a guest checkout, is\nsuspended or scheduled for deletion, or already holds a passkey, TOTP or\nlinked provider. ",[187,1312,1315],{"href":1313,"rel":1314,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fauth\u002Ffirst-account.ts",[191],[182,1316,1317],{},"server\u002Fauth\u002Ffirst-account.ts","\nhas the whole rule.",[178,1320,1321,1322,1325,1326,196],{},"Set ",[182,1323,1324],{},"firstAccountRole: false"," to turn this off. The build refuses a role that\nis not declared or that doesn't grant ",[182,1327,508],{},[203,1329,1148],{"id":1330},"promote-an-account-by-hand",[178,1332,1333,1334,1336,1337,1340,1341,1344,1345,728],{},"Roles live in ",[182,1335,454],{},". Run one statement, against the local database\nwhile ",[182,1338,1339],{},"pnpm dev"," runs, or against production with the ",[182,1342,1343],{},"DATABASE_URL"," from\n",[182,1346,1347],{},".env.production",[208,1349,1353],{"className":1350,"code":1351,"language":1352,"meta":213,"style":213},"language-bash shiki shiki-themes github-light github-light github-dark","psql postgres:\u002F\u002Fpostgres:postgres@127.0.0.1:5432\u002Fpostgres -c \\\n  \"UPDATE users SET role = 'superadmin' WHERE email = 'you@example.com'\"\npsql \"$DATABASE_URL\" -c \\\n  \"UPDATE users SET role = 'superadmin' WHERE email = 'you@example.com'\"\n","bash",[182,1354,1355,1369,1374,1390],{"__ignoreMap":213},[217,1356,1357,1360,1363,1366],{"class":219,"line":12},[217,1358,1359],{"class":222},"psql",[217,1361,1362],{"class":237}," postgres:\u002F\u002Fpostgres:postgres@127.0.0.1:5432\u002Fpostgres",[217,1364,1365],{"class":595}," -c",[217,1367,1368],{"class":595}," \\\n",[217,1370,1371],{"class":219,"line":17},[217,1372,1373],{"class":237},"  \"UPDATE users SET role = 'superadmin' WHERE email = 'you@example.com'\"\n",[217,1375,1376,1378,1381,1384,1386,1388],{"class":219,"line":22},[217,1377,1359],{"class":222},[217,1379,1380],{"class":237}," \"",[217,1382,1383],{"class":225},"$DATABASE_URL",[217,1385,852],{"class":237},[217,1387,1365],{"class":595},[217,1389,1368],{"class":595},[217,1391,1392],{"class":219,"line":27},[217,1393,1373],{"class":237},[178,1395,1396,1397,1399,1400,1403,1404,1407],{},"If ",[182,1398,1339],{}," serves PGlite on another port, use that one: the port\n",[182,1401,1402],{},"NUXT_DEV_PGLITE_PORT"," pins, or the one its startup log names when 5432 was\ntaken. While the dev server runs, ",[182,1405,1406],{},".data\u002Fpglite-server.json"," holds the full URL.",[178,1409,1410,1411,728],{},"To take the role from a stranger who signed up first, set both rows in one\nstatement so there is never a moment with nobody holding ",[182,1412,508],{},[208,1414,1416],{"className":1350,"code":1415,"language":1352,"meta":213,"style":213},"psql \"$DATABASE_URL\" -c \\\n  \"UPDATE users SET role = CASE email WHEN 'you@example.com' THEN 'superadmin' ELSE 'user' END WHERE email IN ('you@example.com', 'them@example.com')\"\n",[182,1417,1418,1432],{"__ignoreMap":213},[217,1419,1420,1422,1424,1426,1428,1430],{"class":219,"line":12},[217,1421,1359],{"class":222},[217,1423,1380],{"class":237},[217,1425,1383],{"class":225},[217,1427,852],{"class":237},[217,1429,1365],{"class":595},[217,1431,1368],{"class":595},[217,1433,1434],{"class":219,"line":17},[217,1435,1436],{"class":237},"  \"UPDATE users SET role = CASE email WHEN 'you@example.com' THEN 'superadmin' ELSE 'user' END WHERE email IN ('you@example.com', 'them@example.com')\"\n",[178,1438,1439],{},"Then suspend them from the customers page. A change made by hand writes no\nactivity entry.",[203,1441,20],{"id":1442},"configuration",[471,1444,1445,1458],{},[474,1446,1447],{},[477,1448,1449,1452,1455],{},[480,1450,1451],{},"Key",[480,1453,1454],{},"Default",[480,1456,1457],{},"What it does",[486,1459,1460,1473,1486,1503,1523],{},[477,1461,1462,1467,1470],{},[491,1463,1464],{},[182,1465,1466],{},"access.permissions",[491,1468,1469],{},"four permissions",[491,1471,1472],{},"Every permission a route may check.",[477,1474,1475,1479,1483],{},[491,1476,1477],{},[182,1478,1154],{},[491,1480,1481],{},[182,1482,274],{},[491,1484,1485],{},"The role of a new account, and of a stored role the config doesn't declare.",[477,1487,1488,1492,1496],{},[491,1489,1490],{},[182,1491,1300],{},[491,1493,1494],{},[182,1495,291],{},[491,1497,1498,1499,1502],{},"Granted to the deploy's first verified account. ",[182,1500,1501],{},"false"," turns it off.",[477,1504,1505,1510,1520],{},[491,1506,1507],{},[182,1508,1509],{},"access.rolePermissions",[491,1511,1512,241,1515,241,1518],{},[182,1513,1514],{},"user",[182,1516,1517],{},"staff",[182,1519,464],{},[491,1521,1522],{},"Each role and what it holds.",[477,1524,1525,1530,1535],{},[491,1526,1527],{},[182,1528,1529],{},"access.planPermissions",[491,1531,1532,1534],{},[182,1533,468],{}," on every paid plan",[491,1536,1537,1538,196],{},"What each plan sells. Needs an entry per plan, plus ",[182,1539,442],{},[203,1541,1543],{"id":1542},"good-to-know","Good to know",[899,1545,1546,1555,1561,1579,1589],{},[416,1547,1548,1549,1551,1552,1554],{},"A misspelled role in ",[182,1550,454],{}," reads as ",[182,1553,458],{}," and grants nothing,\nso a typo in a manual promotion looks like nothing happened.",[416,1556,1557,1558,196],{},"A role change applies on the account's next request. The browser keeps\nshowing the old navigation until its session refreshes. To sign the account\nout as well, call ",[182,1559,1560],{},"revokeSessions(event, userId)",[416,1562,1563,1570,1571,1578],{},[187,1564,1567],{"href":1565,"rel":1566,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fscripts\u002Fseed\u002Fusers.mjs",[191],[182,1568,1569],{},"scripts\u002Fseed\u002Fusers.mjs"," names roles in plain\nJavaScript. ",[187,1572,1575],{"href":1573,"rel":1574,"title":192},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Ftest\u002Funit\u002Fdefault-role.test.ts",[191],[182,1576,1577],{},"test\u002Funit\u002Fdefault-role.test.ts","\nfails when it seeds a role the config no longer declares, so rename roles\nthere too.",[416,1580,1581,1582,1584,1585,1588],{},"A string in ",[182,1583,1529],{}," must never also be in\n",[182,1586,1587],{},"orgs.permissions","; the build fails if it is.",[416,1590,1591,1594,1595,1597],{},[182,1592,1593],{},"GET \u002Fapi\u002Fusers"," answers 400 to a filter value it doesn't know, such as a\n",[182,1596,721],{}," the config doesn't declare, rather than ignoring it.",[1599,1600,1601],"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 .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 .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sovSZ, html code.shiki .sovSZ{--shiki-light:#22863A;--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":213,"searchDepth":17,"depth":17,"links":1603},[1604,1605,1606,1607,1608,1609,1610,1611,1612,1613],{"id":205,"depth":17,"text":206},{"id":533,"depth":17,"text":534},{"id":711,"depth":17,"text":712},{"id":939,"depth":17,"text":940},{"id":1109,"depth":17,"text":1110},{"id":1161,"depth":17,"text":1162},{"id":1293,"depth":17,"text":1294},{"id":1330,"depth":17,"text":1148},{"id":1442,"depth":17,"text":20},{"id":1542,"depth":17,"text":1543},[1615,1616],{"path":48,"title":49,"description":50,"navTitle":49,"group":40,"order":51},{"path":62,"title":59,"description":63,"navTitle":59,"group":59,"order":64}]