diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py
index 412a2a14..79d50440 100644
--- a/fasthtml/_modidx.py
+++ b/fasthtml/_modidx.py
@@ -186,6 +186,38 @@
'fasthtml.jupyter.wait_port_free': ('api/jupyter.html#wait_port_free', 'fasthtml/jupyter.py'),
'fasthtml.jupyter.ws_client': ('api/jupyter.html#ws_client', 'fasthtml/jupyter.py')},
'fasthtml.live_reload': {},
+ 'fasthtml.magickey': { 'fasthtml.magickey.MagicKey': ('api/magickey.html#magickey', 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.__init__': ('api/magickey.html#magickey.__init__', 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._do_auth': ('api/magickey.html#magickey._do_auth', 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._finish_passkey_reg': ( 'api/magickey.html#magickey._finish_passkey_reg',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._logout': ('api/magickey.html#magickey._logout', 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._request_passkey_auth': ( 'api/magickey.html#magickey._request_passkey_auth',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._request_passkey_reg': ( 'api/magickey.html#magickey._request_passkey_reg',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._send_magic_link': ( 'api/magickey.html#magickey._send_magic_link',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._skip_passkey_reg': ( 'api/magickey.html#magickey._skip_passkey_reg',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._verify_magiclink': ( 'api/magickey.html#magickey._verify_magiclink',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey._verify_passkey_auth': ( 'api/magickey.html#magickey._verify_passkey_auth',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.after_magiclink_verify': ( 'api/magickey.html#magickey.after_magiclink_verify',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.get_auth': ('api/magickey.html#magickey.get_auth', 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.get_passkey': ( 'api/magickey.html#magickey.get_passkey',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.get_user_id': ( 'api/magickey.html#magickey.get_user_id',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.has_passkey': ( 'api/magickey.html#magickey.has_passkey',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.save_passkey': ( 'api/magickey.html#magickey.save_passkey',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey.MagicKey.update_passkey': ( 'api/magickey.html#magickey.update_passkey',
+ 'fasthtml/magickey.py'),
+ 'fasthtml.magickey._webauthn_js': ('api/magickey.html#_webauthn_js', 'fasthtml/magickey.py')},
'fasthtml.oauth': { 'fasthtml.oauth.AppleAppClient': ('api/oauth.html#appleappclient', 'fasthtml/oauth.py'),
'fasthtml.oauth.AppleAppClient.__init__': ('api/oauth.html#appleappclient.__init__', 'fasthtml/oauth.py'),
'fasthtml.oauth.AppleAppClient.client_secret': ( 'api/oauth.html#appleappclient.client_secret',
@@ -254,6 +286,19 @@
'fasthtml.pico.PicoBusy': ('api/pico.html#picobusy', 'fasthtml/pico.py'),
'fasthtml.pico.Search': ('api/pico.html#search', 'fasthtml/pico.py'),
'fasthtml.pico.set_pico_cls': ('api/pico.html#set_pico_cls', 'fasthtml/pico.py')},
+ 'fasthtml.ratelimit': { 'fasthtml.ratelimit.Limiter': ('api/ratelimit.html#limiter', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.Limiter.__call__': ('api/ratelimit.html#limiter.__call__', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.Limiter.__init__': ('api/ratelimit.html#limiter.__init__', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.TokenBucket': ('api/ratelimit.html#tokenbucket', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.TokenBucket.__init__': ( 'api/ratelimit.html#tokenbucket.__init__',
+ 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.TokenBucket.__repr__': ( 'api/ratelimit.html#tokenbucket.__repr__',
+ 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.TokenBucket._prune': ( 'api/ratelimit.html#tokenbucket._prune',
+ 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.TokenBucket.wait': ('api/ratelimit.html#tokenbucket.wait', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.client_ip': ('api/ratelimit.html#client_ip', 'fasthtml/ratelimit.py'),
+ 'fasthtml.ratelimit.parse_rate': ('api/ratelimit.html#parse_rate', 'fasthtml/ratelimit.py')},
'fasthtml.starlette': {},
'fasthtml.stripe_otp': { 'fasthtml.stripe_otp.Payment': ('explains/stripe.html#payment', 'fasthtml/stripe_otp.py'),
'fasthtml.stripe_otp._search_app': ('explains/stripe.html#_search_app', 'fasthtml/stripe_otp.py'),
diff --git a/fasthtml/magickey.py b/fasthtml/magickey.py
new file mode 100644
index 00000000..92d78b2b
--- /dev/null
+++ b/fasthtml/magickey.py
@@ -0,0 +1,165 @@
+"""Passwordless auth combining magic links and passkeys"""
+
+# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/10_magickey.ipynb.
+
+# %% auto #0
+__all__ = ['MagicKey']
+
+# %% ../nbs/api/10_magickey.ipynb #d3fd43fe
+import secrets, time
+from json import loads
+from urllib.parse import urlparse
+
+from webauthn import generate_authentication_options, generate_registration_options, verify_authentication_response, verify_registration_response, options_to_json
+from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
+from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement
+
+from fastcore.utils import *
+from fastcore.xml import *
+from fastlite import *
+from .basics import *
+from .starlette import *
+from .fastapp import *
+from .ratelimit import *
+
+# %% ../nbs/api/10_magickey.ipynb #03d86e44
+# _origin_kw removed; public_origin / rp_id inlined at call sites
+
+
+# %% ../nbs/api/10_magickey.ipynb #02b50590
+def _webauthn_js(opts, action, callback):
+ return Script("SimpleWebAuthnBrowser.%s({ optionsJSON: %s }).then(r => {"
+ "htmx.ajax('POST', '%s', { values: r });});" % (action, options_to_json(opts), callback))
+
+# %% ../nbs/api/10_magickey.ipynb #032d5010
+class MagicKey:
+ def __init__(self,
+ app, # FastHTML app instance
+ send_email, # `f(email, magic_url)` — sends the magic link
+ public_origin, # Full origin URL, e.g. `https://example.com`
+ rp_id=None, # WebAuthn relying party ID (default: hostname from `public_origin`)
+ rp_name=None, # WebAuthn relying party display name (default: `rp_id`)
+ skip=None, # Routes to skip auth beforeware (default: all MagicKey routes)
+ login_path='/login', # Login page route
+ logout_path='/logout', # Logout route
+ token_expiry=3600, # Magic link validity in seconds
+ email_rate='3/m', # Per-email rate limit on magic link sends (None to disable)
+ ip_rate='10/m', # Per-IP rate limit on magic link sends (None to disable)
+ webauthn_js=None): # Script tag or URL for SimpleWebAuthn browser JS (default: jsdelivr CDN)
+ "Passwordless auth combining magic links and passkeys"
+ if not rp_id: rp_id = urlparse(public_origin).hostname
+ self.magiclink_db = {}
+ self._email_bucket = TokenBucket(email_rate) if email_rate else None
+ self._ip_bucket = TokenBucket(ip_rate) if ip_rate else None
+ store_attr()
+ async def _before(req, session):
+ if 'auth' not in req.scope: req.scope['auth'] = session.get('auth')
+ if not req.scope['auth']: return RedirectResponse(login_path, status_code=303)
+ if not skip: skip = [login_path, logout_path, '/finish_passkey_reg', '/send_magic_link',
+ '/request_passkey_auth', '/request_passkey_reg', '/setup_passkey',
+ '/skip_passkey_reg', '/verify_magiclink', '/verify_passkey_auth',]
+ app.before.append(Beforeware(_before, skip=skip))
+ if not webauthn_js: webauthn_js = Script(src='https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13.1.0/dist/bundle/index.umd.min.js')
+ elif isinstance(webauthn_js, str): webauthn_js = Script(src=webauthn_js)
+ app.hdrs += (webauthn_js,)
+ app.get(logout_path)(self._logout)
+ app.post('/send_magic_link')(self._send_magic_link)
+ app.get('/verify_magiclink')(self._verify_magiclink)
+ app.post('/request_passkey_auth')(self._request_passkey_auth)
+ app.post('/verify_passkey_auth')(self._verify_passkey_auth)
+ app.post('/request_passkey_reg')(self._request_passkey_reg)
+ app.post('/finish_passkey_reg')(self._finish_passkey_reg)
+ app.post('/skip_passkey_reg')(self._skip_passkey_reg)
+
+ def _logout(self, session):
+ session.pop('auth', None)
+ return RedirectResponse(self.login_path, status_code=303)
+
+ def _send_magic_link(self, email: str, req):
+ for bucket,key in ((self._ip_bucket, client_ip(req)), (self._email_bucket, email)):
+ if bucket and (w:=bucket.wait(key)): return Response('Too many requests', status_code=429, headers={'Retry-After': str(int(w)+1)})
+ self.magiclink_db = {k:v for k,v in self.magiclink_db.items() if not v['used'] and time.time() - v['timestamp'] <= self.token_expiry}
+ token = secrets.token_urlsafe(32)
+ self.magiclink_db[token] = dict(email=email, timestamp=time.time(), used=False)
+ magic_url = f'{self.public_origin}/verify_magiclink?token={token}'
+ return self.send_email(email, magic_url)
+
+ def _verify_magiclink(self, token: str, session, req):
+ link = self.magiclink_db.get(token)
+ if not link or link['used'] or time.time() - link['timestamp'] > self.token_expiry:
+ return RedirectResponse(f'{self.login_path}?error=invalid_link', status_code=303)
+ link['used'] = True
+ return self.after_magiclink_verify(link['email'], session, req)
+
+ def _request_passkey_auth(self, session):
+ auth_opts = generate_authentication_options(
+ rp_id=self.rp_id, user_verification=UserVerificationRequirement.REQUIRED)
+ session['auth_challenge'] = bytes_to_base64url(auth_opts.challenge)
+ return _webauthn_js(auth_opts, 'startAuthentication', '/verify_passkey_auth')
+
+ def _verify_passkey_auth(self, response: str, id: str, rawId: str, type: str, session):
+ challenge_b64 = session.pop('auth_challenge', None)
+ if not challenge_b64: return HttpHeader('HX-Redirect', f'{self.login_path}?error=no_challenge')
+ stored = self.get_passkey(id)
+ if not stored: return HttpHeader('HX-Redirect', f'{self.login_path}?error=passkey_not_found')
+ try:
+ res = verify_authentication_response(
+ credential=dict(id=id, rawId=rawId, response=loads(response), type=type),
+ credential_public_key=stored['public_key'],
+ credential_current_sign_count=stored['sign_count'],
+ expected_challenge=base64url_to_bytes(challenge_b64),
+ require_user_verification=True,
+ expected_origin=self.public_origin, expected_rp_id=self.rp_id)
+ except Exception: return HttpHeader('HX-Redirect', f'{self.login_path}?error=passkey_failed')
+ self.update_passkey(id, res.new_sign_count)
+ session['auth'] = self.get_user_id(stored['email'])
+ return self._do_auth(session['auth'], session, htmx=True)
+
+ def _request_passkey_reg(self, session):
+ email = session.get('pending_email')
+ if not email: return RedirectResponse(f'{self.login_path}?error=no_pending_reg', status_code=303)
+ opts = generate_registration_options(
+ rp_id=self.rp_id, rp_name=self.rp_name or self.rp_id, user_name=email,
+ user_id=str(self.get_user_id(email)).encode(),
+ authenticator_selection=AuthenticatorSelectionCriteria(resident_key=ResidentKeyRequirement.REQUIRED))
+ session['reg_challenge'] = bytes_to_base64url(opts.challenge)
+ return _webauthn_js(opts, 'startRegistration', '/finish_passkey_reg')
+
+ def _finish_passkey_reg(self, response: str, id: str, rawId: str, type: str, session):
+ email = session.pop('pending_email', None)
+ if not email: return RedirectResponse(f'{self.login_path}?error=no_pending_reg', status_code=303)
+ challenge_b64 = session.pop('reg_challenge', None)
+ if not challenge_b64: return RedirectResponse('/setup_passkey?error=no_challenge', status_code=303)
+ try: res = verify_registration_response(credential=dict(id=id, rawId=rawId, response=loads(response), type=type),
+ expected_challenge=base64url_to_bytes(challenge_b64),
+ require_user_verification=True,
+ expected_origin=self.public_origin, expected_rp_id=self.rp_id)
+ except Exception: return RedirectResponse('/setup_passkey?error=reg_failed', status_code=303)
+ self.save_passkey(bytes_to_base64url(res.credential_id), email, res.credential_public_key, res.sign_count)
+ session['auth'] = self.get_user_id(email)
+ return self._do_auth(session['auth'], session, htmx=True)
+
+ def _skip_passkey_reg(self, session):
+ email = session.pop('pending_email', None)
+ if not email: return RedirectResponse(self.login_path, status_code=303)
+ session['auth'] = self.get_user_id(email)
+ return self._do_auth(session['auth'], session, htmx=False)
+
+ def _do_auth(self, user_id, session, htmx=False):
+ url = self.get_auth(user_id, session)
+ if htmx: return HttpHeader('HX-Redirect', url)
+ return RedirectResponse(url, status_code=303)
+
+ def get_auth(self, user_id, session): return '/'
+ def get_user_id(self, email): raise NotImplementedError()
+ def has_passkey(self, email): raise NotImplementedError()
+ def get_passkey(self, credential_id): raise NotImplementedError()
+ def save_passkey(self, credential_id, email, public_key, sign_count): raise NotImplementedError()
+ def update_passkey(self, credential_id, sign_count): raise NotImplementedError()
+
+ def after_magiclink_verify(self, email, session, req):
+ if self.has_passkey(email):
+ session['auth'] = self.get_user_id(email)
+ return self._do_auth(session['auth'], session, htmx=False)
+ session['pending_email'] = email
+ return RedirectResponse('/setup_passkey', status_code=303)
diff --git a/fasthtml/ratelimit.py b/fasthtml/ratelimit.py
new file mode 100644
index 00000000..9c3ded92
--- /dev/null
+++ b/fasthtml/ratelimit.py
@@ -0,0 +1,83 @@
+"""Simple token-bucket rate limiting for FastHTML routes"""
+
+# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/07_ratelimit.ipynb.
+
+# %% auto #0
+__all__ = ['parse_rate', 'TokenBucket', 'client_ip', 'Limiter']
+
+# %% ../nbs/api/07_ratelimit.ipynb #b33aec8a
+import asyncio,re,time
+from contextvars import ContextVar
+from functools import wraps
+from math import ceil
+from typing import Callable
+from starlette.responses import Response
+from fastcore.utils import *
+
+# %% ../nbs/api/07_ratelimit.ipynb #1078d10b
+_units = dict(s=1, m=60, h=3600, d=86400)
+
+def parse_rate(s):
+ "Parse rate string like `'5/m'`, `'1 per day'`, `'100/2h'`"
+ m = re.match(r'(\d+)\s*(?:/|per)\s*(\d+)?\s*([smhd])', s, re.I)
+ if not m: raise ValueError(f"Invalid rate: {s}")
+ n,mult,unit = m.groups()
+ return int(n), _units[unit.lower()] * int(mult or 1)
+
+# %% ../nbs/api/07_ratelimit.ipynb #7b28079a
+class TokenBucket:
+ "Token-bucket rate limiter"
+ def __init__(self,
+ max_reqs:str|int, # Rate string ('5/m') or max requests per window
+ window_secs:int=None # Window in seconds (required if `max_reqs` is int)
+ ):
+ if window_secs is None: max_reqs,window_secs = parse_rate(max_reqs)
+ store_attr()
+ self.rate = max_reqs / window_secs
+ self.buckets = {}
+ def __repr__(self): return f'TokenBucket({self.max_reqs}, {self.window_secs})'
+
+ def _prune(self):
+ cutoff = time.time() - self.window_secs
+ self.buckets = {k:(t,ts) for k,(t,ts) in self.buckets.items() if ts > cutoff}
+ def wait(self, key):
+ "Return 0 if allowed, else seconds to wait"
+ self._prune()
+ now = time.time()
+ tokens, last = self.buckets.get(key, (self.max_reqs, now))
+ tokens = min(self.max_reqs, tokens + (now - last) * self.rate)
+ if tokens < 1: return (1 - tokens) / self.rate
+ self.buckets[key] = (tokens - 1, now)
+ return 0
+
+# %% ../nbs/api/07_ratelimit.ipynb #69b0fb44
+def client_ip(req, **kwargs):
+ "Get client IP from `X-Forwarded-For` header, falling back to `req.client.host`"
+ return req.headers.get('x-forwarded-for', '').split(',')[0].strip() or (req.client and req.client.host) or ''
+
+# %% ../nbs/api/07_ratelimit.ipynb #75b5b395
+class Limiter:
+ "Rate limiter for FastHTML routes"
+ def __init__(self, app):
+ self._req_var = ContextVar('limiter_req')
+ async def _store(req): self._req_var.set(req)
+ app.before.insert(0, _store)
+
+ def __call__(self,
+ rate:str|tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple
+ key:str|Callable=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)
+ on_limit:Callable=None # Optional callback(wait_secs) to return custom response on 429
+ ):
+ "Rate limit decorator: `@limiter('5/m')`"
+ bucket = TokenBucket(*rate) if isinstance(rate, tuple) else TokenBucket(rate)
+ def decorator(f):
+ @wraps(f)
+ async def wrapper(*args, **kwargs):
+ req = self._req_var.get()
+ k = key(req, **kwargs) if callable(key) else str(kwargs.get(key, ''))
+ if w:=bucket.wait(k):
+ if on_limit: return on_limit(w)
+ return Response('Too many requests', status_code=429, headers={'Retry-After': str(ceil(w))})
+ return await maybe_await(f(*args, **kwargs))
+ return wrapper
+ return decorator
diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb
index 5232d7e4..e4bfb90d 100644
--- a/nbs/api/00_core.ipynb
+++ b/nbs/api/00_core.ipynb
@@ -136,9 +136,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "datetime"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -159,9 +157,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "bool"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -196,9 +192,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -268,9 +262,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "HtmxHeaders"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -437,9 +429,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -475,9 +465,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "HttpHeader"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -512,9 +500,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "dict"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -537,9 +523,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "dict"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -760,9 +744,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -855,9 +837,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "Foo"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -972,9 +952,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2120,9 +2098,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2187,9 +2163,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2214,9 +2188,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2314,9 +2286,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2383,9 +2353,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2439,9 +2407,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2481,9 +2447,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2508,9 +2472,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2534,9 +2496,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2591,9 +2551,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2691,9 +2649,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2740,9 +2696,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2930,9 +2884,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -2960,9 +2912,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -3287,9 +3237,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "_mk_locfunc.._lf"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -3941,9 +3889,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "_mk_locfunc.._lf"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -4396,9 +4342,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "_mk_locfunc.._lf"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb
index 5f8943f8..31bf97d7 100644
--- a/nbs/api/01_components.ipynb
+++ b/nbs/api/01_components.ipynb
@@ -94,9 +94,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -164,9 +162,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -199,9 +195,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -234,9 +228,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "str"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -358,9 +350,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -389,9 +379,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -420,9 +408,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -461,9 +447,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -508,9 +492,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -539,9 +521,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -600,9 +580,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -646,9 +624,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -679,9 +655,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -711,9 +685,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -963,9 +935,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1006,9 +976,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1050,9 +1018,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "FT"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1099,9 +1065,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "TodoItem"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1146,9 +1110,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "list"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1309,9 +1271,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "Markdown"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
@@ -1374,9 +1334,7 @@
]
},
"execution_count": null,
- "metadata": {
- "__type": "Markdown"
- },
+ "metadata": {},
"output_type": "execute_result"
}
],
diff --git a/nbs/api/07_ratelimit.ipynb b/nbs/api/07_ratelimit.ipynb
new file mode 100644
index 00000000..e62b8b42
--- /dev/null
+++ b/nbs/api/07_ratelimit.ipynb
@@ -0,0 +1,682 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "6de889b6",
+ "metadata": {},
+ "source": [
+ "# Rate Limiting\n",
+ "> Simple token-bucket rate limiting for FastHTML routes"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ec249d73",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| default_exp ratelimit"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b33aec8a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "import asyncio,re,time\n",
+ "from contextvars import ContextVar\n",
+ "from functools import wraps\n",
+ "from math import ceil\n",
+ "from typing import Callable\n",
+ "from starlette.responses import Response\n",
+ "from fastcore.utils import *"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c1e39599",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| hide\n",
+ "from starlette.testclient import TestClient\n",
+ "from nbdev.showdoc import show_doc\n",
+ "from fastcore.test import *\n",
+ "from fasthtml.fastapp import *"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1078d10b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "_units = dict(s=1, m=60, h=3600, d=86400)\n",
+ "\n",
+ "def parse_rate(s):\n",
+ " \"Parse rate string like `'5/m'`, `'1 per day'`, `'100/2h'`\"\n",
+ " m = re.match(r'(\\d+)\\s*(?:/|per)\\s*(\\d+)?\\s*([smhd])', s, re.I)\n",
+ " if not m: raise ValueError(f\"Invalid rate: {s}\")\n",
+ " n,mult,unit = m.groups()\n",
+ " return int(n), _units[unit.lower()] * int(mult or 1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "23e2936b",
+ "metadata": {},
+ "source": [
+ "`parse_rate` accepts strings in the form `\"{count}/{window}\"` or `\"{count} per {window}\"`, where:\n",
+ "\n",
+ "- **count** — integer number of allowed requests (e.g. `5`, `100`)\n",
+ "- **window** — optional multiplier + unit: `s`econds, `m`inutes, `h`ours, `d`ays\n",
+ "\n",
+ "Examples: `'5/m'`, `'100/2h'`, `'1 per day'`, `5 per 2 hours`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19fecdf0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "test_eq(parse_rate('5/m'), (5, 60))\n",
+ "test_eq(parse_rate('100/2h'), (100, 7200))\n",
+ "test_eq(parse_rate('1 per day'), (1, 86400))\n",
+ "test_eq(parse_rate('5 per 2 hours'), (5, 7200))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7b28079a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "class TokenBucket:\n",
+ " \"Token-bucket rate limiter\"\n",
+ " def __init__(self,\n",
+ " max_reqs:str|int, # Rate string ('5/m') or max requests per window\n",
+ " window_secs:int=None # Window in seconds (required if `max_reqs` is int)\n",
+ " ):\n",
+ " if window_secs is None: max_reqs,window_secs = parse_rate(max_reqs)\n",
+ " store_attr()\n",
+ " self.rate = max_reqs / window_secs\n",
+ " self.buckets = {}\n",
+ " def __repr__(self): return f'TokenBucket({self.max_reqs}, {self.window_secs})'\n",
+ "\n",
+ " def _prune(self):\n",
+ " cutoff = time.time() - self.window_secs\n",
+ " self.buckets = {k:(t,ts) for k,(t,ts) in self.buckets.items() if ts > cutoff}\n",
+ " def wait(self, key):\n",
+ " \"Return 0 if allowed, else seconds to wait\"\n",
+ " self._prune()\n",
+ " now = time.time()\n",
+ " tokens, last = self.buckets.get(key, (self.max_reqs, now))\n",
+ " tokens = min(self.max_reqs, tokens + (now - last) * self.rate)\n",
+ " if tokens < 1: return (1 - tokens) / self.rate\n",
+ " self.buckets[key] = (tokens - 1, now)\n",
+ " return 0"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f27963b0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "---\n",
+ "\n",
+ "### TokenBucket\n",
+ "\n",
+ "```python\n",
+ "\n",
+ "def TokenBucket(\n",
+ " max_reqs:str | int, # Rate string ('5/m') or max requests per window\n",
+ " window_secs:int=None, # Window in seconds (required if `max_reqs` is int)\n",
+ "):\n",
+ "\n",
+ "\n",
+ "```\n",
+ "\n",
+ "*Token-bucket rate limiter*"
+ ],
+ "text/plain": [
+ "def TokenBucket(\n",
+ " max_reqs:str | int, # Rate string ('5/m') or max requests per window\n",
+ " window_secs:int=None, # Window in seconds (required if `max_reqs` is int)\n",
+ "):\n",
+ "\"\"\"Token-bucket rate limiter\"\"\""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "show_doc(TokenBucket)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "349d2064",
+ "metadata": {},
+ "source": [
+ "Implements the [token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket). Tokens are added at a steady rate and consumed by requests. When the bucket is empty, requests are rejected and told how long to wait."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f0028515",
+ "metadata": {},
+ "source": [
+ "You can create a bucket with either a rate string or explicit values:\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "417a59a6",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "TokenBucket(3, 10)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "tb = TokenBucket(3, 10)\n",
+ "tb"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "623ac5d8",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "TokenBucket(3, 10)"
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "tb = TokenBucket('3/10s')\n",
+ "tb"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "38712997",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "---\n",
+ "\n",
+ "### TokenBucket.wait\n",
+ "\n",
+ "```python\n",
+ "\n",
+ "def wait(\n",
+ " key\n",
+ "):\n",
+ "\n",
+ "\n",
+ "```\n",
+ "\n",
+ "*Return 0 if allowed, else seconds to wait*"
+ ],
+ "text/plain": [
+ "def wait(\n",
+ " key\n",
+ "):\n",
+ "\"\"\"Return 0 if allowed, else seconds to wait\"\"\""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "show_doc(TokenBucket.wait)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ff831ed1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "test_eq(tb.wait('x'), 0)\n",
+ "test_eq(tb.wait('x'), 0)\n",
+ "test_eq(tb.wait('x'), 0)\n",
+ "test(tb.wait('x'), 0, operator.gt)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "73587949",
+ "metadata": {},
+ "source": [
+ "Each key gets its own independent token bucket. So if user A exhausts their tokens, user B is unaffected — they still have a full bucket of their own:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6d8039f8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "tb2 = TokenBucket(1, 0.5)\n",
+ "test_eq(tb2.wait('old'), 0)\n",
+ "test_eq(tb2.wait('new'), 0)\n",
+ "test (tb2.wait('old'), 0, operator.gt)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2e48ec6b",
+ "metadata": {},
+ "source": [
+ "Stale keys — inactive longer than the window — are automatically pruned on each wait call:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5276e7bb",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "tb2.wait('old')\n",
+ "time.sleep(0.6)\n",
+ "tb2.wait('new')\n",
+ "test_eq('old' in tb2.buckets, False)\n",
+ "test_eq('new' in tb2.buckets, True)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "69b0fb44",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "def client_ip(req, **kwargs):\n",
+ " \"Get client IP from `X-Forwarded-For` header, falling back to `req.client.host`\"\n",
+ " return req.headers.get('x-forwarded-for', '').split(',')[0].strip() or (req.client and req.client.host) or ''"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "75b5b395",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "class Limiter:\n",
+ " \"Rate limiter for FastHTML routes\"\n",
+ " def __init__(self, app):\n",
+ " self._req_var = ContextVar('limiter_req')\n",
+ " async def _store(req): self._req_var.set(req)\n",
+ " app.before.insert(0, _store)\n",
+ "\n",
+ " def __call__(self,\n",
+ " rate:str|tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n",
+ " key:str|Callable=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n",
+ " on_limit:Callable=None # Optional callback(wait_secs) to return custom response on 429\n",
+ " ):\n",
+ " \"Rate limit decorator: `@limiter('5/m')`\"\n",
+ " bucket = TokenBucket(*rate) if isinstance(rate, tuple) else TokenBucket(rate)\n",
+ " def decorator(f):\n",
+ " @wraps(f)\n",
+ " async def wrapper(*args, **kwargs):\n",
+ " req = self._req_var.get()\n",
+ " k = key(req, **kwargs) if callable(key) else str(kwargs.get(key, ''))\n",
+ " if w:=bucket.wait(k):\n",
+ " if on_limit: return on_limit(w)\n",
+ " return Response('Too many requests', status_code=429, headers={'Retry-After': str(ceil(w))})\n",
+ " return await maybe_await(f(*args, **kwargs))\n",
+ " return wrapper\n",
+ " return decorator"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "066e746b",
+ "metadata": {},
+ "source": [
+ "`Limiter` integrates `TokenBucket` with FastHTML's routing. Decorate routes with `@limiter()` to apply rate limits. Multiple decorators stack — all must pass. The `key` parameter controls what to rate-limit by: by default it uses `client_ip` which extracts the client's IP from `X-Forwarded-For` (falling back to `req.client.host`). Pass a string to match a route parameter name, or a callable for custom logic. Use `on_limit` to customize the 429 response — it receives the wait time in seconds and returns the response to send."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e399153c",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "---\n",
+ "\n",
+ "### Limiter\n",
+ "\n",
+ "```python\n",
+ "\n",
+ "def Limiter(\n",
+ " app\n",
+ "):\n",
+ "\n",
+ "\n",
+ "```\n",
+ "\n",
+ "*Rate limiter for FastHTML routes*"
+ ],
+ "text/plain": [
+ "def Limiter(\n",
+ " app\n",
+ "):\n",
+ "\"\"\"Rate limiter for FastHTML routes\"\"\""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "show_doc(Limiter)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9302a311",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/markdown": [
+ "---\n",
+ "\n",
+ "### Limiter.__call__\n",
+ "\n",
+ "```python\n",
+ "\n",
+ "def __call__(\n",
+ " rate:str | tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n",
+ " key:Union=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n",
+ " on_limit:Callable=None, # Optional callback(wait_secs) to return custom response on 429\n",
+ "):\n",
+ "\n",
+ "\n",
+ "```\n",
+ "\n",
+ "*Rate limit decorator: `@limiter('5/m')`*"
+ ],
+ "text/plain": [
+ "def __call__(\n",
+ " rate:str | tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n",
+ " key:Union=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n",
+ " on_limit:Callable=None, # Optional callback(wait_secs) to return custom response on 429\n",
+ "):\n",
+ "\"\"\"Rate limit decorator: `@limiter('5/m')`\"\"\""
+ ]
+ },
+ "execution_count": null,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "show_doc(Limiter.__call__)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3097f9d4",
+ "metadata": {},
+ "source": [
+ "## Example usage"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19967166",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "app, rt = fast_app()\n",
+ "cli = TestClient(app)\n",
+ "limiter = Limiter(app)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d0e3dd93",
+ "metadata": {},
+ "source": [
+ "### IP-based rate limiting (default)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7255f9dc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "@rt\n",
+ "@limiter('3/m') # keys on client IP by default\n",
+ "def index(): return 'ok'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a2c4118b",
+ "metadata": {},
+ "source": [
+ "Requests are allowed until the bucket is exhausted, then a 429 is returned with a `Retry-After` header."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "89f5bfcf",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for i in range(3): test_eq(cli.get('/').status_code, 200)\n",
+ "r = cli.get('/')\n",
+ "test_eq(r.status_code, 429)\n",
+ "test(r.headers, 'Retry-After', operator.contains)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5c7a87db",
+ "metadata": {},
+ "source": [
+ "### Parameter-based rate limiting:"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6e1b8dc0",
+ "metadata": {},
+ "source": [
+ "Different keys get independent buckets. Here we show `a@test.com` and `b@test.com` each get their own limit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bbe72069",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "@rt('/submit', methods=['POST'])\n",
+ "@limiter('2/m', key='email')\n",
+ "def submit(email: str): return f'hello {email}'"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "40d23160",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for i in range(2): test_eq(cli.post('/submit', data={'email': 'a@test.com'}).status_code, 200)\n",
+ "test_eq(cli.post('/submit', data={'email': 'a@test.com'}).status_code, 429)\n",
+ "test_eq(cli.post('/submit', data={'email': 'b@test.com'}).status_code, 200)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "70172d98",
+ "metadata": {},
+ "source": [
+ "### Callable-based rate limiting:"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dffebde9",
+ "metadata": {},
+ "source": [
+ "For full control, pass a callable as `key`. It receives the Starlette `Request` plus any route `**kwargs`, and should return a string to bucket by. Here we rate-limit by the `x-api-key` header — each API key gets its own bucket:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "197fb532",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "@rt\n",
+ "@limiter('2/m', key=lambda req, **kwargs: req.headers.get('x-api-key', ''))\n",
+ "def custom(): return 'ok'"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6ed4a731",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for i in range(2): test_eq(cli.get('/custom', headers={'x-api-key': 'abc'}).status_code, 200)\n",
+ "test_eq(cli.get('/custom', headers={'x-api-key': 'abc'}).status_code, 429)\n",
+ "test_eq(cli.get('/custom', headers={'x-api-key': 'xyz'}).status_code, 200)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bf91ea45",
+ "metadata": {},
+ "source": [
+ "### Shared limits across routes:"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bd9e28c8",
+ "metadata": {},
+ "source": [
+ "Save the decorator and apply it to multiple routes to share a single bucket."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1fd5a249",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "shared = limiter('2/m')\n",
+ "\n",
+ "@rt\n",
+ "@shared\n",
+ "def users(): return 'users'\n",
+ "\n",
+ "@rt\n",
+ "@shared\n",
+ "def posts(): return 'posts'\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "42e03753",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "test_eq(cli.get('/users').status_code, 200)\n",
+ "test_eq(cli.get('/posts').status_code, 200)\n",
+ "test_eq(cli.get('/users').status_code, 429)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "330b3cba",
+ "metadata": {},
+ "source": [
+ "# Export -"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "84300cc1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| hide\n",
+ "import nbdev; nbdev.nbdev_export()"
+ ]
+ }
+ ],
+ "metadata": {
+ "solveit_dialog_mode": "learning",
+ "solveit_ver": 2
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/nbs/api/10_magickey.ipynb b/nbs/api/10_magickey.ipynb
new file mode 100644
index 00000000..cc011445
--- /dev/null
+++ b/nbs/api/10_magickey.ipynb
@@ -0,0 +1,588 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c01b5481",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| default_exp magickey"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a7f143ec",
+ "metadata": {},
+ "source": [
+ "# MagicKey\n",
+ "> Passwordless auth combining magic links and passkeys"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d3fd43fe",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "import secrets, time\n",
+ "from json import loads\n",
+ "from urllib.parse import urlparse\n",
+ "\n",
+ "from webauthn import generate_authentication_options, generate_registration_options, verify_authentication_response, verify_registration_response, options_to_json\n",
+ "from webauthn.helpers import base64url_to_bytes, bytes_to_base64url\n",
+ "from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement\n",
+ "\n",
+ "from fastcore.utils import *\n",
+ "from fastcore.xml import *\n",
+ "from fastlite import *\n",
+ "from fasthtml.basics import *\n",
+ "from fasthtml.starlette import *\n",
+ "from fasthtml.fastapp import *\n",
+ "from fasthtml.ratelimit import *"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "cdf42c78",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from fastcore.test import *\n",
+ "from starlette.testclient import TestClient"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b7fca791",
+ "metadata": {},
+ "source": [
+ "## Helpers"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9c6b5bcc",
+ "metadata": {},
+ "source": [
+ "`public_origin` and `rp_id` are passed to `MagicKey.__init__` and used directly at both WebAuthn verification call sites — no helper needed.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "03d86e44",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "#| export\n",
+ "# _origin_kw removed; public_origin / rp_id inlined at call sites\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0d037d2c",
+ "metadata": {},
+ "source": [
+ "`_webauthn_js` builds an inline `