@fuzdev/fuz_app 0.118.0 → 0.120.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 (169) hide show
  1. package/dist/actions/CLAUDE.md +301 -73
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +16 -1
  4. package/dist/actions/connection_closer.d.ts +136 -51
  5. package/dist/actions/connection_closer.d.ts.map +1 -1
  6. package/dist/actions/connection_closer.js +133 -34
  7. package/dist/actions/register_action_ws.d.ts +176 -24
  8. package/dist/actions/register_action_ws.d.ts.map +1 -1
  9. package/dist/actions/register_action_ws.js +581 -241
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -0
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +1 -0
  13. package/dist/actions/socket.svelte.d.ts +43 -9
  14. package/dist/actions/socket.svelte.d.ts.map +1 -1
  15. package/dist/actions/socket.svelte.js +59 -8
  16. package/dist/actions/transports.d.ts +55 -1
  17. package/dist/actions/transports.d.ts.map +1 -1
  18. package/dist/actions/transports.js +55 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_auth_guard.js +87 -111
  22. package/dist/actions/transports_ws_backend.d.ts +212 -30
  23. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  24. package/dist/actions/transports_ws_backend.js +327 -70
  25. package/dist/actions/ws_endpoint_spec.d.ts +26 -13
  26. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  27. package/dist/auth/CLAUDE.md +113 -32
  28. package/dist/auth/account_actions.d.ts +12 -18
  29. package/dist/auth/account_actions.d.ts.map +1 -1
  30. package/dist/auth/account_actions.js +47 -33
  31. package/dist/auth/account_routes.d.ts +5 -11
  32. package/dist/auth/account_routes.d.ts.map +1 -1
  33. package/dist/auth/account_routes.js +40 -36
  34. package/dist/auth/account_schema.d.ts +27 -0
  35. package/dist/auth/account_schema.d.ts.map +1 -1
  36. package/dist/auth/account_schema.js +22 -0
  37. package/dist/auth/admin_actions.d.ts +8 -17
  38. package/dist/auth/admin_actions.d.ts.map +1 -1
  39. package/dist/auth/admin_actions.js +33 -21
  40. package/dist/auth/api_token_queries.d.ts +23 -2
  41. package/dist/auth/api_token_queries.d.ts.map +1 -1
  42. package/dist/auth/api_token_queries.js +39 -3
  43. package/dist/auth/audit_emitter.d.ts +61 -38
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +57 -14
  46. package/dist/auth/audit_log_route_schema.js +7 -7
  47. package/dist/auth/audit_log_routes.d.ts +31 -6
  48. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  49. package/dist/auth/audit_log_routes.js +85 -11
  50. package/dist/auth/audit_log_schema.d.ts +48 -0
  51. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  52. package/dist/auth/audit_log_schema.js +58 -0
  53. package/dist/auth/cleanup.d.ts +203 -28
  54. package/dist/auth/cleanup.d.ts.map +1 -1
  55. package/dist/auth/cleanup.js +222 -26
  56. package/dist/auth/deps.d.ts +31 -3
  57. package/dist/auth/deps.d.ts.map +1 -1
  58. package/dist/auth/resolved_auth.d.ts +105 -0
  59. package/dist/auth/resolved_auth.d.ts.map +1 -0
  60. package/dist/auth/resolved_auth.js +124 -0
  61. package/dist/auth/session_middleware.d.ts +8 -1
  62. package/dist/auth/session_middleware.d.ts.map +1 -1
  63. package/dist/auth/session_middleware.js +9 -3
  64. package/dist/auth/session_queries.d.ts +31 -6
  65. package/dist/auth/session_queries.d.ts.map +1 -1
  66. package/dist/auth/session_queries.js +31 -6
  67. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  68. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  69. package/dist/auth/standard_rpc_actions.js +3 -3
  70. package/dist/db/CLAUDE.md +19 -8
  71. package/dist/db/fact_disk_storage.d.ts +10 -8
  72. package/dist/db/fact_disk_storage.d.ts.map +1 -1
  73. package/dist/db/fact_disk_storage.js +13 -17
  74. package/dist/db/fact_store.d.ts +39 -38
  75. package/dist/db/fact_store.d.ts.map +1 -1
  76. package/dist/db/fact_store.js +48 -86
  77. package/dist/db/file_fact_url.d.ts +6 -6
  78. package/dist/db/file_fact_url.js +6 -6
  79. package/dist/db/status.d.ts +58 -1
  80. package/dist/db/status.d.ts.map +1 -1
  81. package/dist/db/status.js +205 -77
  82. package/dist/http/CLAUDE.md +7 -3
  83. package/dist/http/db_routes.d.ts +6 -2
  84. package/dist/http/db_routes.d.ts.map +1 -1
  85. package/dist/http/db_routes.js +2 -2
  86. package/dist/periodic.d.ts +80 -0
  87. package/dist/periodic.d.ts.map +1 -0
  88. package/dist/periodic.js +122 -0
  89. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  90. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  91. package/dist/realtime/sse_auth_guard.js +103 -73
  92. package/dist/realtime/subscriber_registry.d.ts +119 -8
  93. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  94. package/dist/realtime/subscriber_registry.js +184 -25
  95. package/dist/server/app_backend.d.ts +12 -1
  96. package/dist/server/app_backend.d.ts.map +1 -1
  97. package/dist/server/app_backend.js +5 -2
  98. package/dist/server/app_server.d.ts +55 -15
  99. package/dist/server/app_server.d.ts.map +1 -1
  100. package/dist/server/app_server.js +41 -13
  101. package/dist/server/serve_fact_route.d.ts +10 -7
  102. package/dist/server/serve_fact_route.d.ts.map +1 -1
  103. package/dist/server/serve_fact_route.js +11 -8
  104. package/dist/testing/CLAUDE.md +140 -48
  105. package/dist/testing/app_server.d.ts +2 -2
  106. package/dist/testing/app_server.d.ts.map +1 -1
  107. package/dist/testing/app_server.js +11 -2
  108. package/dist/testing/audit_drift_guard.d.ts +1 -48
  109. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  110. package/dist/testing/audit_drift_guard.js +0 -42
  111. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  112. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  113. package/dist/testing/connection_closer_helpers.js +11 -17
  114. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  115. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  116. package/dist/testing/cross_backend/capabilities.js +27 -12
  117. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  119. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  121. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  123. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  124. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/session_cap.js +55 -19
  126. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  127. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  129. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  130. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  132. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  133. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  135. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  136. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  138. package/dist/testing/sse_round_trip.d.ts +6 -4
  139. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  140. package/dist/testing/stubs.d.ts +1 -1
  141. package/dist/testing/stubs.d.ts.map +1 -1
  142. package/dist/testing/stubs.js +6 -4
  143. package/dist/testing/transports/sse_transport.d.ts +1 -1
  144. package/dist/testing/transports/ws_client.d.ts +7 -0
  145. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  146. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  147. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  148. package/dist/testing/transports/ws_raw_client.js +156 -0
  149. package/dist/testing/transports/ws_transport.d.ts +28 -0
  150. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  151. package/dist/testing/transports/ws_transport.js +45 -1
  152. package/dist/testing/ws_round_trip.d.ts +8 -3
  153. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  154. package/dist/testing/ws_round_trip.js +19 -4
  155. package/dist/ui/AdminAccounts.svelte +4 -3
  156. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  157. package/dist/ui/AdminOverview.svelte +3 -1
  158. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  159. package/dist/ui/CLAUDE.md +22 -6
  160. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  161. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  162. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  163. package/dist/ui/auth_state.svelte.d.ts +25 -1
  164. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  165. package/dist/ui/auth_state.svelte.js +28 -1
  166. package/package.json +2 -2
  167. package/dist/server/file_fact_fetcher.d.ts +0 -42
  168. package/dist/server/file_fact_fetcher.d.ts.map +0 -1
  169. package/dist/server/file_fact_fetcher.js +0 -60
