Skip to main content

BetterPortal Python port

Python 3.10+ native runtime with Starlette/ASGI hosting for JSON, HTML, raw responses, finite streams and subscriber feeds. Production acceptance evidence and release gates are tracked in PRODUCTION_READINESS.md.

Start a standalone service with the native init commands.

Install betterportal[asgi] and an ASGI server such as Uvicorn. create_app(service) in betterportal.asgi owns the Service lifespan. A service combines a registry, manifest declaration and optional validated ScopedConfig. With no snapshot its public health endpoint returns only {"ok": false} with status 503; a local snapshot enables request handling. ControlPlaneSync supplies automatic managed synchronization, described below. Health diagnostics require a verified access-token user in the configured management tenant/app; ordinary and invalid credentials receive only public readiness. Health responses use Cache-Control: private, no-store and Vary: Authorization.

The host resolves scope, checks local operation mounts and CORS, and verifies user or delegated/service credentials before invoking handlers. It preserves repeated query/form values and field-name case. Malformed percent escapes and invalid UTF-8 in query strings or URL-encoded forms return 400 before handler invocation. RequestContext.multipart contains the canonical parsed form fields and uploads (file data is an array of byte values). Body buffering defaults to 1 MiB, configurable through max_body_bytes; forms permit at most 1,000 fields and 100 files, with 1 MiB per text field. File resources close after parsing. Disconnects cancel handler waits.

JSON and BP metadata negotiation are supported. Metadata requires the operation's authorization and does not execute its handler. Health, manifest and schema JSON discovery are public. Finite handlers also support NDJSON and SSE, described below. Configure trusted proxies in the ASGI server; raw forwarding and HTMX context headers confer no authority. Renderer selection is bound to the resolved app's shell renderer.

import asyncio
from betterportal.asgi import create_app
from betterportal.registry import Registry
from betterportal.service import Service

async def check():
    async with Service(Registry([]), {"pluginId": "com.example.hello", "title": "Hello",
                                     "description": "Example service", "version": "1.0.0"}) as service:
        app = create_app(service)
        assert not service.ready  # Supply a validated local snapshot to enable operations.

asyncio.run(check())

Handler[Params, Query, Headers, Body, Result] accepts native AnyVali schemas and a sync or async function receiving HandlerContext. invoke parses all four input fields before calling the function and validates its response. A host supplies RequestContext with the resolved scope, verified caller, method/path and config; this low-level helper does not authorize calls. HandlerInputError identifies the invalid field (400), while HandlerOutputError reports invalid output (500). Absent input containers become {}; explicit null remains present. The input_document property exports the four parsed handler input fields for native type generation. Full handler/render context helpers remain delivery work.

Operation, Route and Registry register JSON/raw/finite handlers and derive canonical manifests and discovery schemas from their AnyVali schemas. Every operation needs an explicit auth declaration and a unique stable ID. Methods share the view's params schema; their query, headers, body, response and policy remain separate. Dependency aliases resolve to plugin IDs, and local dependencies must exist with the declared method. Path variants belong to one view and publish API contracts once. See native route discovery for directory-based registration.

Renderer[Result] accepts a sync/async function returning an HTML string. Register it on the typed handler; the adapter selects the exact method, app renderer, kind/key and response status. Page is the default kind; fragments require a location.id key and components require an ID. _f overrides an Accept fragment parameter; _c selects a component. Ambiguous selectors fail. Render modes are page, fragment and embed. Metadata and manifests derive from these registrations.

from html import escape
from typing import Any
from betterportal.contracts import contract
from betterportal.generated_types import TokenLifetimeConfig
from betterportal.handler import Handler, HandlerContext
from betterportal.rendering import Renderer, RenderContext
from betterportal.registry import Operation

def handle(context: HandlerContext[Any, Any, Any, Any]) -> TokenLifetimeConfig:
    context.response.status = 201
    context.response.set_header("HX-Trigger", "bp:created")
    return {"accessTokenSeconds": 900, "refreshTokenSeconds": 604800}

def render(value: TokenLifetimeConfig, context: RenderContext) -> str:
    return f'<p>{escape(context.tenant["title"])}: {value["accessTokenSeconds"]}</p>'

renderer = Renderer[TokenLifetimeConfig]({"renderer": "bootstrap5", "status": 201}, render)
handler = Handler[Any, Any, Any, Any, TokenLifetimeConfig](
    contract("TokenLifetimeConfigSchema"), handle, renderers=[renderer])
operation = Operation(handler, {"operationId": "lifetimes.get", "method": "GET",
    "title": "Lifetimes", "description": "Render token lifetimes", "auth": {}})
assert operation.handler.renderers[0].identity == ("bootstrap5", "page", None, 201)

RenderContext exposes canonical presentation fields for tenant/app, parsed params/query and route selection. Secrets, roles, auth settings and headers are excluded. Escape dynamic HTML with html.escape; no template engine is required. context.response controls status and application headers (append=True preserves multiple cookies). The adapter owns Content-Type/CORS/transport headers. A changed HTML status needs an exact status renderer; otherwise the response is empty. 204/205/304 always have no body.

Operation(..., error_renderers=[Renderer[ViewRenderError](...)]) registers callbacks for errors, separately from successful handler results. Their canonical data contains only error and status; declarations require status 400–599. The selected fragment/component is preserved on errors. Callback failures return a generic 500. Disconnects cancel async callbacks. Pass shared error renderers as Service(..., status_renderers=[...]); exact operation-local error renderers take precedence. Global renderers also cover unmatched routes after resolving a valid app scope.

Handlers and renderers use the same resolver: context.route(input=None) in Python and context.Route(input = null) in .NET. Supply a service path or viewId; omit both to resolve self with the current method, params and query. Explicit targets default to GET. Self preserves repeated query values; scalar overrides replace all values for that key, and null removes the key. Query strings embedded in path preserve repeated pairs too. Dependency aliases, plugin IDs and instance IDs are supported. Unknown targets, unsupported methods and missing params return None/null.

Renderer selection uses the explicit component or fragment option. Reserved _c/_f keys in query input, supplied paths, or the current request are removed before resolution; only view destinations receive an explicit selector. API and mounted page destinations remain free of renderer selectors.

The result has six wire fields: apiPath and viewPath are service-relative; uiPath is the mounted theme path; absoluteApiPath and absoluteViewPath use the service origin; absoluteUiPath is the public app URL. Component/fragment selectors affect view fields only. UI fields are null unless exactly one enabled GET page resolves; select mountId for a frontend alias. Resolve internal links in the renderer from domain data and cursors. API code may return a public absoluteUiPath link where that link is domain data.

from betterportal.generated_types import RouteOptionsInput, ResolvedRoute

request: RouteOptionsInput = {"path": "/items/:id", "params": {"id": 42}, "query": {"page": 2}}
# In either callback: destination = context.route(request)
# API requests use destination["absoluteApiPath"].
# HTML requests use destination["absoluteViewPath"].
# Service views use str(destination) or destination["viewPath"]; emails use destination["absoluteUiPath"].

