betterportal 11.0.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- betterportal-11.0.7/PKG-INFO +1141 -0
- betterportal-11.0.7/README.md +1121 -0
- betterportal-11.0.7/betterportal/__init__.py +4 -0
- betterportal-11.0.7/betterportal/__main__.py +3 -0
- betterportal-11.0.7/betterportal/_contracts/AdminApiDescriptorSchema.json +54 -0
- betterportal-11.0.7/betterportal/_contracts/ApiAuthRequirementSchema.json +98 -0
- betterportal-11.0.7/betterportal/_contracts/ApiCallerModeSchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/ApiContractDescriptorSchema.json +89 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthConfigSchema.json +303 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthPermissionActionSchema.json +15 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthPermissionGrantSchema.json +38 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthProviderConfigSchema.json +70 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthRedirectsSchema.json +55 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthRoleSchema.json +67 -0
- betterportal-11.0.7/betterportal/_contracts/AppAuthViewReferenceSchema.json +24 -0
- betterportal-11.0.7/betterportal/_contracts/AppRouteSchema.json +44 -0
- betterportal-11.0.7/betterportal/_contracts/AppSchema.json +127 -0
- betterportal-11.0.7/betterportal/_contracts/AuthAudienceRuleSchema.json +31 -0
- betterportal-11.0.7/betterportal/_contracts/AuthProviderRuntimeMetadataSchema.json +82 -0
- betterportal-11.0.7/betterportal/_contracts/AuthRoleAuthoritySchema.json +13 -0
- betterportal-11.0.7/betterportal/_contracts/AuthressProviderConfigSchema.json +52 -0
- betterportal-11.0.7/betterportal/_contracts/BPRouteReferenceSchema.json +95 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalAppSchema.json +4465 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalAppShellSchema.json +19 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalBrandingSchema.json +42 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigManagementAuthSchema.json +52 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigManagementSchema.json +81 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigSchema.json +7853 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalFragmentAssignmentSchema.json +34 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalLockSchema.json +54 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalLogLevelSchema.json +15 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalMenuItemSchema.json +3180 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalOriginPolicySchema.json +32 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalProjectConfigSchema.json +43 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalResourceSchema.json +69 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteChromeSchema.json +86 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteChromeValueSchema.json +20 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteMountSchema.json +351 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalSeoConfigSchema.json +57 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalShellFragmentItemSchema.json +57 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalShellFragmentSettingSchema.json +158 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalSlotAssignmentSchema.json +41 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalTenantSchema.json +267 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeBootstrapPaletteSchema.json +70 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeConfigSchema.json +246 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeSurfaceSchema.json +63 -0
- betterportal-11.0.7/betterportal/_contracts/BetterPortalTraceContextSchema.json +24 -0
- betterportal-11.0.7/betterportal/_contracts/BindingRecordSchema.json +109 -0
- betterportal-11.0.7/betterportal/_contracts/BindingTrustSchema.json +43 -0
- betterportal-11.0.7/betterportal/_contracts/BootstrapStateEnvelopeSchema.json +37 -0
- betterportal-11.0.7/betterportal/_contracts/BootstrapStateSchema.json +166 -0
- betterportal-11.0.7/betterportal/_contracts/BpSchemaOutputSchema.json +1779 -0
- betterportal-11.0.7/betterportal/_contracts/BpSchemaRouteSchema.json +144 -0
- betterportal-11.0.7/betterportal/_contracts/CacheHintsSchema.json +29 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigFieldDescriptorSchema.json +232 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigFieldGroupDescriptorSchema.json +42 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigFieldUiDescriptorSchema.json +114 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigOwnershipSchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigSchemaDescriptorSchema.json +318 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigScopeSchema.json +13 -0
- betterportal-11.0.7/betterportal/_contracts/ConfigVisibilitySchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/ControlPlaneSubmissionSchema.json +1442 -0
- betterportal-11.0.7/betterportal/_contracts/CpEnvelopeClaimsSchema.json +71 -0
- betterportal-11.0.7/betterportal/_contracts/DefaultAuthProviderConfigSchema.json +19 -0
- betterportal-11.0.7/betterportal/_contracts/DemoScenarioMatchSchema.json +84 -0
- betterportal-11.0.7/betterportal/_contracts/DemoScenarioSchema.json +116 -0
- betterportal-11.0.7/betterportal/_contracts/DependencyAliasSchema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/DeploymentModeSchema.json +16 -0
- betterportal-11.0.7/betterportal/_contracts/DeveloperResourceSchema.json +60 -0
- betterportal-11.0.7/betterportal/_contracts/ElevationClaimsSchema.json +32 -0
- betterportal-11.0.7/betterportal/_contracts/ElevationRequirementSchema.json +29 -0
- betterportal-11.0.7/betterportal/_contracts/FragmentAssignmentSchema.json +29 -0
- betterportal-11.0.7/betterportal/_contracts/HtmlRepresentationSupportSchema.json +96 -0
- betterportal-11.0.7/betterportal/_contracts/HttpMethodSchema.json +17 -0
- betterportal-11.0.7/betterportal/_contracts/IdentityRealmSchema.json +13 -0
- betterportal-11.0.7/betterportal/_contracts/JsonObjectSchema.json +45 -0
- betterportal-11.0.7/betterportal/_contracts/JsonValueSchema.json +42 -0
- betterportal-11.0.7/betterportal/_contracts/JwtClaimsSchema.json +267 -0
- betterportal-11.0.7/betterportal/_contracts/LocalDependencyLockSchema.json +49 -0
- betterportal-11.0.7/betterportal/_contracts/LocalWorkspacePackageSchema.json +23 -0
- betterportal-11.0.7/betterportal/_contracts/LockedDependencySchema.json +41 -0
- betterportal-11.0.7/betterportal/_contracts/M2MBindingSchema.json +75 -0
- betterportal-11.0.7/betterportal/_contracts/M2MCallerModeSchema.json +13 -0
- betterportal-11.0.7/betterportal/_contracts/M2MConfigSchema.json +162 -0
- betterportal-11.0.7/betterportal/_contracts/M2MGrantSchema.json +71 -0
- betterportal-11.0.7/betterportal/_contracts/M2MMethodSchema.json +17 -0
- betterportal-11.0.7/betterportal/_contracts/M2MRequestDescriptorSchema.json +90 -0
- betterportal-11.0.7/betterportal/_contracts/ManifestDeclarationSchema.json +837 -0
- betterportal-11.0.7/betterportal/_contracts/MultipartRequestSchema.json +114 -0
- betterportal-11.0.7/betterportal/_contracts/ObservabilityAttributesSchema.json +23 -0
- betterportal-11.0.7/betterportal/_contracts/ObservabilityValueSchema.json +20 -0
- betterportal-11.0.7/betterportal/_contracts/OperationDeclarationSchema.json +560 -0
- betterportal-11.0.7/betterportal/_contracts/OperationDependencySchema.json +38 -0
- betterportal-11.0.7/betterportal/_contracts/PersistedServiceConfigStateSchema.json +112 -0
- betterportal-11.0.7/betterportal/_contracts/PlatformServiceSchema.json +167 -0
- betterportal-11.0.7/betterportal/_contracts/PluginCategorySchema.json +17 -0
- betterportal-11.0.7/betterportal/_contracts/PluginIdSchema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/PluginManifestSchema.json +1627 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentDeploymentSchema.json +524 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentDeploymentServiceSchema.json +29 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentGroupSchema.json +486 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentGroupServiceSchema.json +375 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentOidcSchema.json +45 -0
- betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentServiceConfigSchema.json +68 -0
- betterportal-11.0.7/betterportal/_contracts/PublicJwksSchema.json +56 -0
- betterportal-11.0.7/betterportal/_contracts/RegistryPackageListSchema.json +1804 -0
- betterportal-11.0.7/betterportal/_contracts/RegistryPublishResultSchema.json +38 -0
- betterportal-11.0.7/betterportal/_contracts/RegistryReferenceSchema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/RenderModeSchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/RendererDeclarationSchema.json +43 -0
- betterportal-11.0.7/betterportal/_contracts/ResolvedBPRouteReferenceSchema.json +31 -0
- betterportal-11.0.7/betterportal/_contracts/ResolvedRouteSchema.json +50 -0
- betterportal-11.0.7/betterportal/_contracts/RouteDeclarationSchema.json +34 -0
- betterportal-11.0.7/betterportal/_contracts/RouteOptionsSchema.json +131 -0
- betterportal-11.0.7/betterportal/_contracts/RsaPublicJwkSchema.json +44 -0
- betterportal-11.0.7/betterportal/_contracts/ScopedAppSchema.json +5673 -0
- betterportal-11.0.7/betterportal/_contracts/ScopedServiceConfigSchema.json +12344 -0
- betterportal-11.0.7/betterportal/_contracts/ScopedTenantSchema.json +378 -0
- betterportal-11.0.7/betterportal/_contracts/ScopedTenantServiceSchema.json +288 -0
- betterportal-11.0.7/betterportal/_contracts/SemverSchema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceCatalogEntrySchema.json +672 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigActionSchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigManagementModeSchema.json +14 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigReadResponseSchema.json +71 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigSchemaResponseSchema.json +362 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigStateSchema.json +67 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigTicketClaimsSchema.json +92 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigWriteRequestSchema.json +75 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceConfigWriteResponseSchema.json +76 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceHostnameChangeRequestSchema.json +24 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceHostnameChangeResponseSchema.json +24 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceInstallRequestSchema.json +28 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceInstallResponseSchema.json +45 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceInstallationBindingSchema.json +62 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceManifestCacheEntrySchema.json +1428 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceRedeemResponseSchema.json +33 -0
- betterportal-11.0.7/betterportal/_contracts/ServiceTokenClaimsSchema.json +71 -0
- betterportal-11.0.7/betterportal/_contracts/SetupTokenClaimsSchema.json +82 -0
- betterportal-11.0.7/betterportal/_contracts/SharedServiceDefinitionSchema.json +216 -0
- betterportal-11.0.7/betterportal/_contracts/ShellFragmentDescriptorSchema.json +45 -0
- betterportal-11.0.7/betterportal/_contracts/ShellManifestSchema.json +68 -0
- betterportal-11.0.7/betterportal/_contracts/SigningKeyPairSchema.json +34 -0
- betterportal-11.0.7/betterportal/_contracts/StreamEndFrameSchema.json +26 -0
- betterportal-11.0.7/betterportal/_contracts/StreamErrorFrameSchema.json +61 -0
- betterportal-11.0.7/betterportal/_contracts/StreamShellContextSchema.json +67 -0
- betterportal-11.0.7/betterportal/_contracts/TenantAppValidationSchema.json +37 -0
- betterportal-11.0.7/betterportal/_contracts/TenantSchema.json +59 -0
- betterportal-11.0.7/betterportal/_contracts/TenantServiceRegistrationSchema.json +188 -0
- betterportal-11.0.7/betterportal/_contracts/TenantSharedServiceActivationSchema.json +46 -0
- betterportal-11.0.7/betterportal/_contracts/TokenLifetimeConfigSchema.json +26 -0
- betterportal-11.0.7/betterportal/_contracts/TokenTypeSchema.json +16 -0
- betterportal-11.0.7/betterportal/_contracts/UrlScalarSchema.json +26 -0
- betterportal-11.0.7/betterportal/_contracts/UuidV7Schema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/ViewAppContextSchema.json +198 -0
- betterportal-11.0.7/betterportal/_contracts/ViewDemoScenarioMatchSchema.json +84 -0
- betterportal-11.0.7/betterportal/_contracts/ViewDemoScenarioSchema.json +116 -0
- betterportal-11.0.7/betterportal/_contracts/ViewMetadataSchema.json +800 -0
- betterportal-11.0.7/betterportal/_contracts/ViewOperationMetadataSchema.json +750 -0
- betterportal-11.0.7/betterportal/_contracts/ViewPermissionDefinitionSchema.json +38 -0
- betterportal-11.0.7/betterportal/_contracts/ViewRenderDataSchema.json +376 -0
- betterportal-11.0.7/betterportal/_contracts/ViewRenderErrorSchema.json +24 -0
- betterportal-11.0.7/betterportal/_contracts/ViewRendererSupportSchema.json +83 -0
- betterportal-11.0.7/betterportal/_contracts/ViewRendererVariantSchema.json +42 -0
- betterportal-11.0.7/betterportal/_contracts/ViewRoleSchema.json +10 -0
- betterportal-11.0.7/betterportal/_contracts/ViewStreamingSupportSchema.json +64 -0
- betterportal-11.0.7/betterportal/_contracts/ViewTenantContextSchema.json +110 -0
- betterportal-11.0.7/betterportal/_contracts/WebhookEventDescriptorSchema.json +70 -0
- betterportal-11.0.7/betterportal/_contracts/WebhookTargetSchema.json +68 -0
- betterportal-11.0.7/betterportal/access.py +71 -0
- betterportal-11.0.7/betterportal/ai.py +58 -0
- betterportal-11.0.7/betterportal/asgi.py +525 -0
- betterportal-11.0.7/betterportal/auth_helpers.py +102 -0
- betterportal-11.0.7/betterportal/authorization.py +136 -0
- betterportal-11.0.7/betterportal/bootstrap.py +132 -0
- betterportal-11.0.7/betterportal/bp_headers.py +38 -0
- betterportal-11.0.7/betterportal/cli.py +97 -0
- betterportal-11.0.7/betterportal/client_streams.py +58 -0
- betterportal-11.0.7/betterportal/clientgen.py +94 -0
- betterportal-11.0.7/betterportal/clients.py +422 -0
- betterportal-11.0.7/betterportal/config_api.py +87 -0
- betterportal-11.0.7/betterportal/context.py +172 -0
- betterportal-11.0.7/betterportal/contract_export.py +31 -0
- betterportal-11.0.7/betterportal/contracts.py +76 -0
- betterportal-11.0.7/betterportal/cors.py +49 -0
- betterportal-11.0.7/betterportal/discovery.py +144 -0
- betterportal-11.0.7/betterportal/elevation.py +32 -0
- betterportal-11.0.7/betterportal/encryption.py +154 -0
- betterportal-11.0.7/betterportal/feeds.py +37 -0
- betterportal-11.0.7/betterportal/finite.py +90 -0
- betterportal-11.0.7/betterportal/generated_types.py +5410 -0
- betterportal-11.0.7/betterportal/handler.py +208 -0
- betterportal-11.0.7/betterportal/hosting.py +95 -0
- betterportal-11.0.7/betterportal/installation.py +252 -0
- betterportal-11.0.7/betterportal/jsoncodec.py +27 -0
- betterportal-11.0.7/betterportal/keys.py +142 -0
- betterportal-11.0.7/betterportal/media.py +93 -0
- betterportal-11.0.7/betterportal/observability.py +286 -0
- betterportal-11.0.7/betterportal/project.py +232 -0
- betterportal-11.0.7/betterportal/py.typed +0 -0
- betterportal-11.0.7/betterportal/registry.py +231 -0
- betterportal-11.0.7/betterportal/registry_client.py +71 -0
- betterportal-11.0.7/betterportal/rendering.py +116 -0
- betterportal-11.0.7/betterportal/response.py +80 -0
- betterportal-11.0.7/betterportal/scaffold.py +126 -0
- betterportal-11.0.7/betterportal/security.py +266 -0
- betterportal-11.0.7/betterportal/seo.py +65 -0
- betterportal-11.0.7/betterportal/service.py +408 -0
- betterportal-11.0.7/betterportal/settings.py +294 -0
- betterportal-11.0.7/betterportal/sse.py +204 -0
- betterportal-11.0.7/betterportal/storage.py +66 -0
- betterportal-11.0.7/betterportal/streaming.py +113 -0
- betterportal-11.0.7/betterportal/sync.py +214 -0
- betterportal-11.0.7/betterportal/themes.py +179 -0
- betterportal-11.0.7/betterportal/typegen.py +171 -0
- betterportal-11.0.7/betterportal/urls.py +296 -0
- betterportal-11.0.7/betterportal/webhooks.py +86 -0
- betterportal-11.0.7/betterportal.egg-info/PKG-INFO +1141 -0
- betterportal-11.0.7/betterportal.egg-info/SOURCES.txt +59 -0
- betterportal-11.0.7/betterportal.egg-info/dependency_links.txt +1 -0
- betterportal-11.0.7/betterportal.egg-info/entry_points.txt +2 -0
- betterportal-11.0.7/betterportal.egg-info/requires.txt +9 -0
- betterportal-11.0.7/betterportal.egg-info/top_level.txt +1 -0
- betterportal-11.0.7/pyproject.toml +30 -0
- betterportal-11.0.7/setup.cfg +4 -0
- betterportal-11.0.7/setup.py +28 -0
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: betterportal
|
|
3
|
+
Version: 11.0.7
|
|
4
|
+
Summary: BetterPortal native runtime and security policy
|
|
5
|
+
License-Expression: AGPL-3.0-only
|
|
6
|
+
Project-URL: Homepage, https://github.com/BetterCorp/BetterPortal
|
|
7
|
+
Project-URL: Repository, https://github.com/BetterCorp/BetterPortal
|
|
8
|
+
Project-URL: Issues, https://github.com/BetterCorp/BetterPortal/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/BetterCorp/BetterPortal/tree/master/framework/python
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: anyvali==1.1.7
|
|
13
|
+
Requires-Dist: PyJWT[crypto]<3,>=2.15.1
|
|
14
|
+
Requires-Dist: cryptography<51,>=50.0.2
|
|
15
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
16
|
+
Requires-Dist: typing_extensions<5,>=4.4
|
|
17
|
+
Provides-Extra: asgi
|
|
18
|
+
Requires-Dist: starlette<2,>=1.7.0; extra == "asgi"
|
|
19
|
+
Requires-Dist: python-multipart<0.1,>=0.0.32; extra == "asgi"
|
|
20
|
+
|
|
21
|
+
# BetterPortal Python port
|
|
22
|
+
|
|
23
|
+
Python 3.10+ native runtime with Starlette/ASGI hosting for JSON, HTML, raw
|
|
24
|
+
responses, finite streams and subscriber feeds. Production acceptance evidence and
|
|
25
|
+
release gates are tracked in [PRODUCTION_READINESS.md](PRODUCTION_READINESS.md).
|
|
26
|
+
|
|
27
|
+
Start a standalone service with the [native init commands](../../docs/building/route-authoring.md#create-a-standalone-service).
|
|
28
|
+
|
|
29
|
+
Install `betterportal[asgi]` and an ASGI server such as Uvicorn. `create_app(service)`
|
|
30
|
+
in `betterportal.asgi` owns the `Service` lifespan. A service combines a registry,
|
|
31
|
+
manifest declaration and optional validated `ScopedConfig`. With no snapshot its
|
|
32
|
+
public health endpoint returns only `{"ok": false}` with status 503; a local
|
|
33
|
+
snapshot enables request handling. `ControlPlaneSync` supplies automatic managed
|
|
34
|
+
synchronization, described below. Health diagnostics require a verified access-token user in the configured
|
|
35
|
+
management tenant/app; ordinary and invalid credentials receive only public readiness.
|
|
36
|
+
Health responses use `Cache-Control: private, no-store` and `Vary: Authorization`.
|
|
37
|
+
|
|
38
|
+
The host resolves scope, checks local operation mounts and CORS, and verifies user
|
|
39
|
+
or delegated/service credentials before invoking handlers. It preserves repeated
|
|
40
|
+
query/form values and field-name case. Malformed percent escapes and invalid UTF-8
|
|
41
|
+
in query strings or URL-encoded forms return 400 before handler invocation.
|
|
42
|
+
`RequestContext.multipart` contains the
|
|
43
|
+
canonical parsed form fields and uploads (file data is an array of byte values).
|
|
44
|
+
Body buffering defaults to 1 MiB, configurable through `max_body_bytes`; forms
|
|
45
|
+
permit at most 1,000 fields and 100 files, with 1 MiB per text field. File resources
|
|
46
|
+
close after parsing. Disconnects cancel handler waits.
|
|
47
|
+
|
|
48
|
+
JSON and BP metadata negotiation are supported. Metadata requires the operation's
|
|
49
|
+
authorization and does not execute its handler. Health, manifest and schema JSON
|
|
50
|
+
discovery are public. Finite handlers also support NDJSON and SSE, described below. Configure trusted
|
|
51
|
+
proxies in the ASGI server; raw forwarding and HTMX context headers confer no
|
|
52
|
+
authority. Renderer selection is bound to the resolved app's shell renderer.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import asyncio
|
|
56
|
+
from betterportal.asgi import create_app
|
|
57
|
+
from betterportal.registry import Registry
|
|
58
|
+
from betterportal.service import Service
|
|
59
|
+
|
|
60
|
+
async def check():
|
|
61
|
+
async with Service(Registry([]), {"pluginId": "com.example.hello", "title": "Hello",
|
|
62
|
+
"description": "Example service", "version": "1.0.0"}) as service:
|
|
63
|
+
app = create_app(service)
|
|
64
|
+
assert not service.ready # Supply a validated local snapshot to enable operations.
|
|
65
|
+
|
|
66
|
+
asyncio.run(check())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`Handler[Params, Query, Headers, Body, Result]` accepts native AnyVali schemas and
|
|
70
|
+
a sync or async function receiving `HandlerContext`. `invoke` parses all four
|
|
71
|
+
input fields before calling the function and validates its response. A host supplies
|
|
72
|
+
`RequestContext` with the resolved scope, verified caller, method/path and config;
|
|
73
|
+
this low-level helper does not authorize calls. `HandlerInputError` identifies the
|
|
74
|
+
invalid field (400), while `HandlerOutputError` reports invalid output (500).
|
|
75
|
+
Absent input containers become `{}`; explicit null remains present. The
|
|
76
|
+
`input_document` property exports the four parsed handler input fields for native
|
|
77
|
+
type generation. Full handler/render context helpers remain delivery work.
|
|
78
|
+
|
|
79
|
+
`Operation`, `Route` and `Registry` register JSON/raw/finite handlers and derive canonical
|
|
80
|
+
manifests and discovery schemas from their AnyVali schemas. Every operation needs
|
|
81
|
+
an explicit `auth` declaration and a unique stable ID. Methods share the view's
|
|
82
|
+
params schema; their query, headers, body, response and policy remain separate.
|
|
83
|
+
Dependency aliases resolve to plugin IDs, and local dependencies must exist with
|
|
84
|
+
the declared method. Path variants belong to one view and publish API contracts
|
|
85
|
+
once. See [native route discovery](../../docs/building/route-authoring.md) for directory-based registration.
|
|
86
|
+
|
|
87
|
+
`Renderer[Result]` accepts a sync/async function returning an HTML string. Register
|
|
88
|
+
it on the typed handler; the adapter selects the exact method, app renderer,
|
|
89
|
+
kind/key and response status. Page is the default kind; fragments require a
|
|
90
|
+
`location.id` key and components require an ID. `_f` overrides an Accept fragment
|
|
91
|
+
parameter; `_c` selects a component. Ambiguous selectors fail. Render modes are
|
|
92
|
+
page, fragment and embed. Metadata and manifests derive from these registrations.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from html import escape
|
|
96
|
+
from typing import Any
|
|
97
|
+
from betterportal.contracts import contract
|
|
98
|
+
from betterportal.generated_types import TokenLifetimeConfig
|
|
99
|
+
from betterportal.handler import Handler, HandlerContext
|
|
100
|
+
from betterportal.rendering import Renderer, RenderContext
|
|
101
|
+
from betterportal.registry import Operation
|
|
102
|
+
|
|
103
|
+
def handle(context: HandlerContext[Any, Any, Any, Any]) -> TokenLifetimeConfig:
|
|
104
|
+
context.response.status = 201
|
|
105
|
+
context.response.set_header("HX-Trigger", "bp:created")
|
|
106
|
+
return {"accessTokenSeconds": 900, "refreshTokenSeconds": 604800}
|
|
107
|
+
|
|
108
|
+
def render(value: TokenLifetimeConfig, context: RenderContext) -> str:
|
|
109
|
+
return f'<p>{escape(context.tenant["title"])}: {value["accessTokenSeconds"]}</p>'
|
|
110
|
+
|
|
111
|
+
renderer = Renderer[TokenLifetimeConfig]({"renderer": "bootstrap5", "status": 201}, render)
|
|
112
|
+
handler = Handler[Any, Any, Any, Any, TokenLifetimeConfig](
|
|
113
|
+
contract("TokenLifetimeConfigSchema"), handle, renderers=[renderer])
|
|
114
|
+
operation = Operation(handler, {"operationId": "lifetimes.get", "method": "GET",
|
|
115
|
+
"title": "Lifetimes", "description": "Render token lifetimes", "auth": {}})
|
|
116
|
+
assert operation.handler.renderers[0].identity == ("bootstrap5", "page", None, 201)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`RenderContext` exposes canonical presentation fields for tenant/app, parsed
|
|
120
|
+
params/query and route selection. Secrets, roles, auth settings and headers are
|
|
121
|
+
excluded. Escape dynamic HTML with `html.escape`; no template engine is required.
|
|
122
|
+
`context.response` controls status and application headers (`append=True` preserves
|
|
123
|
+
multiple cookies). The adapter owns Content-Type/CORS/transport headers. A changed
|
|
124
|
+
HTML status needs an exact status renderer; otherwise the response is empty.
|
|
125
|
+
204/205/304 always have no body.
|
|
126
|
+
|
|
127
|
+
`Operation(..., error_renderers=[Renderer[ViewRenderError](...)])` registers
|
|
128
|
+
callbacks for errors, separately from successful handler results. Their canonical
|
|
129
|
+
data contains only `error` and `status`; declarations require status 400–599.
|
|
130
|
+
The selected fragment/component is preserved on errors. Callback failures return
|
|
131
|
+
a generic 500. Disconnects cancel async callbacks. Pass shared error renderers as `Service(..., status_renderers=[...])`; exact
|
|
132
|
+
operation-local error renderers take precedence. Global renderers also cover
|
|
133
|
+
unmatched routes after resolving a valid app scope.
|
|
134
|
+
|
|
135
|
+
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`.
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from betterportal.generated_types import RouteOptionsInput, ResolvedRoute
|
|
145
|
+
|
|
146
|
+
request: RouteOptionsInput = {"path": "/items/:id", "params": {"id": 42}, "query": {"page": 2}}
|
|
147
|
+
# In either callback: destination = context.route(request)
|
|
148
|
+
# API requests use destination["absoluteApiPath"].
|
|
149
|
+
# HTML requests use destination["absoluteViewPath"].
|
|
150
|
+
# Service views use str(destination) or destination["viewPath"]; emails use destination["absoluteUiPath"].
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
`RawHandler[Params, Query, Headers, Body]` shares input validation and host
|
|
156
|
+
authorization with JSON handlers. It must return `RawResponse`; JSON handlers
|
|
157
|
+
reject and close raw results. Raw operations publish `raw: true` and bypass
|
|
158
|
+
representation negotiation, including a metadata Accept header. Their metadata
|
|
159
|
+
remains available through discovery. GET registrations also serve HEAD using the
|
|
160
|
+
same policy; a raw HEAD closes the response without pulling its stream.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from typing import Any
|
|
164
|
+
from betterportal.response import RawHandler, RawResponse
|
|
165
|
+
from betterportal.registry import Operation
|
|
166
|
+
|
|
167
|
+
download = RawHandler[Any, Any, Any, Any](
|
|
168
|
+
lambda context: RawResponse.file(b"Hello BP\n", "report.txt", content_type="text/plain"))
|
|
169
|
+
operation = Operation(download, {"operationId": "report.get", "method": "GET",
|
|
170
|
+
"title": "Report", "description": "Download a report", "auth": {"required": True}})
|
|
171
|
+
assert operation.handler.is_raw
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A raw body is bytes or an async iterator yielding bytes. The ASGI host awaits
|
|
175
|
+
each send before requesting the next chunk and calls `aclose()` when available
|
|
176
|
+
on completion, disconnect or failure. Stream failures after headers terminate the
|
|
177
|
+
response. Supply file content, not a filesystem path, to `RawResponse.file`;
|
|
178
|
+
it builds safe ASCII/UTF-8 download headers. Header pairs retain repeated cookies
|
|
179
|
+
and list fields such as `Vary`; duplicate standard singleton fields such as
|
|
180
|
+
`Content-Type` and `Content-Disposition` are rejected regardless of case.
|
|
181
|
+
The host owns CORS and transport headers, computes byte-body length, and rejects
|
|
182
|
+
header injection. Status 204/205/304 forbids a body; 206 and redirects may carry one.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from betterportal.contracts import contract
|
|
186
|
+
from betterportal.handler import Handler
|
|
187
|
+
from betterportal.registry import Operation, Route, Registry
|
|
188
|
+
|
|
189
|
+
operation = Operation(Handler(contract("JsonObjectSchema"), lambda context: {"hello": "world"}), {
|
|
190
|
+
"operationId": "hello.get", "method": "GET", "title": "Hello",
|
|
191
|
+
"description": "Return a greeting", "auth": {},
|
|
192
|
+
})
|
|
193
|
+
registry = Registry([Route("hello.index", "/hello", [operation])])
|
|
194
|
+
manifest = registry.manifest({"pluginId": "com.example.hello", "title": "Hello",
|
|
195
|
+
"description": "Example service", "version": "1.0.0"})
|
|
196
|
+
assert manifest["views"][0]["operations"][0]["operationId"] == "hello.get"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`betterportal.context.ScopedConfig` imports a canonical scoped snapshot and checks
|
|
200
|
+
tenant/app references, duplicate identities and ambiguous hostnames. Browser
|
|
201
|
+
resolution distinguishes scheme/host/port and copies each request's tenant/app
|
|
202
|
+
data. `by_id` supports looking up a claimed machine scope before authentication;
|
|
203
|
+
lookup alone never authorizes a request. Configuration-management app indexes
|
|
204
|
+
are separate from runtime app lookup.
|
|
205
|
+
|
|
206
|
+
`AppAccess(scope, snapshot.local_service_ids)` in `betterportal.access` checks
|
|
207
|
+
the registered operation against enabled inbound app mounts. Pass the matched
|
|
208
|
+
registered path to `allows` for a view with path variants. GET fragment selectors
|
|
209
|
+
and slot mounts are supported; `appRoutes`/`appFragments` are catalogs, not inbound
|
|
210
|
+
allowlists. `permission_aliases()` restricts role aliases to enabled local service
|
|
211
|
+
instances referenced by this app; pass the route and method to restrict aliases
|
|
212
|
+
to its mounted operation. `Service` always applies this restriction. Hosts must still run caller authorization and
|
|
213
|
+
representation selection. Well-known routes use their own declared auth policy.
|
|
214
|
+
|
|
215
|
+
Raw proxy and HTMX context headers are ignored. `resolve(..., trusted_addresses=...)`
|
|
216
|
+
accepts only addresses already verified by host proxy middleware; the host also
|
|
217
|
+
supplies the effective scheme. `OriginPolicy` normalizes configured HTTP origins
|
|
218
|
+
and preserves exact path/query restrictions on referer overrides. Atomic snapshot persistence is described below. `betterportal.hosting.TrustedProxyMiddleware`
|
|
219
|
+
accepts only configured peer IPs/CIDRs and a single forwarded host/protocol pair;
|
|
220
|
+
it strips untrusted forwarding headers. Disable Uvicorn proxy-header handling so
|
|
221
|
+
the middleware sees the actual socket peer.
|
|
222
|
+
|
|
223
|
+
AnyVali 1.1.2 treats explicit null as present and applies defaults only to omitted
|
|
224
|
+
input. Snapshot validation therefore rejects `active: null`; BP still uses AnyVali
|
|
225
|
+
as its only validator.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from betterportal.context import ScopedConfig, http_origin
|
|
229
|
+
|
|
230
|
+
assert http_origin("HTTPS://Example.com:443") == "https://example.com"
|
|
231
|
+
snapshot = ScopedConfig({"managementOrigins": [], "tenants": [], "apps": []})
|
|
232
|
+
assert snapshot.resolve({"host": "unknown.example"}) is None
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`betterportal.cors.Cors` produces response/preflight headers from a trusted
|
|
236
|
+
`OriginPolicy` and the route's allowed methods. Hosts call `preflight` before
|
|
237
|
+
authentication/dispatch, returning empty 204 on success or 403 for `CorsDenied`.
|
|
238
|
+
Allowed origins can request custom operation headers; their values still pass
|
|
239
|
+
through operation schemas. The policy exposes BP/HTMX response headers, uses a
|
|
240
|
+
600-second preflight cache and includes Vary fields. Hosts merge Vary with their
|
|
241
|
+
existing Accept/cache policy. No credential-cookie CORS flag is emitted.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from betterportal.context import OriginPolicy
|
|
245
|
+
from betterportal.cors import Cors
|
|
246
|
+
|
|
247
|
+
policy = OriginPolicy(frozenset(["https://app.example"]), frozenset(["https://app.example"]))
|
|
248
|
+
headers = Cors(policy, ["GET"]).preflight("https://app.example", "GET", "Authorization, X-App-Filter")
|
|
249
|
+
assert headers["access-control-allow-origin"] == "https://app.example"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`betterportal.sse.SseRoute` validates publication input, maps it with subscriber
|
|
253
|
+
context, and validates the resulting event. `EventScope` comes from the trusted
|
|
254
|
+
request context. `EventTransport` is replaceable; the supplied `LocalEvents`
|
|
255
|
+
serves one event loop with no history or cross-replica delivery. It separates
|
|
256
|
+
view/tenant/app addresses, bounds each queue to 256 pending events, and closes
|
|
257
|
+
overflowing subscribers. Payloads default to 1 MiB; all limits are configurable.
|
|
258
|
+
Subscriptions are async context managers. The application closes its transport
|
|
259
|
+
at shutdown, releasing idle subscribers; request cancellation stops pending reads
|
|
260
|
+
and mapper I/O. Publication bytes prevent mutable values leaking across subscribers.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
import asyncio
|
|
264
|
+
import anyvali as av
|
|
265
|
+
from betterportal.sse import LocalEvents, SseRoute, EventScope
|
|
266
|
+
|
|
267
|
+
async def example():
|
|
268
|
+
transport = LocalEvents()
|
|
269
|
+
try:
|
|
270
|
+
route = SseRoute("clock.index", av.string(), av.string(), lambda value, context: value, transport=transport)
|
|
271
|
+
scope = EventScope("tenant-from-context", "app-from-context")
|
|
272
|
+
async with route.subscribe(scope, None) as events:
|
|
273
|
+
await route.publish(scope, "tick")
|
|
274
|
+
assert await events.__anext__() == "tick"
|
|
275
|
+
finally:
|
|
276
|
+
await transport.aclose()
|
|
277
|
+
|
|
278
|
+
asyncio.run(example())
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`route.wire(scope, context, render=...)` owns the subscription and yields UTF-8 SSE
|
|
282
|
+
messages. The optional async or sync renderer returns HTML; failures emit a generic
|
|
283
|
+
`error` event and later events continue. Without a renderer, strings are sent as
|
|
284
|
+
text and other values as JSON. Close the generator with `contextlib.aclosing`
|
|
285
|
+
when stopping early. `SseFeed` supplies operation authorization and renderer selection
|
|
286
|
+
through the host, as shown below.
|
|
287
|
+
`encode_event` also supports bounded event names/IDs, retry, empty data and multiline
|
|
288
|
+
text per [WHATWG SSE](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream).
|
|
289
|
+
The default data bound is 1 MiB before SSE line prefixes; names/IDs are limited to
|
|
290
|
+
1 KiB. Hosts set `Content-Type: text/event-stream`, disable response caching and
|
|
291
|
+
flush each message; the codec does not schedule heartbeats or keep replay history.
|
|
292
|
+
|
|
293
|
+
`SseFeed` binds that contract to an existing GET handler. Register it as `Route(...,
|
|
294
|
+
sse=feed)` to expose `<path>/__sse`, including optional path variants. The connection
|
|
295
|
+
inherits GET input schemas, authentication, permissions and tenant/app mounts without
|
|
296
|
+
executing the GET function. Publish with `await feed.publish(trusted_scope, value)`.
|
|
297
|
+
The transport belongs to the application; disconnects and service shutdown release
|
|
298
|
+
request subscriptions without closing a shared transport.
|
|
299
|
+
|
|
300
|
+
Tick renderers must match a success fragment on the owning GET handler. `_f=nav.clock`
|
|
301
|
+
selects that tick and the resolved app's shell selects the theme. Missing ticks return
|
|
302
|
+
406; a fragment-only mount cannot expose raw events through a missing tick or an
|
|
303
|
+
Accept header. Components are unavailable on feeds. Without `_f`, strings are text
|
|
304
|
+
and other values are JSON. Mapping/validation failures close the connection; renderer
|
|
305
|
+
failures emit a safe error and allow subsequent events. HEAD opens no subscription.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
import asyncio
|
|
309
|
+
from html import escape
|
|
310
|
+
from typing import Any
|
|
311
|
+
import anyvali as av
|
|
312
|
+
from betterportal.feeds import SseFeed
|
|
313
|
+
from betterportal.handler import Handler, HandlerContext
|
|
314
|
+
from betterportal.registry import Operation, Route, Registry
|
|
315
|
+
from betterportal.rendering import Renderer
|
|
316
|
+
from betterportal.sse import LocalEvents, SseRoute
|
|
317
|
+
|
|
318
|
+
async def example():
|
|
319
|
+
transport = LocalEvents()
|
|
320
|
+
try:
|
|
321
|
+
fragment = Renderer[str]({"renderer": "bootstrap5", "kind": "fragment", "key": "nav.clock"},
|
|
322
|
+
lambda value, context: f"<span>{escape(value)}</span>")
|
|
323
|
+
handler = Handler[Any, Any, Any, Any, str](av.string(), lambda context: "Waiting", renderers=[fragment])
|
|
324
|
+
contract = SseRoute[str, str, HandlerContext[Any, Any, Any, Any]]("clock.index", av.string(), av.string(),
|
|
325
|
+
lambda value, context: value, transport=transport)
|
|
326
|
+
feed = SseFeed(handler, contract, renderers=[fragment])
|
|
327
|
+
registry = Registry([Route("clock.index", "/clock", [Operation(handler, {
|
|
328
|
+
"operationId": "clock.get", "method": "GET", "title": "Clock", "description": "Live clock", "auth": {}
|
|
329
|
+
})], sse=feed)])
|
|
330
|
+
assert registry.routes[0].sse is feed
|
|
331
|
+
finally:
|
|
332
|
+
await transport.aclose()
|
|
333
|
+
|
|
334
|
+
asyncio.run(example())
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The existing BP browser assets consume `/clock/__sse?_f=nav.clock` with
|
|
338
|
+
`hx-sse:connect`. No template engine, replay history or external broker is added.
|
|
339
|
+
|
|
340
|
+
`betterportal.streaming.StreamHandler` validates each item and optional `Summary`
|
|
341
|
+
before delivery. Its response schema is derived from the item/summary AnyVali
|
|
342
|
+
documents, preserving recursive definitions. `frames`, `ndjson` and `sse` pull only as
|
|
343
|
+
the consumer advances; `buffered` defaults to 10,000 items and 8 MiB, and each
|
|
344
|
+
frame defaults to 1 MiB. Limits are configurable. Cancellation propagates and
|
|
345
|
+
closes the producer; buffered cancellation never returns partial success. Close
|
|
346
|
+
iterators with `contextlib.aclosing` when leaving early. Producer I/O must be
|
|
347
|
+
cancellation-aware. Errors expose generic messages without producer secrets.
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
import asyncio
|
|
351
|
+
from betterportal.contracts import contract
|
|
352
|
+
from betterportal.streaming import StreamHandler, Summary
|
|
353
|
+
|
|
354
|
+
async def produce(context):
|
|
355
|
+
yield {"result": [None, 1]}
|
|
356
|
+
yield Summary({"total": 1})
|
|
357
|
+
|
|
358
|
+
handler = StreamHandler(contract("JsonValueSchema"), produce, contract("JsonValueSchema"))
|
|
359
|
+
assert asyncio.run(handler.buffered(None))["summary"] == {"total": 1}
|
|
360
|
+
|
|
361
|
+
async def wire():
|
|
362
|
+
return [message async for message in handler.sse(None)]
|
|
363
|
+
messages = asyncio.run(wire())
|
|
364
|
+
assert messages[-1] == b'event: end\ndata: {"kind":"end","count":1}\n\n'
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`FiniteHandler` registers the same producer as a GET `Operation`. JSON and
|
|
368
|
+
page/fragment/component renderers consume its bounded buffered result. NDJSON
|
|
369
|
+
streams validated JSON frames; a GET operation also owns `/<path>/__sse`, with the
|
|
370
|
+
same tenant/app, origin, operation and authentication checks. Stream connections
|
|
371
|
+
reject fragment/component selectors. Metadata and streamed HEAD requests never
|
|
372
|
+
start the producer. Hosts pull one chunk at a time and close it on disconnect.
|
|
373
|
+
|
|
374
|
+
`StreamRenderers` selects the exact app shell renderer. Its shell receives the
|
|
375
|
+
canonical `StreamShellContext` and returns HTML with a connection URL; it never
|
|
376
|
+
runs the producer. SSE item/summary/error callbacks receive validated values and
|
|
377
|
+
the safe `RenderContext`. Callbacks return HTML strings and URL tokens are
|
|
378
|
+
rewritten through the existing helpers. A matching page renderer buffers full
|
|
379
|
+
page requests; fragment mode selects the stream shell. Without a matching stream
|
|
380
|
+
renderer SSE carries JSON frames. Each consuming request creates its own stream.
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
from html import escape
|
|
384
|
+
from betterportal.contracts import contract
|
|
385
|
+
from betterportal.finite import FiniteHandler, StreamRenderers
|
|
386
|
+
from betterportal.registry import Operation, Route, Registry
|
|
387
|
+
from betterportal.streaming import Summary
|
|
388
|
+
|
|
389
|
+
async def rows(context):
|
|
390
|
+
yield "First row"
|
|
391
|
+
yield Summary(1)
|
|
392
|
+
|
|
393
|
+
handler = FiniteHandler(contract("JsonValueSchema"), rows,
|
|
394
|
+
summary=contract("JsonValueSchema"), stream_renderers=[StreamRenderers("bootstrap5",
|
|
395
|
+
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>',
|
|
396
|
+
item=lambda value, context: "<p>" + escape(value) + "</p>")])
|
|
397
|
+
registry = Registry([Route("rows", "/rows", [Operation(handler, {
|
|
398
|
+
"operationId": "rows.get", "method": "GET", "title": "Rows", "description": "Stream rows", "auth": {}})])])
|
|
399
|
+
assert registry.routes[0].operations[0].handler is handler
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Mount this registry with `Service` and `create_app` as above. The consuming shell
|
|
403
|
+
loads BP's existing HTMX/SSE browser assets; the runtime does not add a template
|
|
404
|
+
engine. Native shell fragment helpers are described below.
|
|
405
|
+
|
|
406
|
+
`betterportal.media.negotiate` selects JSON, HTML (page/fragment/embed), metadata,
|
|
407
|
+
or NDJSON from Accept and the operation's available representations. It honors
|
|
408
|
+
specific exclusions, q=0 and request-order ties, and raises `NotAcceptable` (406)
|
|
409
|
+
when no representation is acceptable. Theme parameters never select a renderer.
|
|
410
|
+
The host must supply availability after resolving the exact app shell renderer.
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
from betterportal.media import negotiate
|
|
414
|
+
|
|
415
|
+
assert negotiate("text/html;mode=fragment").mode == "fragment"
|
|
416
|
+
assert negotiate("application/x-ndjson,application/json;q=0.5", ["json"]).kind == "json"
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Implemented: embedded canonical contracts validated with AnyVali 1.1.6, RS256 keys and token
|
|
420
|
+
purposes, tenant/app-bound refresh pairs, config-ticket scope/action checks, and
|
|
421
|
+
service authorization against current scoped bindings and grants, static JWKS
|
|
422
|
+
imports and a cancellable remote JWKS cache. Cryptography
|
|
423
|
+
uses PyJWT/OpenSSL; validation uses AnyVali exclusively.
|
|
424
|
+
|
|
425
|
+
`betterportal.authorization.authorize_request` accepts a trusted `AuthContext`
|
|
426
|
+
from one scoped snapshot and enforces user/service/delegated caller policy.
|
|
427
|
+
User role IDs expand through current app grants, with trusted service aliases.
|
|
428
|
+
Root elevation requires the configured management tenant/app. Delegated requests
|
|
429
|
+
must satisfy both user permissions and the current delegated service grant.
|
|
430
|
+
The lower-level `security.authorize_service` checks only the machine half.
|
|
431
|
+
Optional invalid user auth yields an anonymous result; malformed/revoked machine
|
|
432
|
+
envelopes fail closed. Tenant/app hints never establish request scope.
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
import asyncio
|
|
436
|
+
from betterportal.authorization import AuthContext, authorize_request
|
|
437
|
+
from betterportal.security import KeyPair, TokenIssuer, uuid7
|
|
438
|
+
|
|
439
|
+
async def example():
|
|
440
|
+
key, tenant, app = KeyPair.generate(), uuid7(), uuid7()
|
|
441
|
+
trusted_keys = {key.kid: key.public_key_pem}
|
|
442
|
+
pair = TokenIssuer(key, "https://auth.example", "app").issue_pair(
|
|
443
|
+
{"sub": "user-1", "tenantId": tenant, "appId": app, "roles": []}, include_refresh=False)
|
|
444
|
+
context = AuthContext(tenant, app, {"serviceId": uuid7(), "expectedIssuer": "https://auth.example",
|
|
445
|
+
"expectedAudience": "app", "jwksUri": "https://auth.example/jwks", "roles": []}, trusted_keys.__getitem__)
|
|
446
|
+
caller = await authorize_request({"authorization": "Bearer " + pair["accessToken"]}, {"required": True},
|
|
447
|
+
context, view_id="hello", method="GET")
|
|
448
|
+
assert caller.user is not None and caller.user["sub"] == "user-1"
|
|
449
|
+
asyncio.run(example())
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Configuration encryption supports existing BP v1 reads, v2 strings, v3 typed JSON,
|
|
453
|
+
and authenticated preview envelopes. `preview_schema` builds scoped field schemas
|
|
454
|
+
from canonical descriptors; `encrypt_preview`/`decrypt_preview` use AnyVali's native
|
|
455
|
+
sensitive traversal. Optional fields remain omitted; public/protected fields are
|
|
456
|
+
unchanged. Values have a 1 MiB UTF-8 byte limit, and malformed encodings fail closed.
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
from betterportal.encryption import ConfigCipher, generate_preview_key, encrypt_preview_value, decrypt_preview_value
|
|
460
|
+
|
|
461
|
+
cipher = ConfigCipher(ConfigCipher.generate_key())
|
|
462
|
+
assert cipher.decrypt(cipher.encrypt({"enabled": True, "value": None})) == {"enabled": True, "value": None}
|
|
463
|
+
key = generate_preview_key()
|
|
464
|
+
encrypted = encrypt_preview_value(key, "tenant", ["token"], "")
|
|
465
|
+
assert decrypt_preview_value(key, "tenant", ["token"], encrypted) == ""
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Persist generated keys with the service's protected bootstrap state. The cipher
|
|
469
|
+
holds only two derived keys per instance; it has no global cache of secrets.
|
|
470
|
+
`SettingsSchema` compiles config descriptors whose `jsonSchema` contains an
|
|
471
|
+
AnyVali object document. Descriptor fields must match its properties; field scopes
|
|
472
|
+
control writes. The native document owns defaults, coercion, required fields and
|
|
473
|
+
nested unknown-key behavior. A descriptor `defaultValue`, when present, must match
|
|
474
|
+
that native field default. Partial stored overrides never materialize defaults;
|
|
475
|
+
`effective(tenant, app)` applies tenant defaults followed by app overrides.
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
import anyvali as av
|
|
479
|
+
from betterportal.encryption import ConfigCipher
|
|
480
|
+
from betterportal.settings import SettingsSchema, ServiceSettings
|
|
481
|
+
import asyncio
|
|
482
|
+
|
|
483
|
+
policy = SettingsSchema([{"id": "settings", "title": "Settings", "description": "Settings", "scope": "tenant",
|
|
484
|
+
"jsonSchema": av.export_schema(av.object_({"secret": av.string()}, required=[])),
|
|
485
|
+
"fields": [{"key": "secret", "title": "Secret", "description": "Service credential", "scope": "tenant",
|
|
486
|
+
"visibility": "secret", "ownership": "bp", "sourceOfTruth": "bp"}]}])
|
|
487
|
+
cipher = ConfigCipher(ConfigCipher.generate_key())
|
|
488
|
+
stored = policy.encode("tenant", {"secret": "example"}, cipher)
|
|
489
|
+
assert stored["secret"].startswith("enc:aes256gcm2:")
|
|
490
|
+
assert policy.decode("tenant", stored, cipher) == {"secret": "example"}
|
|
491
|
+
assert policy.redact("tenant", {"secret": "example"}) == {"secret": "__redacted__"}
|
|
492
|
+
assert policy.merge("tenant", {"secret": "example"}, {"secret": "__redacted__"}) == {"secret": "example"}
|
|
493
|
+
assert policy.values("tenant", {}) == {}
|
|
494
|
+
|
|
495
|
+
async def main():
|
|
496
|
+
async with ServiceSettings(policy, cipher) as settings:
|
|
497
|
+
await settings.write("tenant", {"secret": "example"})
|
|
498
|
+
assert settings.values("tenant", redacted=True) == {"secret": "__redacted__"}
|
|
499
|
+
assert settings.effective("tenant", "app") == {"secret": "example"}
|
|
500
|
+
|
|
501
|
+
asyncio.run(main())
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Native AnyVali sensitive APIs perform encryption/decryption and redaction traversal.
|
|
505
|
+
Top-level sensitive fields preserve Node's `enc:` envelopes; nested sensitive fields
|
|
506
|
+
retain the native `encrypted:` prefix around a BP envelope. Ordinary strings are
|
|
507
|
+
never rewritten. `merge` validates field scopes, explicit clears and secret
|
|
508
|
+
placeholders, including nested arrays. A placeholder cannot create a missing secret
|
|
509
|
+
or move an existing secret into a public union branch. Real replacement values win
|
|
510
|
+
over `clear_keys`; a cleared secret cannot simultaneously use a preserve placeholder.
|
|
511
|
+
AnyVali 1.1.2 applies native transforms, redaction and encrypted-storage validation
|
|
512
|
+
to direct sensitive ref nodes at the ref's value path. BP's descriptor visibility
|
|
513
|
+
annotates native fields directly without changing their requiredness.
|
|
514
|
+
|
|
515
|
+
`ServiceSettings` serializes writes and owns encrypted tenant/app state. Supply a
|
|
516
|
+
`FileStateStore(path)` as its third argument for persistence; omission uses memory.
|
|
517
|
+
Call `initialize()` before reads/writes, or use its async context manager. Loading
|
|
518
|
+
validates the whole cache before publishing it. A failed save or cancellation before
|
|
519
|
+
commit preserves both disk and active values. Writes combine clears and replacements
|
|
520
|
+
in one commit. `values` returns stored overrides; `effective` adds native defaults.
|
|
521
|
+
Reads return owned copies. Closing cancels pending writes and rejects further access.
|
|
522
|
+
|
|
523
|
+
The portable cache reads Node's encrypted tenant/app files. Bare legacy buckets or
|
|
524
|
+
the `legacy` envelope require `initialize(legacy_tenant_id=...)`; migration persists
|
|
525
|
+
that explicit owner before becoming ready and rejects an existing owner bucket.
|
|
526
|
+
Unmarked plaintext secrets and tampered ciphertext are rejected. Keep the encryption
|
|
527
|
+
key in protected storage. One instance owns each file; shared replicas need a
|
|
528
|
+
configured repository/transport.
|
|
529
|
+
|
|
530
|
+
Pass `ConfigApi(settings, issuer=cp_url, jwks_uri=cp_jwks_uri)` to the service's
|
|
531
|
+
`config_api` argument. Its settings descriptors must match the manifest. The host
|
|
532
|
+
initializes settings before serving and owns their shutdown. For example, this
|
|
533
|
+
factory returns an unready managed service to connect with `ControlPlaneSync`:
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
from betterportal.config_api import ConfigApi
|
|
537
|
+
from betterportal.contracts import parse
|
|
538
|
+
from betterportal.encryption import ConfigCipher
|
|
539
|
+
from betterportal.registry import Registry
|
|
540
|
+
from betterportal.service import Service
|
|
541
|
+
from betterportal.settings import SettingsSchema, ServiceSettings
|
|
542
|
+
from betterportal.storage import FileStateStore
|
|
543
|
+
|
|
544
|
+
def configured_service(registry: Registry, declaration: dict, cp_url: str, jwks_uri: str, key: str, settings_path: str):
|
|
545
|
+
manifest = parse("ManifestDeclarationSchema", declaration)
|
|
546
|
+
settings = ServiceSettings(SettingsSchema(manifest["configSchemas"]), ConfigCipher(key), FileStateStore(settings_path))
|
|
547
|
+
return Service(registry, manifest, managed=True,
|
|
548
|
+
config_api=ConfigApi(settings, issuer=cp_url, jwks_uri=jwks_uri))
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
`GET /.well-known/bp/config/schema` is public. GET/POST on `/.well-known/bp/config`
|
|
552
|
+
require CP-signed `config.read`/`config.write` tickets for the manifest plugin ID.
|
|
553
|
+
The ticket supplies the tenant. GET's optional `X-BP-App-Id` selects stored app
|
|
554
|
+
overrides; POST uses the canonical tenant/app/values/clearKeys body. Active tenants
|
|
555
|
+
and `configApps` (or `apps` when absent) restrict access. Conflicting scope headers
|
|
556
|
+
are rejected. Responses redact secrets and disable caching. Preflights authorize
|
|
557
|
+
only management origins, before ticket verification. HEAD follows GET policy.
|
|
558
|
+
|
|
559
|
+
`mode`, `custom_ui_path` and `writable` describe API capabilities; ownership mode
|
|
560
|
+
does not grant access. Missing read/write support returns 501. Invalid field input
|
|
561
|
+
returns 400; storage failures return 500 and preserve previous values. Shutdown
|
|
562
|
+
cancels pending writes. A snapshot change during ticket verification rejects the
|
|
563
|
+
request; a committed write finishes before a queued snapshot replacement.
|
|
564
|
+
|
|
565
|
+
Handlers receive stored settings plus native defaults and preview overrides.
|
|
566
|
+
Preview values validate before snapshot publication and never overwrite the settings
|
|
567
|
+
file. Missing required effective values return 503 on operations while the config
|
|
568
|
+
API remains available to supply them. Public health reflects initialized state and
|
|
569
|
+
managed manifest acknowledgment. Protected key storage and `ServiceInstallation` HTTP provisioning are described below.
|
|
570
|
+
|
|
571
|
+
`dev_token` is accepted only when explicitly supplied and
|
|
572
|
+
`BP_ALLOW_DEV_CONFIG_TOKEN=true` at construction. It requires an explicit tenant
|
|
573
|
+
header and the same scope checks; it is a local-development option. Without pinned
|
|
574
|
+
CP trust or this explicit opt-in, the API rejects every ticket.
|
|
575
|
+
|
|
576
|
+
`Service.apply_snapshot` validates a complete scoped document and preview values,
|
|
577
|
+
persists it through the supplied `StateStore`, then replaces active policy. Failed
|
|
578
|
+
validation, decryption, saving or cancellation before commit preserves the previous
|
|
579
|
+
snapshot. Requests capture one snapshot; an update during authentication returns
|
|
580
|
+
503. Successful updates retire old JWKS caches and rebuild URL and access policy.
|
|
581
|
+
|
|
582
|
+
`FileStateStore` writes a sibling file, flushes it, then atomically replaces its
|
|
583
|
+
target. The default limit is 16 MiB. Use one writer per file; replicated services
|
|
584
|
+
need a configured transactional store. Files use POSIX mode 0600 or the directory's
|
|
585
|
+
Windows ACL. The store does not supply key management or cross-replica delivery.
|
|
586
|
+
|
|
587
|
+
```python
|
|
588
|
+
import asyncio
|
|
589
|
+
from pathlib import Path
|
|
590
|
+
import tempfile
|
|
591
|
+
from betterportal.registry import Registry
|
|
592
|
+
from betterportal.service import Service
|
|
593
|
+
from betterportal.storage import FileStateStore
|
|
594
|
+
|
|
595
|
+
async def main():
|
|
596
|
+
declaration = {"pluginId": "com.example.service", "title": "Example", "description": "Example", "version": "1.0.0"}
|
|
597
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
598
|
+
store = FileStateStore(Path(directory) / "snapshot.json")
|
|
599
|
+
async with Service(Registry([]), declaration, state_store=store, managed=True) as service:
|
|
600
|
+
await service.apply_snapshot({"managementOrigins": [], "tenants": [], "apps": []})
|
|
601
|
+
assert service.snapshot() is not None and not service.ready
|
|
602
|
+
async with Service(Registry([]), declaration, state_store=store, managed=True) as restored:
|
|
603
|
+
assert await restored.restore_snapshot()
|
|
604
|
+
assert not restored.ready
|
|
605
|
+
|
|
606
|
+
asyncio.run(main())
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Managed readiness additionally requires `manifest_submitted=True` on an update
|
|
610
|
+
from an acknowledged manifest POST. Restoring a cache cannot supply that proof;
|
|
611
|
+
`ControlPlaneSync` supplies it after a successful POST and persisted snapshot. `preview_key` decrypts preview
|
|
612
|
+
settings only for one unambiguous active tenant/app. Both scopes must validate
|
|
613
|
+
before publication. Their merged values appear in `RequestContext.config`; only
|
|
614
|
+
the encrypted snapshot is persisted. Removing preview config removes that overlay.
|
|
615
|
+
|
|
616
|
+
`ControlPlaneSync` owns manifest submission, scoped SSE updates and reconnect/poll
|
|
617
|
+
fallback. Pass a managed service and its provisioned CP URL/API key. HTTPS is required;
|
|
618
|
+
plain HTTP permits only exact `localhost`, `127.0.0.1` and `[::1]` hosts. Redirects
|
|
619
|
+
are rejected. Each connection starts with a manifest POST; a failed first attempt
|
|
620
|
+
leaves health at 503 and retries after five seconds. Poll requests and idle stream
|
|
621
|
+
reads time out after 30 seconds. Payloads and individual SSE frames are bounded to
|
|
622
|
+
16 MiB. Invalid updates preserve the last accepted snapshot; 401/403/409/412 revoke
|
|
623
|
+
readiness until a new manifest is accepted. Transient failures retain valid policy.
|
|
624
|
+
|
|
625
|
+
```python
|
|
626
|
+
from betterportal.asgi import create_app
|
|
627
|
+
from betterportal.registry import Registry
|
|
628
|
+
from betterportal.service import Service
|
|
629
|
+
from betterportal.storage import FileStateStore
|
|
630
|
+
from betterportal.sync import ControlPlaneSync
|
|
631
|
+
|
|
632
|
+
def managed_app(cp_url: str, api_key: str, cache_path: str):
|
|
633
|
+
service = Service(Registry([]), {"pluginId": "com.example.service", "title": "Example",
|
|
634
|
+
"description": "Example", "version": "1.0.0"}, managed=True, state_store=FileStateStore(cache_path))
|
|
635
|
+
sync = ControlPlaneSync(service, cp_url, api_key)
|
|
636
|
+
return create_app(service, sync=sync)
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
Use your operation registry in this factory, and expose its returned app to your
|
|
640
|
+
ASGI server. The host starts synchronization and closes it before the service at
|
|
641
|
+
shutdown. Without a host, use `async with service` and `async with sync`, in that
|
|
642
|
+
order. `await sync.start()` returns the first bootstrap result; `await sync.aclose()`
|
|
643
|
+
cancels pending requests/retries and clears managed readiness. `sync.status` returns
|
|
644
|
+
safe counters/error codes; protect any endpoint exposing them. `sync.submission()`
|
|
645
|
+
returns the portable typed manifest projection. Optional `key_pair` registers a
|
|
646
|
+
previously persisted RSA public key, and `auth_provider` advertises issuer metadata.
|
|
647
|
+
Use `ServiceInstallation` below for provisioning and install-ticket hosting.
|
|
648
|
+
|
|
649
|
+
`BootstrapStateStore` protects provisioned credentials and a persistent RSA signing
|
|
650
|
+
identity with the Node-compatible authenticated bootstrap envelope. Supply its
|
|
651
|
+
master key from a protected host secret. Generate that key once with
|
|
652
|
+
`BootstrapCipher.generate_key()` and retain it separately; generating a new master
|
|
653
|
+
key on every startup makes existing state unreadable.
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
import asyncio
|
|
657
|
+
from pathlib import Path
|
|
658
|
+
from tempfile import TemporaryDirectory
|
|
659
|
+
from betterportal.bootstrap import BootstrapCipher, BootstrapStateStore
|
|
660
|
+
from betterportal.storage import FileStateStore
|
|
661
|
+
|
|
662
|
+
async def bootstrap_example():
|
|
663
|
+
with TemporaryDirectory() as directory:
|
|
664
|
+
master_key = BootstrapCipher.generate_key() # Example only: retain securely in production.
|
|
665
|
+
file = FileStateStore(Path(directory) / "bootstrap.json")
|
|
666
|
+
state = BootstrapStateStore(file, master_key)
|
|
667
|
+
await state.write({"cpUrl": "https://cp.example", "apiKey": "bp_sk_t_example"})
|
|
668
|
+
identity = await state.identity()
|
|
669
|
+
restarted = BootstrapStateStore(file, master_key)
|
|
670
|
+
assert (await restarted.identity()).kid == identity.kid
|
|
671
|
+
assert (await restarted.read(redacted=True))["apiKey"] == "__redacted__"
|
|
672
|
+
|
|
673
|
+
asyncio.run(bootstrap_example())
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
`write` atomically merges a validated patch; `read` returns owned state. `identity`
|
|
677
|
+
loads or atomically creates one RSA key inside that encrypted state. `clear`
|
|
678
|
+
explicitly removes both credentials and identity. Corrupt files, invalid master
|
|
679
|
+
keys and mismatched public/private keys fail without replacement. Keep one owner
|
|
680
|
+
per store; use a transactional implementation for shared storage. No plaintext
|
|
681
|
+
cache or background tasks are retained. Caller cancellation propagates before
|
|
682
|
+
commit. Pass the returned identity to `ControlPlaneSync(key_pair=...)` or
|
|
683
|
+
`TokenIssuer`, or let `ServiceInstallation` own it.
|
|
684
|
+
|
|
685
|
+
`ServiceInstallation` supplies the setup-token installation endpoint and public
|
|
686
|
+
JWKS, restores protected credentials, activates ticket-protected settings and owns
|
|
687
|
+
manifest synchronization. Configure CP trust and the public service origin in the
|
|
688
|
+
host; forwarded headers and the setup body cannot select either. The installer
|
|
689
|
+
requires a fresh managed `Service` and owns its sync lifecycle.
|
|
690
|
+
|
|
691
|
+
```python
|
|
692
|
+
from pathlib import Path
|
|
693
|
+
from betterportal.asgi import create_app
|
|
694
|
+
from betterportal.bootstrap import BootstrapStateStore
|
|
695
|
+
from betterportal.installation import ServiceInstallation
|
|
696
|
+
from betterportal.registry import Registry
|
|
697
|
+
from betterportal.service import Service
|
|
698
|
+
from betterportal.storage import FileStateStore
|
|
699
|
+
|
|
700
|
+
def installable_app(cp_url: str, public_origin: str, master_key: str, state_directory: str):
|
|
701
|
+
directory = Path(state_directory)
|
|
702
|
+
service = Service(Registry([]), {"pluginId": "com.example.service", "title": "Example",
|
|
703
|
+
"description": "Example", "version": "1.0.0"}, managed=True,
|
|
704
|
+
state_store=FileStateStore(directory / "snapshot.json"))
|
|
705
|
+
installation = ServiceInstallation(service,
|
|
706
|
+
BootstrapStateStore(FileStateStore(directory / "bootstrap.json"), master_key), cp_url, public_origin,
|
|
707
|
+
settings_store=FileStateStore(directory / "settings.json"))
|
|
708
|
+
return create_app(service, installation=installation)
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Use your operation registry and separately protected master key in this factory;
|
|
712
|
+
serve its returned ASGI app. Startup without credentials exposes public keys and
|
|
713
|
+
health 503. POST `{"setupToken":"...","cpUrl":"https://cp.example"}` to
|
|
714
|
+
`/.well-known/bp/install`. A successful installation returns 200 after the manifest
|
|
715
|
+
and scoped snapshot are accepted. Failed first sync returns 503 with `installed:
|
|
716
|
+
true` and retries using durable credentials. Redemption/persistence failures never
|
|
717
|
+
make the service ready. Native responses omit the API key.
|
|
718
|
+
|
|
719
|
+
Replaying the same setup JTI reuses committed credentials; a new signed token may
|
|
720
|
+
rotate credentials for the same instance. Tenant-scoped installation stores its
|
|
721
|
+
signed tenant as `tenantLock`; operation requests for another tenant return 426,
|
|
722
|
+
and config tickets for another tenant are denied. Platform installation without a
|
|
723
|
+
tenant scope can serve the CP's permitted tenants. Existing tenant locks survive
|
|
724
|
+
reconfiguration and restart. An existing Node bootstrap file can be loaded with
|
|
725
|
+
its master key; reconfiguration requires a synchronized matching instance.
|
|
726
|
+
Optional `cp_jwks_uri` pins an alternate JWKS endpoint; `auth_provider` advertises
|
|
727
|
+
provider metadata. Config descriptors require a settings store. `config_mode`,
|
|
728
|
+
`custom_ui_path`, and `writable` configure the existing `ConfigApi` policy.
|
|
729
|
+
Shutdown cancels and drains installation/sync before closing the service. Keep one
|
|
730
|
+
owner per state store; these file stores do not coordinate replicas.
|
|
731
|
+
|
|
732
|
+
To change the public hostname, keep the same protected state, configure the new
|
|
733
|
+
`service_url`, and restart. A stored-address mismatch leaves health 503 until
|
|
734
|
+
POST `{"changeToken":"bp_hc_..."}` to `/.well-known/bp/hostname-change` confirms
|
|
735
|
+
the CP-issued token. The runtime method is `await installation.change_hostname(body)`.
|
|
736
|
+
The configured origin supplies the new address; forwarded headers cannot change
|
|
737
|
+
it. Confirmation checks the CP's current instance/address projection before
|
|
738
|
+
atomically updating the binding and restarting sync. Credentials, signing identity
|
|
739
|
+
and tenant lock are preserved. If CP confirmation succeeds but local persistence
|
|
740
|
+
fails, obtain a fresh token and retry; if local persistence succeeded, restart can
|
|
741
|
+
resume sync. See [the hostname protocol](../../spec/config.md#23-standalone-hostname-changes).
|
|
742
|
+
|
|
743
|
+
```sh
|
|
744
|
+
python -m pip install -r framework/conformance/requirements.txt
|
|
745
|
+
python -m build framework/python
|
|
746
|
+
python -m mypy framework/python/betterportal --follow-imports=silent --follow-untyped-imports
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Wheels and source distributions embed the shared contract corpus. Building a
|
|
750
|
+
wheel from its source distribution and importing it require neither Node nor BSB.
|
|
751
|
+
The repository build copies those documents; there is no independently maintained
|
|
752
|
+
Python schema definition. `betterportal.contracts.document` returns a portable
|
|
753
|
+
document; `contract` imports it natively, including when selecting a named field.
|
|
754
|
+
`object_document` composes portable roots before importing, retaining recursive
|
|
755
|
+
definitions and extensions and rejecting conflicting names or document versions.
|
|
756
|
+
AnyVali 1.1.2 native-parent export also preserves recursive definitions; the helper
|
|
757
|
+
remains useful for portable extension and version checks.
|
|
758
|
+
|
|
759
|
+
Native input/output typing is generated from those documents:
|
|
760
|
+
|
|
761
|
+
```sh
|
|
762
|
+
bp-python types --contracts ./contracts --output ./generated_types.py
|
|
763
|
+
bp-python types --contracts ./contracts --output ./generated_types.py --check
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
`python -m betterportal` is equivalent to `bp-python`. Use `--platform` instead of
|
|
767
|
+
`--contracts` to regenerate the embedded BP types. `betterportal.generated_types`
|
|
768
|
+
exports `TypedDict` declarations, enums as `Literal`, and recursive aliases.
|
|
769
|
+
Input types allow omitted defaults; output types require materialized defaults.
|
|
770
|
+
Nullable fields remain distinct from optional fields. AnyVali performs all runtime
|
|
771
|
+
validation. Python represents AnyVali tuples as lists; positional constraints,
|
|
772
|
+
general intersections and coercion inputs cannot always be expressed precisely by
|
|
773
|
+
Python typing and remain in the AnyVali document.
|
|
774
|
+
|
|
775
|
+
```python
|
|
776
|
+
from betterportal.contracts import parse
|
|
777
|
+
from betterportal.security import KeyPair, TokenIssuer, uuid7
|
|
778
|
+
|
|
779
|
+
settings = parse("JsonObjectSchema", {"feature": {"enabled": True}})
|
|
780
|
+
issuer = TokenIssuer(KeyPair.generate(), "https://auth.example", "my-app")
|
|
781
|
+
pair = issuer.issue_pair({
|
|
782
|
+
"sub": "user-1", "tenantId": uuid7(), "appId": uuid7(), "roles": ["reader"],
|
|
783
|
+
"authProvider": "example", "refreshContext": {"subject": "user-1"},
|
|
784
|
+
})
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Use persistent signing material for a real issuer; this example generates an
|
|
788
|
+
ephemeral key. Use `BootstrapStateStore.identity()` for persistent signing material.
|
|
789
|
+
`verify_token` requires an explicit purpose, trusted key resolver, issuer and
|
|
790
|
+
audience (setup tokens have no audience). Setup claims still need binding to the
|
|
791
|
+
intended installation; accepting a valid signature alone is insufficient.
|
|
792
|
+
|
|
793
|
+
`betterportal.keys.JwksClient(issuer, uri)` is an async context manager; pass its
|
|
794
|
+
`resolve` method as the trusted key resolver. It coalesces concurrent refreshes,
|
|
795
|
+
caches for 30 minutes, throttles unknown-key refreshes for two seconds, and
|
|
796
|
+
supports `invalidate()` on scoped key changes. Closing cancels outstanding
|
|
797
|
+
refreshes. HTTPS is required except exact localhost/127.0.0.1/[::1] HTTP URLs.
|
|
798
|
+
Redirects are rejected; complete responses have a five-second deadline and
|
|
799
|
+
1 MiB limit. A configured JWKS query is allowed. The `secure_endpoint` helper
|
|
800
|
+
defaults to rejecting queries for control-plane base URLs.
|
|
801
|
+
|
|
802
|
+
The shared [security HTTP suite](../conformance/security_cases.py) passes 459
|
|
803
|
+
scenarios across Node, Python and .NET. The Windows [schema gate](../conformance/README.md)
|
|
804
|
+
passes 1,580/1,580 scenarios with AnyVali 1.1.6: 536 checks in each native runtime and 508 in Node.
|
|
805
|
+
The eight C# extension regressions from [AnyVali #141](https://github.com/BetterCorp/AnyVali/issues/141)
|
|
806
|
+
pass with their original expectations and no BP workaround.
|
|
807
|
+
See the conformance ledger for runtime results and remaining
|
|
808
|
+
capabilities. Do not treat package builds as evidence that the full framework plan
|
|
809
|
+
is complete. Nothing is published.
|
|
810
|
+
|
|
811
|
+
## Scoped dependency clients
|
|
812
|
+
|
|
813
|
+
Load a dependency's exported `BpSchemaOutput` with `ClientContract(document)`.
|
|
814
|
+
Inside a handler, `context.request_context.clients.user(contract, service_id)`
|
|
815
|
+
selects an enabled instance or registry alias and forwards the verified original
|
|
816
|
+
user bearer. Its selected operation must be mounted in the current app; public
|
|
817
|
+
operations can be called anonymously. Credentials are absent from render contexts.
|
|
818
|
+
Call `await client.request(operation_id, {"params": ..., "query": ..., "headers": ..., "body": ...})`.
|
|
819
|
+
AnyVali validates each method's inputs and JSON output. Omit unused input keys;
|
|
820
|
+
an explicit body null remains a JSON null when its schema permits it.
|
|
821
|
+
|
|
822
|
+
Use `context.request_context.clients.m2m(request_id, contract)` for a declared
|
|
823
|
+
service or delegated dependency. The manifest request selects the mode. Each call
|
|
824
|
+
checks current bindings, grants, methods, permissions and capabilities, then mints
|
|
825
|
+
a service token lasting at most 60 seconds. Delegated calls retain the original
|
|
826
|
+
user bearer as well. Background work uses `service.clients.scope(tenant_id, app_id)`
|
|
827
|
+
and supports service mode only. Supply `signing_key` to a locally configured service;
|
|
828
|
+
installation/control-plane sync supplies its persisted key automatically. The
|
|
829
|
+
scoped snapshot must confirm the public key and key ID before tokens can be sent.
|
|
830
|
+
|
|
831
|
+
```python
|
|
832
|
+
import asyncio
|
|
833
|
+
import anyvali as av
|
|
834
|
+
from betterportal.clients import ClientContract, ClientError
|
|
835
|
+
from betterportal.handler import Handler
|
|
836
|
+
from betterportal.registry import Operation, Route, Registry
|
|
837
|
+
from betterportal.service import Service
|
|
838
|
+
|
|
839
|
+
async def check():
|
|
840
|
+
declaration = {"pluginId": "com.example.peer", "title": "Peer", "description": "Example", "version": "1.0.0"}
|
|
841
|
+
registry = Registry([Route("value", "/value", [Operation(Handler(av.string(), lambda context: "value"),
|
|
842
|
+
{"operationId": "value.get", "method": "GET", "title": "Value", "description": "Read value", "auth": {}})])])
|
|
843
|
+
contract = ClientContract(registry.schema(declaration))
|
|
844
|
+
async with Service(Registry([]), declaration) as service:
|
|
845
|
+
client = service.clients.scope("01952200-0000-7000-8000-000000000001",
|
|
846
|
+
"01952200-0000-7000-8000-000000000002").m2m("read", contract)
|
|
847
|
+
try:
|
|
848
|
+
await client.request("value.get")
|
|
849
|
+
raise AssertionError("An unready service made an outbound call")
|
|
850
|
+
except ClientError as error:
|
|
851
|
+
assert error.status == 503
|
|
852
|
+
|
|
853
|
+
asyncio.run(check())
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
Destinations require HTTPS, with the same exact-loopback HTTP exceptions as sync.
|
|
857
|
+
Clients reject redirects, cookie replay and caller overrides of BP routing/auth
|
|
858
|
+
headers. JSON requests/responses are bounded to 16 MiB, URLs to 8,192 characters,
|
|
859
|
+
and transport waits to 30 seconds; unsolicited compressed responses are rejected.
|
|
860
|
+
Snapshot replacement invalidates captured request clients and cancels pending JSON,
|
|
861
|
+
raw and streaming HTTP work, including calls waiting for response headers;
|
|
862
|
+
background clients resolve policy again on each call. Cancellation and service
|
|
863
|
+
shutdown stop pending HTTP work. `ClientError` exposes the upstream status with a
|
|
864
|
+
generic message; upstream error bodies are never included.
|
|
865
|
+
|
|
866
|
+
`async with client.raw(operation_id, values) as response` opens an explicitly
|
|
867
|
+
raw operation. `response.status`, `response.headers` (repeated pairs) and
|
|
868
|
+
`response.body` (async byte iterator) expose the response without buffering it.
|
|
869
|
+
The default total limit is 16 MiB; pass `max_bytes` to select another positive
|
|
870
|
+
limit. The context manager closes the response even when consumption stops early.
|
|
871
|
+
|
|
872
|
+
`async with client.stream(operation_id, values) as frames` opens a finite operation
|
|
873
|
+
using NDJSON. Each item and summary is validated against the declared schema.
|
|
874
|
+
Invalid ordering/counts, oversized frames, upstream errors and missing terminal
|
|
875
|
+
frames raise `ClientError`; consume the iterator to completion before treating a
|
|
876
|
+
stream as successful. The default frame limit is 1 MiB (`max_frame_bytes`). Raw
|
|
877
|
+
and finite connections have a 30-second transport inactivity timeout, reject
|
|
878
|
+
redirects/compression, and close on caller cancellation, snapshot retirement or
|
|
879
|
+
service shutdown. Pass `transport="sse"` for a finite SSE data stream (the upstream
|
|
880
|
+
app must have no HTML theme renderer selected). NDJSON always requests typed data.
|
|
881
|
+
`client.subscribe(operation_id, event_schema, values)` consumes an open-ended SSE
|
|
882
|
+
feed with a caller-pinned AnyVali event schema; use `text=True` for plain-text events.
|
|
883
|
+
The manifest does not currently publish subscriber event schemas. The context
|
|
884
|
+
manager owns cleanup; reconnect and replay policy belong to the caller.
|
|
885
|
+
|
|
886
|
+
Generated clients expose raw operations as context-manager methods and add
|
|
887
|
+
`<method>_stream` for finite operations alongside their buffered JSON method.
|
|
888
|
+
Inputs and discriminated item/summary/end frames are typed; streamed payloads
|
|
889
|
+
also validate at runtime.
|
|
890
|
+
|
|
891
|
+
Generate a typed JSON client from the dependency's exported BP schema:
|
|
892
|
+
|
|
893
|
+
```sh
|
|
894
|
+
bp-python client --contract contracts/peer.json --output dependencies/peer.py --class-name PeerClient
|
|
895
|
+
bp-python client --contract contracts/peer.json --output dependencies/peer.py --class-name PeerClient --check
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Import `PeerClient` from that generated module and construct it with
|
|
899
|
+
`PeerClient(context.request_context.clients, service_id="peer")` for user calls,
|
|
900
|
+
or `PeerClient(context.request_context.clients, request_id="read-item")` for a
|
|
901
|
+
declared service/delegated request. An operation `check.get` becomes
|
|
902
|
+
`await client.check_get({"params": {"key": "item"}})`. Input TypedDicts preserve
|
|
903
|
+
required fields, defaults and nullable values; return types describe validated
|
|
904
|
+
JSON. Undeclared schemas remain generic JSON mappings. Each generated module embeds
|
|
905
|
+
the contract and uses the runtime's policy, transport and shutdown handling.
|
|
906
|
+
`--check` detects stale generated code without writing files. Neither command
|
|
907
|
+
requires Node or BSB. AnyVali 1.1.2 preserves Python's empty and from-string
|
|
908
|
+
coercion configurations through export and reimport.
|
|
909
|
+
|
|
910
|
+
## Local dependencies and frozen builds
|
|
911
|
+
|
|
912
|
+
Keep registry identity and dependency selectors in `betterportal.json`. Install
|
|
913
|
+
an exported service project without Node or BSB:
|
|
914
|
+
|
|
915
|
+
```sh
|
|
916
|
+
bp-python deps add example/service@1.0.0 --path ../service --alias peer --project .
|
|
917
|
+
bp-python deps sync --frozen --project .
|
|
918
|
+
bp-python deps sync --frozen --check --project .
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
The provider directory contains `bp-contract.json` or `lib/bp-contracts/*.json`.
|
|
922
|
+
Its `betterportal.json` supplies `registryRef`; a full reference in the selector
|
|
923
|
+
can supply it when absent. Exact versions and identities must match, and ambiguous
|
|
924
|
+
exports fail. The generated client is `bp_dependencies/dep_peer.py`, with class
|
|
925
|
+
`DependencyClient`. Its constructor and methods use the scoped client API above.
|
|
926
|
+
Aliases that collide in native filenames are rejected before files change.
|
|
927
|
+
|
|
928
|
+
The lock retains `registryRef`, `pluginId`, `version` and `digest`, adding
|
|
929
|
+
`digestFormat: "json-bytes"`. The SHA-256 digest covers the exact cached UTF-8
|
|
930
|
+
document. Frozen builds use that cache, verify the configured identity/version,
|
|
931
|
+
and validate every dependency before updating generated sources. They perform no
|
|
932
|
+
network lookup or local override discovery; `--check` performs no writes.
|
|
933
|
+
Explicit installation can migrate a legacy Node lock. Native frozen builds reject
|
|
934
|
+
legacy locale-dependent digests; Node's CLI supports both formats. Native init commands are documented in the route authoring guide.
|
|
935
|
+
|
|
936
|
+
Without `--path` or `--registry`, `deps add` first discovers exported local contracts:
|
|
937
|
+
literal `package.json` workspaces at the nearest Git root, `node_modules` packages
|
|
938
|
+
(including scopes), sibling repositories, then `BP_DEV_PATHS`. Without Git, the
|
|
939
|
+
consumer directory is the root. Native projects need no `package.json`; siblings
|
|
940
|
+
and `BP_DEV_PATHS` work independently of Node. Development paths are relative to
|
|
941
|
+
`--project`, separated by `;` on Windows or `:` on Linux. A development path named
|
|
942
|
+
`node_modules` is expanded into its packages. Workspace glob entries are ignored,
|
|
943
|
+
matching Node's existing discovery; use explicit development paths for those layouts.
|
|
944
|
+
|
|
945
|
+
Automatic candidates must declare `registryRef` in `betterportal.json`. Invalid or
|
|
946
|
+
stale exports are skipped; an explicit `--path` reports their errors. Exact versions
|
|
947
|
+
are honored. Multiple matching identities, versions or different contract bytes
|
|
948
|
+
fail before any writes; identical duplicates are accepted. Use a full identity,
|
|
949
|
+
exact version or `--path` to disambiguate. No service code executes during discovery.
|
|
950
|
+
|
|
951
|
+
## Registry installation and publishing
|
|
952
|
+
|
|
953
|
+
```sh
|
|
954
|
+
bp-python deps add example/service@1.0.0 --alias peer
|
|
955
|
+
bp-python deps add com.example.service@1.0.0 --alias peer
|
|
956
|
+
bp-python publish --contract bp-contract.json --project .
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
When no local contract matches, installation uses `BP_REGISTRY_URL` (default
|
|
960
|
+
`https://io.betterportal.org`). `--registry URL` selects the registry directly and
|
|
961
|
+
bypasses automatic local discovery. Short names use
|
|
962
|
+
`defaultNamespace` or require a unique registry match. An explicit version is
|
|
963
|
+
fetched after short-name resolution; omitted versions and `latest` resolve the
|
|
964
|
+
registry's current version. Installation pins the exact response bytes and uses
|
|
965
|
+
the same offline frozen-build commands above.
|
|
966
|
+
|
|
967
|
+
Publishing requires the project's `registryRef` and a `BP_REGISTRY_TOKEN`
|
|
968
|
+
environment variable. It submits an existing exported contract; the registry
|
|
969
|
+
enforces publisher prefixes, permanent identity bindings and immutable versions.
|
|
970
|
+
Identical retries return `unchanged: true`. Running `publish` writes to the
|
|
971
|
+
selected registry. These tools do not build or export your service implicitly.
|
|
972
|
+
|
|
973
|
+
Both commands require HTTPS with exact-loopback HTTP exceptions, reject
|
|
974
|
+
redirects and compressed/non-JSON responses, and enforce 16 MiB payload limits
|
|
975
|
+
and a 30-second total deadline per request. They send no environment proxy
|
|
976
|
+
credentials or stored cookies; publisher credentials are never sent on lookups
|
|
977
|
+
or included in upstream error messages.
|
|
978
|
+
|
|
979
|
+
## Export an application contract
|
|
980
|
+
|
|
981
|
+
Define a synchronous factory in your application module, using the same registry
|
|
982
|
+
and manifest declaration as the host:
|
|
983
|
+
|
|
984
|
+
```python
|
|
985
|
+
import anyvali as av
|
|
986
|
+
from betterportal.generated_types import BpSchemaOutput
|
|
987
|
+
from betterportal.handler import Handler
|
|
988
|
+
from betterportal.registry import Operation, Registry, Route
|
|
989
|
+
|
|
990
|
+
def contract() -> BpSchemaOutput:
|
|
991
|
+
handler = Handler(av.string(), lambda context: "Hello")
|
|
992
|
+
registry = Registry([Route("hello.index", "/hello", [Operation(handler, {
|
|
993
|
+
"operationId": "hello.get", "method": "GET", "title": "Hello",
|
|
994
|
+
"description": "Hello operation", "auth": {}
|
|
995
|
+
})])])
|
|
996
|
+
return registry.schema({"pluginId": "com.example.hello", "title": "Hello",
|
|
997
|
+
"description": "Example service", "version": "1.0.0"})
|
|
998
|
+
|
|
999
|
+
assert contract()["manifest"]["pluginId"] == "com.example.hello"
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
For a factory in `my_service/definition.py`:
|
|
1003
|
+
|
|
1004
|
+
```sh
|
|
1005
|
+
bp-python export --module my_service.definition:contract --project . --output bp-contract.json
|
|
1006
|
+
bp-python export --module my_service.definition:contract --project . --output bp-contract.json --check
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
The command imports that explicitly selected module with the project directory on
|
|
1010
|
+
the Python import path. Module initialization and the factory execute as application
|
|
1011
|
+
code; keep host startup under its normal entry-point guard. The factory takes no
|
|
1012
|
+
arguments and returns the registry's contract. Export uses AnyVali validation,
|
|
1013
|
+
limits the document to 16 MiB and atomically replaces the output. `--check` detects
|
|
1014
|
+
drift without writing. Output paths are relative to `--project`. No request handler
|
|
1015
|
+
or host lifecycle is invoked by the exporter. Use [native route discovery](../../docs/building/route-authoring.md) to build this registry from
|
|
1016
|
+
index, method and SSE modules.
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
## Production integration additions
|
|
1020
|
+
|
|
1021
|
+
Pass `observability=Observability(logger=..., tracer=..., metrics=...)` to
|
|
1022
|
+
`create_app`, importing `Observability` from `betterportal.observability`. Each
|
|
1023
|
+
nonblocking callback receives an immutable `HttpObservation` at request completion,
|
|
1024
|
+
including trace/span IDs, method, route template, HTTP status, outcome and duration.
|
|
1025
|
+
Callback failures do not change request handling. Core events exclude request
|
|
1026
|
+
bodies, query strings, credentials and exception text. Valid `traceparent`, bounded
|
|
1027
|
+
`tracestate` and `baggage` propagate to dependency calls. `context.trace` exposes
|
|
1028
|
+
the active trace. Use `with context.obs.start_span(name, attributes):` around sync
|
|
1029
|
+
or awaited work; child spans restore the parent context and report cancellation.
|
|
1030
|
+
`context.obs.logger` offers debug/info/warn/error, and `context.obs.metrics` offers
|
|
1031
|
+
counter, gauge, histogram and timer instruments. Instrument callbacks receive
|
|
1032
|
+
`SpanObservation`, `LogObservation` or `MetricObservation`, respectively. Attribute
|
|
1033
|
+
keys associated with credentials/payloads are removed and scalar values bounded;
|
|
1034
|
+
application-supplied log messages must themselves be safe for export.
|
|
1035
|
+
|
|
1036
|
+
Managed services can publish manifest-declared webhooks with
|
|
1037
|
+
`await context.webhook(event_id, payload, idempotency_key=key)`. Background work
|
|
1038
|
+
uses `service.clients.scope(tenant_id, app_id).webhook(...)`. The active control-plane
|
|
1039
|
+
connection owns publication credentials; callers cannot choose a destination or
|
|
1040
|
+
replace the request's tenant/app. AnyVali validates the declared payload, the
|
|
1041
|
+
serialized request is limited to 1 MiB, and redirects are rejected. Publication
|
|
1042
|
+
returns the idempotency key on success. After publication starts, failures raise
|
|
1043
|
+
`betterportal.webhooks.WebhookError` (a `ClientError`) with `idempotency_key`;
|
|
1044
|
+
cancellation raises `WebhookCancelled` (an `asyncio.CancelledError`) with the same
|
|
1045
|
+
attribute. Reuse that key when retrying uncertain delivery. Persist an explicit
|
|
1046
|
+
key before publication when recovery must survive process termination; the
|
|
1047
|
+
publisher does not retry automatically.
|
|
1048
|
+
|
|
1049
|
+
Developer resources are served publicly at `/.well-known/bp/resources` and
|
|
1050
|
+
`/.well-known/bp/resources/<id>`. The index omits content and adds relative URLs;
|
|
1051
|
+
individual responses preserve the declared content and media type. Registration
|
|
1052
|
+
rejects duplicate IDs and content exceeding 512 KiB of UTF-8. Publish only content
|
|
1053
|
+
intended for public discovery. An active `create_app(..., mode="theme")` also serves
|
|
1054
|
+
`/llms.txt`, API/development/UI guides, `/.well-known/bp/ai.json` and `/llms-drop.txt`.
|
|
1055
|
+
The drop includes complete local resources and contracts, source URLs, separate
|
|
1056
|
+
framework/theme versions and a bounded expiry. No remote discovery URL is fetched.
|
|
1057
|
+
Only explicit public metadata is projected from the app; service configuration and
|
|
1058
|
+
installation credentials are excluded. `/sitemap.xml` and `/robots.txt` respect
|
|
1059
|
+
private visibility, enabled public page mounts and route exclusions. Dynamic or
|
|
1060
|
+
provider-generated sitemap paths are omitted until their owning service supplies
|
|
1061
|
+
concrete URLs; discovery never guesses parameter values.
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
## Hosting and native helper APIs
|
|
1065
|
+
|
|
1066
|
+
`DeploymentConfig.from_env().create_app(registry, declaration)` reads `BP_CP_URL`,
|
|
1067
|
+
`BP_PUBLIC_ORIGIN`, `BP_STATE_DIRECTORY`, and exactly one of
|
|
1068
|
+
`BP_BOOTSTRAP_MASTER_KEY` or `BP_BOOTSTRAP_MASTER_KEY_FILE`. The secret is excluded
|
|
1069
|
+
from the configuration's repr. `BP_TRUSTED_PROXIES` is an optional comma-separated
|
|
1070
|
+
list of IP addresses/CIDRs; your proxy must overwrite `X-Forwarded-Host` and
|
|
1071
|
+
`X-Forwarded-Proto`. Never configure arbitrary peers as trusted.
|
|
1072
|
+
|
|
1073
|
+
`bp-python init` includes a Dockerfile with a non-root user, readiness probe and
|
|
1074
|
+
single Uvicorn worker. Mount persistent storage at `/data` and the bootstrap key
|
|
1075
|
+
as a separate read-only secret. Run one process per state directory; independent
|
|
1076
|
+
replicas require separate installations/state or an application-owned coordinated
|
|
1077
|
+
store. See the [deployment acceptance procedure](PRODUCTION_READINESS.md).
|
|
1078
|
+
|
|
1079
|
+
Handlers expose `tenant`, `app`, `config`, `user`, `service_caller`, `caller_mode`,
|
|
1080
|
+
`method`, `path`, `multipart`, `clients`, `urls`, `response`, `bp_headers`, `trace`
|
|
1081
|
+
and `obs`. `context.require_permission(service_id, view_id, action)` resolves an
|
|
1082
|
+
enabled target and checks the current user's app role grants. A denied handler
|
|
1083
|
+
check returns HTTP 403 through the normal JSON or HTML error response. It fails after the
|
|
1084
|
+
captured snapshot is retired. The scoped platform schema can be authored with the
|
|
1085
|
+
canonical generated types and validated with `contracts.parse`; environment
|
|
1086
|
+
configuration never replaces authenticated control-plane scope.
|
|
1087
|
+
|
|
1088
|
+
`betterportal.auth_helpers.ExternalOidc` verifies RS256 external JWTs against an
|
|
1089
|
+
explicit issuer, audience and HTTPS JWKS URI, including optional nonce matching.
|
|
1090
|
+
Application code maps verified external claims to a BP user and issues its own BP
|
|
1091
|
+
tokens; external roles are never automatically granted BP permissions. Close the
|
|
1092
|
+
verifier at shutdown. `auth_redirect` restricts redirects to the current app, and
|
|
1093
|
+
`auth_cookie` emits host-only Secure/HttpOnly cookies with explicit lifetime and
|
|
1094
|
+
SameSite. `context.bp_headers.set/remove` emits validated BP browser directives.
|
|
1095
|
+
|
|
1096
|
+
`betterportal.themes.ShellFragment` combines a canonical declaration and sync/async
|
|
1097
|
+
HTML callback; `ShellFragments` validates defaults and renders app overrides with
|
|
1098
|
+
cycle/depth/output bounds. Pass the same declarations in the manifest's shell
|
|
1099
|
+
metadata and the collection as `create_app(..., shell_fragments=fragments)`.
|
|
1100
|
+
Callbacks receive safe presentation fields, scoped service `config`, `urls`,
|
|
1101
|
+
`request`, `fragment_id` and rendered `items`. `themes.element` creates escaped
|
|
1102
|
+
BP element attributes for service fragments or shell chrome; its child HTML is
|
|
1103
|
+
trusted application output. Shell fragment endpoints require this service to be
|
|
1104
|
+
the active shell for the resolved app, with normal origin restrictions.
|
|
1105
|
+
|
|
1106
|
+
`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.
|
|
1107
|
+
|
|
1108
|
+
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.
|
|
1109
|
+
|
|
1110
|
+
A dependency mount without a resolved service path cannot identify a service endpoint, so resolution returns null rather than guessing or throwing.
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
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:
|
|
1114
|
+
|
|
1115
|
+
```python
|
|
1116
|
+
from betterportal.themes import bp_route
|
|
1117
|
+
|
|
1118
|
+
# Inside a renderer, context is the RenderContext supplied by BetterPortal.
|
|
1119
|
+
# html = bp_route(context, {"viewId": "items.detail", "params": {"id": 42}},
|
|
1120
|
+
# {"ok": "Item", "error": {"as": "label", "id": "unavailable", "content": "Unavailable"}})
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
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.
|
|
1124
|
+
|
|
1125
|
+
The success state's HTML `method` selects the request method unless an explicit
|
|
1126
|
+
`hx-get`/`hx-post`/`hx-put`/`hx-patch`/`hx-delete` attribute selects it. Methods are
|
|
1127
|
+
normalized to uppercase. Explicit `serviceId`, `params` and `query` take precedence
|
|
1128
|
+
over the `service` alias and legacy `args`, matching Node's BPRoute behavior.
|
|
1129
|
+
Conditional `None` values use the next non-null fallback; explicit empty parameter
|
|
1130
|
+
or query dictionaries remain intentional overrides.
|
|
1131
|
+
|
|
1132
|
+
Incoming JSON rejects duplicate members, nonfinite numbers (including overflowing
|
|
1133
|
+
exponents) and excessive nesting before the handler runs. Exact integer tokens
|
|
1134
|
+
retain Python's integer precision. CI checks the entire runtime with mypy,
|
|
1135
|
+
including bodies of functions without annotations, and verifies accepted and
|
|
1136
|
+
rejected consumer typing examples.
|
|
1137
|
+
|
|
1138
|
+
## PyPI publishing
|
|
1139
|
+
|
|
1140
|
+
See [PUBLISHING.md](PUBLISHING.md) for trusted-publisher setup, release versioning
|
|
1141
|
+
and the signed-tag publishing workflow.
|