@@ -20,6 +20,7 @@ import '../assert_dev_env.js';
20
20
  import { WebSocket } from 'ws';
21
21
  import { WS_CLIENT_DEFAULT_TIMEOUT_MS, deliver_inbound, is_response_for } from './ws_client.js';
22
22
  import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
23
+ import { heartbeat_action_spec } from '../../actions/heartbeat.js';
23
24
  /**
24
25
  * Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
25
26
  *
@@ -29,6 +30,13 @@ import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
29
30
  * `messages` array; `wait_for` checks already-received messages first
30
31
  * before waiting for new arrivals.
31
32
  *
33
+ * An open socket is not yet an **admitted** connection: both spines answer
34
+ * the handshake first and admit the connection a credential re-check later,
35
+ * and deliver no server-initiated message to it until then. Requests sent
36
+ * meanwhile are queued and answered, so a suite that only round-trips can
37
+ * use this directly; one that waits for a push (a broadcast, a targeted
38
+ * notification) opens with `create_admitted_ws_transport` instead.
39
+ *
32
40
  * @throws Error if the upgrade fails (status, network) — the rejection
33
41
  * message carries the underlying error so the test surfaces the real
34
42
  * cause rather than hanging.
@@ -70,8 +78,10 @@ export const create_ws_transport = async (options) => {
70
78
  });
71
79
  });
72
80
  let close_code = null;
73
- socket.on('close', (code) => {
81
+ let close_reason = null;
82
+ socket.on('close', (code, reason) => {
74
83
  close_code = code;
84
+ close_reason = reason.toString('utf-8');
75
85
  for (const resolve of close_resolvers)
76
86
  resolve();
77
87
  close_resolvers = [];
@@ -145,6 +155,9 @@ export const create_ws_transport = async (options) => {
145
155
  get close_code() {
146
156
  return close_code;
147
157
  },
158
+ get close_reason() {
159
+ return close_reason;
160
+ },
148
161
  send: send_impl,
149
162
  async request(id, method, params, timeout_ms) {
150
163
  await send_impl(create_jsonrpc_request(method, params, id));
@@ -168,3 +181,34 @@ export const create_ws_transport = async (options) => {
168
181
  wait_for_close
169
182
  };
170
183
  };
184
+ /** The request id `create_admitted_ws_transport` round-trips `heartbeat` under. */
185
+ export const WS_ADMISSION_PROBE_ID = 'admitted';
186
+ /**
187
+ * Open a real-upgrade WS client and round-trip `heartbeat` on it, so the
188
+ * backend has **admitted** the connection by the time this resolves.
189
+ *
190
+ * Both spines answer the upgrade handshake before the connection is
191
+ * registered and admitted (the credential is re-read in between), and deliver
192
+ * no broadcast or targeted notification to a connection still pending
193
+ * admission. A request is answered only once the connection is admitted, so
194
+ * the round trip is the one signal a client has. Suites that open a socket and
195
+ * then wait for a server-initiated message, or that need connections
196
+ * registered in a known order, open through this.
197
+ *
198
+ * `heartbeat` is a protocol action, present on every WS endpoint. Its response
199
+ * frame (id `WS_ADMISSION_PROBE_ID`) stays in the client's `messages`.
200
+ *
201
+ * @throws Error if the upgrade fails, or the heartbeat is refused or times out
202
+ * — the socket is closed first
203
+ */
204
+ export const create_admitted_ws_transport = async (options) => {
205
+ const client = await create_ws_transport(options);
206
+ try {
207
+ await client.request(WS_ADMISSION_PROBE_ID, heartbeat_action_spec.method, {});
208
+ }
209
+ catch (error) {
210
+ await client.close().catch(() => { });
211
+ throw error;
212
+ }
213
+ return client;
214
+ };
@@ -82,8 +82,9 @@ export interface FakeHonoContextOptions {
82
82
  }