The old string-returning route/current/path/UI helpers and literal route tokens have been removed. Update callers to select a destination field. The resolver neither sends requests nor grants access. Fragment composition remains available through the scoped element resolver; no credentials are retained in renderer navigation helpers.

RawHandler[Params, Query, Headers, Body] shares input validation and host authorization with JSON handlers. It must return RawResponse; JSON handlers reject and close raw results. Raw operations publish raw: true and bypass representation negotiation, including a metadata Accept header. Their metadata remains available through discovery. GET registrations also serve HEAD using the same policy; a raw HEAD closes the response without pulling its stream.

from typing import Any
from betterportal.response import RawHandler, RawResponse
from betterportal.registry import Operation

download = RawHandler[Any, Any, Any, Any](
    lambda context: RawResponse.file(b"Hello BP\n", "report.txt", content_type="text/plain"))
operation = Operation(download, {"operationId": "report.get", "method": "GET",
    "title": "Report", "description": "Download a report", "auth": {"required": True}})
assert operation.handler.is_raw

A raw body is bytes or an async iterator yielding bytes. The ASGI host awaits each send before requesting the next chunk and calls aclose() when available on completion, disconnect or failure. Stream failures after headers terminate the response. Supply file content, not a filesystem path, to RawResponse.file; it builds safe ASCII/UTF-8 download headers. Header pairs retain repeated cookies and list fields such as Vary; duplicate standard singleton fields such as Content-Type and Content-Disposition are rejected regardless of case. The host owns CORS and transport headers, computes byte-body length, and rejects header injection. Status 204/205/304 forbids a body; 206 and redirects may carry one.

from betterportal.contracts import contract
from betterportal.handler import Handler
from betterportal.registry import Operation, Route, Registry

operation = Operation(Handler(contract("JsonObjectSchema"), lambda context: {"hello": "world"}), {
    "operationId": "hello.get", "method": "GET", "title": "Hello",
    "description": "Return a greeting", "auth": {},
})
registry = Registry([Route("hello.index", "/hello", [operation])])
manifest = registry.manifest({"pluginId": "com.example.hello", "title": "Hello",
                              "description": "Example service", "version": "1.0.0"})
assert manifest["views"][0]["operations"][0]["operationId"] == "hello.get"

betterportal.context.ScopedConfig imports a canonical scoped snapshot and checks tenant/app references, duplicate identities and ambiguous hostnames. Browser resolution distinguishes scheme/host/port and copies each request's tenant/app data. by_id supports looking up a claimed machine scope before authentication; lookup alone never authorizes a request. Configuration-management app indexes are separate from runtime app lookup.

AppAccess(scope, snapshot.local_service_ids) in betterportal.access checks the registered operation against enabled inbound app mounts. Pass the matched registered path to allows for a view with path variants. GET fragment selectors and slot mounts are supported; appRoutes/appFragments are catalogs, not inbound allowlists. permission_aliases() restricts role aliases to enabled local service instances referenced by this app; pass the route and method to restrict aliases to its mounted operation. Service always applies this restriction. Hosts must still run caller authorization and representation selection. Well-known routes use their own declared auth policy.

Raw proxy and HTMX context headers are ignored. resolve(..., trusted_addresses=...) accepts only addresses already verified by host proxy middleware; the host also supplies the effective scheme. OriginPolicy normalizes configured HTTP origins and preserves exact path/query restrictions on referer overrides. Atomic snapshot persistence is described below. betterportal.hosting.TrustedProxyMiddleware accepts only configured peer IPs/CIDRs and a single forwarded host/protocol pair; it strips untrusted forwarding headers. Disable Uvicorn proxy-header handling so the middleware sees the actual socket peer.

AnyVali 1.1.2 treats explicit null as present and applies defaults only to omitted input. Snapshot validation therefore rejects active: null; BP still uses AnyVali as its only validator.

from betterportal.context import ScopedConfig, http_origin

assert http_origin("HTTPS://Example.com:443") == "https://example.com"
snapshot = ScopedConfig({"managementOrigins": [], "tenants": [], "apps": []})
assert snapshot.resolve({"host": "unknown.example"}) is None

betterportal.cors.Cors produces response/preflight headers from a trusted OriginPolicy and the route's allowed methods. Hosts call preflight before authentication/dispatch, returning empty 204 on success or 403 for CorsDenied. Allowed origins can request custom operation headers; their values still pass through operation schemas. The policy exposes BP/HTMX response headers, uses a 600-second preflight cache and includes Vary fields. Hosts merge Vary with their existing Accept/cache policy. No credential-cookie CORS flag is emitted.

from betterportal.context import OriginPolicy
from betterportal.cors import Cors

policy = OriginPolicy(frozenset(["https://app.example"]), frozenset(["https://app.example"]))
headers = Cors(policy, ["GET"]).preflight("https://app.example", "GET", "Authorization, X-App-Filter")
assert headers["access-control-allow-origin"] == "https://app.example"

betterportal.sse.SseRoute validates publication input, maps it with subscriber context, and validates the resulting event. EventScope comes from the trusted request context. EventTransport is replaceable; the supplied LocalEvents serves one event loop with no history or cross-replica delivery. It separates view/tenant/app addresses, bounds each queue to 256 pending events, and closes overflowing subscribers. Payloads default to 1 MiB; all limits are configurable. Subscriptions are async context managers. The application closes its transport at shutdown, releasing idle subscribers; request cancellation stops pending reads and mapper I/O. Publication bytes prevent mutable values leaking across subscribers.

import asyncio
import anyvali as av
from betterportal.sse import LocalEvents, SseRoute, EventScope

async def example():
    transport = LocalEvents()
    try:
        route = SseRoute("clock.index", av.string(), av.string(), lambda value, context: value, transport=transport)
        scope = EventScope("tenant-from-context", "app-from-context")
        async with route.subscribe(scope, None) as events:
            await route.publish(scope, "tick")
            assert await events.__anext__() == "tick"
    finally:
        await transport.aclose()

asyncio.run(example())

route.wire(scope, context, render=...) owns the subscription and yields UTF-8 SSE messages. The optional async or sync renderer returns HTML; failures emit a generic error event and later events continue. Without a renderer, strings are sent as text and other values as JSON. Close the generator with contextlib.aclosing when stopping early. SseFeed supplies operation authorization and renderer selection through the host, as shown below. encode_event also supports bounded event names/IDs, retry, empty data and multiline text per WHATWG SSE. The default data bound is 1 MiB before SSE line prefixes; names/IDs are limited to 1 KiB. Hosts set Content-Type: text/event-stream, disable response caching and flush each message; the codec does not schedule heartbeats or keep replay history.

SseFeed binds that contract to an existing GET handler. Register it as Route(..., sse=feed) to expose <path>/__sse, including optional path variants. The connection inherits GET input schemas, authentication, permissions and tenant/app mounts without executing the GET function. Publish with await feed.publish(trusted_scope, value). The transport belongs to the application; disconnects and service shutdown release request subscriptions without closing a shared transport.

