Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ab111a89 | ||
|
|
d826146932 | ||
|
|
fda9b2545e | ||
|
|
0cf551cb28 | ||
|
|
632230d05c | ||
|
|
a37198bb4b | ||
|
|
fb71ea1220 | ||
|
|
25283eba9b | ||
|
|
6a217978d6 |
+1
-1
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+35
-91
@@ -13,8 +13,8 @@ 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:
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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", "--"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user