supython 0.1.11__tar.gz → 0.1.13__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 (257) hide show
  1. {supython-0.1.11 → supython-0.1.13}/.gitignore +1 -0
  2. {supython-0.1.11 → supython-0.1.13}/CHANGELOG.md +49 -0
  3. {supython-0.1.11 → supython-0.1.13}/PKG-INFO +1 -1
  4. {supython-0.1.11 → supython-0.1.13}/pyproject.toml +1 -1
  5. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/router.py +48 -8
  6. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/schemas.py +13 -0
  7. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/service.py +88 -10
  8. {supython-0.1.11 → supython-0.1.13}/src/supython/logging_config.py +21 -29
  9. supython-0.1.13/src/supython/migrations/0016_magic_link_redirect.sql +15 -0
  10. {supython-0.1.11 → supython-0.1.13}/src/supython/settings.py +13 -0
  11. {supython-0.1.11 → supython-0.1.13}/LICENSE +0 -0
  12. {supython-0.1.11 → supython-0.1.13}/README.md +0 -0
  13. {supython-0.1.11 → supython-0.1.13}/SECURITY.md +0 -0
  14. {supython-0.1.11 → supython-0.1.13}/src/supython/__init__.py +0 -0
  15. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/__init__.py +0 -0
  16. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/__init__.py +0 -0
  17. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/auth.py +0 -0
  18. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/auth_templates.py +0 -0
  19. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/auth_users.py +0 -0
  20. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/db.py +0 -0
  21. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/functions.py +0 -0
  22. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/jobs.py +0 -0
  23. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/ops.py +0 -0
  24. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/realtime.py +0 -0
  25. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_auth.py +0 -0
  26. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_auth_templates.py +0 -0
  27. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_auth_users.py +0 -0
  28. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_db.py +0 -0
  29. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_functions.py +0 -0
  30. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_jobs.py +0 -0
  31. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_ops.py +0 -0
  32. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_realtime.py +0 -0
  33. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/service_storage.py +0 -0
  34. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/storage.py +0 -0
  35. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/api/system.py +0 -0
  36. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/audit.py +0 -0
  37. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/deps.py +0 -0
  38. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/errors.py +0 -0
  39. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/schemas.py +0 -0
  40. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/session.py +0 -0
  41. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/spa.py +0 -0
  42. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Alert-dluGVkos.js +0 -0
  43. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Alert-dluGVkos.js.map +0 -0
  44. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Audit-Njung3HI.js +0 -0
  45. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Audit-Njung3HI.js.map +0 -0
  46. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Backups-DzPlFgrm.js +0 -0
  47. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Backups-DzPlFgrm.js.map +0 -0
  48. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Buckets-ByacGkU1.js +0 -0
  49. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Buckets-ByacGkU1.js.map +0 -0
  50. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Channels-BoIuTtam.js +0 -0
  51. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Channels-BoIuTtam.js.map +0 -0
  52. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +0 -0
  53. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js.map +0 -0
  54. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +0 -0
  55. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js.map +0 -0
  56. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Crons-B67vc39F.js +0 -0
  57. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Crons-B67vc39F.js.map +0 -0
  58. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js +0 -0
  59. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js.map +0 -0
  60. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DataTable-COAAWEft.js +0 -0
  61. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DataTable-COAAWEft.js.map +0 -0
  62. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +0 -0
  63. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js.map +0 -0
  64. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js +0 -0
  65. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js.map +0 -0
  66. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Empty-cr2r7e2u.js +0 -0
  67. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Empty-cr2r7e2u.js.map +0 -0
  68. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js +0 -0
  69. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js.map +0 -0
  70. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Grid-hFkp9F4P.js +0 -0
  71. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Grid-hFkp9F4P.js.map +0 -0
  72. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Input-DppYTq9C.js +0 -0
  73. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Input-DppYTq9C.js.map +0 -0
  74. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js +0 -0
  75. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js.map +0 -0
  76. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/JsonField-DibyJgun.js +0 -0
  77. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/JsonField-DibyJgun.js.map +0 -0
  78. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/LoginView-BjLyE3Ds.css +0 -0
  79. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/LoginView-CoOjECT_.js +0 -0
  80. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/LoginView-CoOjECT_.js.map +0 -0
  81. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Logs-D9WYrnIT.js +0 -0
  82. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Logs-D9WYrnIT.js.map +0 -0
  83. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Logs-DS1XPa0h.css +0 -0
  84. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js +0 -0
  85. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js.map +0 -0
  86. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +0 -0
  87. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js.map +0 -0
  88. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Queue-CywZs6vI.js +0 -0
  89. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Queue-CywZs6vI.js.map +0 -0
  90. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +0 -0
  91. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js.map +0 -0
  92. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js +0 -0
  93. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js.map +0 -0
  94. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Routes-BiLXE49D.js +0 -0
  95. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Routes-BiLXE49D.js.map +0 -0
  96. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Routes-C-ianIGD.css +0 -0
  97. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +0 -0
  98. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +0 -0
  99. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js.map +0 -0
  100. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Select-DIzZyRZb.js +0 -0
  101. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Select-DIzZyRZb.js.map +0 -0
  102. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Space-n5-XcguU.js +0 -0
  103. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Space-n5-XcguU.js.map +0 -0
  104. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js +0 -0
  105. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js.map +0 -0
  106. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +0 -0
  107. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js.map +0 -0
  108. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/TableData-CQIagLKn.js +0 -0
  109. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/TableData-CQIagLKn.js.map +0 -0
  110. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Tag-D1fOKpTH.js +0 -0
  111. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Tag-D1fOKpTH.js.map +0 -0
  112. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Templates-BS-ugkdq.js +0 -0
  113. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Templates-BS-ugkdq.js.map +0 -0
  114. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Thing-CEAniuMg.js +0 -0
  115. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Thing-CEAniuMg.js.map +0 -0
  116. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Users-wzwajhlh.js +0 -0
  117. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/Users-wzwajhlh.js.map +0 -0
  118. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +0 -0
  119. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/dist-VXIJLCYq.js +0 -0
  120. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/dist-VXIJLCYq.js.map +0 -0
  121. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/format-length-CGCY1rMh.js +0 -0
  122. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/format-length-CGCY1rMh.js.map +0 -0
  123. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/get-Ca6unauB.js +0 -0
  124. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/get-Ca6unauB.js.map +0 -0
  125. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/index-CeE6v959.js +0 -0
  126. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/index-CeE6v959.js.map +0 -0
  127. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/pinia-COXwfrOX.js +0 -0
  128. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/pinia-COXwfrOX.js.map +0 -0
  129. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/resources-Bt6thQCD.js +0 -0
  130. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/resources-Bt6thQCD.js.map +0 -0
  131. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js +0 -0
  132. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js.map +0 -0
  133. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js +0 -0
  134. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js.map +0 -0
  135. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js +0 -0
  136. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js.map +0 -0
  137. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useResource-C_rJCY8C.js +0 -0
  138. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useResource-C_rJCY8C.js.map +0 -0
  139. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useTable-CnZc5zhi.js +0 -0
  140. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useTable-CnZc5zhi.js.map +0 -0
  141. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useTable-Dg0XlRlq.css +0 -0
  142. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useToast-DsZKx0IX.js +0 -0
  143. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/useToast-DsZKx0IX.js.map +0 -0
  144. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/utils-sbXoq7Ir.js +0 -0
  145. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/assets/utils-sbXoq7Ir.js.map +0 -0
  146. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/favicon.svg +0 -0
  147. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/icons.svg +0 -0
  148. {supython-0.1.11 → supython-0.1.13}/src/supython/admin/static/index.html +0 -0
  149. {supython-0.1.11 → supython-0.1.13}/src/supython/app.py +0 -0
  150. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/__init__.py +0 -0
  151. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/_email_job.py +0 -0
  152. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/claims.py +0 -0
  153. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/deps.py +0 -0
  154. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/providers/__init__.py +0 -0
  155. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/providers/github.py +0 -0
  156. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/providers/google.py +0 -0
  157. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/providers/oauth.py +0 -0
  158. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/providers/registry.py +0 -0
  159. {supython-0.1.11 → supython-0.1.13}/src/supython/auth/ratelimit.py +0 -0
  160. {supython-0.1.11 → supython-0.1.13}/src/supython/backups/__init__.py +0 -0
  161. {supython-0.1.11 → supython-0.1.13}/src/supython/backups/_backup_job.py +0 -0
  162. {supython-0.1.11 → supython-0.1.13}/src/supython/backups/schemas.py +0 -0
  163. {supython-0.1.11 → supython-0.1.13}/src/supython/backups/service.py +0 -0
  164. {supython-0.1.11 → supython-0.1.13}/src/supython/body_size.py +0 -0
  165. {supython-0.1.11 → supython-0.1.13}/src/supython/cli.py +0 -0
  166. {supython-0.1.11 → supython-0.1.13}/src/supython/client/__init__.py +0 -0
  167. {supython-0.1.11 → supython-0.1.13}/src/supython/client/_auth.py +0 -0
  168. {supython-0.1.11 → supython-0.1.13}/src/supython/client/_client.py +0 -0
  169. {supython-0.1.11 → supython-0.1.13}/src/supython/client/_config.py +0 -0
  170. {supython-0.1.11 → supython-0.1.13}/src/supython/client/_functions.py +0 -0
  171. {supython-0.1.11 → supython-0.1.13}/src/supython/client/_storage.py +0 -0
  172. {supython-0.1.11 → supython-0.1.13}/src/supython/client/py.typed +0 -0
  173. {supython-0.1.11 → supython-0.1.13}/src/supython/db.py +0 -0
  174. {supython-0.1.11 → supython-0.1.13}/src/supython/db_admin.py +0 -0
  175. {supython-0.1.11 → supython-0.1.13}/src/supython/extensions.py +0 -0
  176. {supython-0.1.11 → supython-0.1.13}/src/supython/functions/__init__.py +0 -0
  177. {supython-0.1.11 → supython-0.1.13}/src/supython/functions/context.py +0 -0
  178. {supython-0.1.11 → supython-0.1.13}/src/supython/functions/loader.py +0 -0
  179. {supython-0.1.11 → supython-0.1.13}/src/supython/functions/router.py +0 -0
  180. {supython-0.1.11 → supython-0.1.13}/src/supython/functions/schemas.py +0 -0
  181. {supython-0.1.11 → supython-0.1.13}/src/supython/gen/__init__.py +0 -0
  182. {supython-0.1.11 → supython-0.1.13}/src/supython/gen/_introspect.py +0 -0
  183. {supython-0.1.11 → supython-0.1.13}/src/supython/gen/types_py.py +0 -0
  184. {supython-0.1.11 → supython-0.1.13}/src/supython/gen/types_ts.py +0 -0
  185. {supython-0.1.11 → supython-0.1.13}/src/supython/health.py +0 -0
  186. {supython-0.1.11 → supython-0.1.13}/src/supython/hooks.py +0 -0
  187. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/__init__.py +0 -0
  188. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/backends.py +0 -0
  189. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/context.py +0 -0
  190. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/cron.py +0 -0
  191. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/cron_inproc.py +0 -0
  192. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/decorators.py +0 -0
  193. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/registry.py +0 -0
  194. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/router.py +0 -0
  195. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/schemas.py +0 -0
  196. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/service.py +0 -0
  197. {supython-0.1.11 → supython-0.1.13}/src/supython/jobs/worker.py +0 -0
  198. {supython-0.1.11 → supython-0.1.13}/src/supython/jwks.py +0 -0
  199. {supython-0.1.11 → supython-0.1.13}/src/supython/keyset.py +0 -0
  200. {supython-0.1.11 → supython-0.1.13}/src/supython/mail.py +0 -0
  201. {supython-0.1.11 → supython-0.1.13}/src/supython/mailer.py +0 -0
  202. {supython-0.1.11 → supython-0.1.13}/src/supython/migrate.py +0 -0
  203. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0001_extensions_and_roles.sql +0 -0
  204. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0002_auth_schema.sql +0 -0
  205. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0004_auth_v0_2.sql +0 -0
  206. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0005_storage_schema.sql +0 -0
  207. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0006_realtime_schema.sql +0 -0
  208. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0007_jobs_schema.sql +0 -0
  209. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0008_jobs_last_error.sql +0 -0
  210. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0009_auth_rate_limits.sql +0 -0
  211. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0010_worker_heartbeat.sql +0 -0
  212. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0011_admin_schema.sql +0 -0
  213. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0012_auth_banned_until.sql +0 -0
  214. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0013_email_templates.sql +0 -0
  215. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0014_realtime_payload_warning.sql +0 -0
  216. {supython-0.1.11 → supython-0.1.13}/src/supython/migrations/0015_backups_schema.sql +0 -0
  217. {supython-0.1.11 → supython-0.1.13}/src/supython/passwords.py +0 -0
  218. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/__init__.py +0 -0
  219. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/broker.py +0 -0
  220. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/protocol.py +0 -0
  221. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/router.py +0 -0
  222. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/schemas.py +0 -0
  223. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/service.py +0 -0
  224. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/topics.py +0 -0
  225. {supython-0.1.11 → supython-0.1.13}/src/supython/realtime/websocket.py +0 -0
  226. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/__init__.py +0 -0
  227. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/init_project.py +0 -0
  228. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/Caddyfile.tmpl +0 -0
  229. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/README.md.tmpl +0 -0
  230. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/apps_hooks_welcome.py.tmpl +0 -0
  231. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/apps_jobs_example.py.tmpl +0 -0
  232. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/asgi.py.tmpl +0 -0
  233. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/docker-compose.prod.yml.tmpl +0 -0
  234. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/docker-compose.yml.tmpl +0 -0
  235. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +0 -0
  236. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +0 -0
  237. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/env.example.tmpl +0 -0
  238. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/functions_README.md.tmpl +0 -0
  239. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/gitignore.tmpl +0 -0
  240. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/manage.py.tmpl +0 -0
  241. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/package_hooks_init.py.tmpl +0 -0
  242. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/package_init.py.tmpl +0 -0
  243. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/package_jobs_init.py.tmpl +0 -0
  244. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/pyproject.toml.tmpl +0 -0
  245. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/settings.py.tmpl +0 -0
  246. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/static/0001_create_todos.sql +0 -0
  247. {supython-0.1.11 → supython-0.1.13}/src/supython/scaffold/templates/static/functions_hello.py +0 -0
  248. {supython-0.1.11 → supython-0.1.13}/src/supython/secretset.py +0 -0
  249. {supython-0.1.11 → supython-0.1.13}/src/supython/security_headers.py +0 -0
  250. {supython-0.1.11 → supython-0.1.13}/src/supython/settings_module.py +0 -0
  251. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/__init__.py +0 -0
  252. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/backends.py +0 -0
  253. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/router.py +0 -0
  254. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/schemas.py +0 -0
  255. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/service.py +0 -0
  256. {supython-0.1.11 → supython-0.1.13}/src/supython/storage/signing.py +0 -0
  257. {supython-0.1.11 → supython-0.1.13}/src/supython/tokens.py +0 -0