Tick renderers must match a success fragment on the owning GET handler. _f=nav.clock selects that tick and the resolved app's shell selects the theme. Missing ticks return 406; a fragment-only mount cannot expose raw events through a missing tick or an Accept header. Components are unavailable on feeds. Without _f, strings are text and other values are JSON. Mapping/validation failures close the connection; renderer failures emit a safe error and allow subsequent events. HEAD opens no subscription.

import asyncio
from html import escape
from typing import Any
import anyvali as av
from betterportal.feeds import SseFeed
from betterportal.handler import Handler, HandlerContext
from betterportal.registry import Operation, Route, Registry
from betterportal.rendering import Renderer
from betterportal.sse import LocalEvents, SseRoute

async def example():
    transport = LocalEvents()
    try:
        fragment = Renderer[str]({"renderer": "bootstrap5", "kind": "fragment", "key": "nav.clock"},
                                 lambda value, context: f"<span>{escape(value)}</span>")
        handler = Handler[Any, Any, Any, Any, str](av.string(), lambda context: "Waiting", renderers=[fragment])
        contract = SseRoute[str, str, HandlerContext[Any, Any, Any, Any]]("clock.index", av.string(), av.string(),
            lambda value, context: value, transport=transport)
        feed = SseFeed(handler, contract, renderers=[fragment])
        registry = Registry([Route("clock.index", "/clock", [Operation(handler, {
            "operationId": "clock.get", "method": "GET", "title": "Clock", "description": "Live clock", "auth": {}
        })], sse=feed)])
        assert registry.routes[0].sse is feed
    finally:
        await transport.aclose()

asyncio.run(example())

The existing BP browser assets consume /clock/__sse?_f=nav.clock with hx-sse:connect. No template engine, replay history or external broker is added.

betterportal.streaming.StreamHandler validates each item and optional Summary before delivery. Its response schema is derived from the item/summary AnyVali documents, preserving recursive definitions. frames, ndjson and sse pull only as the consumer advances; buffered defaults to 10,000 items and 8 MiB, and each frame defaults to 1 MiB. Limits are configurable. Cancellation propagates and closes the producer; buffered cancellation never returns partial success. Close iterators with contextlib.aclosing when leaving early. Producer I/O must be cancellation-aware. Errors expose generic messages without producer secrets.

import asyncio
from betterportal.contracts import contract
from betterportal.streaming import StreamHandler, Summary

async def produce(context):
    yield {"result": [None, 1]}
    yield Summary({"total": 1})

handler = StreamHandler(contract("JsonValueSchema"), produce, contract("JsonValueSchema"))
assert asyncio.run(handler.buffered(None))["summary"] == {"total": 1}

async def wire():
    return [message async for message in handler.sse(None)]
messages = asyncio.run(wire())
assert messages[-1] == b'event: end\ndata: {"kind":"end","count":1}\n\n'

FiniteHandler registers the same producer as a GET Operation. JSON and page/fragment/component renderers consume its bounded buffered result. NDJSON streams validated JSON frames; a GET operation also owns /<path>/__sse, with the same tenant/app, origin, operation and authentication checks. Stream connections reject fragment/component selectors. Metadata and streamed HEAD requests never start the producer. Hosts pull one chunk at a time and close it on disconnect.

StreamRenderers selects the exact app shell renderer. Its shell receives the canonical StreamShellContext and returns HTML with a connection URL; it never runs the producer. SSE item/summary/error callbacks receive validated values and the safe RenderContext. Callbacks return HTML strings and URL tokens are rewritten through the existing helpers. A matching page renderer buffers full page requests; fragment mode selects the stream shell. Without a matching stream renderer SSE carries JSON frames. Each consuming request creates its own stream.

from html import escape
from betterportal.contracts import contract
from betterportal.finite import FiniteHandler, StreamRenderers
from betterportal.registry import Operation, Route, Registry
from betterportal.streaming import Summary

async def rows(context):
    yield "First row"
    yield Summary(1)

handler = FiniteHandler(contract("JsonValueSchema"), rows,
    summary=contract("JsonValueSchema"), stream_renderers=[StreamRenderers("bootstrap5",
        shell=lambda data, context: '<div hx-ext="sse" hx-sse:connect="' + escape(data["sseConnectPath"], quote=True) + '"><div sse-swap="item" hx-swap="beforeend"></div></div>',
        item=lambda value, context: "<p>" + escape(value) + "</p>")])
registry = Registry([Route("rows", "/rows", [Operation(handler, {
    "operationId": "rows.get", "method": "GET", "title": "Rows", "description": "Stream rows", "auth": {}})])])
assert registry.routes[0].operations[0].handler is handler

Mount this registry with Service and create_app as above. The consuming shell loads BP's existing HTMX/SSE browser assets; the runtime does not add a template engine. Native shell fragment helpers are described below.

betterportal.media.negotiate selects JSON, HTML (page/fragment/embed), metadata, or NDJSON from Accept and the operation's available representations. It honors specific exclusions, q=0 and request-order ties, and raises NotAcceptable (406) when no representation is acceptable. Theme parameters never select a renderer. The host must supply availability after resolving the exact app shell renderer.

from betterportal.media import negotiate

assert negotiate("text/html;mode=fragment").mode == "fragment"
assert negotiate("application/x-ndjson,application/json;q=0.5", ["json"]).kind == "json"

Implemented: embedded canonical contracts validated with AnyVali 1.1.6, RS256 keys and token purposes, tenant/app-bound refresh pairs, config-ticket scope/action checks, and service authorization against current scoped bindings and grants, static JWKS imports and a cancellable remote JWKS cache. Cryptography uses PyJWT/OpenSSL; validation uses AnyVali exclusively.

betterportal.authorization.authorize_request accepts a trusted AuthContext from one scoped snapshot and enforces user/service/delegated caller policy. User role IDs expand through current app grants, with trusted service aliases. Root elevation requires the configured management tenant/app. Delegated requests must satisfy both user permissions and the current delegated service grant. The lower-level security.authorize_service checks only the machine half. Optional invalid user auth yields an anonymous result; malformed/revoked machine envelopes fail closed. Tenant/app hints never establish request scope.

import asyncio
from betterportal.authorization import AuthContext, authorize_request
from betterportal.security import KeyPair, TokenIssuer, uuid7

async def example():
    key, tenant, app = KeyPair.generate(), uuid7(), uuid7()
    trusted_keys = {key.kid: key.public_key_pem}
    pair = TokenIssuer(key, "https://auth.example", "app").issue_pair(
        {"sub": "user-1", "tenantId": tenant, "appId": app, "roles": []}, include_refresh=False)
    context = AuthContext(tenant, app, {"serviceId": uuid7(), "expectedIssuer": "https://auth.example",
        "expectedAudience": "app", "jwksUri": "https://auth.example/jwks", "roles": []}, trusted_keys.__getitem__)
    caller = await authorize_request({"authorization": "Bearer " + pair["accessToken"]}, {"required": True},
        context, view_id="hello", method="GET")
    assert caller.user is not None and caller.user["sub"] == "user-1"
asyncio.run(example())

