paskia 0.10.0__py3-none-any.whl → 0.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. paskia/_version.py +2 -2
  2. paskia/bootstrap.py +8 -7
  3. paskia/db/__init__.py +2 -0
  4. paskia/db/background.py +5 -8
  5. paskia/db/operations.py +17 -0
  6. paskia/db/structs.py +3 -2
  7. paskia/fastapi/__main__.py +21 -13
  8. paskia/fastapi/authz.py +8 -1
  9. paskia/fastapi/mainapp.py +6 -4
  10. paskia/fastapi/user.py +22 -0
  11. paskia/frontend-build/auth/admin/index.html +7 -6
  12. paskia/frontend-build/auth/assets/AccessDenied-CVQZxSIL.css +1 -0
  13. paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
  14. paskia/frontend-build/auth/assets/{RestrictedAuth-BOdNrlQB.css → RestrictedAuth-0MFeNWS2.css} +1 -1
  15. paskia/frontend-build/auth/assets/{RestrictedAuth-BSusdAfp.js → RestrictedAuth-DWKMTEV3.js} +1 -1
  16. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
  17. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
  18. paskia/frontend-build/auth/assets/{admin-CmNtuH3s.css → admin-B1H4YqM_.css} +1 -1
  19. paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
  20. paskia/frontend-build/auth/assets/{auth-BKq4T2K2.css → auth-B4EpDxom.css} +1 -1
  21. paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
  22. paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
  23. paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
  24. paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
  25. paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
  26. paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
  27. paskia/frontend-build/auth/index.html +8 -7
  28. paskia/frontend-build/auth/restricted/index.html +7 -6
  29. paskia/frontend-build/int/forward/index.html +6 -6
  30. paskia/frontend-build/int/reset/index.html +3 -3
  31. paskia/frontend-build/paskia.webp +0 -0
  32. paskia/util/__init__.py +0 -0
  33. paskia/util/apistructs.py +110 -0
  34. paskia/util/frontend.py +75 -0
  35. paskia/util/hostutil.py +75 -0
  36. paskia/util/htmlutil.py +47 -0
  37. paskia/util/passphrase.py +20 -0
  38. paskia/util/permutil.py +43 -0
  39. paskia/util/pow.py +45 -0
  40. paskia/util/querysafe.py +11 -0
  41. paskia/util/sessionutil.py +38 -0
  42. paskia/util/startupbox.py +103 -0
  43. paskia/util/timeutil.py +47 -0
  44. paskia/util/useragent.py +10 -0
  45. paskia/util/userinfo.py +63 -0
  46. paskia/util/vitedev.py +71 -0
  47. paskia/util/wordlist.py +54 -0
  48. {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
  49. paskia-0.10.2.dist-info/RECORD +78 -0
  50. paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +0 -1
  51. paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +0 -12
  52. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +0 -49
  53. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +0 -1
  54. paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +0 -1
  55. paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +0 -1
  56. paskia/frontend-build/auth/assets/forward-C86Jm_Uq.js +0 -1
  57. paskia/frontend-build/auth/assets/reset-D71FG0VL.js +0 -1
  58. paskia/frontend-build/auth/assets/restricted-CW0drE_k.js +0 -1
  59. paskia-0.10.0.dist-info/RECORD +0 -60
  60. {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
  61. {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,38 @@
1
+ """Utility functions for session validation and checking."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from paskia.authsession import EXPIRES
6
+ from paskia.db import SessionContext
7
+ from paskia.util.timeutil import parse_duration
8
+
9
+
10
+ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
11
+ """Check if a session satisfies the max_age requirement.
12
+
13
+ Uses the credential's last_used timestamp to determine authentication age,
14
+ since session renewal can happen without re-authentication.
15
+
16
+ Args:
17
+ ctx: The session context containing session and credential info
18
+ max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
19
+
20
+ Returns:
21
+ True if authentication is recent enough or max_age is None, False if too old
22
+
23
+ Raises:
24
+ ValueError: If max_age format is invalid
25
+ """
26
+ if not max_age:
27
+ return True
28
+
29
+ max_age_delta = parse_duration(max_age)
30
+
31
+ # Use credential's last_used time if available, fall back to session renewed time
32
+ if ctx.credential and ctx.credential.last_used:
33
+ auth_time = ctx.credential.last_used
34
+ else:
35
+ auth_time = ctx.session.expiry - EXPIRES
36
+
37
+ time_since_auth = datetime.now(UTC) - auth_time
38
+ return time_since_auth <= max_age_delta
@@ -0,0 +1,103 @@
1
+ """Startup configuration box formatting utilities."""
2
+
3
+ import os
4
+ import re
5
+ from sys import stderr
6
+ from typing import TYPE_CHECKING
7
+
8
+ from paskia._version import __version__
9
+
10
+ if TYPE_CHECKING:
11
+ from paskia.config import PaskiaConfig
12
+
13
+ BOX_WIDTH = 60 # Inner width (excluding box chars)
14
+
15
+ # ANSI color codes
16
+ RESET = "\033[0m"
17
+ YELLOW = "\033[33m" # Dark yellow
18
+ BRIGHT_YELLOW = "\033[93m" # Bright yellow
19
+ BRIGHT_WHITE = "\033[1;37m" # Bold bright white
20
+
21
+
22
+ def _visible_len(text: str) -> int:
23
+ """Calculate visible length of text, ignoring ANSI escape codes."""
24
+ return len(re.sub(r"\033\[[0-9;]*m", "", text))
25
+
26
+
27
+ def line(text: str = "") -> str:
28
+ """Format a line inside the box with proper padding, truncating if needed."""
29
+ visible = _visible_len(text)
30
+ if visible > BOX_WIDTH:
31
+ text = text[: BOX_WIDTH - 1] + "…"
32
+ visible = BOX_WIDTH
33
+ padding = BOX_WIDTH - visible
34
+ return f"┃ {text}{' ' * padding} ┃\n"
35
+
36
+
37
+ def top() -> str:
38
+ return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
39
+
40
+
41
+ def bottom() -> str:
42
+ return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
43
+
44
+
45
+ def print_startup_config(config: "PaskiaConfig") -> None:
46
+ """Print server configuration on startup."""
47
+ # Key graphic with yellow shading (bright for highlights, dark for body)
48
+ Y = YELLOW # Dark yellow for main body
49
+ B = BRIGHT_YELLOW # Bright yellow for highlights/edges
50
+ W = BRIGHT_WHITE # Bold white for URL
51
+ R = RESET
52
+
53
+ lines = [top()]
54
+ lines.append(line(f" {B}▄▄▄▄▄{R}"))
55
+ lines.append(line(f"{B}█{Y} {B}█{R} Paskia " + __version__))
56
+ lines.append(line(f"{B}█{Y} {B}█{Y}▄▄▄▄▄▄▄▄▄▄▄▄{R}"))
57
+ lines.append(
58
+ line(
59
+ f"{B}█{Y} {B}█{Y}▀▀▀▀{B}█{Y}▀▀{B}█{Y}▀▀{B}█{R} {W}"
60
+ + config.site_url
61
+ + config.site_path
62
+ + R
63
+ )
64
+ )
65
+ lines.append(line(f" {Y}▀▀▀▀▀{R}"))
66
+
67
+ # Format auth host section
68
+ if config.auth_host:
69
+ lines.append(line(f"Auth Host: {config.auth_host}"))
70
+
71
+ # Show frontend URL if in dev mode
72
+ devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
73
+ if devmode:
74
+ lines.append(line(f"Dev Frontend: {devmode}"))
75
+
76
+ # Format listen address with scheme
77
+ if config.uds:
78
+ listen = f"unix:{config.uds}"
79
+ elif config.host:
80
+ listen = f"http://{config.host}:{config.port}"
81
+ else:
82
+ listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
83
+ lines.append(line(f"Backend: {listen}"))
84
+
85
+ # Relying Party line (omit name if same as id)
86
+ rp_id = config.rp_id
87
+ rp_name = config.rp_name
88
+ if rp_name and rp_name != rp_id:
89
+ lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
90
+ else:
91
+ lines.append(line(f"Relying Party: {rp_id}"))
92
+
93
+ # Format origins section
94
+ allowed = config.origins
95
+ if allowed:
96
+ lines.append(line("Permitted Origins:"))
97
+ for origin in sorted(allowed):
98
+ lines.append(line(f" - {origin}"))
99
+ else:
100
+ lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
101
+
102
+ lines.append(bottom())
103
+ stderr.write("".join(lines))
@@ -0,0 +1,47 @@
1
+ """Utility functions for parsing time durations."""
2
+
3
+ import re
4
+ from datetime import timedelta
5
+
6
+
7
+ def parse_duration(duration_str: str) -> timedelta:
8
+ """Parse a duration string into a timedelta.
9
+
10
+ Supports units: s, m, min, h, d
11
+ Examples: "30s", "5m", "5min", "2h", "1d"
12
+
13
+ Args:
14
+ duration_str: A string like "30s", "5m", "2h"
15
+
16
+ Returns:
17
+ A timedelta object
18
+
19
+ Raises:
20
+ ValueError: If the format is invalid
21
+ """
22
+ duration_str = duration_str.strip().lower()
23
+
24
+ # Pattern matches: number + unit
25
+ # Units: s (seconds), m/min (minutes), h (hours), d (days)
26
+ pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
27
+ match = re.match(pattern, duration_str)
28
+
29
+ if not match:
30
+ raise ValueError(
31
+ f"Invalid duration format: '{duration_str}'. "
32
+ "Expected format like '30s', '5m', '5min', '2h', or '1d'"
33
+ )
34
+
35
+ value = float(match.group(1))
36
+ unit = match.group(2)
37
+
38
+ if unit == "s":
39
+ return timedelta(seconds=value)
40
+ elif unit in ("m", "min"):
41
+ return timedelta(minutes=value)
42
+ elif unit == "h":
43
+ return timedelta(hours=value)
44
+ elif unit == "d":
45
+ return timedelta(days=value)
46
+ else:
47
+ raise ValueError(f"Unsupported time unit: {unit}")
@@ -0,0 +1,10 @@
1
+ import user_agents
2
+
3
+
4
+ def compact_user_agent(ua: str | None) -> str:
5
+ if not ua:
6
+ return "-"
7
+ u = user_agents.parse(ua)
8
+ ver = u.browser.version_string.split(".")[0]
9
+ dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
10
+ return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
@@ -0,0 +1,63 @@
1
+ """User information formatting and retrieval logic."""
2
+
3
+ from paskia import aaguid, db
4
+ from paskia.authsession import EXPIRES
5
+ from paskia.db import SessionContext
6
+ from paskia.util import hostutil, permutil
7
+ from paskia.util.apistructs import ApiSession
8
+
9
+
10
+ def build_session_context(ctx: SessionContext) -> dict:
11
+ """Build session context dict from SessionContext."""
12
+ result = {
13
+ "user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
14
+ "org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
15
+ "role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
16
+ "permissions": [p.scope for p in ctx.permissions],
17
+ }
18
+ if ctx.user.theme:
19
+ result["user"]["theme"] = ctx.user.theme
20
+ return result
21
+
22
+
23
+ async def build_user_info(
24
+ *,
25
+ user_uuid,
26
+ auth: str,
27
+ session_record,
28
+ request_host: str | None,
29
+ ) -> dict:
30
+ """Build user info dict for authenticated users."""
31
+ ctx = await permutil.session_context(auth, request_host)
32
+ user = db.data().users[user_uuid]
33
+ normalized_host = hostutil.normalize_host(request_host)
34
+
35
+ credentials = sorted(user.credentials, key=lambda c: c.created_at)
36
+ return {
37
+ "ctx": build_session_context(ctx),
38
+ "created_at": ctx.user.created_at,
39
+ "last_seen": ctx.user.last_seen,
40
+ "visits": ctx.user.visits,
41
+ "credentials": [
42
+ {
43
+ "credential": c.uuid,
44
+ "aaguid": c.aaguid,
45
+ "created_at": c.created_at,
46
+ "last_used": c.last_used,
47
+ "last_verified": c.last_verified,
48
+ "sign_count": c.sign_count,
49
+ "is_current_session": session_record.credential == c.uuid,
50
+ }
51
+ for c in credentials
52
+ ],
53
+ "aaguid_info": aaguid.filter(c.aaguid for c in credentials),
54
+ "sessions": [
55
+ ApiSession.from_db(
56
+ s,
57
+ current_key=auth,
58
+ normalized_host=normalized_host,
59
+ expires_delta=EXPIRES,
60
+ )
61
+ for s in user.sessions
62
+ ],
63
+ }
paskia/util/vitedev.py ADDED
@@ -0,0 +1,71 @@
1
+ """Vite dev server proxy for fetching frontend files during development.
2
+
3
+ In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
4
+ In production, reads from the static build directory.
5
+
6
+ This complements fastapi_vue.Frontend which handles static file serving
7
+ but doesn't provide server-side fetching of HTML content.
8
+ """
9
+
10
+ import asyncio
11
+ import mimetypes
12
+ import os
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ __all__ = ["read"]
19
+
20
+
21
+ def _get_dev_server() -> str | None:
22
+ """Get the dev server URL from environment, or None if not in dev mode."""
23
+ return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
24
+
25
+
26
+ def _resolve_static_dir() -> Path:
27
+ """Resolve the static files directory."""
28
+
29
+ # Try packaged path via importlib.resources (works for wheel/installed).
30
+ try: # pragma: no cover - trivial path resolution
31
+ pkg_dir = resources.files("paskia") / "frontend-build"
32
+ fs_path = Path(str(pkg_dir))
33
+ if fs_path.is_dir():
34
+ return fs_path
35
+ except Exception: # pragma: no cover - defensive
36
+ pass
37
+ # Fallback for editable/development before build.
38
+ return Path(__file__).parent.parent / "frontend-build"
39
+
40
+
41
+ _static_dir: Path = _resolve_static_dir()
42
+
43
+
44
+ async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
45
+ """Read file content and return response tuple.
46
+
47
+ In dev mode, fetches from the Vite dev server.
48
+ In production, reads from the static build directory.
49
+
50
+ Args:
51
+ filepath: Path relative to frontend root, e.g. "/auth/index.html"
52
+
53
+ Returns:
54
+ Tuple of (content, status_code, headers) suitable for
55
+ FastAPI Response(*args).
56
+ """
57
+ dev_server = _get_dev_server()
58
+ if dev_server:
59
+ async with httpx.AsyncClient() as client:
60
+ resp = await client.get(f"{dev_server}{filepath}")
61
+ resp.raise_for_status()
62
+ mime = resp.headers.get("content-type", "application/octet-stream")
63
+ # Strip charset suffix if present
64
+ mime = mime.split(";")[0].strip()
65
+ return resp.content, resp.status_code, {"content-type": mime}
66
+ else:
67
+ # Production: read from static build
68
+ file_path = _static_dir / filepath.lstrip("/")
69
+ content = await asyncio.to_thread(file_path.read_bytes)
70
+ mime, _ = mimetypes.guess_type(str(file_path))
71
+ return content, 200, {"content-type": mime or "application/octet-stream"}
@@ -0,0 +1,54 @@
1
+ # A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
2
+ words: list = """
3
+ able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
4
+ aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal
5
+ ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom
6
+ attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become
7
+ beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue
8
+ board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget
9
+ build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement
10
+ census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee
11
+ coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve
12
+ custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial
13
+ depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain
14
+ donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east
15
+ echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine
16
+ enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact
17
+ excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch
18
+ fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow
19
+ food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas
20
+ gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern
21
+ gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk
22
+ hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse
23
+ host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income
24
+ index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james
25
+ jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind
26
+ kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal
27
+ lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load
28
+ local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male
29
+ mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind
30
+ mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name
31
+ napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal
32
+ nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit
33
+ once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen
34
+ oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit
35
+ pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond
36
+ pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen
37
+ quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform
38
+ region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot
39
+ ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage
40
+ sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle
41
+ seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski
42
+ skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south
43
+ space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden
44
+ suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk
45
+ tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt
46
+ time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade
47
+ tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock
48
+ until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein
49
+ velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage
50
+ wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish
51
+ wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane
52
+ zara zebra zen zero zippo zone zoo zorro zulu
53
+ """.split()
54
+ assert len(words) == 1024 # Exactly 10 bits of entropy per word
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paskia
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
5
5
  Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
6
6
  Project-URL: Repository, https://github.com/LeoVasanko/paskia
@@ -31,6 +31,8 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  # Paskia
33
33
 
34
+ ![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
35
+
34
36
  An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
35
37
 
36
38
  ## What is Paskia?
@@ -58,12 +60,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
58
60
  Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
59
61
 
60
62
  ```fish
61
- uvx paskia serve --rp-id example.com
63
+ uvx paskia --rp-id example.com
62
64
  ```
63
65
 
64
- On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
66
+ On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
65
67
 
66
- Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
68
+ For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
67
69
 
68
70
  For a permanent install of `paskia` CLI command, not needing `uvx`:
69
71
 
@@ -73,19 +75,20 @@ uv tool install paskia
73
75
 
74
76
  ## Configuration
75
77
 
76
- There is no config file. Pass only the options on CLI:
78
+ There is no config file. All settings are passed as CLI options:
77
79
 
78
80
  ```text
79
- paskia serve [options]
81
+ paskia [options]
82
+ paskia reset [user] # Generate passkey reset link
80
83
  ```
81
84
 
82
85
  | Option | Description | Default |
83
86
  |--------|-------------|---------|
84
- | Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
85
- | --rp-id *domain* | Main/top domain | **localhost** |
86
- | --rp-name *"text"* | Name of your company or site | Same as rp-id |
87
- | --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
88
- | --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
87
+ | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
88
+ | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
89
+ | --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
90
+ | --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
91
+ | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
89
92
 
90
93
  ## Further Documentation
91
94
 
@@ -0,0 +1,78 @@
1
+ paskia/__init__.py,sha256=6eopO87IOFA2zfOuqt8Jj8Tdtp93HBMOgUBtTzMRweM,57
2
+ paskia/_version.py,sha256=3BHpMBCrd3LaStkZpM-CWwrZNXfv2IZT3WCvvrFKMbU,706
3
+ paskia/authsession.py,sha256=7TGZuJw4RLsRJ-pemii6zRXTR-wdLx3nFVItytKFz5s,1259
4
+ paskia/bootstrap.py,sha256=T5j7cJ2VkOIYS-FG-iO4fbyux2vDlqNkxz5HRDW8Y9k,3944
5
+ paskia/config.py,sha256=BdGzQ3Ja1enSTHmkDkBDGQk_JluT3VaK3Y7AqB5xMlk,723
6
+ paskia/globals.py,sha256=ip03kLoS_27cNIgXTVcXNoeQDjTAC_IILuXaHKShTws,1712
7
+ paskia/remoteauth.py,sha256=IyC1Z9YTYjPi4h8B0UwwoWfxaheEgyfksbKraOrAcqA,12418
8
+ paskia/sansio.py,sha256=LQRdV1kW_aGwDWC8fhyEvqWPwKZVx_8qzQv65et6utg,9727
9
+ paskia/aaguid/__init__.py,sha256=I5HMkAwiyLKKlkZOb9z5s2xGApXIbhbBuuX3IhW2y8E,1047
10
+ paskia/aaguid/combined_aaguid.json,sha256=CaZ96AiwdAjBnyVZnJ1eolAHxUQMB2H6mDgZkorYg_A,4124722
11
+ paskia/db/__init__.py,sha256=jYCTHa3pBsDIr1cnD0PQ66X8y5L_TibsqE52RSCwDmo,3268
12
+ paskia/db/background.py,sha256=nYiv7UcpyCk135k6RdaTROwpfrnkUK5LFuXI5bI_F00,3398
13
+ paskia/db/jsonl.py,sha256=1uHBSTtLEc6CChoRJx2zYjFrMcPkGCZN_8MQpHCohFw,9959
14
+ paskia/db/logging.py,sha256=ENCHdrlUz27hjWPFLezpF9bpO-pnrkgO6bNnfCyHcJ4,11017
15
+ paskia/db/migrations.py,sha256=XHHC0LbxXBN6gldiKm4PhDVqEqroMN1E8tz0LEqGPbQ,957
16
+ paskia/db/operations.py,sha256=chAf3tYfX7NGlqYi3IGvSnF_Yhu_LFhZwcluMykiB0Y,26950
17
+ paskia/db/structs.py,sha256=ZwfEVxjNkdorYbVjJtLq8OcKgysLaRQNGdXysdiRUh8,14153
18
+ paskia/fastapi/__init__.py,sha256=NFsTX1qytoyZKiur7RDTa2fxiOWHrop5CAAx8rqK9E0,58
19
+ paskia/fastapi/__main__.py,sha256=UbD7MPbrjgckXawU2SkOqqmPPOC3eovAyrcDLzAEYII,8245
20
+ paskia/fastapi/admin.py,sha256=8raj__JkNTYuvjdZejnK4BhaZBl219qjobmUafQMRkI,30343
21
+ paskia/fastapi/api.py,sha256=RTRhhn6d44aE3MNrepgLBWIPgDPCNr6dHLKSEja_sgU,9240
22
+ paskia/fastapi/auth_host.py,sha256=Y5w9Mz6jyq0hj7SX8LfwebaesUOLGcWzGW9lsmw5WOo,3242
23
+ paskia/fastapi/authz.py,sha256=-T_fRxw2D28cDFXhEdGun1aQ_Ra1CClhDBPCXXAzvPk,3542
24
+ paskia/fastapi/logging.py,sha256=xP7dxwy8bfKRft3jZPBdsNQk8eQ3nIHB8BUyz7A7sI0,8840
25
+ paskia/fastapi/mainapp.py,sha256=L45a9RTw8igJf7YKRHqrZ_TXZbGbRAuLUkQnBPmNgQw,5154
26
+ paskia/fastapi/remote.py,sha256=0hsJj8qK89h1U7-5nJFCk9cTte2tvh02b510v-_TGxU,17789
27
+ paskia/fastapi/reset.py,sha256=MuZqhOF68P9kGq5zKPV19FjTIvhZQ423YowzHKcVjlc,3573
28
+ paskia/fastapi/response.py,sha256=gTPNJtS92HfQYey2DMMN8oM1YazNSj6tjBBuFJuuBhc,611
29
+ paskia/fastapi/session.py,sha256=BRnlgR8pTY7o0f7qFnkdyepS2fKEAgqwT9Hj951sZJM,1479
30
+ paskia/fastapi/user.py,sha256=Bp4J6pT3RcQ21F7MN12n5CNNMaF4atF2MTbOf5cUEWQ,5296
31
+ paskia/fastapi/ws.py,sha256=iY8CVO0W117Vln0DW2xHxiCk__KI29oTMOB2BZEwv34,3581
32
+ paskia/fastapi/wschat.py,sha256=qbEY6AlDlvjeUoIGac3K3DH41MPxPCYuiaFOnz9gxMU,3699
33
+ paskia/fastapi/wsutil.py,sha256=JMPSSWDzMJr7cVobWTFM0HwJrSj_tfKmoO7JkfewuAE,2898
34
+ paskia/frontend-build/paskia.webp,sha256=kITjUmN3cn8R5B8pLlgOtxHN1n1NQDeJabwzqbPy1Do,44164
35
+ paskia/frontend-build/auth/index.html,sha256=-kSBisbhjW17nbLDOuUzPZgrNjnKxQyJP897SS0CpJU,1017
36
+ paskia/frontend-build/auth/admin/index.html,sha256=Iiw2UQ10UW8i2_y9aLxGl0mMouCoNMLqwti3z5rbv58,943
37
+ paskia/frontend-build/auth/assets/AccessDenied-CVQZxSIL.css,sha256=DswiyzjtT82EsIv4vZtxjO8bMcUyTDi0Qx37uI7XxME,7941
38
+ paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js,sha256=qKl4pddBqme4ChR8WUJs4WPYLzXJeOWkwgE5u-KttCM,51832
39
+ paskia/frontend-build/auth/assets/RestrictedAuth-0MFeNWS2.css,sha256=L40sxVzeRc4yy6OX1rNmxGyoV1VN0oQtYkP2_UpZYBQ,5397
40
+ paskia/frontend-build/auth/assets/RestrictedAuth-DWKMTEV3.js,sha256=24lUAXw-frzXRXAI92q3qCT8XARZQgsfGmbmsFpZGqQ,9761
41
+ paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js,sha256=MiiDrQ7oz-5jGmZ7lNNQls1tOHQ5UTbnT4zE6lpZ8hM,86596
42
+ paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css,sha256=Huc_Qvhy5d5vFDjRMnXEwpFhI928RI96vmSEsNbRF6I,12258
43
+ paskia/frontend-build/auth/assets/admin-B1H4YqM_.css,sha256=pyWMaST2Lpl5_nTqhuAU699UH_VGSthT4TWkvnTRlvg,7599
44
+ paskia/frontend-build/auth/assets/admin-CZKsX1OI.js,sha256=5t-E3rf4zC5J4xWG9Z14T4DZ6yYophoN0BBO2i3LcXY,41141
45
+ paskia/frontend-build/auth/assets/auth-B4EpDxom.css,sha256=Oo0vjcm8H_akR3PgaBecp8pYkDUmAic9ihAnMK9Qrf4,5307
46
+ paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js,sha256=xu57452IjPjLE9VSqNO_MucASm5f96-iLy6eTf5XNBs,26303
47
+ paskia/frontend-build/auth/assets/forward-BC0p23CH.js,sha256=9vp2gxaZElJaegaAZrmggrao9bGX7FxqCw2RRvOwukA,782
48
+ paskia/frontend-build/auth/assets/helpers-DzjFIx78.js,sha256=w_IsCBn3QwidsuwQhVRycd8Fa53lvbgRGGojTBXVlUc,940
49
+ paskia/frontend-build/auth/assets/pow-DUr-T9XX.js,sha256=bmfbethWb9KJw71ocq-RIStpNtjs7PzgBzbnhhsqiCM,9447
50
+ paskia/frontend-build/auth/assets/reset-B8PlNXuP.css,sha256=19f4h_GGjADzWvaYag9lnwrmttNXvwVYYHcGGy5X61E,238
51
+ paskia/frontend-build/auth/assets/reset-CkY9h28U.js,sha256=Kg5duHlSkKN3IHnOCW7XofN_0abMaDsOEPU-DU_rvqY,3964
52
+ paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js,sha256=hD5i64I_vUkeLJZmvFs0owutEtVmv9sF8tkVKuMK3xk,1201
53
+ paskia/frontend-build/auth/assets/theme-C2WysaSw.js,sha256=ElesHQoJVlHOKnF6LEwPkNDzo7V2Q_T_sbk7PrQh_YQ,2072
54
+ paskia/frontend-build/auth/restricted/index.html,sha256=9arIuAWlqKJUFCx934VrbnZ7zxI20RF7znIcz_JFBwM,866
55
+ paskia/frontend-build/int/forward/index.html,sha256=GI3Wz-aaPfai9KE3Bhvd9bp702uU9LA4UizwrmYPQ34,870
56
+ paskia/frontend-build/int/reset/index.html,sha256=VSKqoEMoNXtKxDaD4UGG5MqcRseV48Tv6-DxIrEMiAY,612
57
+ paskia/migrate/__init__.py,sha256=r2s99mbHfvlzQJ1V3xnxZOwIyMEMxEXDtFqFj7sJC2U,9916
58
+ paskia/migrate/sql.py,sha256=5HNEUnPP0kqis69URJ5keE4JNbwqWe8oYXwf-zKheLI,14333
59
+ paskia/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
+ paskia/util/apistructs.py,sha256=YqOEKDjiA-VB4UJZ2KKGq_jc0dRudkhRuWilFzpwMSI,2943
61
+ paskia/util/frontend.py,sha256=ceKt1wCvaqtc7CtGFBFlhnIGybNUgFzlW2zZ9xmdpy8,2515
62
+ paskia/util/hostutil.py,sha256=Odhjp4tliGReN5kenpc2IommoTMqwQ1LNVlwJMkT-6c,2293
63
+ paskia/util/htmlutil.py,sha256=r0YR3AKIsIg5Gg45N92Lh7UYz2kwF1coreZahzLZIX8,1580
64
+ paskia/util/passphrase.py,sha256=oAfLJwaBvCmXY3CMv8KeAogE9V5SejnTLfXWIiQYCOo,562
65
+ paskia/util/permutil.py,sha256=FkaH4y_DPKlvfBq-wN_pDKzNf4ZLYy4HdkzxYmq_9IQ,1387
66
+ paskia/util/pow.py,sha256=u99Phs1DBiv9Ptm8agaA7ZSOnRPtDcpgkLgGzNTcJWo,1395
67
+ paskia/util/querysafe.py,sha256=iHfY2z5MX0Y6gso2oeq-SiHhg97vq35Jp1D16sXY0FE,294
68
+ paskia/util/sessionutil.py,sha256=I85Diaho9ajfOwcaQSg3N4pKGf3LGsuXsULlTETWPHE,1249
69
+ paskia/util/startupbox.py,sha256=-bhm-HGPyjTHwkuZbPud2CTKiTWR_KRerBH77-bJkDU,3188
70
+ paskia/util/timeutil.py,sha256=1Zf8rXa76oLXDuZrGyuVDNhFjxl-28Z_Xb6pIH06ORk,1258
71
+ paskia/util/useragent.py,sha256=wOeueToxKHdJ91vT5jMVBoIhelNwxD5u7vgWQGSjImA,325
72
+ paskia/util/userinfo.py,sha256=Y-z6LHnkKhQMdHxJBmhF6cQovmdGxOKlTWtODFKFWz0,2119
73
+ paskia/util/vitedev.py,sha256=FebrKaaQy7maVGhIFb9Nd-yDgC2gwy2GcYNr4Q3lWBw,2436
74
+ paskia/util/wordlist.py,sha256=EKwrABkqRO1f59--muegOoluPydPJAHlWJNXwV0IFyA,6069
75
+ paskia-0.10.2.dist-info/METADATA,sha256=Ykuur7O20U5UMLWLlBu9PExz79OtRWHpcwcpwBLi0e8,4314
76
+ paskia-0.10.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
77
+ paskia-0.10.2.dist-info/entry_points.txt,sha256=vvx6RYetgd61I2ODqQPHqrKHgCfuo08w_T35yDlHenE,93
78
+ paskia-0.10.2.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- .breadcrumbs[data-v-6344dbb8]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-6344dbb8]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-6344dbb8]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-6344dbb8]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs .sep[data-v-6344dbb8]{color:var(--color-text-muted);margin:0}.btn-card-delete{display:none}.credential-item:focus .btn-card-delete{display:block}.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr 2fr;grid-template-areas:"heading heading extra" "org org extra" "label1 value1 extra" "label2 value2 extra" "label3 value3 extra"}.user-info[data-v-ce373d6c]:not(.has-extra){grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3"}@media (max-width: 720px){.user-info.has-extra[data-v-ce373d6c]{grid-template-columns:auto 1fr;grid-template-areas:"heading heading" "org org" "label1 value1" "label2 value2" "label3 value3" "extra extra"}}.user-name-heading[data-v-ce373d6c]{grid-area:heading;display:flex;align-items:center;flex-wrap:wrap;margin:0 0 .25rem}.org-role-sub[data-v-ce373d6c]{grid-area:org;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-ce373d6c]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-ce373d6c]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.info-label[data-v-ce373d6c]:nth-of-type(1){grid-area:label1}.info-value[data-v-ce373d6c]:nth-of-type(2){grid-area:value1}.info-label[data-v-ce373d6c]:nth-of-type(3){grid-area:label2}.info-value[data-v-ce373d6c]:nth-of-type(4){grid-area:value2}.info-label[data-v-ce373d6c]:nth-of-type(5){grid-area:label3}.info-value[data-v-ce373d6c]:nth-of-type(6){grid-area:value3}.user-info-extra[data-v-ce373d6c]{grid-area:extra;padding-left:2rem;border-left:1px solid var(--color-border)}.user-name-row[data-v-ce373d6c]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-ce373d6c]{flex:1 1 auto}.display-name[data-v-ce373d6c]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-ce373d6c]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-ce373d6c]{width:auto}.name-input[data-v-ce373d6c]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-ce373d6c]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;cursor:pointer}.mini-btn[data-v-ce373d6c]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-ce373d6c]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-ce373d6c]:disabled{opacity:.5;cursor:not-allowed}@media (max-width: 720px){.user-info-extra[data-v-ce373d6c]{padding-left:0;padding-top:1rem;margin-top:1rem;border-left:none;border-top:1px solid var(--color-border)}}dialog[data-v-2ebcbb0a]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto;position:fixed;inset:0;margin:auto;height:fit-content}dialog[data-v-2ebcbb0a]::backdrop{background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7)}dialog[data-v-2ebcbb0a] .modal-title,dialog[data-v-2ebcbb0a] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}dialog[data-v-2ebcbb0a] form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}dialog[data-v-2ebcbb0a] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}dialog[data-v-2ebcbb0a] .modal-form input,dialog[data-v-2ebcbb0a] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}dialog[data-v-2ebcbb0a] .modal-form input:focus,dialog[data-v-2ebcbb0a] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}dialog[data-v-2ebcbb0a] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-b73321cf]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-b73321cf]{color:var(--color-danger-text)}.small[data-v-b73321cf]{font-size:.9rem}.qr-display[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.75rem}.qr-section[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;gap:.5rem}.qr-link[data-v-727427c4]{display:flex;flex-direction:column;align-items:center;text-decoration:none;color:inherit;border-radius:var(--radius-sm, 6px);overflow:hidden}.qr-code[data-v-727427c4]{display:block;width:200px;height:200px;max-width:100%;object-fit:contain;border-radius:var(--radius-sm, 6px);background:#fff;cursor:pointer}.link-text[data-v-727427c4]{padding:.5rem;font-size:.75rem;color:var(--color-text-muted);font-family:monospace;word-break:break-all;line-height:1.2;transition:color .2s ease}.qr-link:hover .link-text[data-v-727427c4]{color:var(--color-text)}dialog[data-v-26360b56]{border:none;background:transparent;padding:0;max-width:none;width:fit-content;height:fit-content;position:fixed;inset:0;margin:auto}dialog[data-v-26360b56]::backdrop{-webkit-backdrop-filter:blur(.2rem) brightness(.5);backdrop-filter:blur(.2rem) brightness(.5)}.icon-btn[data-v-26360b56]{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6}.icon-btn[data-v-26360b56]:hover{opacity:1}.reg-header-row[data-v-26360b56]{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.75rem}.reg-title[data-v-26360b56]{margin:0;font-size:1.25rem;font-weight:600}.device-dialog[data-v-26360b56]{background:var(--color-surface);padding:1.25rem 1.25rem 1rem;border-radius:var(--radius-md);max-width:480px;width:100%;box-shadow:0 6px 28px #00000040}.reg-help[data-v-26360b56]{margin:.5rem 0 .75rem;font-size:.85rem;line-height:1.4;text-align:center;color:var(--color-text-muted)}.reg-actions[data-v-26360b56]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}.expiry-note[data-v-26360b56]{font-size:.75rem;color:var(--color-text-muted);text-align:center;margin-top:.75rem}.loading-container[data-v-130f5abf]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem}.loading-spinner[data-v-130f5abf]{width:40px;height:40px;border:4px solid var(--color-border);border-top:4px solid var(--color-primary);border-radius:50%;animation:spin-130f5abf 1s linear infinite}@keyframes spin-130f5abf{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-container p[data-v-130f5abf]{color:var(--color-text-muted);margin:0}.message-container[data-v-a7b258e7]{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:2rem}.message-content[data-v-a7b258e7]{text-align:center;max-width:480px}.message-content h2[data-v-a7b258e7]{margin:0 0 1rem;color:var(--color-heading)}.message-content .error-detail[data-v-a7b258e7]{margin:0 0 1.5rem;color:var(--color-text-muted)}.message-content .button-row[data-v-a7b258e7]{display:flex;gap:.75rem;justify-content:center}