Compare commits

..
9 Commits
17 changed files with 491 additions and 195 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ dist/
*.lock *.lock
package-lock.json package-lock.json
paskia.sqlite paskia.sqlite
paskia.jsonl *.paskiadb
/paskia/frontend-build /paskia/frontend-build
/paskia/_version.py /paskia/_version.py
coverage-html/ coverage-html/
+9 -6
View File
@@ -51,7 +51,7 @@ uv tool install paskia
## Configuration ## Configuration
All configuration is passed by CLI arguments, of which there are just a few. You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
```text ```text
paskia [options] paskia [options]
@@ -61,9 +61,12 @@ paskia [options]
|--------|-------------|---------| |--------|-------------|---------|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** | | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** | | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id | | --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id | | --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production ## Tutorial: From Local Testing to Production
@@ -84,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```fish ```fish
paskia --rp-id example.com --rp-name "Example Corp" paskia --rp-id example.com --rp-name "Example Corp" --save
``` ```
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`.
### Step 3: Set Up Caddy ### Step 3: Set Up Caddy
@@ -187,7 +190,7 @@ Description=Paskia Authentication Server
Type=simple Type=simple
User=paskia User=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id example.com --rp-name "Example Corp" ExecStart=uvx paskia --rp-id=example.com
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+6 -5
View File
@@ -1,17 +1,18 @@
/** /**
* FastAPI-Vue Vite Plugin * FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
* *
* Configures Vite for FastAPI backend integration: * Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend * - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory * - Builds to the Python module's frontend-build directory
* *
* Environment variables (with defaults): * Options:
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying * paths - Array of paths to proxy (default: ["/api"])
*/ */
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
for (const path of paths) { for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
} }
return { return {
name: "fastapi-vite", name: "vite-plugin-fastapi-paskia",
config: () => ({ config: () => ({
server: { proxy }, server: { proxy },
build: { build: {
+11 -4
View File
@@ -10,6 +10,7 @@ import asyncio
import logging import logging
from paskia import authsession, db, globals from paskia import authsession, db, globals
from paskia.db.structs import Config
from paskia.util import hostutil from paskia.util import hostutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,15 +31,18 @@ def _log_reset_link(passphrase: str, message: str | None = None) -> str:
return reset_link return reset_link
async def bootstrap_system() -> None: async def bootstrap_system(config: Config | None = None) -> None:
""" """
Bootstrap the entire system with default data. Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction. Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes. The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
""" """
# Call the single-transaction bootstrap function # Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log) # Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!") _log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
@@ -89,10 +93,13 @@ async def check_admin_credentials() -> bool:
return False return False
async def bootstrap_if_needed() -> bool: async def bootstrap_if_needed(config: Config | None = None) -> bool:
""" """
Check if system needs bootstrapping and perform it if necessary. Check if system needs bootstrapping and perform it if necessary.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up bool: True if bootstrapping was performed, False if system was already set up
""" """
@@ -105,7 +112,7 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap # No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system() await bootstrap_system(config=config)
return True return True
+4
View File
@@ -47,6 +47,7 @@ from paskia.db.operations import (
delete_session, delete_session,
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
get_config,
get_organization_users, get_organization_users,
get_reset_token, get_reset_token,
get_user_credential_ids, get_user_credential_ids,
@@ -55,6 +56,7 @@ from paskia.db.operations import (
login, login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
set_config,
set_session_host, set_session_host,
update_credential_sign_count, update_credential_sign_count,
update_org_name, update_org_name,
@@ -110,6 +112,7 @@ __all__ = [
"build_session", "build_session",
"build_user", "build_user",
# Read ops # Read ops
"get_config",
"get_organization_users", "get_organization_users",
"get_reset_token", "get_reset_token",
"get_user_credential_ids", "get_user_credential_ids",
@@ -138,6 +141,7 @@ __all__ = [
"login", "login",
"remove_permission_from_org", "remove_permission_from_org",
"remove_permission_from_role", "remove_permission_from_role",
"set_config",
"set_session_host", "set_session_host",
"update_credential_sign_count", "update_credential_sign_count",
"update_org_name", "update_org_name",
+34 -23
View File
@@ -4,6 +4,8 @@ JSONL persistence layer for the database.
import copy import copy
import logging import logging
import os
import signal
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -69,22 +71,25 @@ def create_change_record(
# Actions that are allowed to create a new database file # Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"}) _BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
# Flag to prevent duplicate error messages on fatal flush failure
_flush_failed = False
async def flush_changes( async def flush_changes(
db_path: Path, db_path: Path,
pending_changes: deque[_ChangeRecord], pending_changes: deque[_ChangeRecord],
) -> bool: ) -> None:
"""Write all pending changes to disk. """Write all pending changes to disk.
Args: Args:
db_path: Path to the JSONL database file db_path: Path to the JSONL database file
pending_changes: Queue of pending change records (will be cleared on success) pending_changes: Queue of pending change records (will be cleared on success)
Returns: On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
True if flush succeeded, False otherwise
""" """
if not pending_changes: global _flush_failed
return True if _flush_failed or not pending_changes:
return
if not db_path.exists(): if not db_path.exists():
first_action = pending_changes[0].a first_action = pending_changes[0].a
@@ -94,26 +99,25 @@ async def flush_changes(
"only bootstrap can create a new database", "only bootstrap can create a new database",
first_action, first_action,
) )
pending_changes.clear() _flush_failed = True
return False os.kill(os.getpid(), signal.SIGTERM)
return
changes_to_write = list(pending_changes) changes_to_write = list(pending_changes)
pending_changes.clear()
try: try:
lines = [_change_encoder.encode(change) for change in changes_to_write] lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines: if not lines:
return True pending_changes.clear()
return
async with aiofiles.open(db_path, "ab") as f: async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n") await f.write(b"\n".join(lines) + b"\n")
return True pending_changes.clear()
except OSError: except OSError as e:
_logger.exception("Failed to flush database changes") _logger.error("Failed to flush database: %s", e)
# Re-queue the changes on failure _flush_failed = True
for change in reversed(changes_to_write): os.kill(os.getpid(), signal.SIGTERM)
pending_changes.appendleft(change)
return False
class JsonlStore: class JsonlStore:
@@ -130,10 +134,13 @@ class JsonlStore:
self._transaction_snapshot: dict[str, Any] | None = None self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases self._current_version: int = DBVER # Schema version for new databases
async def load(self, db_path: str | None = None) -> None: async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost"
) -> None:
"""Load data from JSONL change log.""" """Load data from JSONL change log."""
if db_path is not None: if db_path is not None:
self.db_path = Path(db_path) self.db_path = Path(db_path)
self._rp_id = rp_id
if not self.db_path.exists(): if not self.db_path.exists():
return return
@@ -152,8 +159,10 @@ class JsonlStore:
self._current_version = change.get("v", 0) self._current_version = change.get("v", 0)
except Exception as e: except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}") raise ValueError(f"Error parsing line {line_num}: {e}")
except (OSError, ValueError, msgspec.DecodeError) as e: except OSError as e:
raise ValueError(f"Failed to load database: {e}") raise SystemExit(f"Failed to load database: {e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"Failed to load database: {e}")
if not data_dict: if not data_dict:
return return
@@ -169,7 +178,9 @@ class JsonlStore:
self._queue_change(action, new_version, current) self._queue_change(action, new_version, current)
# Apply schema migrations one at a time # Apply schema migrations one at a time
await apply_all_migrations(data_dict, self._current_version, persist_migration) await apply_all_migrations(
data_dict, self._current_version, persist_migration, rp_id=rp_id
)
# Decode to msgspec struct # Decode to msgspec struct
decoder = msgspec.json.Decoder(DB) decoder = msgspec.json.Decoder(DB)
@@ -209,7 +220,7 @@ class JsonlStore:
except (ValueError, KeyError): except (ValueError, KeyError):
user_display = user user_display = user
log_change(action, diff, user_display, self._previous_builtins) log_change(action, diff, user_display, self._previous_builtins, self.db)
self._previous_builtins = copy.deepcopy(current) self._previous_builtins = copy.deepcopy(current)
@contextmanager @contextmanager
@@ -277,6 +288,6 @@ class JsonlStore:
self._in_transaction = False self._in_transaction = False
self._transaction_snapshot = None self._transaction_snapshot = None
async def flush(self) -> bool: async def flush(self) -> None:
"""Write all pending changes to disk.""" """Write all pending changes to disk."""
return await flush_changes(self.db_path, self._pending_changes) await flush_changes(self.db_path, self._pending_changes)
+244 -43
View File
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding. in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the last
section of the UUID hex for types without display names.
""" """
import logging import logging
import re import re
import sys import sys
from typing import Any from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db") logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides # Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile( _UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
@@ -32,13 +44,145 @@ _ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display _USER = "\033[0;34m" # Blue for user display
def _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
def _uuid_suffix(uuid_str: str) -> str:
"""Get the last section of a UUID (after the last hyphen)."""
return uuid_str.rsplit("-", 1)[-1]
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or short suffix."""
display = self._get_display_name(uuid_str)
if display:
return display
return _uuid_suffix(uuid_str)
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
# Check credentials - look up user name
if (
"credentials" in self._previous
and uuid_str in self._previous["credentials"]
):
cred_data = self._previous["credentials"][uuid_str]
if isinstance(cred_data, dict) and "user" in cred_data:
user_uuid = cred_data["user"]
if "users" in self._previous and user_uuid in self._previous["users"]:
user_data = self._previous["users"][user_uuid]
if isinstance(user_data, dict) and "display_name" in user_data:
return f"credential of {user_data['display_name']}"
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
# Check credentials - identify by user name
if uuid_obj in self._db.credentials:
cred = self._db.credentials[uuid_obj]
if cred.user_uuid in self._db.users:
user_name = self._db.users[cred.user_uuid].display_name
return f"credential of {user_name}"
return None
def _use_color() -> bool: def _use_color() -> bool:
"""Check if we should use color output.""" """Check if we should use color output."""
return sys.stderr.isatty() return sys.stderr.isatty()
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str: def _format_value(
"""Format a value for display, truncating if needed.""" value: Any,
use_color: bool,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None: if value is None:
return "null" return "null"
@@ -49,6 +193,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return str(value) return str(value)
if isinstance(value, str): if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides # Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value) value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings # Truncate long strings
@@ -59,18 +206,26 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
if isinstance(value, dict): if isinstance(value, dict):
if not value: if not value:
return "{}" return "{}"
# For small dicts, show inline # Check if all values are True - render as set-like {key1, key2}
if len(value) == 1: all_true = all(v is True for v in value.values())
k, v = next(iter(value.items())) parts = []
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}" for k, v in value.items():
return f"{{...{len(value)} keys}}" # Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, use_color, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list): if isinstance(value, list):
if not value: if not value:
return "[]" return "[]"
if len(value) == 1: parts = [
return "[" + _format_value(value[0], use_color, max_len=30) + "]" _format_value(v, use_color, max_len=30, resolver=resolver) for v in value
return f"[...{len(value)} items]" ]
return "[" + ", ".join(parts) + "]"
# Fallback for other types # Fallback for other types
text = str(value) text = str(value)
@@ -79,10 +234,20 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return text return text
def _format_path(path: list[str], use_color: bool) -> str: def _format_path(
"""Format a path as dot notation with prefix in dark grey, final in default.""" path: list[str], use_color: bool, resolver: UuidResolver | None = None
) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path: if not path:
return "" return ""
# Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if not use_color: if not use_color:
return ".".join(path) return ".".join(path)
if len(path) == 1: if len(path) == 1:
@@ -176,16 +341,32 @@ def _collect_changes(
def _format_change_lines( def _format_change_lines(
change_type: str, path: list[str], value: Any, use_color: bool change_type: str,
path: list[str],
value: Any,
use_color: bool,
resolver: UuidResolver | None = None,
) -> list[str]: ) -> list[str]:
"""Format a single change as one or more lines.""" """Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
return p
formatted_path = fmt_path(path)
if change_type == "delete": if change_type == "delete":
if not use_color: if not use_color:
return [f" {'.'.join(path)}"] return [f" {'.'.join(formatted_path)}"]
if len(path) == 1: if len(formatted_path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"] return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(path[:-1]) prefix = ".".join(formatted_path[:-1])
final = path[-1] final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"] return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add": if change_type == "add":
@@ -195,56 +376,66 @@ def _format_change_lines(
lines = [] lines = []
# First line: path with green final element and grey = # First line: path with green final element and grey =
if not use_color: if not use_color:
lines.append(f" {'.'.join(path)} =") lines.append(f" {'.'.join(formatted_path)} =")
elif len(path) == 1: elif len(formatted_path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}") lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
else: else:
prefix = ".".join(path[:-1]) prefix = ".".join(formatted_path[:-1])
final = path[-1] final = formatted_path[-1]
lines.append( lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}" f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
) )
# Child lines: indented key: value, with aligned values # Child lines: indented key: value, with aligned values
max_key_len = max(len(k) for k in value.keys()) # Format keys (may contain UUIDs)
field_width = max(max_key_len, 12) # minimum 12 chars formatted_items = []
for k, v in value.items(): for k, v in value.items():
v_str = _format_value(v, use_color) k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
padding = " " * (field_width - len(k)) v_str = _format_value(v, use_color, resolver=resolver)
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
if use_color: if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}") lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
else: else:
lines.append(f" {k}:{padding} {v_str}") lines.append(f" {k_display}:{padding} {v_str}")
return lines return lines
else: else:
value_str = _format_value(value, use_color) value_str = _format_value(value, use_color, resolver=resolver)
if not use_color: if not use_color:
return [f" {'.'.join(path)} = {value_str}"] return [f" {'.'.join(formatted_path)} = {value_str}"]
if len(path) == 1: if len(formatted_path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"] return [
prefix = ".".join(path[:-1]) f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
final = path[-1] ]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [ return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}" f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
] ]
# update: Existing item being updated - normal path colors # update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color) value_str = _format_value(value, use_color, resolver=resolver)
path_str = _format_path(path, use_color) path_str = _format_path(path, use_color, resolver=resolver)
if use_color: if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"] return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"] return [f" {path_str} = {value_str}"]
def format_diff(diff: dict, previous: dict | None = None) -> list[str]: def format_diff(
diff: dict, previous: dict | None = None, db: "DB | None" = None
) -> list[str]:
""" """
Format a JSON diff as human-readable lines. Format a JSON diff as human-readable lines.
Args: Args:
diff: The JSON diff dict diff: The JSON diff dict
previous: The previous state dict (for determining add vs update) previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines). Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines. UUIDs are replaced with display names (using previous state for lookups).
""" """
use_color = _use_color() use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = [] changes: list[tuple[str, list[str], Any]] = []
@@ -253,10 +444,15 @@ def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
if not changes: if not changes:
return [] return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change # Format each change
lines = [] lines = []
for change_type, path, value in changes: for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color)) lines.extend(
_format_change_lines(change_type, path, value, use_color, resolver)
)
return lines return lines
@@ -282,18 +478,23 @@ def log_change(
diff: dict, diff: dict,
user_display: str | None = None, user_display: str | None = None,
previous: dict | None = None, previous: dict | None = None,
db: "DB | None" = None,
) -> None: ) -> None:
""" """
Log a database change with pretty-printed diff. Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names (e.g., credentials), the last section of the UUID is used.
Args: Args:
action: The action name (e.g., "login", "admin:delete_user") action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict diff: The JSON diff dict
user_display: Optional display name of the user who performed the action user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update) previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
""" """
header = format_action_header(action, user_display) header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous) diff_lines = format_diff(diff, previous, db)
if not diff_lines: if not diff_lines:
logger.info(header) logger.info(header)
+10 -2
View File
@@ -8,12 +8,18 @@ Each migration should be idempotent and only run when needed.
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
def migrate_v1(d: dict) -> None: def migrate_v1(d: dict, **kwargs) -> None:
"""Remove Org.created_at fields.""" """Remove Org.created_at fields."""
for org_data in d["orgs"].values(): for org_data in d["orgs"].values():
org_data.pop("created_at", None) org_data.pop("created_at", None)
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
"""Add config field if missing."""
if "config" not in d:
d["config"] = {"rp_id": rp_id}
migrations = sorted( migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")], [f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")), key=lambda f: int(f.__name__.removeprefix("migrate_v")),
@@ -26,8 +32,10 @@ async def apply_all_migrations(
data_dict: dict, data_dict: dict,
current_version: int, current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]], persist: Callable[[str, int, dict], Awaitable[None]],
*,
rp_id: str = "localhost",
) -> None: ) -> None:
while current_version < DBVER: while current_version < DBVER:
migrations[current_version](data_dict) migrations[current_version](data_dict, rp_id=rp_id)
current_version += 1 current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict) await persist(f"migrate:v{current_version}", current_version, data_dict)
+28 -6
View File
@@ -17,11 +17,11 @@ import uuid7
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import ( from paskia.db.jsonl import (
DB_PATH_DEFAULT,
JsonlStore, JsonlStore,
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
@@ -42,16 +42,15 @@ _db._store = _store
_initialized = False _initialized = False
async def init(*args, **kwargs): async def init(rp_id: str = "localhost", *args, **kwargs):
"""Load database from JSONL file.""" """Load database from JSONL file."""
global _db, _initialized global _db, _initialized
if _initialized: if _initialized:
_logger.debug("Database already initialized, skipping reload") _logger.debug("Database already initialized, skipping reload")
return return
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) default_path = f"{rp_id}.paskiadb"
if db_path.startswith("json:"): db_path = os.environ.get("PASKIA_DB", default_path)
db_path = db_path[5:] await _store.load(db_path, rp_id=rp_id)
await _store.load(db_path)
_db = _store.db _db = _store.db
_initialized = True _initialized = True
@@ -724,6 +723,7 @@ def bootstrap(
admin_name: str = "Admin", admin_name: str = "Admin",
reset_passphrase: str | None = None, reset_passphrase: str | None = None,
reset_expiry: datetime | None = None, reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str: ) -> str:
"""Bootstrap the entire system in a single transaction. """Bootstrap the entire system in a single transaction.
@@ -733,6 +733,7 @@ def bootstrap(
- Organization with Administration role - Organization with Administration role
- Admin user with Administration role - Admin user with Administration role
- Reset token for admin registration - Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file. This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written. All data is created atomically - if any step fails, nothing is written.
@@ -742,6 +743,7 @@ def bootstrap(
admin_name: Display name for the admin user (default: "Admin") admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided) reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days) reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns: Returns:
The reset passphrase for admin registration. The reset passphrase for admin registration.
@@ -822,4 +824,24 @@ def bootstrap(
) )
_db.reset_tokens[reset_token.key] = reset_token _db.reset_tokens[reset_token.key] = reset_token
# Set config if provided
if config is not None:
_db.config = config
return reset_passphrase return reset_passphrase
# -------------------------------------------------------------------------
# Config operations
# -------------------------------------------------------------------------
def get_config() -> Config:
"""Get the stored configuration."""
return _db.config
async def set_config(config: Config) -> None:
"""Update the stored configuration."""
with _db.transaction("update_config"):
_db.config = config
+12
View File
@@ -7,6 +7,7 @@ from uuid import UUID
import msgspec import msgspec
import uuid7 import uuid7
from msgspec import field
from paskia import db from paskia import db
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
@@ -397,6 +398,16 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = [] permissions: list[Permission] = []
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
"""Stored configuration for the instance."""
rp_id: str
rp_name: str | None = None
origins: list[str] | None = None
auth_host: str | None = None
listen: str | None = None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Database storage structure # Database storage structure
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -412,6 +423,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {} credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {} sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {} reset_tokens: dict[bytes, ResetToken] = {}
config: Config = field(default_factory=lambda: Config(rp_id="localhost"))
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
+50 -7
View File
@@ -6,17 +6,22 @@ import os
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server from uvicorn import Config as UvicornConfig
from uvicorn import Server
from uvicorn import run as uvicorn_run from uvicorn import run as uvicorn_run
from paskia import globals as _globals from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig from paskia.config import PaskiaConfig
from paskia.db import get_config, set_config
from paskia.db import init as db_init
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.structs import Config
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import normalize_origin
DEFAULT_PORT = 4401 DEFAULT_PORT = 4401
DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL"))
EPILOG = """\ EPILOG = """\
Example: Example:
@@ -61,6 +66,11 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
"--auth-host", "--auth-host",
help=("Dedicated authentication site (optionally with scheme/port)"), help=("Dedicated authentication site (optionally with scheme/port)"),
) )
p.add_argument(
"--save",
action="store_true",
help="Save the CLI options to database for future runs.",
)
def main(): def main():
@@ -87,6 +97,28 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Handle clearing options
if getattr(args, "auth_host", None) == "":
args.auth_host = None
if getattr(args, "rp_name", None) == "":
args.rp_name = None
if getattr(args, "listen", None) == "":
args.listen = None
# Init db and load stored config
asyncio.run(db_init(rp_id=args.rp_id))
stored_config = get_config()
# Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None:
args.rp_name = stored_config.rp_name
if args.origins is None and stored_config.origins is not None:
args.origins = stored_config.origins
if args.auth_host is None and stored_config.auth_host is not None:
args.auth_host = stored_config.auth_host
if args.listen is None and stored_config.listen is not None:
args.listen = stored_config.listen
# Parse endpoint using fastapi_vue.hostutil # Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.listen, DEFAULT_PORT) endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
@@ -167,14 +199,21 @@ def main():
startupbox.print_startup_config(config) startupbox.print_startup_config(config)
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL")) # Build config to save (for bootstrap or explicit --save)
cli_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
run_kwargs: dict = { run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging "log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead "access_log": False, # We use custom AccessLogMiddleware instead
} }
if devmode: if DEVMODE:
# Security: dev mode must run on localhost:4402 to prevent # Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server # accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402: if host != "localhost" or port != 4402:
@@ -189,7 +228,11 @@ def main():
origins=config.origins, origins=config.origins,
bootstrap=False, bootstrap=False,
) )
await bootstrap_if_needed() # Pass config to bootstrap - it will be saved within the bootstrap transaction
await bootstrap_if_needed(config=cli_config)
# Also save config if --save was explicitly used (even without bootstrap)
if args.save:
await set_config(cli_config)
await flush() await flush()
if len(endpoints) > 1: if len(endpoints) > 1:
@@ -197,16 +240,16 @@ def main():
for ep in endpoints: for ep in endpoints:
tg.create_task( tg.create_task(
Server( Server(
Config(app="paskia.fastapi:app", **run_kwargs, **ep) UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve() ).serve()
) )
elif devmode: elif DEVMODE:
# Use uvicorn.run for proper reload support (it handles subprocess spawning) # Use uvicorn.run for proper reload support (it handles subprocess spawning)
ep = endpoints[0] ep = endpoints[0]
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep) uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else: else:
server = Server( server = Server(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0]) UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
) )
await server.serve() await server.serve()
+3 -1
View File
@@ -12,6 +12,7 @@ from paskia import globals
from paskia.db import start_background, stop_background from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, ws from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.__main__ import DEVMODE
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev from paskia.util import hostutil, passphrase, vitedev
@@ -59,7 +60,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Restore uvicorn info logging (suppressed during startup in dev mode) # Restore uvicorn info logging (suppressed during startup in dev mode)
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
if frontend.devmode: if app.debug:
logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING) logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
await frontend.load() await frontend.load()
@@ -74,6 +75,7 @@ app = FastAPI(
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
openapi_url=None, openapi_url=None,
debug=DEVMODE,
) )
# Custom access logging (uvicorn's access_log is disabled) # Custom access logging (uvicorn's access_log is disabled)
+2 -2
View File
@@ -42,7 +42,7 @@ async def init(
Database configuration: Database configuration:
Set PASKIA_DB environment variable to specify the JSONL database file path. Set PASKIA_DB environment variable to specify the JSONL database file path.
Default: paskia.jsonl Default: {rp_id}.paskiadb
""" """
# Initialize passkey instance with provided parameters # Initialize passkey instance with provided parameters
@@ -53,7 +53,7 @@ async def init(
) )
# Initialize database # Initialize database
await db.init() await db.init(rp_id=rp_id)
# Initialize remote auth manager # Initialize remote auth manager
await remoteauth.init() await remoteauth.init()
+17
View File
@@ -8,6 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
""" """
import json import json
import re
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@@ -62,6 +63,7 @@ class Passkey:
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id. ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
""" """
self.rp_id = rp_id self.rp_id = rp_id
self._validate_rp_id(rp_id)
self.rp_name = rp_name or rp_id self.rp_name = rp_name or rp_id
self.allowed_origins: set[str] | None = None self.allowed_origins: set[str] | None = None
if origins: if origins:
@@ -75,6 +77,21 @@ class Passkey:
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
] ]
def _validate_rp_id(self, rp_id: str) -> None:
"""Validate that rp_id is a valid domain name."""
if not rp_id:
raise ValueError("rp_id cannot be empty")
# Allow localhost, or domain-like strings
if rp_id == "localhost":
return
# Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc.
# Simplified: alphanumeric, dots, hyphens
if not re.match(
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
rp_id,
):
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
def _validate_origin(self, origin: str, rp_id: str) -> None: def _validate_origin(self, origin: str, rp_id: str) -> None:
"""Validate an origin URL against the rp_id.""" """Validate an origin URL against the rp_id."""
hostname = urlparse(origin).hostname hostname = urlparse(origin).hostname
+36 -92
View File
@@ -13,9 +13,9 @@ All other options are forwarded to `paskia`.
Backend always listens on localhost:4402. Backend always listens on localhost:4402.
Environment: Environment:
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is. PASKIA_FRONTEND_URL Set by this script for the backend to know where Vite is.
FASTAPI_VUE_BACKEND_URL Set by this script for Vite to know where to proxy API calls. PASKIA_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP). PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
Options: Options:
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo) --caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
@@ -34,12 +34,16 @@ from contextlib import suppress
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path) # Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from buildutil import find_dev_tool, find_install_tool, logger # noqa: E402 from devutil import ( # noqa: E402
from devutil import ProcessGroup, check_ports_free # noqa: E402 ProcessGroup,
check_ports_free,
logger,
ready,
setup_cli,
setup_vite,
)
DEFAULT_VITE_PORT = 4403 # overrides by CLI option DEFAULT_VITE_PORT = 4403 # overrides by CLI option
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
@@ -60,35 +64,6 @@ SITE_ADDR {
""" """
def build_vite_cmd(vite_host: str, vite_port: int) -> list[str] | None:
"""Build the Vite dev command, or None if not available."""
devpath = Path(__file__).parent.parent / "frontend"
if not (devpath / "package.json").exists():
logger.warning("Frontend source not found at %s", devpath)
return None
try:
cmd = find_dev_tool()
except RuntimeError as e:
logger.warning(str(e))
return None
# Add Vite CLI args for host/port
cmd.extend([f"--port={vite_port}", "--logLevel=silent"])
if vite_host and vite_host != "localhost":
cmd.append("--host" if vite_host == "0.0.0.0" else f"--host={vite_host}")
return cmd
def build_npm_install_cmd() -> list[str] | None:
"""Build the npm install command, or None if not available."""
try:
return find_install_tool()
except RuntimeError:
return None
def build_caddyfile(origins: list[str], vite_port: int) -> str: def build_caddyfile(origins: list[str], vite_port: int) -> str:
"""Build a Caddyfile for the given origins.""" """Build a Caddyfile for the given origins."""
caddyfile_parts = [] caddyfile_parts = []
@@ -181,22 +156,26 @@ async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Pr
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
"""Run the development server with all components.""" """Run the development server with all components."""
# Parse Vite endpoint reporoot = Path(__file__).parent.parent
endpoints = parse_endpoint(args.listen, DEFAULT_VITE_PORT) frontend_path = reporoot / "frontend"
ep = endpoints[0] if not (frontend_path / "package.json").exists():
logger.warning("Frontend source not found at %s", frontend_path)
if "uds" in ep:
logger.warning("Unix sockets are not supported for Vite frontend")
raise SystemExit(1) raise SystemExit(1)
vite_host = ep["host"] viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
vite_port = ep["port"] backurl, paskia = setup_cli("paskia", f"localhost:{BACKEND_PORT}", BACKEND_PORT)
# Multiple endpoints means all-interfaces (:port syntax)
if len(endpoints) > 1:
vite_host = "0.0.0.0"
vite_url = f"http://localhost:{vite_port}" # Extract vite port for Caddy config
backend_url = f"http://localhost:{BACKEND_PORT}" vite_port = int(viteurl.rsplit(":", 1)[1])
# Build paskia command with options
paskia.extend(["--rp-id", args.rp_id])
if args.auth_host:
paskia.extend(["--auth-host", args.auth_host])
if args.origins:
for origin in args.origins:
paskia.extend(["--origin", origin])
paskia.extend(remaining)
# Compute origins for Caddy # Compute origins for Caddy
caddy_origins = [] caddy_origins = []
@@ -217,30 +196,13 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
seen = set() seen = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
# Check ports are free before starting
await check_ports_free(vite_url, backend_url)
# Set environment for subprocesses # Set environment for subprocesses
os.environ["FASTAPI_VUE_FRONTEND_URL"] = vite_url os.environ["PASKIA_FRONTEND_URL"] = viteurl
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else vite_url os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
if args.auth_host: if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host os.environ["PASKIA_AUTH_HOST"] = args.auth_host
# Build commands
frontend_path = Path(__file__).parent.parent / "frontend"
vite_cmd = build_vite_cmd(vite_host, vite_port)
install_cmd = build_npm_install_cmd()
paskia_cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
paskia_cmd.extend(["--rp-id", args.rp_id])
if args.auth_host:
paskia_cmd.extend(["--auth-host", args.auth_host])
if args.origins:
for origin in args.origins:
paskia_cmd.extend(["--origin", origin])
paskia_cmd.extend(remaining)
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports) # Start Caddy first if requested (needs to bind ports)
if args.caddy: if args.caddy:
@@ -248,29 +210,11 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
pg._procs.append(caddy_proc) pg._procs.append(caddy_proc)
pg._cmds[caddy_proc.pid] = "caddy" pg._cmds[caddy_proc.pid] = "caddy"
# Run npm install concurrently with backend startup npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
if install_cmd and (frontend_path / "package.json").exists(): await check_ports_free(viteurl, backurl)
npm_proc = await pg.spawn(*install_cmd, cwd=str(frontend_path)) await pg.spawn(*paskia)
else: await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
npm_proc = None await pg.spawn(*vite, cwd=frontend_path)
# Start paskia backend
logger.info(">>> (devmode) %s", " ".join(paskia_cmd))
paskia_proc = await asyncio.create_subprocess_exec(*paskia_cmd)
pg._procs.append(paskia_proc)
pg._cmds[paskia_proc.pid] = "paskia"
# Wait for npm install to complete before starting Vite
if npm_proc:
await pg.wait(npm_proc)
# Start Vite dev server
if vite_cmd:
await pg.spawn(*vite_cmd, cwd=str(frontend_path))
else:
logger.info(
"Backend expects Vite at %s - start it manually if needed", vite_url
)
def main(): def main():
+1 -1
View File
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
Raises RuntimeError if no runtime is found. Raises RuntimeError if no runtime is found.
""" """
dev_args = { dev_args = {
"deno": ("run", "dev", "--"), "deno": ("run", "-A", "npm:vite"),
"npm": ("--silent", "run", "dev", "--"), "npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"), "bun": ("run", "dev", "--"),
} }
+23 -2
View File
@@ -21,12 +21,12 @@ class ProcessGroup:
self._cmds: dict[int, str] = {} # pid -> command name self._cmds: dict[int, str] = {} # pid -> command name
async def spawn( async def spawn(
self, *cmd: str, cwd: str | None = None, env: dict | None = None self, *cmd: str, cwd: str | None = None
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it.""" """Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]])) logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd, env=env) proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc) self._procs.append(proc)
self._cmds[proc.pid] = cmd_name self._cmds[proc.pid] = cmd_name
return proc return proc
@@ -188,3 +188,24 @@ def setup_fastapi(
"--forwarded-allow-ips=*", "--forwarded-allow-ips=*",
] ]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
def setup_cli(
cli: str, endpoint: str, default_port: int = 8000
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build CLI command.
Returns (url, cli_cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, default_port)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
host = endpoints[0]["host"]
port = endpoints[0]["port"]
cmd = [cli, f"--listen={host}:{port}"]
return f"http://{host}:{port}", cmd