Configuration encryption supports existing BP v1 reads, v2 strings, v3 typed JSON, and authenticated preview envelopes. preview_schema builds scoped field schemas from canonical descriptors; encrypt_preview/decrypt_preview use AnyVali's native sensitive traversal. Optional fields remain omitted; public/protected fields are unchanged. Values have a 1 MiB UTF-8 byte limit, and malformed encodings fail closed.

from betterportal.encryption import ConfigCipher, generate_preview_key, encrypt_preview_value, decrypt_preview_value

cipher = ConfigCipher(ConfigCipher.generate_key())
assert cipher.decrypt(cipher.encrypt({"enabled": True, "value": None})) == {"enabled": True, "value": None}
key = generate_preview_key()
encrypted = encrypt_preview_value(key, "tenant", ["token"], "")
assert decrypt_preview_value(key, "tenant", ["token"], encrypted) == ""

Persist generated keys with the service's protected bootstrap state. The cipher holds only two derived keys per instance; it has no global cache of secrets. SettingsSchema compiles config descriptors whose jsonSchema contains an AnyVali object document. Descriptor fields must match its properties; field scopes control writes. The native document owns defaults, coercion, required fields and nested unknown-key behavior. A descriptor defaultValue, when present, must match that native field default. Partial stored overrides never materialize defaults; effective(tenant, app) applies tenant defaults followed by app overrides.

import anyvali as av
from betterportal.encryption import ConfigCipher
from betterportal.settings import SettingsSchema, ServiceSettings
import asyncio

policy = SettingsSchema([{"id": "settings", "title": "Settings", "description": "Settings", "scope": "tenant",
    "jsonSchema": av.export_schema(av.object_({"secret": av.string()}, required=[])),
    "fields": [{"key": "secret", "title": "Secret", "description": "Service credential", "scope": "tenant",
        "visibility": "secret", "ownership": "bp", "sourceOfTruth": "bp"}]}])
cipher = ConfigCipher(ConfigCipher.generate_key())
stored = policy.encode("tenant", {"secret": "example"}, cipher)
assert stored["secret"].startswith("enc:aes256gcm2:")
assert policy.decode("tenant", stored, cipher) == {"secret": "example"}
assert policy.redact("tenant", {"secret": "example"}) == {"secret": "__redacted__"}
assert policy.merge("tenant", {"secret": "example"}, {"secret": "__redacted__"}) == {"secret": "example"}
assert policy.values("tenant", {}) == {}

async def main():
    async with ServiceSettings(policy, cipher) as settings:
        await settings.write("tenant", {"secret": "example"})
        assert settings.values("tenant", redacted=True) == {"secret": "__redacted__"}
        assert settings.effective("tenant", "app") == {"secret": "example"}

asyncio.run(main())

Native AnyVali sensitive APIs perform encryption/decryption and redaction traversal. Top-level sensitive fields preserve Node's enc: envelopes; nested sensitive fields retain the native encrypted: prefix around a BP envelope. Ordinary strings are never rewritten. merge validates field scopes, explicit clears and secret placeholders, including nested arrays. A placeholder cannot create a missing secret or move an existing secret into a public union branch. Real replacement values win over clear_keys; a cleared secret cannot simultaneously use a preserve placeholder. AnyVali 1.1.2 applies native transforms, redaction and encrypted-storage validation to direct sensitive ref nodes at the ref's value path. BP's descriptor visibility annotates native fields directly without changing their requiredness.

ServiceSettings serializes writes and owns encrypted tenant/app state. Supply a FileStateStore(path) as its third argument for persistence; omission uses memory. Call initialize() before reads/writes, or use its async context manager. Loading validates the whole cache before publishing it. A failed save or cancellation before commit preserves both disk and active values. Writes combine clears and replacements in one commit. values returns stored overrides; effective adds native defaults. Reads return owned copies. Closing cancels pending writes and rejects further access.

The portable cache reads Node's encrypted tenant/app files. Bare legacy buckets or the legacy envelope require initialize(legacy_tenant_id=...); migration persists that explicit owner before becoming ready and rejects an existing owner bucket. Unmarked plaintext secrets and tampered ciphertext are rejected. Keep the encryption key in protected storage. One instance owns each file; shared replicas need a configured repository/transport.

Pass ConfigApi(settings, issuer=cp_url, jwks_uri=cp_jwks_uri) to the service's config_api argument. Its settings descriptors must match the manifest. The host initializes settings before serving and owns their shutdown. For example, this factory returns an unready managed service to connect with ControlPlaneSync:

from betterportal.config_api import ConfigApi
from betterportal.contracts import parse
from betterportal.encryption import ConfigCipher
from betterportal.registry import Registry
from betterportal.service import Service
from betterportal.settings import SettingsSchema, ServiceSettings
from betterportal.storage import FileStateStore

def configured_service(registry: Registry, declaration: dict, cp_url: str, jwks_uri: str, key: str, settings_path: str):
    manifest = parse("ManifestDeclarationSchema", declaration)
    settings = ServiceSettings(SettingsSchema(manifest["configSchemas"]), ConfigCipher(key), FileStateStore(settings_path))
    return Service(registry, manifest, managed=True,
        config_api=ConfigApi(settings, issuer=cp_url, jwks_uri=jwks_uri))

GET /.well-known/bp/config/schema is public. GET/POST on /.well-known/bp/config require CP-signed config.read/config.write tickets for the manifest plugin ID. The ticket supplies the tenant. GET's optional X-BP-App-Id selects stored app overrides; POST uses the canonical tenant/app/values/clearKeys body. Active tenants and configApps (or apps when absent) restrict access. Conflicting scope headers are rejected. Responses redact secrets and disable caching. Preflights authorize only management origins, before ticket verification. HEAD follows GET policy.

mode, custom_ui_path and writable describe API capabilities; ownership mode does not grant access. Missing read/write support returns 501. Invalid field input returns 400; storage failures return 500 and preserve previous values. Shutdown cancels pending writes. A snapshot change during ticket verification rejects the request; a committed write finishes before a queued snapshot replacement.

Handlers receive stored settings plus native defaults and preview overrides. Preview values validate before snapshot publication and never overwrite the settings file. Missing required effective values return 503 on operations while the config API remains available to supply them. Public health reflects initialized state and managed manifest acknowledgment. Protected key storage and ServiceInstallation HTTP provisioning are described below.

dev_token is accepted only when explicitly supplied and allow_dev_config_token=True at construction. It requires an explicit tenant header and the same scope checks; it is a local-development option. Without pinned CP trust or this explicit opt-in, the API rejects every ticket.

Service.apply_snapshot validates a complete scoped document and preview values, persists it through the supplied StateStore, then replaces active policy. Failed validation, decryption, saving or cancellation before commit preserves the previous snapshot. Requests capture one snapshot; an update during authentication returns 503. Successful updates retire old JWKS caches and rebuild URL and access policy.

FileStateStore writes a sibling file, flushes it, then atomically replaces its target. The default limit is 16 MiB. Use one writer per file; replicated services need a configured transactional store. Files use POSIX mode 0600 or the directory's Windows ACL. The store does not supply key management or cross-replica delivery.