@@ -23,6 +23,7 @@ htmlcov/
23
23
  # dev-app/ is the dogfooded scaffolded project living inside the lib repo.
24
24
  # Its checked-in shape (compose files, functions, storage fixtures) mirrors
25
25
  # what `supython init` would produce; runtime state stays untracked.
26
+ dev-app/
26
27
  dev-app/.env
27
28
  dev-app/.supython/
28
29
  dev-app/backups/
@@ -30,6 +30,53 @@ Each entry links the relevant `PROJECT.md` section and decision-log row
30
30
 
31
31
  ---
32
32
 
33
+ ## [0.1.13] — 2026-07-16
34
+
35
+ ### Added
36
+ - `POST /auth/v1/magiclink` accepts an optional `redirect_url`: when its origin
37
+ is in the new `MAGIC_LINK_REDIRECT_ALLOWLIST` setting, `GET
38
+ /auth/v1/magiclink/verify` 302-redirects the browser to it with the token
39
+ pair in the fragment (the same shape as the OAuth callback), instead of
40
+ returning JSON. Omitting `redirect_url` keeps the existing JSON response.
41
+ Lets a consumer send a magic link straight to its own SPA route (e.g. an
42
+ invite-accept page) rather than the API's bare verify endpoint.
43
+ - `POST /auth/v1/magiclink` accepts an optional `ttl` (seconds), clamped to
44
+ `[60, MAGIC_LINK_MAX_TTL]` (new setting, default 7 days). Lets a single
45
+ caller mint a longer-lived link (e.g. a multi-day operator invite) without
46
+ moving `MAGIC_LINK_TOKEN_TTL`, which every other magic-link request still
47
+ uses unchanged.
48
+ - Migration `0016_magic_link_redirect.sql`: nullable `auth.one_time_tokens.redirect_url`.
49
+
50
+ ### Security
51
+ - `redirect_url` is validated against an explicit origin allowlist before any
52
+ email is sent (empty allowlist ⇒ every redirect is rejected, fail-closed);
53
+ a non-allowlisted origin, embedded credentials (`user:pass@host`), or a
54
+ non-http(s) scheme all reject the request with `400 invalid_redirect`. The
55
+ target is stored server-side on the one-time token, not echoed through the
56
+ emailed link, so an intercepted link can't be repointed at a different origin.
57
+
58
+ ---
59
+
60
+ ## [0.1.12] — 2026-07-01
61
+
62
+ ### Fixed
63
+
64
+ - `RequestLoggingMiddleware` no longer truncates request bodies larger than
65
+ `_REQUEST_LOG_MAX_BODY_BYTES` (10 KiB) before they reach the application. It
66
+ had eagerly drained the whole body, kept only the first 10 KiB for the 5xx
67
+ log copy, then replayed *that truncated buffer* to the app — so every request
68
+ with a larger body was silently corrupted before the handler saw it: multipart
69
+ uploads lost their trailing bytes and closing boundary (Starlette then dropped
70
+ the file part, surfacing as a misleading `a multipart 'file' part is required`
71
+ 400), and large JSON payloads were cut mid-document. The middleware now **tees**
72
+ the body — forwarding each ASGI message to the app untouched while copying only
73
+ a bounded prefix aside for logging — so the stream stays intact, true streaming
74
+ (storage/functions) is never buffered whole, and `http.disconnect` still
75
+ propagates for streaming-response hangup detection. The logged `request_body`
76
+ now reflects the bytes the app actually consumed. (#5)
77
+
78
+ ---
79
+
33
80
  ## [0.1.11] — 2026-06-13
34
81
 
35
82
  Project-initialization ergonomics. `supython init` now produces a project that
@@ -298,6 +345,8 @@ v0.1–v0.7 plus a v1.1.x admin track; see §19 decision log
298
345
  ---
299
346
 
300
347
 
348
+ [0.1.13]: https://github.com/Tkeby/supython/releases/tag/v0.1.13
349
+ [0.1.12]: https://github.com/Tkeby/supython/releases/tag/v0.1.12
301
350
  [0.1.11]: https://github.com/Tkeby/supython/releases/tag/v0.1.11
302
351
  [0.1.10]: https://github.com/Tkeby/supython/releases/tag/v0.1.10
303
352
  [0.1.9]: https://github.com/Tkeby/supython/releases/tag/v0.1.9
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supython
3
- Version: 0.1.11
3
+ Version: 0.1.13
4
4
  Summary: A lightweight Postgres-first BaaS framework for Python
5
5
  Project-URL: Homepage, https://github.com/Tkeby/supython
6
6
  Project-URL: Repository, https://github.com/Tkeby/supython
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "supython"
7
- version = "0.1.11"
7
+ version = "0.1.13"
8
8
  description = "A lightweight Postgres-first BaaS framework for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,8 +2,18 @@ from typing import Annotated
2
2
  from uuid import UUID
3
3
 
4
4
  import jwt
5
- from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
6
- from fastapi.responses import RedirectResponse
5
+ from fastapi import (
6
+ APIRouter,
7
+ Depends,
8
+ Header,
9
+ HTTPException,
10
+ Query,
11
+ Request,
12
+ Response,
13
+ status,
14
+ )
15
+ from fastapi.encoders import jsonable_encoder
16
+ from fastapi.responses import JSONResponse, RedirectResponse
7
17
 
8
18
  from .. import db, tokens
9
19
  from ..settings import get_settings
@@ -198,24 +208,54 @@ async def verify_recover(payload: RecoverVerifyRequest, request: Request) -> Tok
198
208
 
199
209
  @router.post("/magiclink", status_code=202, dependencies=[Depends(_rl_magiclink)])
200
210
  async def request_magic_link(payload: MagicLinkRequest) -> None:
201
- async with db.acquire() as conn:
202
- await service.request_magic_link(conn, payload.email)
211
+ try:
212
+ async with db.acquire() as conn:
213
+ await service.request_magic_link(
214
+ conn,
215
+ payload.email,
216
+ redirect_url=payload.redirect_url,
217
+ ttl=payload.ttl,
218
+ )
219
+ except service.AuthError as exc:
220
+ # Currently only invalid_redirect (400) — a bad email is filtered by
221
+ # EmailStr at the schema layer before this handler ever runs.
222
+ raise _auth_error(exc) from exc
203
223
 
204
224
 
205
225
  @router.get(
206
226
  "/magiclink/verify",
207
- response_model=TokenResponse,
208
227
  dependencies=[Depends(_rl_magiclink)],
209
228
  )
210
229
  async def verify_magic_link(
211
230
  token: Annotated[str, Query(description="Raw magic-link token from the email")],
212
- ) -> TokenResponse:
231
+ ) -> Response:
232
+ """Verify a magic-link token.
233
+
234
+ Legacy/JSON callers (no ``redirect_url`` at request time) get the same
235
+ ``TokenResponse`` body as always. A caller that requested a redirect gets a
236
+ 302 to that URL with the token pair in the fragment — the same shape as the
237
+ OAuth callback (`oauth_callback` below) — so a browser landing here from an
238
+ emailed link ends up on the SPA's own page with a session, not looking at
239
+ raw JSON.
240
+ """
213
241
  try:
214
242
  async with db.acquire() as conn:
215
- result = await service.verify_magic_link(conn, token)
243
+ user, access, refresh, ttl, redirect_url = await service.verify_magic_link(
244
+ conn, token
245
+ )
216
246
  except service.AuthError as exc:
217
247
  raise _auth_error(exc) from exc
218
- return _to_token_response(*result)
248
+ if redirect_url:
249
+ fragment = (
250
+ f"access_token={access}"
251
+ f"&refresh_token={refresh}"
252
+ f"&expires_in={ttl}"
253
+ f"&token_type=bearer"
254
+ )
255
+ return RedirectResponse(f"{redirect_url}#{fragment}", status_code=302)
256
+ return JSONResponse(
257
+ jsonable_encoder(_to_token_response(user, access, refresh, ttl))
258
+ )
219
259
 
220
260
 
221
261
  @router.post("/otp", status_code=202, dependencies=[Depends(_rl_otp)])
@@ -17,6 +17,8 @@ MAX_PASSWORD_LEN = 128
17
17
  # chars in practice). 4096 keeps a generous head-room for any future
18
18
  # encoding while still rejecting "1 MB token" abuse.
19
19
  MAX_TOKEN_LEN = 4096
20
+ # Redirect URLs are ordinary web URLs; 2048 is the de-facto browser cap.
21
+ MAX_REDIRECT_URL_LEN = 2048
20
22
 
