Admin Server Options panel added for configuring rp-name, auth-host and origins.

This commit is contained in:
2026-02-19 18:53:53 +00:00
parent 528a728eb8
commit 3f51d06f13
8 changed files with 387 additions and 39 deletions
+39
View File
@@ -462,6 +462,23 @@ function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
} }
async function openServerConfig() {
try {
const config = await apiJson('/auth/api/admin/server-config')
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
openDialog('server-config', {
rp_name: config.rp_name || '',
auth_host,
origins,
originValidation: origins.map(() => null),
})
} catch (e) {
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
}
}
function deleteOidcClient(client) { function deleteOidcClient(client) {
openDialog('confirm', { openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`, message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
@@ -886,6 +903,27 @@ async function submitDialog() {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'server-config') {
const rp_name = dialog.value.data.rp_name?.trim() || ''
const auth_host = dialog.value.data.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins
.map(o => o.trim())
.filter(o => o)
closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
.then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500)
// Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') { } else if (t === 'confirm') {
const action = dialog.value.data.action const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage) // Close dialog first, then perform action (errors shown via showMessage)
@@ -951,6 +989,7 @@ async function submitDialog() {
@create-oidc-client="createOidcClient" @create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient" @open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient" @delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
/> />
+191 -1
View File
@@ -17,6 +17,21 @@ const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain') const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`) const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') {
if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null
}
}
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false
const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
return false
})
// Copy-to-clipboard helper // Copy-to-clipboard helper
const authStore = useAuthStore() const authStore = useAuthStore()
function copyText(value, label) { function copyText(value, label) {
@@ -24,6 +39,135 @@ function copyText(value, label) {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500) authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
}) })
} }
function addOrigin() {
const d = props.dialog?.data
if (d) {
d.origins.push(rpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
function validateOriginDomain(origin, rpId) {
if (!origin.trim()) return false
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const hostname = url.hostname
return hostname === rpId || hostname.endsWith('.' + rpId)
} catch {
return false
}
}
async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data
if (!d) return
d.originValidation[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
} else {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
}
function validateOrigin(origin, i) {
const d = props.dialog?.data
if (!d) return
const id = rpId.value
if (validateOriginDomain(origin, id)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
}
}
async function validateAuthHostConnectivity(authHost) {
const d = props.dialog?.data
if (!d) return
d.authHostValidation = 'validating'
try {
const cleanAuthHost = authHost.replace(/\/+$/, '')
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
} else {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
} catch (e) {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
}
function validateAuthHost() {
const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty
return
}
const id = rpId.value
if (validateOriginDomain(d.auth_host, id)) {
validateAuthHostConnectivity(d.auth_host)
} else {
d.authHostValidation = 'invalid-domain'
}
}
</script> </script>
<template> <template>
@@ -37,6 +181,7 @@ function copyText(value, label) {
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template> <template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template> <template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template> <template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template> <template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3> </h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form"> <form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -96,6 +241,38 @@ function copyText(value, label) {
</label> </label>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p> <p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template> </template>
<template v-else-if="dialog.type==='server-config'">
<label>Site Branding (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
</label>
<label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
</label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input
:value="dialog.data.origins[i]"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin"></button>
</div>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
</template>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
</template> </template>
@@ -112,7 +289,7 @@ function copyText(value, label) {
<button <button
type="submit" type="submit"
class="btn-primary" class="btn-primary"
:disabled="dialog.busy" :disabled="dialog.busy || isValidationInvalid"
> >
{{ dialog.type==='confirm' ? 'OK' : 'Save' }} {{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button> </button>
@@ -141,4 +318,17 @@ function copyText(value, label) {
.oidc-groups { cursor: default; } .oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; } .oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; } .oidc-group output { white-space: normal; word-break: break-all; }
/* Server config origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.input-error {
border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
</style> </style>
+15 -1
View File
@@ -12,7 +12,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null) const orgSection = ref(null)
@@ -431,6 +431,16 @@ defineExpose({ focusFirstElement })
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isMasterAdmin" class="server-options-section">
<div class="section-header">
<h2>Server</h2>
<p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins.
</p>
</div>
<button @click="$emit('openServerConfig')"> Server Options</button>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -455,4 +465,8 @@ defineExpose({ focusFirstElement })
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); } .oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); } .oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); } .client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
/* Server Options Section */
.server-options-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
</style> </style>
+14 -34
View File
@@ -1,7 +1,6 @@
import argparse import argparse
import logging import logging
import os import os
from urllib.parse import urlparse
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import server
@@ -9,7 +8,11 @@ from fastapi_vue.hostutil import parse_endpoints
from paskia.db.jsonl import load_readonly from paskia.db.jsonl import load_readonly
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import (
normalize_auth_host_and_origins,
normalize_origin,
validate_auth_host,
)
from paskia.util.runtime import RuntimeConfig from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401 DEFAULT_PORT = 4401
@@ -21,27 +24,6 @@ Example:
""" """
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise SystemExit(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def add_common_options(p: argparse.ArgumentParser) -> None: def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument( p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)" "--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
@@ -104,18 +86,16 @@ def main():
if args.listen is not None: if args.listen is not None:
config.listen = None if args.listen == [""] else args.listen config.listen = None if args.listen == [""] else args.listen
# Process and normalize auth_host # Process and normalize auth_host and origins
if config.auth_host: try:
if "://" not in config.auth_host: validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
config.auth_host = f"https://{config.auth_host}" except ValueError as e:
config.auth_host = config.auth_host.rstrip("/") raise SystemExit(str(e))
validate_auth_host(config.auth_host, config.rp_id)
if config.origins:
config.origins.insert(0, config.auth_host) # Ensure first in origins
# Normalize and deduplicate while preserving order
if config.origins: if config.origins:
config.origins = list({normalize_origin(o): ... for o in config.origins}) config.origins = [normalize_origin(o) for o in config.origins]
config.auth_host, config.origins = normalize_auth_host_and_origins(
config.auth_host, config.origins
)
# Parse first endpoint for site_url fallback # Parse first endpoint for site_url fallback
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {}) ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
+78 -1
View File
@@ -12,12 +12,13 @@ from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC from paskia.db import Role as RoleDC
from paskia.db import User as UserDC from paskia.db import User as UserDC
from paskia.db.operations import _UNSET from paskia.db.operations import _UNSET
from paskia.db.structs import Client from paskia.db.structs import Client, Config
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey from paskia.globals import passkey
from paskia.sansio import Passkey
from paskia.util import ( from paskia.util import (
hostutil, hostutil,
permutil, permutil,
@@ -1176,3 +1177,79 @@ async def admin_delete_oidc_client(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"} return {"status": "ok"}
# -------------------- Server Configuration --------------------
@app.get("/server-config")
async def admin_get_server_config(
request: Request,
auth=AUTH_COOKIE,
):
"""Get current server configuration (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
pk = passkey.instance
config = db.data().config
return {
"rp_name": pk.rp_name,
"auth_host": config.auth_host or "",
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
}
@app.patch("/server-config")
async def admin_update_server_config(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update server configuration (master admin only).
Updates rp_name, auth_host, and origins in both the runtime Passkey
instance and the persisted database config.
"""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
pk = passkey.instance
rp_name = payload.get("rp_name", "").strip() or None
auth_host = payload.get("auth_host", "").strip() or None
raw_origins = payload.get("origins", [])
origins = [
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
] or None
# Normalize auth_host and origins (matching CLI startup behavior)
if auth_host:
try:
hostutil.validate_auth_host(auth_host, config.rp_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
# Validate origins against the current rp_id
if origins:
for o in origins:
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
# Update runtime Passkey instance
pk.rp_name = rp_name or config.rp_id
pk.allowed_origins = set(origins) if origins else None
# Persist to database
new_config = Config(
rp_id=config.rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
listen=config.listen,
)
await db.update_config(new_config)
# Reload hostutil cached config so auth_host changes take effect
hostutil.reload_config()
return {"status": "ok"}
+2 -1
View File
@@ -185,7 +185,8 @@ async def get_settings():
auth_site_url=hostutil.auth_site_url(), auth_site_url=hostutil.auth_site_url(),
session_cookie=AUTH_COOKIE_NAME, session_cookie=AUTH_COOKIE_NAME,
version=__version__, version=__version__,
) ),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
) )
+45
View File
@@ -49,6 +49,51 @@ def normalize_origin(origin: str) -> str:
return origin.rstrip("/") return origin.rstrip("/")
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id.
Raises ValueError on invalid auth_host.
"""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise ValueError(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise ValueError(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def normalize_auth_host_and_origins(
auth_host: str | None, origins: list[str] | None
) -> tuple[str | None, list[str] | None]:
"""Normalize auth_host and origins, matching CLI startup behavior.
- Adds https:// to auth_host if no scheme present, strips trailing slashes
- Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host)
- Inserts auth_host as first origin if both are specified and not already present
- Deduplicates origins while preserving order
"""
if auth_host:
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
auth_host = auth_host.rstrip("/")
if origins is not None and auth_host not in origins:
origins.insert(0, auth_host)
if origins:
origins = list(dict.fromkeys(origins))
return auth_host, origins
def reload_config() -> None: def reload_config() -> None:
_load_config.cache_clear() _load_config.cache_clear()
+3 -1
View File
@@ -186,7 +186,9 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path) npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl) await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia) await pg.spawn(*paskia)
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py")) await pg.wait(
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
)
await pg.spawn(*vite, cwd=frontend_path) await pg.spawn(*vite, cwd=frontend_path)