83
83
  /**
84
84
  * Build a fake Hono `Context` exposing the auth keys the dispatcher
85
- * reads via `c.get(...)`. Only `.get()` is populated — no other Hono
86
- * context surface is simulated.
85
+ * reads via `c.get(...)` — the ones the production auth middleware sets,
86
+ * including the session token hash a session connection is registered under.
87
+ * Only `.get()` is populated — no other Hono context surface is simulated.
87
88
  */
88
89
  export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
89
90
  /** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture. */
@@ -121,7 +122,11 @@ export interface WsConnectIdentity {
121
122
  account_id?: Uuid;
122
123
  /** Credential type. Defaults to `'session'`. Keeper actions require `'daemon_token'`. */
123
124
  credential_type?: CredentialType;
124
- /** Session id (any string). Defaults to a fresh uuid. Hashed by the dispatcher. */
125
+ /**
126
+ * Session id (any string). Defaults to a fresh uuid. The connection is
127
+ * registered under `hash_session_token(session_id)`, the hash the request
128
+ * context middleware would have resolved.
129
+ */
125
130
  session_id?: string;
126
131
  /** Api token id; set for bearer connections, null otherwise. */
127
132
  api_token_id?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,8 @@ import { create_broadcast_api } from '../actions/broadcast_api.js';
7
7
  import { register_action_ws } from '../actions/register_action_ws.js';
8
8
  import { create_stub_db } from './stubs.js';
9
9
  import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
10
- import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
10
+ import { AUTH_SESSION_TOKEN_HASH_KEY, REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
11
+ import { hash_session_token } from '../auth/session_queries.js';
11
12
  import { ROLE_KEEPER } from '../auth/role_schema.js';
12
13
  import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
13
14
  import { create_jsonrpc_request } from '../http/jsonrpc_helpers.js';
@@ -34,16 +35,21 @@ export const create_fake_ws = () => {
34
35
  };
35
36
  /**
36
37
  * Build a fake Hono `Context` exposing the auth keys the dispatcher
37
- * reads via `c.get(...)`. Only `.get()` is populated — no other Hono
38
- * context surface is simulated.
38
+ * reads via `c.get(...)` — the ones the production auth middleware sets,
39
+ * including the session token hash a session connection is registered under.
40
+ * Only `.get()` is populated — no other Hono context surface is simulated.
39
41
  */
40
42
  export const create_fake_hono_context = (opts) => {
41
43
  const request_context = opts.request_context ?? build_simple_request_context(opts.role);
44
+ const auth_session_id = opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null);
42
45
  const vars = {
43
46
  [ACCOUNT_ID_KEY]: request_context.account.id,
44
47
  [REQUEST_CONTEXT_KEY]: request_context,
45
48
  [CREDENTIAL_TYPE_KEY]: opts.credential_type,
46
- auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null),
49
+ auth_session_id,
50
+ // what `create_request_context_middleware` sets beside the account id —
51
+ // the key the dispatcher registers a session connection under
52
+ [AUTH_SESSION_TOKEN_HASH_KEY]: auth_session_id === null ? null : hash_session_token(auth_session_id),
47
53
  [AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null,
48
54
  [TEST_CONTEXT_PRESET_KEY]: true
49
55
  };
@@ -186,6 +192,9 @@ export const create_ws_test_harness = (options) => {
186
192
  actions,
187
193
  db: stub_db,
188
194
  transport,
195
+ // the harness drives the dispatcher against a stub db — no revocation
196
+ // handler runs here, so there is no closer to reach this transport
197
+ connection_closer: null,
189
198
  heartbeat,
190
199
  log,
191
200
  on_socket_open,
@@ -203,6 +212,7 @@ export const create_ws_test_harness = (options) => {
203
212
  [REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)],
204
213
  [CREDENTIAL_TYPE_KEY, credential_type],
205
214
  ['auth_session_id', session_id],
215
+ [AUTH_SESSION_TOKEN_HASH_KEY, hash_session_token(session_id)],
206
216
  [AUTH_API_TOKEN_ID_KEY, api_token_id],
207
217
  [TEST_CONTEXT_PRESET_KEY, true]
208
218
  ]);
@@ -224,6 +234,7 @@ export const create_ws_test_harness = (options) => {
224
234
  const close_waiters = [];
225
235
  let is_closed = false;
226
236
  let close_code = null;
237
+ let close_reason = null;
227
238
  // Captured in `ws.close` below; `client.close(...)` returns it so
228
239
  // tests can await async `on_socket_close` cleanup.
229
240
  let close_pending = null;
@@ -242,6 +253,7 @@ export const create_ws_test_harness = (options) => {
242
253
  return;
243
254
  is_closed = true;
244
255
  close_code = code ?? 1000;
256
+ close_reason = reason ?? '';
245
257
  for (const resolve of close_waiters.splice(0))
246
258
  resolve();
247
259
  const close_event = new Event('close');
@@ -331,6 +343,9 @@ export const create_ws_test_harness = (options) => {
331
343
  get close_code() {
332
344
  return close_code;
333
345
  },
346
+ get close_reason() {
347
+ return close_reason;
348
+ },
334
349
  wait_for: wait_for_impl,
335
350
  wait_for_close: (timeout_ms = WS_CLIENT_DEFAULT_TIMEOUT_MS) => {
336
351
  if (is_closed)
@@ -5,6 +5,8 @@
5
5
  * and `format_scope_context` for label rendering. Per-row actions:
6
6
  * grant role (`role_grant_offer_create`), revoke role_grant (`role_grant_revoke`,
7
7
  * keyed by `actor_id`), retract pending offer (`role_grant_offer_retract`).
8
+ * The grant button offers the role globally, so `can_offer_global_role`
9
+ * hides it only for a global grant or pending global offer of that role.
8
10
  *
9
11
  * @module
10
12
  */
@@ -12,6 +14,7 @@
12
14
  import {
13
15
  AdminAccountsState,
14
16
  admin_accounts_rpc_context,
17
+ can_offer_global_role,
15
18
  grant_key
16
19
  } from './admin_accounts_state.svelte.js';
17
20
  import ConfirmButton from './ConfirmButton.svelte';
@@ -158,9 +161,7 @@
158
161
  {#each admin_accounts.grantable_roles as role (role)}
159
162
  {@const key = grant_key(row.account.id, role)}
160
163
  {@const grant_error = admin_accounts.grant.error(key)}
161
- {#if !row.role_grants.some((p) => p.role === role) &&
162
- !row.pending_offers.some((o) => o.role === role)
163
- }
164
+ {#if can_offer_global_role(row, role)}
164
165
  <ConfirmButton
165
166
  onconfirm={() => admin_accounts.submit_grant(row.account.id, role)}
166
167
  title="offer {role}"
@@ -1 +1 @@
1
- {"version":3,"file":"AdminAccounts.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminAccounts.svelte"],"names":[],"mappings":"AAqMA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AdminAccounts.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminAccounts.svelte"],"names":[],"mappings":"AAuMA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -34,7 +34,9 @@
34
34
  const audit_log = new AuditLogState({ get_rpc: get_audit_log_rpc });
35
35
  const app_settings = new AppSettingsState({ get_rpc: get_app_settings_rpc });
36
36
 
37
- // accounts - dynamic role breakdown
37
+ // accounts - dynamic role breakdown; display only, and scope-blind on purpose:
38
+ // an account counts under each role it holds at any scope, so a count is not
39
+ // "who has this role globally" (that's `has_global_role` over the row's grants)
38
40
  const role_counts = $derived.by(() => {
39
41
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
40
42
  const counts = new Map<string, number>();
@@ -1 +1 @@
1
- {"version":3,"file":"AdminOverview.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminOverview.svelte"],"names":[],"mappings":"AA+UA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AdminOverview.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AdminOverview.svelte"],"names":[],"mappings":"AAiVA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
package/dist/ui/CLAUDE.md CHANGED
@@ -281,8 +281,10 @@ destructive actions.
281
281
  - `AdminAccounts.svelte` — accounts + role_grants + pending offers.
282
282
  Consumes `admin_accounts_rpc_context`. Per-row actions: grant (+role
283
283
  chip with `ConfirmButton`), revoke (`actor_id` + `role_grant_id`),
284
- retract pending offer. Reads per-row spinner + error state via
285
- `state.grant.loading(key)` / `state.revoke.loading(role_grant_id)` /
284
+ retract pending offer. The grant button offers the role globally, so it
285
+ shows per `can_offer_global_role` — hidden by a global grant or pending
286
+ global offer of that role, not by a scoped one. Reads per-row spinner +
287
+ error state via `state.grant.loading(key)` / `state.revoke.loading(role_grant_id)` /
286
288
  `state.retract.loading(offer_id)` and their `.error(key)` siblings —
287
289
  per-row error displays inline next to the failing button (no
288
290
  top-level rollup).
@@ -297,7 +299,8 @@ destructive actions.
297
299
  invites / recent activity / security / system). Consumes all four
298
300
  RPC contexts plus `auth_state_context`; fetches in parallel on mount.
299
301
  Derives `role_counts`, `failed_logins`, `role_grant_changes` from
300
- the audit log.
302
+ the audit log. `role_counts` is display-only and scope-blind — an account
303
+ counts under each role it holds at any scope.
301
304
  - `AdminRoleGrantHistory.svelte` — role-grant-create/revoke history table.
302
305
  Consumes `audit_log_rpc_context`, calls
303
306
  `audit_log.fetch_role_grant_history()` once on mount.
@@ -401,10 +404,21 @@ destructive actions.
401
404
  Fields: `verifying`, `verified`, `verify_error`, `account`, `actor`
402
405
  (the caller's own `ActorSummaryJson` — surfaced directly so consumers
403
406
  don't derive `actor_id` from the role_grant list), `role_grants`,
404
- `active_role_grants` (derived via `is_role_grant_active`), `roles` (derived),
407
+ `active_role_grants` (derived via `is_role_grant_active`, every scope),
408
+ `global_roles` (derived `ReadonlySet` — the roles of active grants with
409
+ `scope_id === null`), `is_admin` (derived — a global `admin` grant),
405
410
  `needs_bootstrap`. Methods: `check_session()`
406
411
  (GET `/api/account/status`), `login`, `bootstrap`, `signup`,
407
- `logout`. Handles 401/403/409/429 translations inline.
412
+ `logout`, `has_global_role(role)`. Handles 401/403/409/429 translations
413
+ inline.
414
+ **Role gates in the UI read the global view** — `is_admin` /
415
+ `has_global_role(role)`, the client form of the server's
416
+ `has_scoped_role(ctx, role, null)`. There is no scope-blind list of role
417
+ names: matching a grant on its role alone reads one scoped to a single
418
+ resource as global (and a scoped `admin` grant confers nothing). A scoped
419
+ check reads `active_role_grants` by `scope_id`; outside the class, the pure
420
+ `has_global_role(role_grants, role)` (`auth/account_schema.ts`) answers the
421
+ same question over any grant list.
408
422
  - `table_state.svelte.ts` — `TableState`. Paginated DB browser state.
409
423
  Holds one `AsyncSlot` (`list`) + payload fields (`table_name`,
410
424
  `columns`, `rows`, `total`, `offset`, `limit` capped by
@@ -470,7 +484,9 @@ the `submit_*` prefix where the verb collides with a slot name.
470
484
  (`KeyedAsyncSlot<Uuid, void>` keyed by `offer_id`). `submit_revoke`
471
485
  takes `actor_id` as the first arg (role_grants are actor-scoped —
472
486
  matches `row.actor.id` straight from the listing) with optional
473
- `reason`.
487
+ `reason`. Also exports the pure `can_offer_global_role(entry, role)` —
488
+ whether a listing row has neither a global grant nor a pending global
489
+ offer of the role.
474
490
  - `admin_invites_state.svelte.ts` — `AdminInvitesState` +
475
491
  `admin_invites_rpc_context` + narrow `AdminInvitesRpc` (`list`,
476
492
  `create`, `delete`). Slots: `list`, `create` (both AsyncSlot),
@@ -15,7 +15,7 @@
15
15
  import type { Uuid } from '@fuzdev/fuz_util/id.ts';
16
16
  import { AsyncSlot } from './async_slot.svelte.js';
17
17
  import { KeyedAsyncSlot } from './keyed_async_slot.svelte.js';
18
- import type { AdminAccountEntryJson } from '../auth/account_schema.js';
18
+ import { type AdminAccountEntryJson } from '../auth/account_schema.js';
19
19
  import type { RoleName } from '../auth/role_schema.js';
20
20
  import type { RoleGrantOfferJson } from '../auth/role_grant_offer_schema.js';
21
21
  import type { AdminAccountListOutput, AccountDeleteOutput, AccountUndeleteOutput, AdminSessionListOutput, AdminSessionRevokeAllInput, AdminSessionRevokeAllOutput, AdminTokenRevokeAllInput, AdminTokenRevokeAllOutput } from '../auth/admin_action_specs.js';
@@ -78,6 +78,20 @@ export interface AdminAccountsStateOptions {
78
78
  * colliding on per-row spinners.
79
79
  */
80
80
  export declare const grant_key: (account_id: Uuid, role: RoleName, to_actor_id?: Uuid | null) => string;
81
+ /**
82
+ * Whether a listing row can be offered a global grant of `role` — the test
83
+ * behind `AdminAccounts`' `+ role` button, which `submit_grant` backs with an
84
+ * unscoped offer. Only what the offer would duplicate rules it out: a global
85
+ * grant of the role, or a pending global offer of it. A grant or offer scoped
86
+ * to one resource is a different grant, so it doesn't.
87
+ *
88
+ * @param entry - the account's listing row
89
+ * @param role - the role the button would offer
90
+ * @param now - current time for the grant-expiry check (defaults to `new Date()`,
91
+ * pass for testability); pending offers are taken as listed — the listing
92
+ * returns only unexpired ones
93
+ */
94
+ export declare const can_offer_global_role: (entry: Pick<AdminAccountEntryJson, "role_grants" | "pending_offers">, role: string, now?: Date) => boolean;
81
95
  export declare class AdminAccountsState {
82
96
  #private;
83
97
  readonly list: AsyncSlot<void, string>;
@@ -1 +1 @@
1
- {"version":3,"file":"admin_accounts_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/admin_accounts_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EACX,sBAAsB,EACtB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,0BAA0B,EAC1B,2BAA2B,EAC3B,wBAAwB,EACxB,yBAAyB,EACzB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EACX,yBAAyB,EACzB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,qBAAqB,EACrB,MAAM,0CAA0C,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,gBAAgB;IAChC,aAAa,EAAE,CAAC,eAAe,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC9E,cAAc,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACnE,gBAAgB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACvE,aAAa,EAAE,MAAM,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACrD,iBAAiB,EAAE,CAAC,MAAM,EAAE,yBAAyB,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC;IAC9F,iBAAiB,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACpF,aAAa,EAAE,CAAC,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACnE,kBAAkB,EAAE,CAAC,MAAM,EAAE,0BAA0B,KAAK,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACjG,gBAAgB,EAAE,CAAC,MAAM,EAAE,wBAAwB,KAAK,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC3F;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B;2CAAwB,gBAAgB;4BAAhB,gBAAgB;uBAAhB,gBAAgB,WAAhB,gBAAgB;CAAG,CAAC;AAEnF,MAAM,WAAW,yBAAyB;IACzC;;;;;OAKG;IACH,OAAO,EAAE,MAAM,gBAAgB,CAAC;CAChC;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,YAAY,IAAI,EAAE,MAAM,QAAQ,EAAE,cAAc,IAAI,GAAG,IAAI,KAAG,MACT,CAAC;AAEhF,qBAAa,kBAAkB;;IAG9B,QAAQ,CAAC,IAAI,0BAAyB;IACtC,QAAQ,CAAC,KAAK;;;;;;;;;;;;;;;;;eAAoD;IAClE,QAAQ,CAAC,MAAM,sEAAoC;IACnD,QAAQ,CAAC,OAAO,sEAAoC;IAEpD,QAAQ,CAAC,WAAW,sEAAoC;IACxD,QAAQ,CAAC,QAAQ,sEAAoC;IAErD,QAAQ,EAAE,KAAK,CAAC,qBAAqB,CAAC,CAAkB;IACxD,eAAe,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAkB;IAClD;;;OAGG;IACH,YAAY,EAAE,OAAO,CAAiB;IAEtC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAkC;gBAEpD,OAAO,EAAE,yBAAyB;IAIxC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B;;;;OAIG;IACG,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAMrD;;;;;OAKG;IACG,aAAa,CAAC,UAAU,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAOpD;;;;OAIG;IACG,eAAe,CAAC,UAAU,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtD;;;;;;;;;;;;;;;;OAgBG;IACG,YAAY,CACjB,UAAU,EAAE,IAAI,EAChB,IAAI,EAAE,QAAQ,EACd,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,GACvB,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAc1C;;;;;;;;OAQG;IACG,aAAa,CAAC,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAW/F;;;;;;;OAOG;IACG,cAAc,CAAC,QAAQ,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAMnD"}
1
+ {"version":3,"file":"admin_accounts_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/admin_accounts_state.svelte.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,7 @@
15
15
  import { create_context } from '@fuzdev/fuz_ui/context_helpers.ts';
16
16
  import { AsyncSlot } from './async_slot.svelte.js';
17
17
  import { KeyedAsyncSlot } from './keyed_async_slot.svelte.js';
18
+ import { has_global_role } from '../auth/account_schema.js';
18
19
  /**
19
20
  * Svelte context carrying the reactive `AdminAccountsRpc` accessor. The
20
21
  * provisioner (typically the admin route shell) calls `set(() => rpc)`;
@@ -32,6 +33,21 @@ export const admin_accounts_rpc_context = create_context();
32
33
  * colliding on per-row spinners.
33
34
  */
34
35
  export const grant_key = (account_id, role, to_actor_id) => to_actor_id ? `${account_id}:${role}:${to_actor_id}` : `${account_id}:${role}`;
36
+ /**
37
+ * Whether a listing row can be offered a global grant of `role` — the test
38
+ * behind `AdminAccounts`' `+ role` button, which `submit_grant` backs with an
39
+ * unscoped offer. Only what the offer would duplicate rules it out: a global
40
+ * grant of the role, or a pending global offer of it. A grant or offer scoped
41
+ * to one resource is a different grant, so it doesn't.
42
+ *
43
+ * @param entry - the account's listing row
44
+ * @param role - the role the button would offer
45
+ * @param now - current time for the grant-expiry check (defaults to `new Date()`,
46
+ * pass for testability); pending offers are taken as listed — the listing
47
+ * returns only unexpired ones
48
+ */
49
+ export const can_offer_global_role = (entry, role, now = new Date()) => !has_global_role(entry.role_grants, role, now) &&
50
+ !entry.pending_offers.some((o) => o.role === role && o.scope_id === null);
35
51
  export class AdminAccountsState {
36
52
  #get_rpc;
37
53
  list = new AsyncSlot();
@@ -48,10 +48,34 @@ export declare class AuthState {
48
48
  account: SessionAccount | null;
49
49
  actor: ActorSummaryJson | null;
50
50
  role_grants: Array<RoleGrantSummaryJson>;
51
+ /**
52
+ * The live subset of `role_grants`, at every scope. Liveness is judged when
53
+ * `role_grants` is assigned, so a grant that expires while the page stays
54
+ * open reads as active until the next `check_session`.
55
+ */
51
56
  readonly active_role_grants: Array<RoleGrantSummaryJson>;
52
- readonly roles: Array<string>;
57
+ /**
58
+ * The roles held globally — the role of each active grant whose `scope_id`
59
+ * is `null`. A grant scoped to one resource is not in here: it confers its
60
+ * role on that resource only, and a scoped grant of a builtin role confers
61
+ * nothing, so a role gate reads the global grant. Scoped grants are read
62
+ * from `active_role_grants`, by `scope_id`.
63
+ *
64
+ * Shapes the UI only — the server re-checks every request.
65
+ */
66
+ readonly global_roles: ReadonlySet<string>;
67
+ /** True for an active global `admin` grant, the rule the server's admin gates apply. */
68
+ readonly is_admin: boolean;
53
69
  /** True when bootstrap is available (no accounts exist yet). */
54
70
  needs_bootstrap: boolean;
71
+ /**
72
+ * Whether the account holds an active global grant of `role` — reactive
73
+ * when read in a `$derived` or the template. A scoped grant doesn't count;
74
+ * see `global_roles`.
75
+ *
76
+ * @param role - the role to check
77
+ */
78
+ has_global_role(role: string): boolean;
55
79
  /**
56
80
  * Check auth state and bootstrap availability.
57
81
  *
@@ -1 +1 @@
1
- {"version":3,"file":"auth_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/auth_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAMH,OAAO,EACN,KAAK,gBAAgB,EAErB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,MAAM,2BAA2B,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;CAA8B,CAAC;AAE9D,qBAAa,SAAS;IACrB,SAAS,UAAqB;IAC9B,QAAQ,UAAqB;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC/C,OAAO,EAAE,cAAc,GAAG,IAAI,CAAoB;IAClD,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAoB;IAClD,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,CAAkB;IAC1D,QAAQ,CAAC,kBAAkB,EAAE,KAAK,CAAC,oBAAoB,CAAC,CAEtD;IACF,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAwD;IAErF,gEAAgE;IAChE,eAAe,UAAqB;IAEpC;;;;;;;;OAQG;IACG,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IA6BpC;;;;;;;OAOG;IACG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA0CjE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiCpF;;;;;;;OAOG;IACG,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA8ClF;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;CAW7B"}
1
+ {"version":3,"file":"auth_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/auth_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAMH,OAAO,EACN,KAAK,gBAAgB,EAErB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,MAAM,2BAA2B,CAAC;AAGnC;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;CAA8B,CAAC;AAE9D,qBAAa,SAAS;IACrB,SAAS,UAAqB;IAC9B,QAAQ,UAAqB;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC/C,OAAO,EAAE,cAAc,GAAG,IAAI,CAAoB;IAClD,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAoB;IAClD,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,CAAkB;IAC1D;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,EAAE,KAAK,CAAC,oBAAoB,CAAC,CAEtD;IACF;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAExC;IACF,wFAAwF;IACxF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAA+C;IAEzE,gEAAgE;IAChE,eAAe,UAAqB;IAEpC;;;;;;OAMG;IACH,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAItC;;;;;;;;OAQG;IACG,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IA6BpC;;;;;;;OAOG;IACG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA0CjE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiCpF;;;;;;;OAOG;IACG,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA8ClF;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;CAW7B"}
@@ -35,6 +35,7 @@ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
35
35
  import { create_context } from '@fuzdev/fuz_ui/context_helpers.ts';
36
36
  import { ui_fetch } from './ui_fetch.js';
37
37
  import { is_role_grant_active } from '../auth/account_schema.js';
38
+ import { ROLE_ADMIN } from '../auth/role_schema.js';
38
39
  /**
39
40
  * Svelte context for `AuthState`.
40
41
  * Use `auth_state_context.set(state)` in the provider and `auth_state_context.get()` to access.
@@ -47,10 +48,36 @@ export class AuthState {
47
48
  account = $state.raw(null);
48
49
  actor = $state.raw(null);
49
50
  role_grants = $state.raw([]);
51
+ /**
52
+ * The live subset of `role_grants`, at every scope. Liveness is judged when
53
+ * `role_grants` is assigned, so a grant that expires while the page stays
54
+ * open reads as active until the next `check_session`.
55
+ */
50
56
  active_role_grants = $derived(this.role_grants.filter((p) => is_role_grant_active(p)));
51
- roles = $derived(this.active_role_grants.map((p) => p.role));
57
+ /**
58
+ * The roles held globally — the role of each active grant whose `scope_id`
59
+ * is `null`. A grant scoped to one resource is not in here: it confers its
60
+ * role on that resource only, and a scoped grant of a builtin role confers
61
+ * nothing, so a role gate reads the global grant. Scoped grants are read
62
+ * from `active_role_grants`, by `scope_id`.
63
+ *
64
+ * Shapes the UI only — the server re-checks every request.
65
+ */
66
+ global_roles = $derived(new Set(this.active_role_grants.filter((p) => p.scope_id === null).map((p) => p.role)));
67
+ /** True for an active global `admin` grant, the rule the server's admin gates apply. */
68
+ is_admin = $derived(this.global_roles.has(ROLE_ADMIN));
52
69
  /** True when bootstrap is available (no accounts exist yet). */
53
70
  needs_bootstrap = $state.raw(false);
71
+ /**
72
+ * Whether the account holds an active global grant of `role` — reactive
73
+ * when read in a `$derived` or the template. A scoped grant doesn't count;
74
+ * see `global_roles`.
75
+ *
76
+ * @param role - the role to check
77
+ */
78
+ has_global_role(role) {
79
+ return this.global_roles.has(role);
80
+ }
54
81
  /**
55
82
  * Check auth state and bootstrap availability.
56
83
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.118.0",
3
+ "version": "0.120.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",
@@ -33,7 +33,7 @@
33
33
  "peerDependencies": {
34
34
  "@electric-sql/pglite": ">=0.4",
35
35
  "@fuzdev/blake3-wasm": ">=0.2.0",
36
- "@fuzdev/fuz_util": ">=0.68.0",
36
+ "@fuzdev/fuz_util": ">=0.69.0",
37
37
  "@hono/node-server": ">=1",
38
38
  "@hono/node-ws": ">=1",
39
39
  "@node-rs/argon2": ">=2",
@@ -1,42 +0,0 @@
1
- /**
2
- * Filesystem-backed `FactExternalFetcher`.
3
- *
4
- * Resolves `file:<shard>/<rest>` external URLs against a configured
5
- * facts directory. The URL shape is the canonical relative-path scheme:
6
- * 2 hex chars for the shard subdir + 62 hex chars for the rest of the
7
- * blake3 hash (64 hex chars total). Files live at
8
- * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
9
- * them in.
10
- *
11
- * **Pre-filter regex**: `^file:[0-9a-f]{2}/[0-9a-f]{62}$`. A `..`
12
- * segment can't match (`.` isn't in `[0-9a-f]`); neither can absolute
13
- * paths, query strings, or any non-hex character. Defense-in-depth in
14
- * front of `path.join` — the fetcher never trusts the URL came from a
15
- * fact row, even though it always does in practice.
16
- *
17
- * The fetcher does NOT verify hash content — `PgFactStore.get` calls
18
- * `fact_hash_verify(hash, bytes)` after the fetch and returns null on
19
- * mismatch.
20
- *
21
- * Runtime: uses `node:fs/promises` + `node:fs` `createReadStream` so
22
- * the same code works under Deno (via node compat) and vitest.
23
- *
24
- * @module
25
- */
26
- import type { FactExternalFetcher } from '../db/fact_store.js';
27
- /** Construction options. */
28
- export interface FileFactFetcherOptions {
29
- /**
30
- * Absolute path to the facts directory. Files resolve to
31
- * `<facts_dir>/<shard>/<rest>`.
32
- */
33
- facts_dir: string;
34
- }
35
- /**
36
- * Build a `FactExternalFetcher` that resolves `file:` URLs against the
37
- * filesystem. Throws on a malformed URL before touching the disk so
38
- * `PgFactStore.get` logs the warning + returns null without an I/O
39
- * round-trip on bad data.
40
- */
41
- export declare const create_file_fact_fetcher: (options: FileFactFetcherOptions) => FactExternalFetcher;
42
- //# sourceMappingURL=file_fact_fetcher.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"file_fact_fetcher.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/file_fact_fetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAOH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAG/D,4BAA4B;AAC5B,MAAM,WAAW,sBAAsB;IACtC;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,sBAAsB,KAAG,mBA0B1E,CAAC"}
@@ -1,60 +0,0 @@
1
- /**
2
- * Filesystem-backed `FactExternalFetcher`.
3
- *
4
- * Resolves `file:<shard>/<rest>` external URLs against a configured
5
- * facts directory. The URL shape is the canonical relative-path scheme:
6
- * 2 hex chars for the shard subdir + 62 hex chars for the rest of the
7
- * blake3 hash (64 hex chars total). Files live at
8
- * `<facts_dir>/<shard>/<rest>` after the writer atomically temp+renames
9
- * them in.
10
- *
11
- * **Pre-filter regex**: `^file:[0-9a-f]{2}/[0-9a-f]{62}$`. A `..`
12
- * segment can't match (`.` isn't in `[0-9a-f]`); neither can absolute
13
- * paths, query strings, or any non-hex character. Defense-in-depth in
14
- * front of `path.join` — the fetcher never trusts the URL came from a
15
- * fact row, even though it always does in practice.
16
- *
17
- * The fetcher does NOT verify hash content — `PgFactStore.get` calls
18
- * `fact_hash_verify(hash, bytes)` after the fetch and returns null on
19
- * mismatch.
20
- *
21
- * Runtime: uses `node:fs/promises` + `node:fs` `createReadStream` so
22
- * the same code works under Deno (via node compat) and vitest.
23
- *
24
- * @module
25
- */
26
- import { readFile } from 'node:fs/promises';
27
- import { createReadStream } from 'node:fs';
28
- import { Readable } from 'node:stream';
29
- import { join } from 'node:path';
30
- import { parse_file_fact_url } from '../db/file_fact_url.js';
31
- /**
32
- * Build a `FactExternalFetcher` that resolves `file:` URLs against the
33
- * filesystem. Throws on a malformed URL before touching the disk so
34
- * `PgFactStore.get` logs the warning + returns null without an I/O
35
- * round-trip on bad data.
36
- */
37
- export const create_file_fact_fetcher = (options) => {
38
- const { facts_dir } = options;
39
- const resolve_path = (url) => {
40
- const parsed = parse_file_fact_url(url);
41
- if (!parsed) {
42
- throw new Error(`invalid file fact url: ${url}`);
43
- }
44
- return join(facts_dir, parsed.shard, parsed.rest);
45
- };
46
- return {
47
- fetch_bytes: async (url) => {
48
- const path = resolve_path(url);
49
- const buf = await readFile(path);
50
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
51
- },
52
- // `Promise.resolve().then(...)` (rather than `async`) funnels any
53
- // `resolve_path` throw into a rejection without an unused `await`.
54
- fetch_stream: (url) => Promise.resolve().then(() => {
55
- const path = resolve_path(url);
56
- const node_stream = createReadStream(path);
57
- return Readable.toWeb(node_stream);
58
- })
59
- };
60
- };