@fuzdev/fuz_app 0.140.0 → 0.142.0

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 (311) hide show
  1. package/dist/actions/CLAUDE.md +72 -22
  2. package/dist/actions/action_rpc.d.ts +5 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +9 -1
  5. package/dist/actions/connection_closer.d.ts +44 -4
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +33 -3
  8. package/dist/actions/perform_action.d.ts +10 -0
  9. package/dist/actions/perform_action.d.ts.map +1 -1
  10. package/dist/actions/perform_action.js +23 -9
  11. package/dist/actions/register_action_ws.d.ts +42 -6
  12. package/dist/actions/register_action_ws.d.ts.map +1 -1
  13. package/dist/actions/register_action_ws.js +87 -19
  14. package/dist/actions/register_ws_endpoint.d.ts +15 -6
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +10 -4
  17. package/dist/actions/socket.svelte.d.ts +19 -4
  18. package/dist/actions/socket.svelte.d.ts.map +1 -1
  19. package/dist/actions/socket.svelte.js +31 -4
  20. package/dist/actions/transports.d.ts +30 -1
  21. package/dist/actions/transports.d.ts.map +1 -1
  22. package/dist/actions/transports.js +30 -1
  23. package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
  24. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  25. package/dist/actions/transports_ws_auth_guard.js +24 -10
  26. package/dist/actions/transports_ws_backend.d.ts +51 -5
  27. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  28. package/dist/actions/transports_ws_backend.js +62 -9
  29. package/dist/actions/ws_endpoint_spec.d.ts +11 -10
  30. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  31. package/dist/auth/CLAUDE.md +44 -22
  32. package/dist/auth/account_actions.d.ts.map +1 -1
  33. package/dist/auth/account_actions.js +12 -1
  34. package/dist/auth/account_route_schema.d.ts +1 -0
  35. package/dist/auth/account_route_schema.d.ts.map +1 -1
  36. package/dist/auth/account_route_schema.js +12 -0
  37. package/dist/auth/account_routes.d.ts +7 -2
  38. package/dist/auth/account_routes.d.ts.map +1 -1
  39. package/dist/auth/account_routes.js +240 -187
  40. package/dist/auth/admin_action_specs.d.ts +6 -1
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  42. package/dist/auth/admin_action_specs.js +6 -1
  43. package/dist/auth/admin_actions.d.ts.map +1 -1
  44. package/dist/auth/admin_actions.js +14 -2
  45. package/dist/auth/api_token.d.ts +20 -0
  46. package/dist/auth/api_token.d.ts.map +1 -1
  47. package/dist/auth/api_token.js +21 -1
  48. package/dist/auth/api_token_queries.d.ts +22 -4
  49. package/dist/auth/api_token_queries.d.ts.map +1 -1
  50. package/dist/auth/api_token_queries.js +11 -6
  51. package/dist/auth/audit_log_routes.d.ts +10 -0
  52. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  53. package/dist/auth/audit_log_routes.js +20 -0
  54. package/dist/auth/audit_log_schema.d.ts +30 -5
  55. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  56. package/dist/auth/audit_log_schema.js +30 -0
  57. package/dist/auth/bearer_auth.d.ts +4 -2
  58. package/dist/auth/bearer_auth.d.ts.map +1 -1
  59. package/dist/auth/bearer_auth.js +19 -8
  60. package/dist/auth/bootstrap_route_schema.d.ts +1 -0
  61. package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
  62. package/dist/auth/bootstrap_route_schema.js +1 -0
  63. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  64. package/dist/auth/bootstrap_routes.js +85 -76
  65. package/dist/auth/cell_action_specs.d.ts +7 -3
  66. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  67. package/dist/auth/cell_action_specs.js +9 -5
  68. package/dist/auth/cell_actions.d.ts +3 -3
  69. package/dist/auth/cell_actions.d.ts.map +1 -1
  70. package/dist/auth/cell_actions.js +67 -38
  71. package/dist/auth/cell_authorize.d.ts +69 -8
  72. package/dist/auth/cell_authorize.d.ts.map +1 -1
  73. package/dist/auth/cell_authorize.js +89 -9
  74. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  75. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  76. package/dist/auth/cell_field_action_specs.js +7 -7
  77. package/dist/auth/cell_field_actions.d.ts +7 -5
  78. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  79. package/dist/auth/cell_field_actions.js +24 -22
  80. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  81. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  82. package/dist/auth/cell_grant_action_specs.js +6 -0
  83. package/dist/auth/cell_grant_actions.d.ts +3 -1
  84. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  85. package/dist/auth/cell_grant_actions.js +16 -2
  86. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  87. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  88. package/dist/auth/cell_item_action_specs.js +7 -7
  89. package/dist/auth/cell_item_actions.d.ts +6 -5
  90. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  91. package/dist/auth/cell_item_actions.js +23 -22
  92. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  93. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  94. package/dist/auth/cell_relation_list_limit.js +27 -0
  95. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  96. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  97. package/dist/auth/cell_relation_visibility.js +6 -5
  98. package/dist/auth/cell_steward.d.ts +45 -0
  99. package/dist/auth/cell_steward.d.ts.map +1 -0
  100. package/dist/auth/cell_steward.js +53 -0
  101. package/dist/auth/cleanup.d.ts +9 -9
  102. package/dist/auth/cleanup.js +9 -9
  103. package/dist/auth/credential_deadline.d.ts +95 -0
  104. package/dist/auth/credential_deadline.d.ts.map +1 -0
  105. package/dist/auth/credential_deadline.js +139 -0
  106. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  107. package/dist/auth/daemon_token_middleware.js +4 -1
  108. package/dist/auth/middleware.d.ts +90 -12
  109. package/dist/auth/middleware.d.ts.map +1 -1
  110. package/dist/auth/middleware.js +115 -16
  111. package/dist/auth/request_context.d.ts +8 -2
  112. package/dist/auth/request_context.d.ts.map +1 -1
  113. package/dist/auth/request_context.js +20 -4
  114. package/dist/auth/resolved_auth.d.ts +13 -2
  115. package/dist/auth/resolved_auth.d.ts.map +1 -1
  116. package/dist/auth/resolved_auth.js +5 -4
  117. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  118. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  119. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  120. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  121. package/dist/auth/role_grant_offer_actions.js +23 -16
  122. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  123. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  124. package/dist/auth/role_grant_offer_queries.js +16 -8
  125. package/dist/auth/session_cookie.d.ts +4 -0
  126. package/dist/auth/session_cookie.d.ts.map +1 -1
  127. package/dist/auth/session_cookie.js +3 -2
  128. package/dist/auth/session_middleware.d.ts +1 -1
  129. package/dist/auth/session_middleware.d.ts.map +1 -1
  130. package/dist/auth/session_middleware.js +4 -1
  131. package/dist/auth/session_queries.d.ts +19 -2
  132. package/dist/auth/session_queries.d.ts.map +1 -1
  133. package/dist/auth/session_queries.js +8 -2
  134. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  135. package/dist/auth/signup_route_schema.js +1 -0
  136. package/dist/auth/signup_routes.d.ts +4 -3
  137. package/dist/auth/signup_routes.d.ts.map +1 -1
  138. package/dist/auth/signup_routes.js +157 -134
  139. package/dist/auth/token_scope.d.ts +7 -1
  140. package/dist/auth/token_scope.d.ts.map +1 -1
  141. package/dist/auth/token_scope.js +8 -2
  142. package/dist/db/CLAUDE.md +32 -19
  143. package/dist/db/cell_ddl.d.ts +1 -2
  144. package/dist/db/cell_ddl.d.ts.map +1 -1
  145. package/dist/db/cell_ddl.js +1 -2
  146. package/dist/db/cell_field_queries.d.ts +20 -18
  147. package/dist/db/cell_field_queries.d.ts.map +1 -1
  148. package/dist/db/cell_field_queries.js +28 -22
  149. package/dist/db/cell_item_queries.d.ts +20 -14
  150. package/dist/db/cell_item_queries.d.ts.map +1 -1
  151. package/dist/db/cell_item_queries.js +28 -18
  152. package/dist/db/cell_moderation.d.ts +67 -0
  153. package/dist/db/cell_moderation.d.ts.map +1 -0
  154. package/dist/db/cell_moderation.js +66 -0
  155. package/dist/db/cell_queries.d.ts +44 -14
  156. package/dist/db/cell_queries.d.ts.map +1 -1
  157. package/dist/db/cell_queries.js +68 -45
  158. package/dist/db/cell_view_sql.d.ts +102 -0
  159. package/dist/db/cell_view_sql.d.ts.map +1 -0
  160. package/dist/db/cell_view_sql.js +112 -0
  161. package/dist/db/db_pg.d.ts +1 -1
  162. package/dist/db/db_pg.js +1 -1
  163. package/dist/hono_context.d.ts +23 -0
  164. package/dist/hono_context.d.ts.map +1 -1
  165. package/dist/hono_context.js +19 -0
  166. package/dist/http/CLAUDE.md +41 -15
  167. package/dist/http/auth_shape.d.ts +5 -2
  168. package/dist/http/auth_shape.d.ts.map +1 -1
  169. package/dist/http/auth_shape.js +5 -2
  170. package/dist/http/ip_canonical.d.ts +30 -3
  171. package/dist/http/ip_canonical.d.ts.map +1 -1
  172. package/dist/http/ip_canonical.js +50 -3
  173. package/dist/http/middleware_spec.d.ts +16 -0
  174. package/dist/http/middleware_spec.d.ts.map +1 -1
  175. package/dist/http/origin.d.ts +113 -18
  176. package/dist/http/origin.d.ts.map +1 -1
  177. package/dist/http/origin.js +247 -127
  178. package/dist/http/route_spec.d.ts +21 -0
  179. package/dist/http/route_spec.d.ts.map +1 -1
  180. package/dist/http/schema_helpers.d.ts +22 -0
  181. package/dist/http/schema_helpers.d.ts.map +1 -1
  182. package/dist/http/schema_helpers.js +24 -0
  183. package/dist/http/surface.d.ts +6 -1
  184. package/dist/http/surface.d.ts.map +1 -1
  185. package/dist/http/surface.js +12 -5
  186. package/dist/primitive_schemas.d.ts +10 -1
  187. package/dist/primitive_schemas.d.ts.map +1 -1
  188. package/dist/primitive_schemas.js +11 -2
  189. package/dist/rate_limiter.d.ts +179 -12
  190. package/dist/rate_limiter.d.ts.map +1 -1
  191. package/dist/rate_limiter.js +352 -42
  192. package/dist/realtime/sse_auth_guard.d.ts +22 -6
  193. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  194. package/dist/realtime/sse_auth_guard.js +31 -12
  195. package/dist/realtime/subscriber_registry.d.ts +10 -1
  196. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  197. package/dist/realtime/subscriber_registry.js +10 -1
  198. package/dist/server/app_server.d.ts +20 -7
  199. package/dist/server/app_server.d.ts.map +1 -1
  200. package/dist/server/app_server.js +8 -3
  201. package/dist/server/env.d.ts +2 -1
  202. package/dist/server/env.d.ts.map +1 -1
  203. package/dist/server/env.js +7 -17
  204. package/dist/server/serve_fact_route.d.ts +8 -4
  205. package/dist/server/serve_fact_route.d.ts.map +1 -1
  206. package/dist/server/serve_fact_route.js +16 -6
  207. package/dist/testing/CLAUDE.md +164 -25
  208. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  209. package/dist/testing/adversarial_headers.js +13 -6
  210. package/dist/testing/connection_closer_helpers.d.ts +6 -1
  211. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  212. package/dist/testing/connection_closer_helpers.js +4 -0
  213. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  214. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  215. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  216. package/dist/testing/cross_backend/body_size.js +14 -65
  217. package/dist/testing/cross_backend/capabilities.d.ts +26 -2
  218. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  219. package/dist/testing/cross_backend/capabilities.js +9 -3
  220. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  221. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  222. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  223. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  224. package/dist/testing/cross_backend/cell_relations.js +145 -2
  225. package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
  226. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  227. package/dist/testing/cross_backend/conformance_case.js +6 -0
  228. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  229. package/dist/testing/cross_backend/conformance_table.js +20 -19
  230. package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
  231. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
  232. package/dist/testing/cross_backend/credential_deadline.js +185 -0
  233. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  234. package/dist/testing/cross_backend/default_backend_configs.js +13 -2
  235. package/dist/testing/cross_backend/expected_schema.json +0 -1
  236. package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
  237. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  238. package/dist/testing/cross_backend/full_spine_mount.js +7 -1
  239. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  240. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  241. package/dist/testing/cross_backend/login_security.js +154 -0
  242. package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
  243. package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
  244. package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
  245. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  246. package/dist/testing/cross_backend/origin.js +6 -26
  247. package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
  248. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
  249. package/dist/testing/cross_backend/origin_conformance.js +136 -0
  250. package/dist/testing/cross_backend/origin_conformance.json +1679 -0
  251. package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
  252. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
  253. package/dist/testing/cross_backend/origin_scope.js +140 -0
  254. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  255. package/dist/testing/cross_backend/query_shape.js +24 -2
  256. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
  257. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
  258. package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
  259. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
  260. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  261. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
  262. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  263. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  264. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  265. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
  266. package/dist/testing/cross_backend/spine_surface_constants.d.ts +20 -1
  267. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  268. package/dist/testing/cross_backend/spine_surface_constants.js +23 -1
  269. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  270. package/dist/testing/cross_backend/testing_backdoor.js +20 -7
  271. package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
  272. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  273. package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
  274. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  275. package/dist/testing/cross_backend/token_scope_surface.js +25 -21
  276. package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
  277. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
  278. package/dist/testing/cross_backend/transport_composition.js +364 -0
  279. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  280. package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
  281. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  282. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  283. package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
  284. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
  285. package/dist/testing/cross_backend/ws_role_gated.js +142 -0
  286. package/dist/testing/entities.d.ts +23 -0
  287. package/dist/testing/entities.d.ts.map +1 -1
  288. package/dist/testing/entities.js +37 -0
  289. package/dist/testing/integration.d.ts.map +1 -1
  290. package/dist/testing/integration.js +3 -1
  291. package/dist/testing/middleware.d.ts +10 -0
  292. package/dist/testing/middleware.d.ts.map +1 -1
  293. package/dist/testing/middleware.js +31 -7
  294. package/dist/testing/schema_introspect.js +1 -1
  295. package/dist/testing/schema_parity.d.ts +4 -3
  296. package/dist/testing/schema_parity.d.ts.map +1 -1
  297. package/dist/testing/schema_parity.js +4 -3
  298. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  299. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  300. package/dist/testing/schema_ready_fixture.js +2 -4
  301. package/dist/testing/stubs.d.ts +16 -3
  302. package/dist/testing/stubs.d.ts.map +1 -1
  303. package/dist/testing/stubs.js +20 -7
  304. package/dist/testing/surface_invariants.js +2 -2
  305. package/dist/ui/auth_state.svelte.d.ts +10 -0
  306. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  307. package/dist/ui/auth_state.svelte.js +22 -4
  308. package/package.json +1 -1
  309. package/dist/db/cell_history_ddl.d.ts +0 -38
  310. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  311. package/dist/db/cell_history_ddl.js +0 -61
