@fuzdev/fuz_app 0.141.0 → 0.143.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 (159) hide show
  1. package/dist/actions/CLAUDE.md +10 -5
  2. package/dist/actions/connection_closer.d.ts +19 -2
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +25 -2
  5. package/dist/actions/perform_action.d.ts +10 -0
  6. package/dist/actions/perform_action.d.ts.map +1 -1
  7. package/dist/actions/perform_action.js +23 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -2
  10. package/dist/auth/CLAUDE.md +33 -13
  11. package/dist/auth/account_actions.d.ts.map +1 -1
  12. package/dist/auth/account_actions.js +12 -1
  13. package/dist/auth/account_route_schema.d.ts.map +1 -1
  14. package/dist/auth/account_route_schema.js +8 -0
  15. package/dist/auth/account_routes.d.ts +7 -2
  16. package/dist/auth/account_routes.d.ts.map +1 -1
  17. package/dist/auth/account_routes.js +240 -187
  18. package/dist/auth/admin_action_specs.d.ts +6 -1
  19. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  20. package/dist/auth/admin_action_specs.js +6 -1
  21. package/dist/auth/admin_actions.d.ts.map +1 -1
  22. package/dist/auth/admin_actions.js +14 -2
  23. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  24. package/dist/auth/bootstrap_routes.js +85 -76
  25. package/dist/auth/cell_action_specs.d.ts +7 -3
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +9 -5
  28. package/dist/auth/cell_actions.d.ts +3 -3
  29. package/dist/auth/cell_actions.d.ts.map +1 -1
  30. package/dist/auth/cell_actions.js +67 -38
  31. package/dist/auth/cell_authorize.d.ts +69 -8
  32. package/dist/auth/cell_authorize.d.ts.map +1 -1
  33. package/dist/auth/cell_authorize.js +89 -9
  34. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  35. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  36. package/dist/auth/cell_field_action_specs.js +7 -7
  37. package/dist/auth/cell_field_actions.d.ts +7 -5
  38. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_field_actions.js +24 -22
  40. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  41. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  42. package/dist/auth/cell_grant_action_specs.js +6 -0
  43. package/dist/auth/cell_grant_actions.d.ts +3 -1
  44. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  45. package/dist/auth/cell_grant_actions.js +16 -2
  46. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  47. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  48. package/dist/auth/cell_item_action_specs.js +7 -7
  49. package/dist/auth/cell_item_actions.d.ts +6 -5
  50. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  51. package/dist/auth/cell_item_actions.js +23 -22
  52. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  53. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  54. package/dist/auth/cell_relation_list_limit.js +27 -0
  55. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  56. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  57. package/dist/auth/cell_relation_visibility.js +6 -5
  58. package/dist/auth/cell_steward.d.ts +45 -0
  59. package/dist/auth/cell_steward.d.ts.map +1 -0
  60. package/dist/auth/cell_steward.js +53 -0
  61. package/dist/auth/request_context.d.ts +4 -0
  62. package/dist/auth/request_context.d.ts.map +1 -1
  63. package/dist/auth/request_context.js +6 -1
  64. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  65. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  66. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  67. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_actions.js +23 -16
  69. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +16 -8
  72. package/dist/auth/signup_routes.d.ts +4 -3
  73. package/dist/auth/signup_routes.d.ts.map +1 -1
  74. package/dist/auth/signup_routes.js +157 -134
  75. package/dist/auth/token_scope.d.ts +7 -1
  76. package/dist/auth/token_scope.d.ts.map +1 -1
  77. package/dist/auth/token_scope.js +8 -2
  78. package/dist/db/CLAUDE.md +32 -19
  79. package/dist/db/cell_ddl.d.ts +1 -2
  80. package/dist/db/cell_ddl.d.ts.map +1 -1
  81. package/dist/db/cell_ddl.js +1 -2
  82. package/dist/db/cell_field_queries.d.ts +20 -18
  83. package/dist/db/cell_field_queries.d.ts.map +1 -1
  84. package/dist/db/cell_field_queries.js +28 -22
  85. package/dist/db/cell_item_queries.d.ts +20 -14
  86. package/dist/db/cell_item_queries.d.ts.map +1 -1
  87. package/dist/db/cell_item_queries.js +28 -18
  88. package/dist/db/cell_moderation.d.ts +67 -0
  89. package/dist/db/cell_moderation.d.ts.map +1 -0
  90. package/dist/db/cell_moderation.js +66 -0
  91. package/dist/db/cell_queries.d.ts +44 -14
  92. package/dist/db/cell_queries.d.ts.map +1 -1
  93. package/dist/db/cell_queries.js +68 -45
  94. package/dist/db/cell_view_sql.d.ts +102 -0
  95. package/dist/db/cell_view_sql.d.ts.map +1 -0
  96. package/dist/db/cell_view_sql.js +112 -0
  97. package/dist/db/db_pg.d.ts +1 -1
  98. package/dist/db/db_pg.js +1 -1
  99. package/dist/http/CLAUDE.md +1 -1
  100. package/dist/http/auth_shape.d.ts +5 -2
  101. package/dist/http/auth_shape.d.ts.map +1 -1
  102. package/dist/http/auth_shape.js +5 -2
  103. package/dist/http/ip_canonical.d.ts +56 -5
  104. package/dist/http/ip_canonical.d.ts.map +1 -1
  105. package/dist/http/ip_canonical.js +147 -35
  106. package/dist/primitive_schemas.d.ts +10 -1
  107. package/dist/primitive_schemas.d.ts.map +1 -1
  108. package/dist/primitive_schemas.js +11 -2
  109. package/dist/rate_limiter.d.ts +179 -12
  110. package/dist/rate_limiter.d.ts.map +1 -1
  111. package/dist/rate_limiter.js +352 -42
  112. package/dist/server/serve_fact_route.d.ts +8 -4
  113. package/dist/server/serve_fact_route.d.ts.map +1 -1
  114. package/dist/server/serve_fact_route.js +15 -6
  115. package/dist/testing/CLAUDE.md +16 -7
  116. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  119. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  121. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/cell_relations.js +145 -2
  123. package/dist/testing/cross_backend/expected_schema.json +0 -1
  124. package/dist/testing/cross_backend/full_spine_mount.js +2 -1
  125. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  126. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/login_security.js +154 -0
  128. package/dist/testing/cross_backend/origin_conformance.d.ts +1 -1
  129. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/origin_conformance.js +1 -1
  131. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  132. package/dist/testing/cross_backend/query_shape.js +24 -2
  133. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  134. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  135. package/dist/testing/cross_backend/spine_surface_constants.d.ts +13 -21
  136. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/spine_surface_constants.js +13 -21
  138. package/dist/testing/cross_backend/testing_origin_check.d.ts +128 -0
  139. package/dist/testing/cross_backend/testing_origin_check.d.ts.map +1 -0
  140. package/dist/testing/cross_backend/testing_origin_check.js +87 -0
  141. package/dist/testing/cross_backend/testing_reset_actions.d.ts +7 -119
  142. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  143. package/dist/testing/cross_backend/testing_reset_actions.js +8 -92
  144. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  145. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  146. package/dist/testing/schema_introspect.js +1 -1
  147. package/dist/testing/schema_parity.d.ts +4 -3
  148. package/dist/testing/schema_parity.d.ts.map +1 -1
  149. package/dist/testing/schema_parity.js +4 -3
  150. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  151. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  152. package/dist/testing/schema_ready_fixture.js +2 -4
  153. package/dist/ui/auth_state.svelte.d.ts +10 -0
  154. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  155. package/dist/ui/auth_state.svelte.js +22 -4
  156. package/package.json +1 -1
  157. package/dist/db/cell_history_ddl.d.ts +0 -38
  158. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  159. package/dist/db/cell_history_ddl.js +0 -61