import asyncio
from pathlib import Path
import tempfile
from betterportal.registry import Registry
from betterportal.service import Service
from betterportal.storage import FileStateStore

async def main():
    declaration = {"pluginId": "com.example.service", "title": "Example", "description": "Example", "version": "1.0.0"}
    with tempfile.TemporaryDirectory() as directory:
        store = FileStateStore(Path(directory) / "snapshot.json")
        async with Service(Registry([]), declaration, state_store=store, managed=True) as service:
            await service.apply_snapshot({"managementOrigins": [], "tenants": [], "apps": []})
            assert service.snapshot() is not None and not service.ready
        async with Service(Registry([]), declaration, state_store=store, managed=True) as restored:
            assert await restored.restore_snapshot()
            assert not restored.ready

asyncio.run(main())

Managed readiness additionally requires manifest_submitted=True on an update from an acknowledged manifest POST. Restoring a cache cannot supply that proof; ControlPlaneSync supplies it after a successful POST and persisted snapshot. preview_key decrypts preview settings only for one unambiguous active tenant/app. Both scopes must validate before publication. Their merged values appear in RequestContext.config; only the encrypted snapshot is persisted. Removing preview config removes that overlay.

ControlPlaneSync owns manifest submission, scoped SSE updates and reconnect/poll fallback. Pass a managed service and its provisioned CP URL/API key. HTTPS is required; plain HTTP permits only exact localhost, 127.0.0.1 and [::1] hosts. Redirects are rejected. Each connection starts with a manifest POST; a failed first attempt leaves health at 503 and retries after five seconds. Poll requests and idle stream reads time out after 30 seconds. Payloads and individual SSE frames are bounded to 16 MiB. Invalid updates preserve the last accepted snapshot; 401/403/409/412 revoke readiness until a new manifest is accepted. Transient failures retain valid policy.

from betterportal.asgi import create_app
from betterportal.registry import Registry
from betterportal.service import Service
from betterportal.storage import FileStateStore
from betterportal.sync import ControlPlaneSync

def managed_app(cp_url: str, api_key: str, cache_path: str):
    service = Service(Registry([]), {"pluginId": "com.example.service", "title": "Example",
        "description": "Example", "version": "1.0.0"}, managed=True, state_store=FileStateStore(cache_path))
    sync = ControlPlaneSync(service, cp_url, api_key)
    return create_app(service, sync=sync)

Use your operation registry in this factory, and expose its returned app to your ASGI server. The host starts synchronization and closes it before the service at shutdown. Without a host, use async with service and async with sync, in that order. await sync.start() returns the first bootstrap result; await sync.aclose() cancels pending requests/retries and clears managed readiness. sync.status returns safe counters/error codes; protect any endpoint exposing them. sync.submission() returns the portable typed manifest projection. Optional key_pair registers a previously persisted RSA public key, and auth_provider advertises issuer metadata. Use ServiceInstallation below for provisioning and install-ticket hosting.

BootstrapStateStore protects provisioned credentials and a persistent RSA signing identity with the Node-compatible authenticated bootstrap envelope. Supply its master key from a protected host secret. Generate that key once with BootstrapCipher.generate_key() and retain it separately; generating a new master key on every startup makes existing state unreadable.

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from betterportal.bootstrap import BootstrapCipher, BootstrapStateStore
from betterportal.storage import FileStateStore

async def bootstrap_example():
    with TemporaryDirectory() as directory:
        master_key = BootstrapCipher.generate_key()  # Example only: retain securely in production.
        file = FileStateStore(Path(directory) / "bootstrap.json")
        state = BootstrapStateStore(file, master_key)
        await state.write({"cpUrl": "https://cp.example", "apiKey": "bp_sk_t_example"})
        identity = await state.identity()
        restarted = BootstrapStateStore(file, master_key)
        assert (await restarted.identity()).kid == identity.kid
        assert (await restarted.read(redacted=True))["apiKey"] == "__redacted__"

asyncio.run(bootstrap_example())

write atomically merges a validated patch; read returns owned state. identity loads or atomically creates one RSA key inside that encrypted state. clear explicitly removes both credentials and identity. Corrupt files, invalid master keys and mismatched public/private keys fail without replacement. Keep one owner per store; use a transactional implementation for shared storage. No plaintext cache or background tasks are retained. Caller cancellation propagates before commit. Pass the returned identity to ControlPlaneSync(key_pair=...) or TokenIssuer, or let ServiceInstallation own it.

ServiceInstallation supplies the setup-token installation endpoint and public JWKS, restores protected credentials, activates ticket-protected settings and owns manifest synchronization. Configure the public service origin in the host. Pass None as cp_url to wait for first pairing, or supply a URL to pin the control plane in advance. First successful pairing persists its control-plane trust; subsequent startups restore it from protected state. Failed pairing does not pin a control plane. Forwarded headers cannot select the public origin. The installer requires a fresh managed Service and owns its sync lifecycle.

from pathlib import Path
from betterportal.asgi import create_app
from betterportal.bootstrap import BootstrapStateStore
from betterportal.installation import ServiceInstallation
from betterportal.registry import Registry
from betterportal.service import Service
from betterportal.storage import FileStateStore

def installable_app(cp_url: str | None, public_origin: str, master_key: str, state_directory: str):
    directory = Path(state_directory)
    service = Service(Registry([]), {"pluginId": "com.example.service", "title": "Example",
        "description": "Example", "version": "1.0.0"}, managed=True,
        state_store=FileStateStore(directory / "snapshot.json"))
    installation = ServiceInstallation(service,
        BootstrapStateStore(FileStateStore(directory / "bootstrap.json"), master_key), cp_url, public_origin,
        settings_store=FileStateStore(directory / "settings.json"))
    return create_app(service, installation=installation)

Use your operation registry and separately protected master key in this factory; serve its returned ASGI app. Startup without credentials exposes public keys and health 503. POST {"setupToken":"...","cpUrl":"https://cp.example"} to /.well-known/bp/install. A successful installation returns 200 after the manifest and scoped snapshot are accepted. Failed first sync returns 503 with installed: true and retries using durable credentials. Redemption/persistence failures never make the service ready. Native responses omit the API key.

Replaying the same setup JTI reuses committed credentials; a new signed token may rotate credentials for the same instance. Tenant-scoped installation stores its signed tenant as tenantLock; operation requests for another tenant return 426, and config tickets for another tenant are denied. Platform installation without a tenant scope can serve the CP's permitted tenants. Existing tenant locks survive reconfiguration and restart. An existing Node bootstrap file can be loaded with its master key; reconfiguration requires a synchronized matching instance. Optional cp_jwks_uri pins an alternate JWKS endpoint; auth_provider advertises provider metadata. Config descriptors require a settings store. config_mode, custom_ui_path, and writable configure the existing ConfigApi policy. Shutdown cancels and drains installation/sync before closing the service. Keep one owner per state store; these file stores do not coordinate replicas.