@@ -14,7 +14,8 @@ import { query_invite_find_unclaimed_match_for_update, query_invite_claim_unscop
14
14
  import { create_signup_route_shape } from './signup_route_schema.js';
15
15
  import { get_route_input } from '../http/route_spec.js';
16
16
  import { get_client_ip } from '../http/client_ip.js';
17
- import { rate_limit_exceeded_response } from '../rate_limiter.js';
17
+ import { rate_limit_ip_key } from '../http/ip_canonical.js';
18
+ import { rate_limit_exceeded_response, reserve_optional } from '../rate_limiter.js';
18
19
  import { ERROR_NO_MATCHING_INVITE, ERROR_SIGNUP_CONFLICT } from '../http/error_schemas.js';
19
20
  import { is_pg_unique_violation } from '../db/pg_error.js';
20
21
  /**
@@ -62,148 +63,170 @@ export const create_signup_route_specs = (deps, options) => {
62
63
  signup_account_rate_limited: signup_account_rate_limiter !== null
63
64
  }),
64
65
  handler: async (c, route) => {
65
- // Per-IP rate limit check (before any work). 429 stays fast.
66
- const ip = signup_ip_rate_limiter ? get_client_ip(c) : null;
67
- if (signup_ip_rate_limiter && ip) {
68
- const check = signup_ip_rate_limiter.check(ip);
69
- if (!check.allowed) {
70
- return rate_limit_exceeded_response(c, check.retry_after);
71
- }
66
+ // Per-IP rate limit (before any work). 429 stays fast. Reserved,
67
+ // not checked: the attempt counts against the cap until it settles
68
+ // below, so a concurrent burst can't all pass before any of it
69
+ // records — and the Argon2 hash every attempt pays is bounded with
70
+ // it. The `finally` releases whatever no branch recorded.
71
+ // keyed by the address's rate-limit key (an IPv6 client's /64)
72
+ const ip_key = signup_ip_rate_limiter ? rate_limit_ip_key(get_client_ip(c)) : null;
73
+ const ip_reserved = await reserve_optional(signup_ip_rate_limiter, ip_key, {
74
+ signal: c.req.raw.signal
75
+ });
76
+ if (!ip_reserved.ok) {
77
+ return rate_limit_exceeded_response(c, ip_reserved.retry_after);
72
78
  }
73
- const { username, password: pw, email } = get_route_input(c);
74
- // Per-account rate limit check (after input parsing, before DB work).
75
- // 429 stays fast — same precedent as login.
76
- const account_key = username.toLowerCase();
77
- if (signup_account_rate_limiter) {
78
- const check = signup_account_rate_limiter.check(account_key);
79
- if (!check.allowed) {
80
- return rate_limit_exceeded_response(c, check.retry_after);
79
+ let account_reservation = null;
80
+ try {
81
+ const { username, password: pw, email } = get_route_input(c);
82
+ // Per-account rate limit (after input parsing, before DB work).
83
+ // 429 stays fast — same precedent as login.
84
+ const account_key = username.toLowerCase();
85
+ const account_reserved = await reserve_optional(signup_account_rate_limiter, account_key, { signal: c.req.raw.signal });
86
+ if (!account_reserved.ok) {
87
+ return rate_limit_exceeded_response(c, account_reserved.retry_after);
81
88
  }
82
- }
83
- // Load the open-signup toggle fresh from the DB on every
84
- // request — the authoritative source, so multiple server
85
- // processes never serve a stale in-memory value. Bounded by
86
- // the per-IP + per-account rate limiters above.
87
- const { open_signup } = await query_app_settings_load(route);
88
- // Start the denial-time floor concurrently with failure work.
89
- // Observed response time for 403 / 409 is `max(work, delay)`
90
- // so the cheap `no_match` path (no Argon2, find returns
91
- // nothing) and the expensive `signup_conflict` path (Argon2
92
- // + tx + rollback) converge — closes the username-enumeration
93
- // timing oracle. Mirrors login's `login_fail_delay`. Started
94
- // after rate-limit checks so 429 stays fast.
95
- const delay = signup_fail_delay(signup_fail_floor_ms, signup_fail_jitter_ms);
96
- // Hash before the transaction so the connection isn't held
97
- // across the ~100ms Argon2id. Paid unconditionally — bounded
98
- // by the per-IP + per-account rate limiters above.
99
- const password_hash = await password.hash_password(pw);
100
- // `invite` is assigned inside the tx by the FOR UPDATE find;
101
- // captured at the outer scope so the failure-audit catch
102
- // branch can still reference `invite.id` after the tx rolls
103
- // back on PG unique violation.
104
- let invite;
105
- const emit_failure_audit = (reason) => {
89
+ account_reservation = account_reserved.reservation;
90
+ // Load the open-signup toggle fresh from the DB on every
91
+ // request — the authoritative source, so multiple server
92
+ // processes never serve a stale in-memory value. Its cost, and
93
+ // the hash below, are bounded per address by the per-IP
94
+ // reservation above: every classified failure and every
95
+ // successful open signup records it. What escapes is an
96
+ // invite-gated success (which spends an invite), a 5xx, and a
97
+ // request with no resolved client IP. The per-account limiter
98
+ // bounds retries of one username only.
99
+ const { open_signup } = await query_app_settings_load(route);
100
+ // Start the denial-time floor concurrently with failure work.
101
+ // Observed response time for 403 / 409 is `max(work, delay)`
102
+ // so the cheap `no_match` path (no Argon2, find returns
103
+ // nothing) and the expensive `signup_conflict` path (Argon2
104
+ // + tx + rollback) converge — closes the username-enumeration
105
+ // timing oracle. Mirrors login's `login_fail_delay`. Started
106
+ // after rate-limit checks so 429 stays fast.
107
+ const delay = signup_fail_delay(signup_fail_floor_ms, signup_fail_jitter_ms);
108
+ // Hash before the transaction so the connection isn't held
109
+ // across the ~100ms Argon2id. Paid on every attempt that gets
110
+ // here — bounded as described above.
111
+ const password_hash = await password.hash_password(pw);
112
+ // `invite` is assigned inside the tx by the FOR UPDATE find;
113
+ // captured at the outer scope so the failure-audit catch
114
+ // branch can still reference `invite.id` after the tx rolls
115
+ // back on PG unique violation.
116
+ let invite;
117
+ const emit_failure_audit = (reason) => {
118
+ deps.audit.emit(route, {
119
+ event_type: 'signup',
120
+ outcome: 'failure',
121
+ ip: get_client_ip(c),
122
+ metadata: {
123
+ username,
124
+ reason,
125
+ ...(invite && { invite_id: invite.id }),
126
+ ...(email != null && { email }),
127
+ ...(open_signup && { open_signup: true })
128
+ }
129
+ });
130
+ };
131
+ let result;
132
+ try {
133
+ result = await route.db.transaction(async (tx) => {
134
+ const tx_deps = { db: tx };
135
+ // Find + claim run inside the same transaction so the
136
+ // row lock makes them atomic. Concurrent signups for
137
+ // the same (username, email) tuple block on the lock
138
+ // and observe the post-commit state on retry — the
139
+ // loser's `find_for_update` returns no row (winner
140
+ // flipped `claimed_at`) and falls through to
141
+ // `ERROR_NO_MATCHING_INVITE`. No race window.
142
+ if (!open_signup) {
143
+ invite = await query_invite_find_unclaimed_match_for_update(tx_deps, email ?? null, username);
144
+ if (!invite) {
145
+ throw new NoMatchingInviteError();
146
+ }
147
+ }
148
+ const { account, actor } = await query_create_account_with_actor(tx_deps, {
149
+ username,
150
+ password_hash,
151
+ email
152
+ });
153
+ if (invite) {
154
+ // Guaranteed to succeed: FOR UPDATE held the row
155
+ // for the duration of the tx, so no concurrent
156
+ // claim could flip `claimed_at` between the find
157
+ // and this UPDATE.
158
+ await query_invite_claim_unscoped(tx_deps, invite.id, account.id);
159
+ }
160
+ await create_session_and_set_cookie({
161
+ keyring,
162
+ deps: tx_deps,
163
+ c,
164
+ account_id: account.id,
165
+ session_options
166
+ });
167
+ return { account, actor };
168
+ });
169
+ }
170
+ catch (e) {
171
+ if (e instanceof NoMatchingInviteError) {
172
+ ip_reserved.reservation.record();
173
+ account_reservation?.record();
174
+ emit_failure_audit('no_match');
175
+ await delay;
176
+ return c.json({ error: ERROR_NO_MATCHING_INVITE }, 403);
177
+ }
178
+ // Unique constraint violation: username or email already exists.
179
+ if (is_pg_unique_violation(e)) {
180
+ ip_reserved.reservation.record();
181
+ account_reservation?.record();
182
+ emit_failure_audit('signup_conflict');
183
+ await delay;
184
+ return c.json({ error: ERROR_SIGNUP_CONFLICT }, 409);
185
+ }
186
+ // Unclassified failure (e.g. session create error, Argon2
187
+ // fault on hash, DB outage mid-tx). Tx is rolled back so
188
+ // no account persists, but the *attempt* should leave a
189
+ // forensic trail — emit `outcome: 'failure'` with reason
190
+ // `internal_error` before rethrowing. 5xx responses are
191
+ // not floored: they aren't response-time-controlled
192
+ // enumeration oracles.
193
+ emit_failure_audit('internal_error');
194
+ throw e;
195
+ }
196
+ // Clear the account-grain bucket only — the IP aggregate stands.
197
+ // This is the cheapest version of the spray path `RateLimiter.reset`
198
+ // describes: where login at least costs the attacker a credential,
199
+ // an open-signup deployment let an *unauthenticated* caller zero a
200
+ // shared IP budget by creating throwaway accounts, and the
201
+ // per-account limiter never applies to the attacker's own signups.
202
+ account_reservation?.release();
203
+ if (signup_account_rate_limiter)
204
+ signup_account_rate_limiter.reset(account_key);
205
+ // A successful *open* signup is charged to the IP bucket like a
206
+ // failure: with no invite to spend, account creation is otherwise
207
+ // unbounded from a single address — throwaway accounts, each one a
208
+ // session and a row. An invite-gated success is bounded by the
209
+ // invites an admin minted, so it records nothing (a class signing up
210
+ // on its invites from one NAT egress would otherwise run the bucket
211
+ // dry); the `finally` releases it.
212
+ if (open_signup)
213
+ ip_reserved.reservation.record();
106
214
  deps.audit.emit(route, {
107
215
  event_type: 'signup',
108
- outcome: 'failure',
216
+ account_id: result.account.id,
109
217
  ip: get_client_ip(c),
110
- metadata: {
111
- username,
112
- reason,
113
- ...(invite && { invite_id: invite.id }),
114
- ...(email != null && { email }),
115
- ...(open_signup && { open_signup: true })
116
- }
218
+ metadata: invite ? { invite_id: invite.id, username } : { open_signup: true, username }
117
219
  });
118
- };
119
- let result;
120
- try {
121
- result = await route.db.transaction(async (tx) => {
122
- const tx_deps = { db: tx };
123
- // Find + claim run inside the same transaction so the
124
- // row lock makes them atomic. Concurrent signups for
125
- // the same (username, email) tuple block on the lock
126
- // and observe the post-commit state on retry — the
127
- // loser's `find_for_update` returns no row (winner
128
- // flipped `claimed_at`) and falls through to
129
- // `ERROR_NO_MATCHING_INVITE`. No race window.
130
- if (!open_signup) {
131
- invite = await query_invite_find_unclaimed_match_for_update(tx_deps, email ?? null, username);
132
- if (!invite) {
133
- throw new NoMatchingInviteError();
134
- }
135
- }
136
- const { account, actor } = await query_create_account_with_actor(tx_deps, {
137
- username,
138
- password_hash,
139
- email
140
- });
141
- if (invite) {
142
- // Guaranteed to succeed: FOR UPDATE held the row
143
- // for the duration of the tx, so no concurrent
144
- // claim could flip `claimed_at` between the find
145
- // and this UPDATE.
146
- await query_invite_claim_unscoped(tx_deps, invite.id, account.id);
147
- }
148
- await create_session_and_set_cookie({
149
- keyring,
150
- deps: tx_deps,
151
- c,
152
- account_id: account.id,
153
- session_options
154
- });
155
- return { account, actor };
220
+ return c.json({
221
+ ok: true,
222
+ account: { id: result.account.id, username: result.account.username },
223
+ actor: { id: result.actor.id }
156
224
  });
157
225
  }
158
- catch (e) {
159
- if (e instanceof NoMatchingInviteError) {
160
- if (signup_ip_rate_limiter && ip)
161
- signup_ip_rate_limiter.record(ip);
162
- if (signup_account_rate_limiter)
163
- signup_account_rate_limiter.record(account_key);
164
- emit_failure_audit('no_match');
165
- await delay;
166
- return c.json({ error: ERROR_NO_MATCHING_INVITE }, 403);
167
- }
168
- // Unique constraint violation: username or email already exists.
169
- if (is_pg_unique_violation(e)) {
170
- if (signup_ip_rate_limiter && ip)
171
- signup_ip_rate_limiter.record(ip);
172
- if (signup_account_rate_limiter)
173
- signup_account_rate_limiter.record(account_key);
174
- emit_failure_audit('signup_conflict');
175
- await delay;
176
- return c.json({ error: ERROR_SIGNUP_CONFLICT }, 409);
177
- }
178
- // Unclassified failure (e.g. session create error, Argon2
179
- // fault on hash, DB outage mid-tx). Tx is rolled back so
180
- // no account persists, but the *attempt* should leave a
181
- // forensic trail — emit `outcome: 'failure'` with reason
182
- // `internal_error` before rethrowing. 5xx responses are
183
- // not floored: they aren't response-time-controlled
184
- // enumeration oracles.
185
- emit_failure_audit('internal_error');
186
- throw e;
226
+ finally {
227
+ ip_reserved.reservation.release();
228
+ account_reservation?.release();
187
229
  }
188
- // Clear the account-grain bucket only — the IP aggregate stands.
189
- // This is the cheapest version of the spray path `RateLimiter.reset`
190
- // describes: where login at least costs the attacker a credential,
191
- // an open-signup deployment let an *unauthenticated* caller zero a
192
- // shared IP budget by creating throwaway accounts, and the
193
- // per-account limiter never applies to the attacker's own signups.
194
- if (signup_account_rate_limiter)
195
- signup_account_rate_limiter.reset(account_key);
196
- deps.audit.emit(route, {
197
- event_type: 'signup',
198
- account_id: result.account.id,
199
- ip: get_client_ip(c),
200
- metadata: invite ? { invite_id: invite.id, username } : { open_signup: true, username }
201
- });
202
- return c.json({
203
- ok: true,
204
- account: { id: result.account.id, username: result.account.username },
205
- actor: { id: result.actor.id }
206
- });
207
230
  }
208
231
  }
209
232
  ];
@@ -77,7 +77,13 @@ export type TokenScopeInput = z.infer<typeof TokenScopeInput>;
77
77
  export declare const token_scope_full: () => TokenScope;
78
78
  /** A narrowed scope over `methods`. */
79
79
  export declare const token_scope_methods: (methods: Array<string>) => TokenScope;
80
- /** Build the stored scope from a mint-time input. */
80
+ /**
81
+ * Build the stored scope from a mint-time input.
82
+ *
83
+ * Duplicate method names collapse into one, first occurrence kept: the set is
84
+ * what the scope admits, and the Rust spine stores a set, so both count the
85
+ * same methods (the `account_token_list` label reads `N methods`).
86
+ */
81
87
  export declare const token_scope_from_input: (input: TokenScopeInput) => TokenScope;
82
88
  /**
83
89
  * Does `scope` admit dispatching `method`?
@@ -1 +1 @@
1
- {"version":3,"file":"token_scope.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/token_scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAW,CAAC;AAE9C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU;;;;;;;;;4BAYrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;;;4BAG1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,QAAO,UAGlC,CAAC;AAEH,uCAAuC;AACvC,eAAO,MAAM,mBAAmB,GAAI,SAAS,KAAK,CAAC,MAAM,CAAC,KAAG,UAI3D,CAAC;AAEH,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,GAAI,OAAO,eAAe,KAAG,UACgB,CAAC;AAEjF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,UAAU,EAAE,QAAQ,MAAM,KAAG,OACtB,CAAC;AAEzD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,0BAA0B,GAAI,OAAO,UAAU,KAAG,OAAgC,CAAC;AAEhG;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,iFAMjB,CAAC;AACX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,KAAG,KAAK,IAAI,YACA,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,QAAgC,CAAC;AAQtE;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GAAI,QAAQ,MAAM,KAAG,MAC3B,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,oBAAoB,GAC/B;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,4BAA4B,GAAI,OAAO,MAAM,KAAG,oBAAoB,GAAG,IAUnF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACzC,OAAO,UAAU,EACjB,YAAY,oBAAoB,KAC9B,OAGkC,CAAC;AAEtC;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GACnC,YAAY,MAAM,KAChB;IAAE,KAAK,EAAE,sBAAsB,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAGxD,CAAC;AAEH,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAAU,KAAG,MAGrD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,UAAU,GAAG,IAU5D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,UAAU,KAAG,MAQzD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,OAAO,KAAG,UAAU,GAAG,IAQtE,CAAC"}
1
+ {"version":3,"file":"token_scope.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/token_scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAW,CAAC;AAE9C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU;;;;;;;;;4BAYrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;;;4BAG1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,QAAO,UAGlC,CAAC;AAEH,uCAAuC;AACvC,eAAO,MAAM,mBAAmB,GAAI,SAAS,KAAK,CAAC,MAAM,CAAC,KAAG,UAI3D,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,eAAe,KAAG,UAC8B,CAAC;AAE/F;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,UAAU,EAAE,QAAQ,MAAM,KAAG,OACtB,CAAC;AAEzD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,0BAA0B,GAAI,OAAO,UAAU,KAAG,OAAgC,CAAC;AAEhG;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,iFAMjB,CAAC;AACX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,KAAG,KAAK,IAAI,YACA,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,QAAgC,CAAC;AAQtE;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GAAI,QAAQ,MAAM,KAAG,MAC3B,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,oBAAoB,GAC/B;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,4BAA4B,GAAI,OAAO,MAAM,KAAG,oBAAoB,GAAG,IAUnF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACzC,OAAO,UAAU,EACjB,YAAY,oBAAoB,KAC9B,OAGkC,CAAC;AAEtC;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GACnC,YAAY,MAAM,KAChB;IAAE,KAAK,EAAE,sBAAsB,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAGxD,CAAC;AAEH,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAAU,KAAG,MAGrD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,UAAU,GAAG,IAU5D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,UAAU,KAAG,MAQzD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,OAAO,KAAG,UAAU,GAAG,IAQtE,CAAC"}
@@ -83,8 +83,14 @@ export const token_scope_methods = (methods) => ({
83
83
  kind: 'methods',
84
84
  methods
85
85
  });
86
- /** Build the stored scope from a mint-time input. */
87
- export const token_scope_from_input = (input) => input.kind === 'full' ? token_scope_full() : token_scope_methods(input.methods);
86
+ /**
87
+ * Build the stored scope from a mint-time input.
88
+ *
89
+ * Duplicate method names collapse into one, first occurrence kept: the set is
90
+ * what the scope admits, and the Rust spine stores a set, so both count the
91
+ * same methods (the `account_token_list` label reads `N methods`).
92
+ */
93
+ export const token_scope_from_input = (input) => input.kind === 'full' ? token_scope_full() : token_scope_methods([...new Set(input.methods)]);
88
94
  /**
89
95
  * Does `scope` admit dispatching `method`?
90
96
  *
package/dist/db/CLAUDE.md CHANGED
@@ -95,21 +95,33 @@ The wire schemas + RPC handlers + authz predicates for this layer live in
95
95
  authorizer's verdict + the `cell_moderate` verb, never in `data`. Nullable
96
96
  `created_by` / `updated_by` FKs to `actor` (NULL = system origin). GIN on `data`
97
97
  / `refs`; global partial-unique on `path`.
98
- - **`cell_history_ddl.ts`** — dormant `cell_history` table
99
- (`CELL_HISTORY_MIGRATION_NS`, namespace `fuz_cell_history`), FK → `cell.id`.
100
- Ships present-but-unwritten; no snapshot lifecycle yet.
101
98
  - **`cell_queries.ts`** — `query_cell_create / get / get_by_path / update /
102
99
  delete / set_moderation`, `_list_by_kind / _list_by_creator`, the
103
100
  generic `query_cell_list` (filter + SQL-side visibility predicate mirroring
104
- `can_view_cell`; the `kind` filter narrows by the `cell.kind` column, the
105
- `ref` filter by `cell.refs`, plus `root_id` / `moderation` filters for the
106
- per-root feed + mod queue), and
107
- `query_cell_load_many` (bulk id load, no visibility filter — feeds the
108
- strict relation-read filter). `query_cell_set_moderation` is the dedicated
101
+ `can_view_cell`, moderation admission included; the `kind` filter narrows
102
+ by the `cell.kind` column, the `ref` filter by `cell.refs`, plus `root_id` /
103
+ `moderation` filters for the per-root feed + mod queue),
104
+ `query_cell_load_many` (bulk id load of full rows, no visibility filter —
105
+ the deep-clone walk), and `query_cell_view_load_many` (the
106
+ `CELL_VIEW_COLUMNS` view facts alone — feeds `filter_visible_target_ids`,
107
+ the in-memory relation filter the `cell_get` bundle and the clone walk
108
+ run). `query_cell_set_moderation` is the dedicated
109
109
  `cell_moderate` write (`moderation` is absent from `CellUpdatePatch`, so an
110
110
  author can't self-approve via `cell_update`). `cell.refs` derived from
111
111
  `data` via `extract_refs` on create/update. `CellRow.grant_count` is a
112
112
  derived projection (correlated subquery on `idx_cell_grant_cell`).
113
+ - **`cell_moderation.ts`** — the `cell.moderation` read rules in pure and
114
+ SQL form side by side (`cell_moderation_admits_audience` /
115
+ `_sql`: the allowlist unmoderated ‖ `'approved'`), composed by
116
+ `auth/cell_authorize.ts` and `query_cell_list` so the two can't drift; the
117
+ `CELL_MODERATION_*` literals. Twin of the Rust `fuz_cell::cell_moderation`.
118
+ - **`cell_view_sql.ts`** — `can_view_cell` in SQL: `can_view_cell_sql`
119
+ (admin ‖ owner ‖ moderation-admitted public or grant), the
120
+ `caller_role_grants_cte` its role-shaped grant arm reads, and `CellViewer`
121
+ (the caller it binds — `auth/cell_authorize.ts`'s `cell_viewer_from_auth`
122
+ builds one; `CELL_VIEWER_UNRESTRICTED` filters nothing). `query_cell_list`
123
+ embeds it beside its steward arm, and the relation lists apply it to the
124
+ far end before `LIMIT`. Twin of the Rust `fuz_cell::cell_view_sql`.
113
125
  - **`cell_grant_queries.ts`** — resource-side ACL: `query_cell_grant_create`
114
126
  (UPSERT on the partial unique index, actor- vs role-shaped principal),
115
127
  `_get`, `_delete` (returns deleted row), `_list_for_cell`,
@@ -119,11 +131,14 @@ delete / set_moderation`, `_list_by_kind / _list_by_creator`, the
119
131
  - **`cell_field_queries.ts`** — named relations (`(source_id, name) →
120
132
  target_id`): `query_cell_field_set` (UPSERT), `_get`, `_delete`,
121
133
  `_list_for_source` (`{limit, name_after}`), `_list_for_target`. Reads JOIN
122
- cell with `deleted_at IS NULL` so dangling relations don't surface.
134
+ cell with `deleted_at IS NULL` so dangling relations don't surface; the
135
+ list reads take a `CellViewer` and keep only edges whose far end it can
136
+ view, before `LIMIT`.
123
137
  - **`cell_item_queries.ts`** — ordered children (`(parent_id, position) →
124
138
  child_id`, fractional-index keyed): `query_cell_item_insert` (throws
125
139
  `23505` on collision), `_get`, `_move`, `_delete`, `_list_for_parent`
126
- (`{limit, position_after}`), `_list_for_child`. Same soft-delete JOIN.
140
+ (`{limit, position_after}`), `_list_for_child`. Same soft-delete JOIN and
141
+ viewer filter.
127
142
  - **`cell_audit_queries.ts`** — `query_audit_log_list_by_cell`: matches
128
143
  `audit_log` rows whose `metadata` names the cell on any of six cell-domain
129
144
  keys (`cell_id` / `source_id` / `new_id` / `parent_id` / `child_id` /
@@ -191,22 +206,20 @@ DO NOTHING`), `_put_fact_refs`, `_get_fact` / `_get_fact_meta` / `_has_fact`
191
206
  FK dependency dictates order; consumers register via `migration_namespaces`
192
207
  on `create_app_backend` (after the built-in `fuz_auth` namespace). The fact
193
208
  namespace is FK-independent so its slot is free; the spine binary and every
194
- consumer place it last, keeping cell + its `cell_history` FK-child adjacent
195
- (cross-namespace order is immaterial to the `schema_version` rows — `sequence`
196
- is assigned per-namespace — so only the cell→cell_history FK constrains it):
209
+ consumer place it last (cross-namespace order is immaterial to the
210
+ `schema_version` rows — `sequence` is assigned per-namespace):
197
211
 
198
212
  ```
199
213
  auth_migration_ns (built-in, fuz_auth)
200
- → CELL_MIGRATION_NS (fuz_cell — FKs actor.id)
201
- → CELL_HISTORY_MIGRATION_NS (fuz_cell_history — FKs cell.id)
202
- → FACT_MIGRATION_NS (fuz_facts — no external FK; slot is free)
214
+ → CELL_MIGRATION_NS (fuz_cell — FKs actor.id)
215
+ → FACT_MIGRATION_NS (fuz_facts — no external FK; slot is free)
203
216
  ```
204
217
 
205
218
  ### Pre-stable migration policy
206
219
 
207
- Scoped to the namespaces on this page (`fuz_cell` / `fuz_cell_history` /
208
- `fuz_facts`): each is a single `full_*_schema` entry, so when it needs to
209
- change shape, **rewrite the original in place** rather than appending a
220
+ Scoped to the namespaces on this page (`fuz_cell` / `fuz_facts`): each is a
221
+ single `full_*_schema` entry, so when it needs to change shape, **rewrite the
222
+ original in place** rather than appending a
210
223
  follow-up — consumers re-bootstrap dev DBs on upgrade. The `fuz_auth`
211
224
  namespace is the opposite — frozen and append-only, since consumers hold
212
225
  deployed databases on it. See ../../../docs/migrations.md §Schema stability.
@@ -29,8 +29,7 @@
29
29
  *
30
30
  * **Single-migration shape**: `full_cell_schema` creates the canonical
31
31
  * cell + cell_grant + cell_field + cell_item layout in one shot from the
32
- * live exported constants. The dormant `cell_history` table lives in the
33
- * separate `fuz_cell_history` namespace (`cell_history_ddl.ts`).
32
+ * live exported constants.
34
33
  *
35
34
  * @module
36
35
  */
@@ -1 +1 @@
1
- {"version":3,"file":"cell_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/cell_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,oIAI1B,CAAC;AAER;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,WAAW,gkBAgBtB,CAAC;AAEH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,MAAM,CAkBtC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,0iBAc5B,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,MAAM,CAW5C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,+RAO5B,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,MAAM,CAE5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,qSAO3B,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,MAAM,CAG3C,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,4DAA6D,CAAC;AAE3F,uBAAuB;AACvB,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAuB5C,CAAC;AAEF,gDAAgD;AAChD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
1
+ {"version":3,"file":"cell_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/cell_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,oIAI1B,CAAC;AAER;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,WAAW,gkBAgBtB,CAAC;AAEH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,MAAM,CAkBtC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,0iBAc5B,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,MAAM,CAW5C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,+RAO5B,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,MAAM,CAE5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,qSAO3B,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,MAAM,CAG3C,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,4DAA6D,CAAC;AAE3F,uBAAuB;AACvB,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CAuB5C,CAAC;AAEF,gDAAgD;AAChD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
@@ -29,8 +29,7 @@
29
29
  *
30
30
  * **Single-migration shape**: `full_cell_schema` creates the canonical
31
31
  * cell + cell_grant + cell_field + cell_item layout in one shot from the
32
- * live exported constants. The dormant `cell_history` table lives in the
33
- * separate `fuz_cell_history` namespace (`cell_history_ddl.ts`).
32
+ * live exported constants.
34
33
  *
35
34
  * @module
36
35
  */
@@ -8,7 +8,10 @@
8
8
  * not by allowing duplicate field rows.
9
9
  *
10
10
  * Reads filter both endpoints by `cell.deleted_at IS NULL` so relations
11
- * dangling off a soft-deleted cell don't surface to the live graph.
11
+ * dangling off a soft-deleted cell don't surface to the live graph, and the
12
+ * list reads apply the view predicate (`db/cell_view_sql.ts`) to the far end
13
+ * before `LIMIT` — a page is up to `limit` edges whose far end the caller can
14
+ * view.
12
15
  *
13
16
  * `query_cell_field_set` upserts on the `(source_id, name)` PK so
14
17
  * re-pointing a name updates `target_id` in place — JSON-object semantics
@@ -18,6 +21,7 @@
18
21
  */
19
22
  import type { QueryDeps } from './query_deps.js';
20
23
  import type { Uuid } from '../primitive_schemas.js';
24
+ import { type CellViewer } from './cell_view_sql.js';
21
25
  /** Row shape returned by `cell_field` SELECTs. */
22
26
  export interface CellFieldRow {
23
27
  source_id: Uuid;
@@ -77,38 +81,36 @@ export declare const query_cell_field_delete: (deps: QueryDeps, source_id: Uuid,
77
81
  /**
78
82
  * Forward fields list (`source.fields[]`).
79
83
  *
80
- * Filters target by `deleted_at IS NULL` so relations to tombstoned cells
81
- * don't surface; the source filter is the caller's responsibility (gated
82
- * upstream by `can_view_cell(source)`).
84
+ * Up to `limit` fields whose target is live and one `viewer` may view
85
+ * (`can_view_cell_sql`, applied before the limit); `name_after` pages on.
86
+ * The source-view gate is the caller's responsibility.
83
87
  *
84
88
  * @param deps - query deps
89
+ * @param viewer - who is reading; `CELL_VIEWER_UNRESTRICTED` filters nothing
85
90
  * @param source_id - source cell id
86
- * @param options - `limit` caps the row count; `name_after` resumes after a
87
- * name (keyset pagination)
91
+ * @param options - `limit` caps the row count (omitted = unbounded); `name_after`
92
+ * resumes after a name (keyset pagination)
88
93
  * @returns matching rows in lex `name` order
89
94
  */
90
- export declare const query_cell_field_list_for_source: (deps: QueryDeps, source_id: Uuid, options?: {
95
+ export declare const query_cell_field_list_for_source: (deps: QueryDeps, viewer: CellViewer, source_id: Uuid, options?: {
91
96
  limit?: number;
92
97
  name_after?: string;
93
98
  }) => Promise<Array<CellFieldRow>>;
94
99
  /**
95
100
  * Reverse fields list (`target.upfields[]`).
96
101
  *
97
- * Returns rows whose `target_id = $1`, joined to `cell` on `source_id` so
98
- * relations from tombstoned sources don't surface. The caller-side
99
- * authz filter (per-source `can_view_cell`) runs after the SQL fetch
100
- * — see the 2-layer authz contract on `cell_field_list({target_id})`.
101
- *
102
- * Bounded by `limit` (the wire `cell_field_list` cap) so a heavily
103
- * inbound-linked target can't force an unbounded fetch + per-source authz
104
- * pass on the public, IP-rate-limited reverse endpoint.
102
+ * Up to `limit` fields whose source is live and one `viewer` may view —
103
+ * filtered before the limit (`can_view_cell_sql`), so sources the caller
104
+ * can't see never crowd out ones it can. The target-view gate is the
105
+ * caller's responsibility.
105
106
  *
106
107
  * @param deps - query deps
108
+ * @param viewer - who is reading; `CELL_VIEWER_UNRESTRICTED` filters nothing
107
109
  * @param target_id - target cell id
108
- * @param options - `limit` caps the row count
109
- * @returns matching rows, oldest field first (by the field row's `created_at`)
110
+ * @param options - `limit` caps the row count (omitted = unbounded)
111
+ * @returns matching rows, oldest field first (by the field row's `created_at`, then its key)
110
112
  */
111
- export declare const query_cell_field_list_for_target: (deps: QueryDeps, target_id: Uuid, options?: {
113
+ export declare const query_cell_field_list_for_target: (deps: QueryDeps, viewer: CellViewer, target_id: Uuid, options?: {
112
114
  limit?: number;
113
115
  }) => Promise<Array<CellFieldRow>>;
114
116
  //# sourceMappingURL=cell_field_queries.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cell_field_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/cell_field_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAIpD,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,IAAI,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,2DAKuB,CAAC;AAKvD,wCAAwC;AACxC,MAAM,WAAW,sBAAsB;IACtC,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,IAAI,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,OAAO,sBAAsB,KAC3B,OAAO,CAAC,YAAY,CAUtB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,WAAW,IAAI,EACf,MAAM,MAAM,KACV,OAAO,CAAC,YAAY,GAAG,IAAI,CAM7B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,WAAW,IAAI,EACf,MAAM,MAAM,KACV,OAAO,CAAC,YAAY,GAAG,IAAI,CAO7B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,WAAW,IAAI,EACf,UAAU;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,KAC/C,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAa7B,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,WAAW,IAAI,EACf,UAAU;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAC1B,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAS5B,CAAC"}
1
+ {"version":3,"file":"cell_field_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/cell_field_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAGpD,OAAO,EAIN,KAAK,UAAU,EACf,MAAM,oBAAoB,CAAC;AAE5B,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,IAAI,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,2DAKuB,CAAC;AAKvD,wCAAwC;AACxC,MAAM,WAAW,sBAAsB;IACtC,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,IAAI,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,OAAO,sBAAsB,KAC3B,OAAO,CAAC,YAAY,CAUtB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,WAAW,IAAI,EACf,MAAM,MAAM,KACV,OAAO,CAAC,YAAY,GAAG,IAAI,CAM7B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,WAAW,IAAI,EACf,MAAM,MAAM,KACV,OAAO,CAAC,YAAY,GAAG,IAAI,CAO7B,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,QAAQ,UAAU,EAClB,WAAW,IAAI,EACf,UAAU;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,KAC/C,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAe7B,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,QAAQ,UAAU,EAClB,WAAW,IAAI,EACf,UAAU;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAC1B,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAW5B,CAAC"}