21
23
  EmailField = Annotated[EmailStr, Field(max_length=MAX_EMAIL_LEN)]
22
24
  PasswordField = Annotated[
@@ -68,6 +70,17 @@ class RecoverVerifyRequest(BaseModel):
68
70
 
69
71
  class MagicLinkRequest(BaseModel):
70
72
  email: EmailField
73
+ # Where /auth/v1/magiclink/verify should send the browser after issuing
74
+ # tokens: a 302 to this URL with the token pair in the fragment (as OAuth
75
+ # does), instead of a JSON response. Must match MAGIC_LINK_REDIRECT_
76
+ # ALLOWLIST or the request is rejected 400. Omit for the JSON flow.
77
+ redirect_url: Annotated[
78
+ str | None, Field(default=None, max_length=MAX_REDIRECT_URL_LEN)
79
+ ] = None
80
+ # Per-request token lifetime in seconds, clamped to [60, MAGIC_LINK_MAX_TTL].
81
+ # Omit to use MAGIC_LINK_TOKEN_TTL. Lets a caller mint a longer-lived link
82
+ # (e.g. an operator invite) without changing the global default.
83
+ ttl: Annotated[int | None, Field(default=None, ge=1)] = None
71
84
 
72
85
 
73
86
  class OtpRequest(BaseModel):
@@ -6,6 +6,7 @@ import logging
6
6
  import secrets
7
7
  from datetime import UTC, datetime, timedelta
8
8
  from typing import Any
9
+ from urllib.parse import urlsplit
9
10
  from uuid import UUID
10
11
 
11
12
  import asyncpg
@@ -82,19 +83,26 @@ async def _store_one_time_token(
82
83
  user_id: UUID,
83
84
  token_type: str,
84
85
  ttl_seconds: int,
86
+ *,
87
+ redirect_url: str | None = None,
85
88
  ) -> str:
86
- """Generate, sha256-hash, and store a one-time token. Returns the raw token."""
89
+ """Generate, sha256-hash, and store a one-time token. Returns the raw token.
90
+
91
+ ``redirect_url`` is stored only for magic-link tokens (recover/otp pass
92
+ None ⇒ NULL); verify reads it back to decide between a 302 and JSON.
93
+ """
87
94
  raw = secrets.token_urlsafe(32)
88
95
  expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
89
96
  await conn.execute(
90
97
  """
91
- insert into auth.one_time_tokens (user_id, type, token_hash, expires_at)
92
- values ($1, $2, $3, $4)
98
+ insert into auth.one_time_tokens (user_id, type, token_hash, expires_at, redirect_url)
99
+ values ($1, $2, $3, $4, $5)
93
100
  """,
94
101
  user_id,
95
102
  token_type,
96
103
  _sha256(raw),
97
104
  expires_at,
105
+ redirect_url,
98
106
  )
99
107
  return raw
100
108
 
@@ -110,7 +118,7 @@ async def _verify_one_time_token(
110
118
  if email is not None:
111
119
  row = await conn.fetchrow(
112
120
  """
113
- select ott.id as ott_id, u.id, u.email, u.created_at
121
+ select ott.id as ott_id, ott.redirect_url, u.id, u.email, u.created_at
114
122
  from auth.one_time_tokens ott
115
123
  join auth.users u on u.id = ott.user_id
116
124
  where ott.token_hash = $1
@@ -126,7 +134,7 @@ async def _verify_one_time_token(
126
134
  else:
127
135
  row = await conn.fetchrow(
128
136
  """
129
- select ott.id as ott_id, u.id, u.email, u.created_at
137
+ select ott.id as ott_id, ott.redirect_url, u.id, u.email, u.created_at
130
138
  from auth.one_time_tokens ott
131
139
  join auth.users u on u.id = ott.user_id
132
140
  where ott.token_hash = $1
@@ -354,17 +362,85 @@ async def verify_recover(
354
362
  return user, access, refresh, ttl
355
363
 
356
364
 
365
+ def _url_origin(url: str) -> str | None:
366
+ """Return the ``scheme://host[:port]`` origin of an absolute http(s) URL.
367
+
368
+ Returns None for anything that isn't a well-formed absolute http(s) URL, or
369
+ that carries embedded credentials (``user:pass@host``) — those have no place
370
+ in a redirect target and are a classic obfuscation vector. Origins are
371
+ lower-cased so comparison is case-insensitive on scheme/host.
372
+ """
373
+ try:
374
+ parts = urlsplit(url)
375
+ except ValueError:
376
+ return None
377
+ if parts.scheme not in ("http", "https") or not parts.netloc or "@" in parts.netloc:
378
+ return None
379
+ return f"{parts.scheme}://{parts.netloc}".lower()
380
+
381
+
382
+ def validate_magic_link_redirect(redirect_url: str, allowlist_csv: str) -> str:
383
+ """Return ``redirect_url`` when its origin is allowlisted, else raise.
384
+
385
+ ``allowlist_csv`` is the comma-separated ``MAGIC_LINK_REDIRECT_ALLOWLIST``
386
+ setting. An empty allowlist rejects every redirect (the feature is off and
387
+ fails closed). Matching is by origin only, so any path/query/fragment under
388
+ an allowlisted origin is accepted. Raises ``AuthError('invalid_redirect')``
389
+ (400) on any miss — the caller turns that into an HTTP 400.
390
+ """
391
+ origin = _url_origin(redirect_url)
392
+ if origin is None:
393
+ raise AuthError(
394
+ "invalid_redirect", "redirect_url must be an absolute http(s) URL", 400
395
+ )
396
+ allowed = {
397
+ o for entry in allowlist_csv.split(",") if (o := _url_origin(entry.strip()))
398
+ }
399
+ if origin not in allowed:
400
+ raise AuthError(
401
+ "invalid_redirect", "redirect_url origin is not allowlisted", 400
402
+ )
403
+ return redirect_url
404
+
405
+
406
+ def _clamp_magic_link_ttl(ttl: int | None, settings: Any) -> int:
407
+ """Resolve the effective magic-link TTL in seconds.
408
+
409
+ ``None`` ⇒ the ``magic_link_token_ttl`` default. A supplied value is clamped
410
+ to ``[60, magic_link_max_ttl]`` so a caller can neither mint a link that
411
+ expires too fast to click nor one that outlives the configured ceiling.
412
+ """
413
+ if ttl is None:
414
+ return settings.magic_link_token_ttl
415
+ return max(60, min(ttl, settings.magic_link_max_ttl))
416
+
417
+
357
418
  async def request_magic_link(
358
- conn: asyncpg.Connection, email: str
419
+ conn: asyncpg.Connection,
420
+ email: str,
421
+ *,
422
+ redirect_url: str | None = None,
423
+ ttl: int | None = None,
359
424
  ) -> None:
425
+ s = get_settings()
426
+ # Validate the redirect before the user lookup: a bad redirect is a client
427
+ # error regardless of whether the email exists, and checking it here keeps
428
+ # the email-enumeration silence below intact (unknown emails still 202).
429
+ if redirect_url is not None:
430
+ validate_magic_link_redirect(redirect_url, s.magic_link_redirect_allowlist)
360
431
  row = await conn.fetchrow(
361
432
  "select id from auth.users where email = $1", email
362
433
  )
363
434
  if not row:
364
435
  return
365
- s = get_settings()
366
436
  async with conn.transaction():
367
- raw = await _store_one_time_token(conn, row["id"], "magic_link", s.magic_link_token_ttl)
437
+ raw = await _store_one_time_token(
438
+ conn,
439
+ row["id"],
440
+ "magic_link",
441
+ _clamp_magic_link_ttl(ttl, s),
442
+ redirect_url=redirect_url,
443
+ )
368
444
  verify_url = f"{s.site_url}/auth/v1/magiclink/verify?token={raw}"
369
445
  await mail.dispatch(
370
446
  conn,
@@ -379,7 +455,7 @@ async def request_magic_link(
379
455
 
380
456
  async def verify_magic_link(
381
457
  conn: asyncpg.Connection, token: str
382
- ) -> tuple[UserResponse, str, str, int]:
458
+ ) -> tuple[UserResponse, str, str, int, str | None]:
383
459
  row = await _verify_one_time_token(conn, token, "magic_link")
384
460
  user = _row_to_user(row)
385
461
  async with conn.transaction():
@@ -388,7 +464,9 @@ async def verify_magic_link(
388
464
  row["ott_id"],
389
465
  )
390
466
  access, refresh, ttl = await _issue_pair(conn, user)
391
- return user, access, refresh, ttl
467
+ # redirect_url is None for legacy/JSON callers; the router 302-redirects
468
+ # (OAuth-style, tokens in the fragment) only when it is set.
469
+ return user, access, refresh, ttl, row["redirect_url"]
392
470
 
393
471
 
394
472
  async def request_otp(
@@ -179,18 +179,29 @@ class RequestLoggingMiddleware:
179
179
 
180
180
  rid = get_request_id()
181
181
 
182
+ # Capture a bounded prefix of the request body for diagnostic logging
183
+ # WITHOUT altering the byte stream the inner app receives. Each ASGI
184
+ # message is forwarded through untouched while up to
185
+ # _REQUEST_LOG_MAX_BODY_BYTES is copied aside for the log record.
186
+ #
187
+ # This must never pre-read or rewrite the body. The previous approach
188
+ # eagerly drained the whole request, kept only the first
189
+ # _REQUEST_LOG_MAX_BODY_BYTES, then replayed *that truncated buffer* to
190
+ # the app — silently corrupting every request whose body exceeded the
191
+ # cap (multipart file uploads, large JSON payloads: the trailing bytes,
192
+ # and with them the multipart closing boundary, never reached the
193
+ # handler). Teeing keeps the stream intact, preserves true streaming
194
+ # (storage/functions are never buffered whole), and still forwards
195
+ # http.disconnect so streaming responses can detect client hangups.
182
196
  body_chunks: list[bytes] = []
183
197
  body_size = 0
184
198
  body_truncated = False
185
199
 
186
- async def _drain_body() -> None:
200
+ async def _receive() -> dict[str, Any]:
187
201
  nonlocal body_size, body_truncated
188
- while True:
189
- msg = await receive()
190
- if msg["type"] != "http.request":
191
- continue
202
+ msg = await receive()
203
+ if msg["type"] == "http.request":
192
204
  chunk = msg.get("body", b"")
193
- more = msg.get("more_body", False)
194
205
  if chunk and body_size < _REQUEST_LOG_MAX_BODY_BYTES:
195
206
  space = _REQUEST_LOG_MAX_BODY_BYTES - body_size
196
207
  if len(chunk) > space:
@@ -200,29 +211,7 @@ class RequestLoggingMiddleware:
200
211
  else:
201
212
  body_chunks.append(chunk)
202
213
  body_size += len(chunk)
203
- if not more:
204
- break
205
-
206
- await _drain_body()
207
-
208
- full_body = b"".join(body_chunks)
209
- body_consumed = False
210
-
211
- async def _receive() -> dict[str, Any]:
212
- nonlocal body_consumed
213
- if not body_consumed:
214
- body_consumed = True
215
- return {
216
- "type": "http.request",
217
- "body": full_body,
218
- "more_body": False,
219
- }
220
- # Body already delivered. Forward subsequent calls to the real
221
- # receive() so callers awaiting http.disconnect (e.g. Starlette's
222
- # StreamingResponse.listen_for_disconnect) actually block on the
223
- # event loop instead of busy-looping on synchronous returns,
224
- # which would starve the streaming task and hang the request.
225
- return await receive()
214
+ return msg
226
215
 
227
216
  status_code: int = 0
228
217
  response_size: int = 0
@@ -270,6 +259,9 @@ class RequestLoggingMiddleware:
270
259
  is_server_error = 500 <= status_code < 600 or exc_info is not None
271
260
 
272
261
  if is_server_error:
262
+ # The bounded prefix the app actually consumed (see the tee in
263
+ # _receive above); empty if the handler errored before reading.
264
+ full_body = b"".join(body_chunks)
273
265
  fields["request_body"] = full_body.decode("utf-8", errors="replace")
274
266
  if body_truncated:
275
267
  fields["body_truncated"] = True
@@ -0,0 +1,15 @@
1
+ -- Magic-link redirect target.
2
+ --
3
+ -- When a POST /auth/v1/magiclink request carries a redirect_url (validated
4
+ -- against MAGIC_LINK_REDIRECT_ALLOWLIST at request time), it is stored on the
5
+ -- one-time token here. GET /auth/v1/magiclink/verify then 302-redirects the
6
+ -- browser to that SPA URL with the token pair in the fragment — mirroring the
7
+ -- OAuth callback — instead of returning JSON. Storing the target server-side
8
+ -- (rather than echoing it in the emailed link) means an attacker who intercepts
9
+ -- the link cannot repoint it at a token-stealing origin.
10
+ --
11
+ -- Null ⇒ legacy behaviour: verify returns a JSON TokenResponse. Backfill-safe
12
+ -- (nullable, no default), so existing rows keep working.
13
+
14
+ alter table auth.one_time_tokens
15
+ add column if not exists redirect_url text;
@@ -82,6 +82,19 @@ class Settings(BaseSettings):
82
82
  recover_token_ttl: int = 3600
83
83
  magic_link_token_ttl: int = 15 * 60
84
84
  otp_token_ttl: int = 10 * 60
85
+
86
+ # Ceiling for a per-request magic-link lifetime. A POST /auth/v1/magiclink
87
+ # may pass its own `ttl` (e.g. a multi-day operator invite) without moving
88
+ # the magic_link_token_ttl default that ordinary sign-in links use; the
89
+ # requested value is clamped to [60, magic_link_max_ttl].
90
+ magic_link_max_ttl: int = 60 * 60 * 24 * 7 # 7 days
91
+
92
+ # Comma-separated origins (scheme://host[:port]) a magic-link `redirect_url`
93
+ # may target. Empty ⇒ redirects are disabled and verify keeps returning
94
+ # JSON. A redirect_url whose origin is not listed is rejected 400 at request
95
+ # time, so an attacker can't aim a victim's emailed link at a token-stealing
96
+ # site. Example: MAGIC_LINK_REDIRECT_ALLOWLIST=https://app.example.com,http://localhost:5173
97
+ magic_link_redirect_allowlist: str = ""
85
98
  auth_rate_limit_enabled: bool = True
86
99
  auth_rate_limit_window_seconds: int = 60
87
100
  auth_rate_limit_token_per_window: int = 10
File without changes
File without changes
File without changes