To change the public hostname, keep the same protected state, configure the new service_url, and restart. A stored-address mismatch leaves health 503 until POST {"changeToken":"bp_hc_..."} to /.well-known/bp/hostname-change confirms the CP-issued token. The runtime method is await installation.change_hostname(body). The configured origin supplies the new address; forwarded headers cannot change it. Confirmation checks the CP's current instance/address projection before atomically updating the binding and restarting sync. Credentials, signing identity and tenant lock are preserved. If CP confirmation succeeds but local persistence fails, obtain a fresh token and retry; if local persistence succeeded, restart can resume sync. See the hostname protocol.

python -m pip install -r framework/conformance/requirements.txt
python -m build framework/python
python -m mypy framework/python/betterportal --follow-imports=silent --follow-untyped-imports

Wheels and source distributions embed the shared contract corpus. Building a wheel from its source distribution and importing it require neither Node nor BSB. The repository build copies those documents; there is no independently maintained Python schema definition. betterportal.contracts.document returns a portable document; contract imports it natively, including when selecting a named field. object_document composes portable roots before importing, retaining recursive definitions and extensions and rejecting conflicting names or document versions. AnyVali 1.1.2 native-parent export also preserves recursive definitions; the helper remains useful for portable extension and version checks.

Native input/output typing is generated from those documents:

bp-python types --contracts ./contracts --output ./generated_types.py
bp-python types --contracts ./contracts --output ./generated_types.py --check

python -m betterportal is equivalent to bp-python. Use --platform instead of --contracts to regenerate the embedded BP types. betterportal.generated_types exports TypedDict declarations, enums as Literal, and recursive aliases. Input types allow omitted defaults; output types require materialized defaults. Nullable fields remain distinct from optional fields. AnyVali performs all runtime validation. Python represents AnyVali tuples as lists; positional constraints, general intersections and coercion inputs cannot always be expressed precisely by Python typing and remain in the AnyVali document.

from betterportal.contracts import parse
from betterportal.security import KeyPair, TokenIssuer, uuid7

settings = parse("JsonObjectSchema", {"feature": {"enabled": True}})
issuer = TokenIssuer(KeyPair.generate(), "https://auth.example", "my-app")
pair = issuer.issue_pair({
    "sub": "user-1", "tenantId": uuid7(), "appId": uuid7(), "roles": ["reader"],
    "authProvider": "example", "refreshContext": {"subject": "user-1"},
})

Use persistent signing material for a real issuer; this example generates an ephemeral key. Use BootstrapStateStore.identity() for persistent signing material. verify_token requires an explicit purpose, trusted key resolver, issuer and audience (setup tokens have no audience). Setup claims still need binding to the intended installation; accepting a valid signature alone is insufficient.

betterportal.keys.JwksClient(issuer, uri) is an async context manager; pass its resolve method as the trusted key resolver. It coalesces concurrent refreshes, caches for 30 minutes, throttles unknown-key refreshes for two seconds, and supports invalidate() on scoped key changes. Closing cancels outstanding refreshes. HTTPS is required except exact localhost/127.0.0.1/[::1] HTTP URLs. Redirects are rejected; complete responses have a five-second deadline and 1 MiB limit. A configured JWKS query is allowed. The secure_endpoint helper defaults to rejecting queries for control-plane base URLs.

The shared security HTTP suite passes 459 scenarios across Node, Python and .NET. The Windows schema gate passes 1,580/1,580 scenarios with AnyVali 1.1.6: 536 checks in each native runtime and 508 in Node. The eight C# extension regressions from AnyVali #141 pass with their original expectations and no BP workaround. See the conformance ledger for runtime results and remaining capabilities. Do not treat package builds as evidence that the full framework plan is complete. Nothing is published.

Scoped dependency clients

Load a dependency's exported BpSchemaOutput with ClientContract(document). Inside a handler, context.request_context.clients.user(contract, service_id) selects an enabled instance or registry alias and forwards the verified original user bearer. Its selected operation must be mounted in the current app; public operations can be called anonymously. Credentials are absent from render contexts. Call await client.request(operation_id, {"params": ..., "query": ..., "headers": ..., "body": ...}). AnyVali validates each method's inputs and JSON output. Omit unused input keys; an explicit body null remains a JSON null when its schema permits it.

Use context.request_context.clients.m2m(request_id, contract) for a declared service or delegated dependency. The manifest request selects the mode. Each call checks current bindings, grants, methods, permissions and capabilities, then mints a service token lasting at most 60 seconds. Delegated calls retain the original user bearer as well. Background work uses service.clients.scope(tenant_id, app_id) and supports service mode only. Supply signing_key to a locally configured service; installation/control-plane sync supplies its persisted key automatically. The scoped snapshot must confirm the public key and key ID before tokens can be sent.

import asyncio
import anyvali as av
from betterportal.clients import ClientContract, ClientError
from betterportal.handler import Handler
from betterportal.registry import Operation, Route, Registry
from betterportal.service import Service

async def check():
    declaration = {"pluginId": "com.example.peer", "title": "Peer", "description": "Example", "version": "1.0.0"}
    registry = Registry([Route("value", "/value", [Operation(Handler(av.string(), lambda context: "value"),
        {"operationId": "value.get", "method": "GET", "title": "Value", "description": "Read value", "auth": {}})])])
    contract = ClientContract(registry.schema(declaration))
    async with Service(Registry([]), declaration) as service:
        client = service.clients.scope("01952200-0000-7000-8000-000000000001",
                                       "01952200-0000-7000-8000-000000000002").m2m("read", contract)
        try:
            await client.request("value.get")
            raise AssertionError("An unready service made an outbound call")
        except ClientError as error:
            assert error.status == 503

asyncio.run(check())

Destinations require HTTPS, with the same exact-loopback HTTP exceptions as sync. Clients reject redirects, cookie replay and caller overrides of BP routing/auth headers. JSON requests/responses are bounded to 16 MiB, URLs to 8,192 characters, and transport waits to 30 seconds; unsolicited compressed responses are rejected. Snapshot replacement invalidates captured request clients and cancels pending JSON, raw and streaming HTTP work, including calls waiting for response headers; background clients resolve policy again on each call. Cancellation and service shutdown stop pending HTTP work. ClientError exposes the upstream status with a generic message; upstream error bodies are never included.

async with client.raw(operation_id, values) as response opens an explicitly raw operation. response.status, response.headers (repeated pairs) and response.body (async byte iterator) expose the response without buffering it. The default total limit is 16 MiB; pass max_bytes to select another positive limit. The context manager closes the response even when consumption stops early.

async with client.stream(operation_id, values) as frames opens a finite operation using NDJSON. Each item and summary is validated against the declared schema. Invalid ordering/counts, oversized frames, upstream errors and missing terminal frames raise ClientError; consume the iterator to completion before treating a stream as successful. The default frame limit is 1 MiB (max_frame_bytes). Raw and finite connections have a 30-second transport inactivity timeout, reject redirects/compression, and close on caller cancellation, snapshot retirement or service shutdown. Pass transport="sse" for a finite SSE data stream (the upstream app must have no HTML theme renderer selected). NDJSON always requests typed data. client.subscribe(operation_id, event_schema, values) consumes an open-ended SSE feed with a caller-pinned AnyVali event schema; use text=True for plain-text events. The manifest does not currently publish subscriber event schemas. The context manager owns cleanup; reconnect and replay policy belong to the caller.

