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.
Files changed (225) hide show
  1. betterportal-11.0.7/PKG-INFO +1141 -0
  2. betterportal-11.0.7/README.md +1121 -0
  3. betterportal-11.0.7/betterportal/__init__.py +4 -0
  4. betterportal-11.0.7/betterportal/__main__.py +3 -0
  5. betterportal-11.0.7/betterportal/_contracts/AdminApiDescriptorSchema.json +54 -0
  6. betterportal-11.0.7/betterportal/_contracts/ApiAuthRequirementSchema.json +98 -0
  7. betterportal-11.0.7/betterportal/_contracts/ApiCallerModeSchema.json +14 -0
  8. betterportal-11.0.7/betterportal/_contracts/ApiContractDescriptorSchema.json +89 -0
  9. betterportal-11.0.7/betterportal/_contracts/AppAuthConfigSchema.json +303 -0
  10. betterportal-11.0.7/betterportal/_contracts/AppAuthPermissionActionSchema.json +15 -0
  11. betterportal-11.0.7/betterportal/_contracts/AppAuthPermissionGrantSchema.json +38 -0
  12. betterportal-11.0.7/betterportal/_contracts/AppAuthProviderConfigSchema.json +70 -0
  13. betterportal-11.0.7/betterportal/_contracts/AppAuthRedirectsSchema.json +55 -0
  14. betterportal-11.0.7/betterportal/_contracts/AppAuthRoleSchema.json +67 -0
  15. betterportal-11.0.7/betterportal/_contracts/AppAuthViewReferenceSchema.json +24 -0
  16. betterportal-11.0.7/betterportal/_contracts/AppRouteSchema.json +44 -0
  17. betterportal-11.0.7/betterportal/_contracts/AppSchema.json +127 -0
  18. betterportal-11.0.7/betterportal/_contracts/AuthAudienceRuleSchema.json +31 -0
  19. betterportal-11.0.7/betterportal/_contracts/AuthProviderRuntimeMetadataSchema.json +82 -0
  20. betterportal-11.0.7/betterportal/_contracts/AuthRoleAuthoritySchema.json +13 -0
  21. betterportal-11.0.7/betterportal/_contracts/AuthressProviderConfigSchema.json +52 -0
  22. betterportal-11.0.7/betterportal/_contracts/BPRouteReferenceSchema.json +95 -0
  23. betterportal-11.0.7/betterportal/_contracts/BetterPortalAppSchema.json +4465 -0
  24. betterportal-11.0.7/betterportal/_contracts/BetterPortalAppShellSchema.json +19 -0
  25. betterportal-11.0.7/betterportal/_contracts/BetterPortalBrandingSchema.json +42 -0
  26. betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigManagementAuthSchema.json +52 -0
  27. betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigManagementSchema.json +81 -0
  28. betterportal-11.0.7/betterportal/_contracts/BetterPortalConfigSchema.json +7853 -0
  29. betterportal-11.0.7/betterportal/_contracts/BetterPortalFragmentAssignmentSchema.json +34 -0
  30. betterportal-11.0.7/betterportal/_contracts/BetterPortalLockSchema.json +54 -0
  31. betterportal-11.0.7/betterportal/_contracts/BetterPortalLogLevelSchema.json +15 -0
  32. betterportal-11.0.7/betterportal/_contracts/BetterPortalMenuItemSchema.json +3180 -0
  33. betterportal-11.0.7/betterportal/_contracts/BetterPortalOriginPolicySchema.json +32 -0
  34. betterportal-11.0.7/betterportal/_contracts/BetterPortalProjectConfigSchema.json +43 -0
  35. betterportal-11.0.7/betterportal/_contracts/BetterPortalResourceSchema.json +69 -0
  36. betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteChromeSchema.json +86 -0
  37. betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteChromeValueSchema.json +20 -0
  38. betterportal-11.0.7/betterportal/_contracts/BetterPortalRouteMountSchema.json +351 -0
  39. betterportal-11.0.7/betterportal/_contracts/BetterPortalSeoConfigSchema.json +57 -0
  40. betterportal-11.0.7/betterportal/_contracts/BetterPortalShellFragmentItemSchema.json +57 -0
  41. betterportal-11.0.7/betterportal/_contracts/BetterPortalShellFragmentSettingSchema.json +158 -0
  42. betterportal-11.0.7/betterportal/_contracts/BetterPortalSlotAssignmentSchema.json +41 -0
  43. betterportal-11.0.7/betterportal/_contracts/BetterPortalTenantSchema.json +267 -0
  44. betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeBootstrapPaletteSchema.json +70 -0
  45. betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeConfigSchema.json +246 -0
  46. betterportal-11.0.7/betterportal/_contracts/BetterPortalThemeSurfaceSchema.json +63 -0
  47. betterportal-11.0.7/betterportal/_contracts/BetterPortalTraceContextSchema.json +24 -0
  48. betterportal-11.0.7/betterportal/_contracts/BindingRecordSchema.json +109 -0
  49. betterportal-11.0.7/betterportal/_contracts/BindingTrustSchema.json +43 -0
  50. betterportal-11.0.7/betterportal/_contracts/BootstrapStateEnvelopeSchema.json +37 -0
  51. betterportal-11.0.7/betterportal/_contracts/BootstrapStateSchema.json +166 -0
  52. betterportal-11.0.7/betterportal/_contracts/BpSchemaOutputSchema.json +1779 -0
  53. betterportal-11.0.7/betterportal/_contracts/BpSchemaRouteSchema.json +144 -0
  54. betterportal-11.0.7/betterportal/_contracts/CacheHintsSchema.json +29 -0
  55. betterportal-11.0.7/betterportal/_contracts/ConfigFieldDescriptorSchema.json +232 -0
  56. betterportal-11.0.7/betterportal/_contracts/ConfigFieldGroupDescriptorSchema.json +42 -0
  57. betterportal-11.0.7/betterportal/_contracts/ConfigFieldUiDescriptorSchema.json +114 -0
  58. betterportal-11.0.7/betterportal/_contracts/ConfigOwnershipSchema.json +14 -0
  59. betterportal-11.0.7/betterportal/_contracts/ConfigSchemaDescriptorSchema.json +318 -0
  60. betterportal-11.0.7/betterportal/_contracts/ConfigScopeSchema.json +13 -0
  61. betterportal-11.0.7/betterportal/_contracts/ConfigVisibilitySchema.json +14 -0
  62. betterportal-11.0.7/betterportal/_contracts/ControlPlaneSubmissionSchema.json +1442 -0
  63. betterportal-11.0.7/betterportal/_contracts/CpEnvelopeClaimsSchema.json +71 -0
  64. betterportal-11.0.7/betterportal/_contracts/DefaultAuthProviderConfigSchema.json +19 -0
  65. betterportal-11.0.7/betterportal/_contracts/DemoScenarioMatchSchema.json +84 -0
  66. betterportal-11.0.7/betterportal/_contracts/DemoScenarioSchema.json +116 -0
  67. betterportal-11.0.7/betterportal/_contracts/DependencyAliasSchema.json +10 -0
  68. betterportal-11.0.7/betterportal/_contracts/DeploymentModeSchema.json +16 -0
  69. betterportal-11.0.7/betterportal/_contracts/DeveloperResourceSchema.json +60 -0
  70. betterportal-11.0.7/betterportal/_contracts/ElevationClaimsSchema.json +32 -0
  71. betterportal-11.0.7/betterportal/_contracts/ElevationRequirementSchema.json +29 -0
  72. betterportal-11.0.7/betterportal/_contracts/FragmentAssignmentSchema.json +29 -0
  73. betterportal-11.0.7/betterportal/_contracts/HtmlRepresentationSupportSchema.json +96 -0
  74. betterportal-11.0.7/betterportal/_contracts/HttpMethodSchema.json +17 -0
  75. betterportal-11.0.7/betterportal/_contracts/IdentityRealmSchema.json +13 -0
  76. betterportal-11.0.7/betterportal/_contracts/JsonObjectSchema.json +45 -0
  77. betterportal-11.0.7/betterportal/_contracts/JsonValueSchema.json +42 -0
  78. betterportal-11.0.7/betterportal/_contracts/JwtClaimsSchema.json +267 -0
  79. betterportal-11.0.7/betterportal/_contracts/LocalDependencyLockSchema.json +49 -0
  80. betterportal-11.0.7/betterportal/_contracts/LocalWorkspacePackageSchema.json +23 -0
  81. betterportal-11.0.7/betterportal/_contracts/LockedDependencySchema.json +41 -0
  82. betterportal-11.0.7/betterportal/_contracts/M2MBindingSchema.json +75 -0
  83. betterportal-11.0.7/betterportal/_contracts/M2MCallerModeSchema.json +13 -0
  84. betterportal-11.0.7/betterportal/_contracts/M2MConfigSchema.json +162 -0
  85. betterportal-11.0.7/betterportal/_contracts/M2MGrantSchema.json +71 -0
  86. betterportal-11.0.7/betterportal/_contracts/M2MMethodSchema.json +17 -0
  87. betterportal-11.0.7/betterportal/_contracts/M2MRequestDescriptorSchema.json +90 -0
  88. betterportal-11.0.7/betterportal/_contracts/ManifestDeclarationSchema.json +837 -0
  89. betterportal-11.0.7/betterportal/_contracts/MultipartRequestSchema.json +114 -0
  90. betterportal-11.0.7/betterportal/_contracts/ObservabilityAttributesSchema.json +23 -0
  91. betterportal-11.0.7/betterportal/_contracts/ObservabilityValueSchema.json +20 -0
  92. betterportal-11.0.7/betterportal/_contracts/OperationDeclarationSchema.json +560 -0
  93. betterportal-11.0.7/betterportal/_contracts/OperationDependencySchema.json +38 -0
  94. betterportal-11.0.7/betterportal/_contracts/PersistedServiceConfigStateSchema.json +112 -0
  95. betterportal-11.0.7/betterportal/_contracts/PlatformServiceSchema.json +167 -0
  96. betterportal-11.0.7/betterportal/_contracts/PluginCategorySchema.json +17 -0
  97. betterportal-11.0.7/betterportal/_contracts/PluginIdSchema.json +10 -0
  98. betterportal-11.0.7/betterportal/_contracts/PluginManifestSchema.json +1627 -0
  99. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentDeploymentSchema.json +524 -0
  100. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentDeploymentServiceSchema.json +29 -0
  101. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentGroupSchema.json +486 -0
  102. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentGroupServiceSchema.json +375 -0
  103. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentOidcSchema.json +45 -0
  104. betterportal-11.0.7/betterportal/_contracts/PreviewEnvironmentServiceConfigSchema.json +68 -0
  105. betterportal-11.0.7/betterportal/_contracts/PublicJwksSchema.json +56 -0
  106. betterportal-11.0.7/betterportal/_contracts/RegistryPackageListSchema.json +1804 -0
  107. betterportal-11.0.7/betterportal/_contracts/RegistryPublishResultSchema.json +38 -0
  108. betterportal-11.0.7/betterportal/_contracts/RegistryReferenceSchema.json +10 -0
  109. betterportal-11.0.7/betterportal/_contracts/RenderModeSchema.json +14 -0
  110. betterportal-11.0.7/betterportal/_contracts/RendererDeclarationSchema.json +43 -0
  111. betterportal-11.0.7/betterportal/_contracts/ResolvedBPRouteReferenceSchema.json +31 -0
  112. betterportal-11.0.7/betterportal/_contracts/ResolvedRouteSchema.json +50 -0
  113. betterportal-11.0.7/betterportal/_contracts/RouteDeclarationSchema.json +34 -0
  114. betterportal-11.0.7/betterportal/_contracts/RouteOptionsSchema.json +131 -0
  115. betterportal-11.0.7/betterportal/_contracts/RsaPublicJwkSchema.json +44 -0
  116. betterportal-11.0.7/betterportal/_contracts/ScopedAppSchema.json +5673 -0
  117. betterportal-11.0.7/betterportal/_contracts/ScopedServiceConfigSchema.json +12344 -0
  118. betterportal-11.0.7/betterportal/_contracts/ScopedTenantSchema.json +378 -0
  119. betterportal-11.0.7/betterportal/_contracts/ScopedTenantServiceSchema.json +288 -0
  120. betterportal-11.0.7/betterportal/_contracts/SemverSchema.json +10 -0
  121. betterportal-11.0.7/betterportal/_contracts/ServiceCatalogEntrySchema.json +672 -0
  122. betterportal-11.0.7/betterportal/_contracts/ServiceConfigActionSchema.json +14 -0
  123. betterportal-11.0.7/betterportal/_contracts/ServiceConfigManagementModeSchema.json +14 -0
  124. betterportal-11.0.7/betterportal/_contracts/ServiceConfigReadResponseSchema.json +71 -0
  125. betterportal-11.0.7/betterportal/_contracts/ServiceConfigSchemaResponseSchema.json +362 -0
  126. betterportal-11.0.7/betterportal/_contracts/ServiceConfigStateSchema.json +67 -0
  127. betterportal-11.0.7/betterportal/_contracts/ServiceConfigTicketClaimsSchema.json +92 -0
  128. betterportal-11.0.7/betterportal/_contracts/ServiceConfigWriteRequestSchema.json +75 -0
  129. betterportal-11.0.7/betterportal/_contracts/ServiceConfigWriteResponseSchema.json +76 -0
  130. betterportal-11.0.7/betterportal/_contracts/ServiceHostnameChangeRequestSchema.json +24 -0
  131. betterportal-11.0.7/betterportal/_contracts/ServiceHostnameChangeResponseSchema.json +24 -0
  132. betterportal-11.0.7/betterportal/_contracts/ServiceInstallRequestSchema.json +28 -0
  133. betterportal-11.0.7/betterportal/_contracts/ServiceInstallResponseSchema.json +45 -0
  134. betterportal-11.0.7/betterportal/_contracts/ServiceInstallationBindingSchema.json +62 -0
  135. betterportal-11.0.7/betterportal/_contracts/ServiceManifestCacheEntrySchema.json +1428 -0
  136. betterportal-11.0.7/betterportal/_contracts/ServiceRedeemResponseSchema.json +33 -0
  137. betterportal-11.0.7/betterportal/_contracts/ServiceTokenClaimsSchema.json +71 -0
  138. betterportal-11.0.7/betterportal/_contracts/SetupTokenClaimsSchema.json +82 -0
  139. betterportal-11.0.7/betterportal/_contracts/SharedServiceDefinitionSchema.json +216 -0
  140. betterportal-11.0.7/betterportal/_contracts/ShellFragmentDescriptorSchema.json +45 -0
  141. betterportal-11.0.7/betterportal/_contracts/ShellManifestSchema.json +68 -0
  142. betterportal-11.0.7/betterportal/_contracts/SigningKeyPairSchema.json +34 -0
  143. betterportal-11.0.7/betterportal/_contracts/StreamEndFrameSchema.json +26 -0
  144. betterportal-11.0.7/betterportal/_contracts/StreamErrorFrameSchema.json +61 -0
  145. betterportal-11.0.7/betterportal/_contracts/StreamShellContextSchema.json +67 -0
  146. betterportal-11.0.7/betterportal/_contracts/TenantAppValidationSchema.json +37 -0
  147. betterportal-11.0.7/betterportal/_contracts/TenantSchema.json +59 -0
  148. betterportal-11.0.7/betterportal/_contracts/TenantServiceRegistrationSchema.json +188 -0
  149. betterportal-11.0.7/betterportal/_contracts/TenantSharedServiceActivationSchema.json +46 -0
  150. betterportal-11.0.7/betterportal/_contracts/TokenLifetimeConfigSchema.json +26 -0
  151. betterportal-11.0.7/betterportal/_contracts/TokenTypeSchema.json +16 -0
  152. betterportal-11.0.7/betterportal/_contracts/UrlScalarSchema.json +26 -0
  153. betterportal-11.0.7/betterportal/_contracts/UuidV7Schema.json +10 -0
  154. betterportal-11.0.7/betterportal/_contracts/ViewAppContextSchema.json +198 -0
  155. betterportal-11.0.7/betterportal/_contracts/ViewDemoScenarioMatchSchema.json +84 -0
  156. betterportal-11.0.7/betterportal/_contracts/ViewDemoScenarioSchema.json +116 -0
  157. betterportal-11.0.7/betterportal/_contracts/ViewMetadataSchema.json +800 -0
  158. betterportal-11.0.7/betterportal/_contracts/ViewOperationMetadataSchema.json +750 -0
  159. betterportal-11.0.7/betterportal/_contracts/ViewPermissionDefinitionSchema.json +38 -0
  160. betterportal-11.0.7/betterportal/_contracts/ViewRenderDataSchema.json +376 -0
  161. betterportal-11.0.7/betterportal/_contracts/ViewRenderErrorSchema.json +24 -0
  162. betterportal-11.0.7/betterportal/_contracts/ViewRendererSupportSchema.json +83 -0
  163. betterportal-11.0.7/betterportal/_contracts/ViewRendererVariantSchema.json +42 -0
  164. betterportal-11.0.7/betterportal/_contracts/ViewRoleSchema.json +10 -0
  165. betterportal-11.0.7/betterportal/_contracts/ViewStreamingSupportSchema.json +64 -0
  166. betterportal-11.0.7/betterportal/_contracts/ViewTenantContextSchema.json +110 -0
  167. betterportal-11.0.7/betterportal/_contracts/WebhookEventDescriptorSchema.json +70 -0
  168. betterportal-11.0.7/betterportal/_contracts/WebhookTargetSchema.json +68 -0
  169. betterportal-11.0.7/betterportal/access.py +71 -0
  170. betterportal-11.0.7/betterportal/ai.py +58 -0
  171. betterportal-11.0.7/betterportal/asgi.py +525 -0
  172. betterportal-11.0.7/betterportal/auth_helpers.py +102 -0
  173. betterportal-11.0.7/betterportal/authorization.py +136 -0
  174. betterportal-11.0.7/betterportal/bootstrap.py +132 -0
  175. betterportal-11.0.7/betterportal/bp_headers.py +38 -0
  176. betterportal-11.0.7/betterportal/cli.py +97 -0
  177. betterportal-11.0.7/betterportal/client_streams.py +58 -0
  178. betterportal-11.0.7/betterportal/clientgen.py +94 -0
  179. betterportal-11.0.7/betterportal/clients.py +422 -0
  180. betterportal-11.0.7/betterportal/config_api.py +87 -0
  181. betterportal-11.0.7/betterportal/context.py +172 -0
  182. betterportal-11.0.7/betterportal/contract_export.py +31 -0
  183. betterportal-11.0.7/betterportal/contracts.py +76 -0
  184. betterportal-11.0.7/betterportal/cors.py +49 -0
  185. betterportal-11.0.7/betterportal/discovery.py +144 -0
  186. betterportal-11.0.7/betterportal/elevation.py +32 -0
  187. betterportal-11.0.7/betterportal/encryption.py +154 -0
  188. betterportal-11.0.7/betterportal/feeds.py +37 -0
  189. betterportal-11.0.7/betterportal/finite.py +90 -0
  190. betterportal-11.0.7/betterportal/generated_types.py +5410 -0
  191. betterportal-11.0.7/betterportal/handler.py +208 -0
  192. betterportal-11.0.7/betterportal/hosting.py +95 -0
  193. betterportal-11.0.7/betterportal/installation.py +252 -0
  194. betterportal-11.0.7/betterportal/jsoncodec.py +27 -0
  195. betterportal-11.0.7/betterportal/keys.py +142 -0
  196. betterportal-11.0.7/betterportal/media.py +93 -0
  197. betterportal-11.0.7/betterportal/observability.py +286 -0
  198. betterportal-11.0.7/betterportal/project.py +232 -0
  199. betterportal-11.0.7/betterportal/py.typed +0 -0
  200. betterportal-11.0.7/betterportal/registry.py +231 -0
  201. betterportal-11.0.7/betterportal/registry_client.py +71 -0
  202. betterportal-11.0.7/betterportal/rendering.py +116 -0
  203. betterportal-11.0.7/betterportal/response.py +80 -0
  204. betterportal-11.0.7/betterportal/scaffold.py +126 -0
  205. betterportal-11.0.7/betterportal/security.py +266 -0
  206. betterportal-11.0.7/betterportal/seo.py +65 -0
  207. betterportal-11.0.7/betterportal/service.py +408 -0
  208. betterportal-11.0.7/betterportal/settings.py +294 -0
  209. betterportal-11.0.7/betterportal/sse.py +204 -0
  210. betterportal-11.0.7/betterportal/storage.py +66 -0
  211. betterportal-11.0.7/betterportal/streaming.py +113 -0
  212. betterportal-11.0.7/betterportal/sync.py +214 -0
  213. betterportal-11.0.7/betterportal/themes.py +179 -0
  214. betterportal-11.0.7/betterportal/typegen.py +171 -0
  215. betterportal-11.0.7/betterportal/urls.py +296 -0
  216. betterportal-11.0.7/betterportal/webhooks.py +86 -0
  217. betterportal-11.0.7/betterportal.egg-info/PKG-INFO +1141 -0
  218. betterportal-11.0.7/betterportal.egg-info/SOURCES.txt +59 -0
  219. betterportal-11.0.7/betterportal.egg-info/dependency_links.txt +1 -0
  220. betterportal-11.0.7/betterportal.egg-info/entry_points.txt +2 -0
  221. betterportal-11.0.7/betterportal.egg-info/requires.txt +9 -0
  222. betterportal-11.0.7/betterportal.egg-info/top_level.txt +1 -0
  223. betterportal-11.0.7/pyproject.toml +30 -0
  224. betterportal-11.0.7/setup.cfg +4 -0
  225. 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.