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.8
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| betterportal-11.0.8.tar.gz | 331.3 kB | With provenance | Details for betterportal-11.0.8.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| betterportal-11.0.8-py3-none-any.whl | Python 3 | none | any | Details for betterportal-11.0.8-py3-none-any.whl |
Total release size: 721.6 kB
Release files / betterportal-11.0.8.tar.gz
| Download URL | betterportal-11.0.8.tar.gz |
|---|---|
| Size | 331.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ca0f129f2f22d298422fdf48c79bfe56a2371592acd298857783d1735c18d590
|
|
BLAKE2b-256 checksum How to use checksums |
8a4c0e41ebf2acb801d97de397981ce47cb52f8df9468d40f3aadd139f4e8c42
|
| 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 logRelease files / betterportal-11.0.8-py3-none-any.whl
| Download URL | betterportal-11.0.8-py3-none-any.whl |
|---|---|
| Size | 390.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1c488ec488daf939ac6dceafecafde54885a65aa39b225d2ebe138f813e06316
|
|
BLAKE2b-256 checksum How to use checksums |
4c7711cdeba63f613cec7f2080f5be570229568aad459696ef71ae87fd4ba5bc
|
| 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 logSecurity for betterportal 11.0.8
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.
Attestation for betterportal-11.0.8.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 logA 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.