Generated clients expose raw operations as context-manager methods and add <method>_stream for finite operations alongside their buffered JSON method. Inputs and discriminated item/summary/end frames are typed; streamed payloads also validate at runtime.

Generate a typed JSON client from the dependency's exported BP schema:

bp-python client --contract contracts/peer.json --output dependencies/peer.py --class-name PeerClient
bp-python client --contract contracts/peer.json --output dependencies/peer.py --class-name PeerClient --check

Import PeerClient from that generated module and construct it with PeerClient(context.request_context.clients, service_id="peer") for user calls, or PeerClient(context.request_context.clients, request_id="read-item") for a declared service/delegated request. An operation check.get becomes await client.check_get({"params": {"key": "item"}}). Input TypedDicts preserve required fields, defaults and nullable values; return types describe validated JSON. Undeclared schemas remain generic JSON mappings. Each generated module embeds the contract and uses the runtime's policy, transport and shutdown handling. --check detects stale generated code without writing files. Neither command requires Node or BSB. AnyVali 1.1.2 preserves Python's empty and from-string coercion configurations through export and reimport.

Local dependencies and frozen builds

Keep registry identity and dependency selectors in betterportal.json. Install an exported service project without Node or BSB:

bp-python deps add example/service@1.0.0 --path ../service --alias peer --project .
bp-python deps sync --frozen --project .
bp-python deps sync --frozen --check --project .