@@ -379,15 +379,17 @@ const query_grantor_holds_global_admin = async (deps, grantor_actor_id) => {
379
379
  * inside a transaction — the caller's route spec should declare
380
380
  * `transaction: true` (or wrap explicitly).
381
381
  *
382
- * Idempotent on race: if a second concurrent call observes the offer
383
- * already accepted, returns the existing role_grant rather than creating a
384
- * duplicate or throwing.
382
+ * Idempotent on replay and on race: a call that observes the offer already
383
+ * accepted returns the existing role_grant (`created: false`) rather than
384
+ * creating a duplicate or throwing — while that grant is still held. A grant
385
+ * revoked since the accept, or gone, makes the offer terminal.
385
386
  *
386
387
  * Error map:
387
388
  * - `RoleGrantOfferNotFoundError` — offer does not exist, or belongs to a
388
389
  * different recipient (IDOR guard). The offer row is untouched.
389
390
  * - `RoleGrantOfferAlreadyTerminalError` — offer is declined, retracted, or
390
- * superseded.
391
+ * superseded; or accepted, and its grant revoked, gone, or bound to another
392
+ * actor.
391
393
  * - `RoleGrantOfferExpiredError` — offer is pending but past `expires_at`.
392
394
  * - `RoleGrantOfferActorMismatchError` — actor-targeted offer, different actor.
393
395
  * - `RoleGrantOfferBuiltinRefusedError` — a builtin-role offer that must not
@@ -404,7 +406,7 @@ const query_grantor_holds_global_admin = async (deps, grantor_actor_id) => {
404
406
  * @mutates `role_grant_offer` siblings - stamps `superseded_at` on every other pending offer for the tuple
405
407
  * @mutates `audit_log` table - emits `role_grant_offer_accept` + `role_grant_create` + one `role_grant_offer_supersede` per sibling
406
408
  * @throws RoleGrantOfferNotFoundError if the offer is missing or belongs to another recipient
407
- * @throws RoleGrantOfferAlreadyTerminalError if the offer is declined, retracted, or superseded
409
+ * @throws RoleGrantOfferAlreadyTerminalError if the offer is declined, retracted, or superseded, or accepted with its grant revoked, gone, or bound to another actor
408
410
  * @throws RoleGrantOfferExpiredError if the offer is pending but past `expires_at`
409
411
  * @throws RoleGrantOfferActorMismatchError if the offer is actor-targeted to a different actor
410
412
  * @throws RoleGrantOfferBuiltinRefusedError if a builtin-role offer must not mint (see `BuiltinOfferRefusal`)
@@ -436,15 +438,21 @@ export const query_accept_offer = async (deps, input) => {
436
438
  // it off so `locked` stays exactly `ROLE_GRANT_OFFER_COLUMNS`.
437
439
  const { still_valid, ...locked } = locked_row;
438
440
  if (locked.accepted_at) {
439
- // Race winner already committed; return the pre-existing role_grant.
441
+ // Already accepted (a replay, or a race winner already committed);
442
+ // return the pre-existing role_grant.
440
443
  // `role_grant_offer_role_grant_iff_accepted` CHECK guarantees resulting_role_grant_id is non-null.
441
- const role_grant = assert_row(await query_role_grant_by_id(deps, locked.resulting_role_grant_id), 'resulting_role_grant lookup');
444
+ const role_grant = await query_role_grant_by_id(deps, locked.resulting_role_grant_id);
445
+ // The idempotent answer stands only while the grant it names does. A
446
+ // grant revoked since the accept (or gone) makes a replay terminal:
447
+ // answering OK with the revoked grant's id would tell the caller "you
448
+ // hold this role" when they don't.
449
+ //
442
450
  // Multi-actor guard: two actors on the same recipient account may
443
451
  // both race an account-grain offer — the loser must not silently
444
452
  // receive the winner's role_grant (which would tell them "you got it"
445
453
  // while the actor on the role_grant is someone else). Treat the offer
446
454
  // as terminal for the loser.
447
- if (role_grant.actor_id !== actor_id) {
455
+ if (!role_grant || role_grant.revoked_at !== null || role_grant.actor_id !== actor_id) {
448
456
  throw new RoleGrantOfferAlreadyTerminalError(offer_id);
449
457
  }
450
458
  return {
@@ -41,9 +41,10 @@ export interface SignupRouteOptions extends AuthSessionRouteOptions {
41
41
  * disable. Its own instance, not login's: signup failures bound username
42
42
  * *enumeration* (the 403/409 split), not credential guessing, and an
43
43
  * open-signup deployment lets any unauthenticated caller spend this budget
44
- * — which must not cost anyone their login attempts. Never refunded on
45
- * success (see `RateLimiter.reset`). Mirrors the Rust spine's
46
- * `signup_ip_rate_limiter`.
44
+ * — which must not cost anyone their login attempts. Charged by every
45
+ * classified failure and by every successful open signup (an invite-gated
46
+ * success spends an invite instead); never refunded on success (see
47
+ * `RateLimiter.reset`). Mirrors the Rust spine's `signup_ip_rate_limiter`.
47
48
  */
48
49
  signup_ip_rate_limiter: RateLimiter | null;
49
50
  /** Rate limiter for signup attempts, keyed by submitted username. Pass `null` to disable. */
@@ -1 +1 @@
1
- {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE;;;;;;;;OAQG;IACH,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAgLjB,CAAC"}
1
+ {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAGxE,OAAO,EAGN,KAAK,WAAW,EAEhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE;;;;;;;;;OASG;IACH,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CA2MjB,CAAC"}
@@ -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
  */