The provider directory contains bp-contract.json or lib/bp-contracts/*.json. Its betterportal.json supplies registryRef; a full reference in the selector can supply it when absent. Exact versions and identities must match, and ambiguous exports fail. The generated client is bp_dependencies/dep_peer.py, with class DependencyClient. Its constructor and methods use the scoped client API above. Aliases that collide in native filenames are rejected before files change.

The lock retains registryRef, pluginId, version and digest, adding digestFormat: "json-bytes". The SHA-256 digest covers the exact cached UTF-8 document. Frozen builds use that cache, verify the configured identity/version, and validate every dependency before updating generated sources. They perform no network lookup or local override discovery; --check performs no writes. Explicit installation can migrate a legacy Node lock. Native frozen builds reject legacy locale-dependent digests; Node's CLI supports both formats. Native init commands are documented in the route authoring guide.

Without --path or --registry, deps add first discovers exported local contracts: literal package.json workspaces at the nearest Git root, node_modules packages (including scopes), sibling repositories, then BP_DEV_PATHS. Without Git, the consumer directory is the root. Native projects need no package.json; siblings and BP_DEV_PATHS work independently of Node. Development paths are relative to --project, separated by ; on Windows or : on Linux. A development path named node_modules is expanded into its packages. Workspace glob entries are ignored, matching Node's existing discovery; use explicit development paths for those layouts.

Automatic candidates must declare registryRef in betterportal.json. Invalid or stale exports are skipped; an explicit --path reports their errors. Exact versions are honored. Multiple matching identities, versions or different contract bytes fail before any writes; identical duplicates are accepted. Use a full identity, exact version or --path to disambiguate. No service code executes during discovery.

Registry installation and publishing

bp-python deps add example/service@1.0.0 --alias peer
bp-python deps add com.example.service@1.0.0 --alias peer
bp-python publish --contract bp-contract.json --project .

When no local contract matches, installation uses BP_REGISTRY_URL (default https://io.betterportal.org). --registry URL selects the registry directly and bypasses automatic local discovery. Short names use defaultNamespace or require a unique registry match. An explicit version is fetched after short-name resolution; omitted versions and latest resolve the registry's current version. Installation pins the exact response bytes and uses the same offline frozen-build commands above.

Publishing requires the project's registryRef and a BP_REGISTRY_TOKEN environment variable. It submits an existing exported contract; the registry enforces publisher prefixes, permanent identity bindings and immutable versions. Identical retries return unchanged: true. Running publish writes to the selected registry. These tools do not build or export your service implicitly.

Both commands require HTTPS with exact-loopback HTTP exceptions, reject redirects and compressed/non-JSON responses, and enforce 16 MiB payload limits and a 30-second total deadline per request. They send no environment proxy credentials or stored cookies; publisher credentials are never sent on lookups or included in upstream error messages.

Export an application contract

Define a synchronous factory in your application module, using the same registry and manifest declaration as the host:

import anyvali as av
from betterportal.generated_types import BpSchemaOutput
from betterportal.handler import Handler
from betterportal.registry import Operation, Registry, Route

def contract() -> BpSchemaOutput:
    handler = Handler(av.string(), lambda context: "Hello")
    registry = Registry([Route("hello.index", "/hello", [Operation(handler, {
        "operationId": "hello.get", "method": "GET", "title": "Hello",
        "description": "Hello operation", "auth": {}
    })])])
    return registry.schema({"pluginId": "com.example.hello", "title": "Hello",
                            "description": "Example service", "version": "1.0.0"})

assert contract()["manifest"]["pluginId"] == "com.example.hello"

For a factory in my_service/definition.py:

bp-python export --module my_service.definition:contract --project . --output bp-contract.json
bp-python export --module my_service.definition:contract --project . --output bp-contract.json --check

The command imports that explicitly selected module with the project directory on the Python import path. Module initialization and the factory execute as application code; keep host startup under its normal entry-point guard. The factory takes no arguments and returns the registry's contract. Export uses AnyVali validation, limits the document to 16 MiB and atomically replaces the output. --check detects drift without writing. Output paths are relative to --project. No request handler or host lifecycle is invoked by the exporter. Use native route discovery to build this registry from index, method and SSE modules.

Production integration additions

Pass observability=Observability(logger=..., tracer=..., metrics=...) to create_app, importing Observability from betterportal.observability. Each nonblocking callback receives an immutable HttpObservation at request completion, including trace/span IDs, method, route template, HTTP status, outcome and duration. Callback failures do not change request handling. Core events exclude request bodies, query strings, credentials and exception text. Valid traceparent, bounded tracestate and baggage propagate to dependency calls. context.trace exposes the active trace. Use with context.obs.start_span(name, attributes): around sync or awaited work; child spans restore the parent context and report cancellation. context.obs.logger offers debug/info/warn/error, and context.obs.metrics offers counter, gauge, histogram and timer instruments. Instrument callbacks receive SpanObservation, LogObservation or MetricObservation, respectively. Attribute keys associated with credentials/payloads are removed and scalar values bounded; application-supplied log messages must themselves be safe for export.

Managed services can publish manifest-declared webhooks with await context.webhook(event_id, payload, idempotency_key=key). Background work uses service.clients.scope(tenant_id, app_id).webhook(...). The active control-plane connection owns publication credentials; callers cannot choose a destination or replace the request's tenant/app. AnyVali validates the declared payload, the serialized request is limited to 1 MiB, and redirects are rejected. Publication returns the idempotency key on success. After publication starts, failures raise betterportal.webhooks.WebhookError (a ClientError) with idempotency_key; cancellation raises WebhookCancelled (an asyncio.CancelledError) with the same attribute. Reuse that key when retrying uncertain delivery. Persist an explicit key before publication when recovery must survive process termination; the publisher does not retry automatically.

Developer resources are served publicly at /.well-known/bp/resources and /.well-known/bp/resources/<id>. The index omits content and adds relative URLs; individual responses preserve the declared content and media type. Registration rejects duplicate IDs and content exceeding 512 KiB of UTF-8. Publish only content intended for public discovery. An active create_app(..., mode="theme") also serves /llms.txt, API/development/UI guides, /.well-known/bp/ai.json and /llms-drop.txt. The drop includes complete local resources and contracts, source URLs, separate framework/theme versions and a bounded expiry. No remote discovery URL is fetched. Only explicit public metadata is projected from the app; service configuration and installation credentials are excluded. /sitemap.xml and /robots.txt respect private visibility, enabled public page mounts and route exclusions. Dynamic or provider-generated sitemap paths are omitted until their owning service supplies concrete URLs; discovery never guesses parameter values.

Hosting and native helper APIs

DeploymentConfig.from_env().create_app(registry, declaration) reads optional BP_CP_URL (omit it to wait for first pairing), BP_PUBLIC_ORIGIN, BP_STATE_DIRECTORY, and exactly one of BP_BOOTSTRAP_MASTER_KEY or BP_BOOTSTRAP_MASTER_KEY_FILE. The secret is excluded from the configuration's repr. All Python environment reads, including authoring CLI settings, pass through betterportal.environment and its AnyVali schema before use. Unknown process variables are ignored, recognized invalid settings fail without printing secret values. BP_TRUSTED_PROXIES is an optional comma-separated list of IP addresses/CIDRs; your proxy must overwrite X-Forwarded-Host and X-Forwarded-Proto. Never configure arbitrary peers as trusted.

bp-python init includes a Dockerfile with a non-root user, readiness probe and single Uvicorn worker. Mount persistent storage at /data and the bootstrap key as a separate read-only secret. Run one process per state directory; independent replicas require separate installations/state or an application-owned coordinated store. See the deployment acceptance procedure.

Handlers expose tenant, app, config, user, service_caller, caller_mode, method, path, multipart, clients, urls, response, bp_headers, trace and obs. context.require_permission(service_id, view_id, action) resolves an enabled target and checks the current user's app role grants. A denied handler check returns HTTP 403 through the normal JSON or HTML error response. It fails after the captured snapshot is retired. The scoped platform schema can be authored with the canonical generated types and validated with contracts.parse; environment configuration never replaces authenticated control-plane scope.

betterportal.auth_helpers.ExternalOidc verifies RS256 external JWTs against an explicit issuer, audience and HTTPS JWKS URI, including optional nonce matching. Application code maps verified external claims to a BP user and issues its own BP tokens; external roles are never automatically granted BP permissions. Close the verifier at shutdown. auth_redirect restricts redirects to the current app, and auth_cookie emits host-only Secure/HttpOnly cookies with explicit lifetime and SameSite. context.bp_headers.set/remove emits validated BP browser directives.

betterportal.themes.ShellFragment combines a canonical declaration and sync/async HTML callback; ShellFragments validates defaults and renders app overrides with cycle/depth/output bounds. Pass the same declarations in the manifest's shell metadata and the collection as create_app(..., shell_fragments=fragments). Callbacks receive safe presentation fields, scoped service config, urls, request, fragment_id and rendered items. themes.element creates escaped BP element attributes for service fragments or shell chrome; its child HTML is trusted application output. Shell fragment endpoints require this service to be the active shell for the resolved app, with normal origin restrictions.

sse: true resolves only a known registered GET SSE/finite-stream endpoint on the current service. Ordinary routes, non-GET methods and dependency mounts without streaming capability return null instead of a guessed stream URL.

Embedded query values may contain raw question marks, for example /items?q=what?x&next=/a?b; the resolver preserves and encodes the complete values before applying query overrides.

A dependency mount without a resolved service path cannot identify a service endpoint, so resolution returns null rather than guessing or throwing.

Use betterportal.themes.bp_route for native HTML controls/embeds; its states use the shared browser runtime. Component/fragment success wraps fetched HTML rather than authored children:

from betterportal.themes import bp_route

# Inside a renderer, context is the RenderContext supplied by BetterPortal.
# html = bp_route(context, {"viewId": "items.detail", "params": {"id": 42}},
#     {"ok": "Item", "error": {"as": "label", "id": "unavailable", "content": "Unavailable"}})

Route results stringify to apiPath in handlers and viewPath in renderers. Immutable selector chaining uses destination.component("summary") or .fragment("body.live"). The theme maps service controls to public UI mounts; do not prefer uiPath for service-renderer hrefs.

The success state's HTML method selects the request method unless an explicit hx-get/hx-post/hx-put/hx-patch/hx-delete attribute selects it. Methods are normalized to uppercase. Explicit serviceId, params and query take precedence over the service alias and legacy args, matching Node's BPRoute behavior. Conditional None values use the next non-null fallback; explicit empty parameter or query dictionaries remain intentional overrides.

Incoming JSON rejects duplicate members, nonfinite numbers (including overflowing exponents) and excessive nesting before the handler runs. Exact integer tokens retain Python's integer precision. CI checks the entire runtime with mypy, including bodies of functions without annotations, and verifies accepted and rejected consumer typing examples.

PyPI publishing

See PUBLISHING.md for trusted-publisher setup, release versioning and the signed-tag publishing workflow.

Metadata

Release files for betterportal 11.0.9

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for betterportal 11.0.9
File Size Uploaded Provenance Actions
betterportal-11.0.9.tar.gz 331.4 kB With provenance Details for betterportal-11.0.9.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for betterportal 11.0.9
File Interpreter ABI Platform Actions
betterportal-11.0.9-py3-none-any.whl Python 3 none any Details for betterportal-11.0.9-py3-none-any.whl

Total release size: 721.6 kB

Release files / betterportal-11.0.9.tar.gz

Download URL betterportal-11.0.9.tar.gz
Size 331.4 kB
Tags Source
SHA-256 checksum
How to use checksums
d69ce55467d35af568c442147e3ec4301c6454c09f28cde8e77e521e69e0b04e
BLAKE2b-256 checksum
How to use checksums
208834050fb0cf58bd846a3ea4b67ef65f0f644b43de33cebdb55761702441c1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Release files / betterportal-11.0.9-py3-none-any.whl

Download URL betterportal-11.0.9-py3-none-any.whl
Size 390.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3dc0f965e842659bcfc479823d42e235072b995a22ce26b0ea7a86e5096989ee
BLAKE2b-256 checksum
How to use checksums
4901764265e1f2fda47ba8910aad0cdb56eb35bf33d4ff3e9cfb803784015969
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Security for betterportal 11.0.9

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for betterportal-11.0.9.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page