@fuzdev/fuz_app 0.75.0 → 0.77.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.
@@ -0,0 +1,256 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-process **role-grant-offer lifecycle WS notification** suite — the
4
+ * machinery proof for the consentful-role-grants notification fan-out across
5
+ * any spine backend. Covers all seven server-initiated notifications:
6
+ *
7
+ * - `role_grant_offer_received` → recipient (offer created)
8
+ * - `role_grant_offer_accepted` → grantor (recipient accepts)
9
+ * - `role_grant_offer_declined` → grantor (recipient declines)
10
+ * - `role_grant_offer_retracted` → recipient (grantor retracts)
11
+ * - `role_grant_revoke` (flat, omits `revoked_by`) → revokee (active grant revoked)
12
+ * - `role_grant_offer_supersede` → each superseded sibling's grantor, fired on
13
+ * BOTH the accept-cascade (`reason: 'sibling_accepted'`) and the
14
+ * revoke-cascade (`reason: 'role_grant_revoked'`)
15
+ *
16
+ * These exercise only spine primitives (accounts, role-grants, offers, WS
17
+ * notifications) — zero consumer domain — so the suite lives here and runs
18
+ * against any backend that wires the standard RPC actions' `notification_sender`
19
+ * and mounts a registered WS socket: fuz_app's own spine self-tests
20
+ * (`testing_spine_server` + the Rust `testing_spine_stub`) and downstream
21
+ * twin-impl consumers (the fuz_forge Deno/Hono + Rust `fuz_forge_server`
22
+ * backends) alike.
23
+ *
24
+ * Each case is a *targeted* server-initiated notification (vs the broadcast in
25
+ * a `repo_updated`-style suite), so it opens the affected counterparty's socket,
26
+ * drives the lifecycle RPC over HTTP, then asserts the frame lands on that
27
+ * socket and strict-parses against the canonical wire schema — the guard
28
+ * against serialization drift (field / null / datetime / the flat revoke shape /
29
+ * the supersede `reason` + `cause_id`).
30
+ *
31
+ * Sends are queued on the post-commit drain (handler-emit, not audit-derived),
32
+ * so a frame may land a beat after the RPC resolves — `WsClient.wait_for` polls
33
+ * already-received messages then waits, absorbing the fan-out latency without a
34
+ * sleep, and its method+predicate filter ignores unrelated frames (e.g. the
35
+ * `received` push the recipient also gets). `ROLE_ADMIN` is the only
36
+ * admin-grantable role; accounts that already hold it can still be offered it
37
+ * again (a fresh pending row — the prior accept is terminal, no already-granted
38
+ * guard), and accept stays idempotent on the role_grant while still superseding
39
+ * pending siblings. Gated on `capabilities.ws`.
40
+ *
41
+ * Cross-process only: `create_ws_transport` needs a real bound socket, so wire
42
+ * it from a `*.cross.test.ts` file, never an in-process setup. Authed cookies
43
+ * come from the per-account session minted by `fixture.create_account` /
44
+ * `fixture.create_session_headers`.
45
+ *
46
+ * @module
47
+ */
48
+ import { assert, describe } from 'vitest';
49
+ import { rpc_call } from '../rpc_helpers.js';
50
+ import { create_ws_transport } from '../transports/ws_transport.js';
51
+ import { is_notification_with } from '../transports/ws_client.js';
52
+ import { ROLE_ADMIN } from '../../auth/role_schema.js';
53
+ import { ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, RoleGrantOfferReceivedParams, RoleGrantOfferAcceptedParams, RoleGrantOfferDeclinedParams, RoleGrantOfferRetractedParams, RoleGrantOfferSupersedeParams, RoleGrantRevokeParams, } from '../../auth/role_grant_offer_notifications.js';
54
+ import { test_if } from './capabilities.js';
55
+ /** JSON-RPC endpoint path — matches the spine's `/api/rpc` (and the forge's). */
56
+ const RPC_PATH = '/api/rpc';
57
+ /**
58
+ * Register the role-grant-offer WS notification suite — seven cases over a real
59
+ * upgrade, one per server-initiated notification (received / accepted /
60
+ * declined / retracted / revoke + supersede on both the accept and revoke
61
+ * cascades). Each opens the affected counterparty's socket, drives the
62
+ * lifecycle RPC, and strict-parses the delivered frame against its canonical
63
+ * params schema. Gated on `capabilities.ws`.
64
+ */
65
+ export const describe_role_grant_offer_notification_ws_tests = (options) => {
66
+ const { setup_test, capabilities, base_url, ws_path } = options;
67
+ // -- shared helpers -------------------------------------------------------
68
+ /** Open a WS transport for a single session cookie (`<name>=<value>`). */
69
+ const open_ws = (cookie) => {
70
+ assert.ok(cookie, 'expected a session cookie for the WS upgrade');
71
+ return create_ws_transport({ base_url, ws_path, cookies: [cookie], origin: base_url });
72
+ };
73
+ /** Drive a JSON-RPC call over the fixture's HTTP transport. */
74
+ const rpc = (fixture, method, params, headers) => rpc_call({ app: fixture.transport, path: RPC_PATH, method, params, headers });
75
+ /**
76
+ * Create a fresh pending `ROLE_ADMIN` offer from `grantor_headers` (defaults
77
+ * to the keeper) to `to_account_id`, returning the created offer.
78
+ */
79
+ const create_pending_offer = async (fixture, to_account_id, grantor_headers = fixture.create_session_headers()) => {
80
+ const res = await rpc(fixture, 'role_grant_offer_create', { to_account_id, role: ROLE_ADMIN }, grantor_headers);
81
+ if (!res.ok)
82
+ assert.fail(`offer create failed: ${JSON.stringify(res)}`);
83
+ return res.result.offer;
84
+ };
85
+ /**
86
+ * Materialize an active `ROLE_ADMIN` role_grant on `recipient` via a fresh
87
+ * keeper offer + recipient accept (idempotent on a grant the account already
88
+ * holds), returning its `role_grant_id` — the handle `role_grant_revoke`
89
+ * keys on.
90
+ */
91
+ const create_active_role_grant = async (fixture, recipient) => {
92
+ const offer = await create_pending_offer(fixture, recipient.account.id);
93
+ const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
94
+ if (!accepted.ok)
95
+ assert.fail(`accept failed: ${JSON.stringify(accepted)}`);
96
+ return accepted.result.role_grant_id;
97
+ };
98
+ // -- tests ----------------------------------------------------------------
99
+ describe('role_grant_offer WS notifications (cross-process)', () => {
100
+ test_if(capabilities.ws, 'an offer create delivers role_grant_offer_received to the recipient WS', async () => {
101
+ const fixture = await setup_test();
102
+ // Seed a second admin account — admin so it can open the
103
+ // ROLE_ADMIN-gated WS (forge); harmless on the auth-only spine.
104
+ // `create_account` rides the real offer/accept handlers (that
105
+ // earlier notification lands before the socket opens, so it's not
106
+ // the one we observe).
107
+ const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
108
+ const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
109
+ try {
110
+ // The keeper (grantor, holds ROLE_ADMIN) offers the recipient a
111
+ // role over RPC — fresh pending offer, emits the notification
112
+ // post-commit.
113
+ const created = await rpc(fixture, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: ROLE_ADMIN }, fixture.create_session_headers());
114
+ assert.isTrue(created.ok, `offer create failed: ${JSON.stringify(created)}`);
115
+ const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === recipient.account.id), 5000);
116
+ // Params strict-parse against the canonical wire schema — guards
117
+ // the serialization against field/null/datetime drift.
118
+ const params = RoleGrantOfferReceivedParams.parse(frame.params);
119
+ assert.strictEqual(params.offer.to_account_id, recipient.account.id);
120
+ assert.strictEqual(params.offer.role, ROLE_ADMIN);
121
+ }
122
+ finally {
123
+ await recipient_ws.close();
124
+ }
125
+ });
126
+ test_if(capabilities.ws, 'accept delivers role_grant_offer_accepted to the grantor WS', async () => {
127
+ const fixture = await setup_test();
128
+ const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
129
+ // Grantor = keeper; open its socket BEFORE the recipient accepts.
130
+ const grantor = await open_ws(fixture.create_session_headers().cookie);
131
+ try {
132
+ const offer = await create_pending_offer(fixture, recipient.account.id);
133
+ const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
134
+ assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
135
+ const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
136
+ const params = RoleGrantOfferAcceptedParams.parse(frame.params);
137
+ assert.strictEqual(params.offer.id, offer.id);
138
+ assert.isNotNull(params.offer.accepted_at);
139
+ }
140
+ finally {
141
+ await grantor.close();
142
+ }
143
+ });
144
+ test_if(capabilities.ws, 'decline delivers role_grant_offer_declined to the grantor WS', async () => {
145
+ const fixture = await setup_test();
146
+ const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
147
+ const grantor = await open_ws(fixture.create_session_headers().cookie);
148
+ try {
149
+ const offer = await create_pending_offer(fixture, recipient.account.id);
150
+ const declined = await rpc(fixture, 'role_grant_offer_decline', { offer_id: offer.id, reason: 'no thanks' }, recipient.create_session_headers());
151
+ assert.isTrue(declined.ok, `decline failed: ${JSON.stringify(declined)}`);
152
+ const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
153
+ const params = RoleGrantOfferDeclinedParams.parse(frame.params);
154
+ assert.strictEqual(params.offer.id, offer.id);
155
+ // Decline reason rides inside the offer row, not a sibling field.
156
+ assert.strictEqual(params.offer.decline_reason, 'no thanks');
157
+ }
158
+ finally {
159
+ await grantor.close();
160
+ }
161
+ });
162
+ test_if(capabilities.ws, 'retract delivers role_grant_offer_retracted to the recipient WS', async () => {
163
+ const fixture = await setup_test();
164
+ const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
165
+ // Recipient learns their pending offer was pulled — open its socket.
166
+ const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
167
+ try {
168
+ const offer = await create_pending_offer(fixture, recipient.account.id);
169
+ const retracted = await rpc(fixture, 'role_grant_offer_retract', { offer_id: offer.id }, fixture.create_session_headers());
170
+ assert.isTrue(retracted.ok, `retract failed: ${JSON.stringify(retracted)}`);
171
+ const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
172
+ const params = RoleGrantOfferRetractedParams.parse(frame.params);
173
+ assert.strictEqual(params.offer.id, offer.id);
174
+ assert.strictEqual(params.offer.to_account_id, recipient.account.id);
175
+ assert.isNotNull(params.offer.retracted_at);
176
+ }
177
+ finally {
178
+ await recipient_ws.close();
179
+ }
180
+ });
181
+ test_if(capabilities.ws, 'revoke delivers a flat role_grant_revoke to the revokee WS', async () => {
182
+ const fixture = await setup_test();
183
+ const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
184
+ const role_grant_id = await create_active_role_grant(fixture, revokee);
185
+ const revokee_ws = await open_ws(revokee.create_session_headers().cookie);
186
+ try {
187
+ const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id, reason: 'cleanup' }, fixture.create_session_headers());
188
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
189
+ const frame = await revokee_ws.wait_for(is_notification_with(ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, (p) => p.role_grant_id === role_grant_id), 5000);
190
+ // Flat params — guards the no-`offer`-wrapper, `revoked_by`-omitted
191
+ // shape against drift.
192
+ const params = RoleGrantRevokeParams.parse(frame.params);
193
+ assert.strictEqual(params.role_grant_id, role_grant_id);
194
+ assert.strictEqual(params.role, ROLE_ADMIN);
195
+ assert.strictEqual(params.reason, 'cleanup');
196
+ assert.notProperty(frame.params, 'revoked_by');
197
+ }
198
+ finally {
199
+ await revokee_ws.close();
200
+ }
201
+ });
202
+ test_if(capabilities.ws, 'accept of a sibling delivers role_grant_offer_supersede to the other grantor WS', async () => {
203
+ const fixture = await setup_test();
204
+ // Three identities: keeper (grantor 1, socket), a second admin grantor
205
+ // (grantor 2), and the recipient.
206
+ const grantor2 = await fixture.create_account({ roles: [ROLE_ADMIN] });
207
+ const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
208
+ // Two coexisting pending offers for the same (recipient, role) — keyed
209
+ // per grantor, so they don't upsert each other.
210
+ const keeper_offer = await create_pending_offer(fixture, recipient.account.id);
211
+ const grantor2_offer = await create_pending_offer(fixture, recipient.account.id, grantor2.create_session_headers());
212
+ // Open grantor 1 (keeper) — its offer is the one that gets superseded
213
+ // when the recipient accepts grantor 2's offer.
214
+ const grantor1_ws = await open_ws(fixture.create_session_headers().cookie);
215
+ try {
216
+ const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: grantor2_offer.id }, recipient.create_session_headers());
217
+ assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
218
+ const frame = await grantor1_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === keeper_offer.id), 5000);
219
+ const params = RoleGrantOfferSupersedeParams.parse(frame.params);
220
+ assert.strictEqual(params.offer.id, keeper_offer.id);
221
+ assert.strictEqual(params.reason, 'sibling_accepted');
222
+ // cause_id points at the accepted sibling (grantor 2's offer).
223
+ assert.strictEqual(params.cause_id, grantor2_offer.id);
224
+ assert.isNotNull(params.offer.superseded_at);
225
+ }
226
+ finally {
227
+ await grantor1_ws.close();
228
+ }
229
+ });
230
+ test_if(capabilities.ws, 'revoke supersedes a pending sibling offer and notifies its grantor WS', async () => {
231
+ const fixture = await setup_test();
232
+ const grantor = await fixture.create_account({ roles: [ROLE_ADMIN] });
233
+ const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
234
+ const role_grant_id = await create_active_role_grant(fixture, revokee);
235
+ // A *pending* sibling offer for the same (account, role) — this is what
236
+ // the revoke cascade supersedes.
237
+ const sibling_offer = await create_pending_offer(fixture, revokee.account.id, grantor.create_session_headers());
238
+ // Watch the sibling grantor's socket for the supersede push.
239
+ const grantor_ws = await open_ws(grantor.create_session_headers().cookie);
240
+ try {
241
+ const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id }, fixture.create_session_headers());
242
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
243
+ const frame = await grantor_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === sibling_offer.id), 5000);
244
+ const params = RoleGrantOfferSupersedeParams.parse(frame.params);
245
+ assert.strictEqual(params.offer.id, sibling_offer.id);
246
+ assert.strictEqual(params.reason, 'role_grant_revoked');
247
+ // cause_id points at the revoked role_grant.
248
+ assert.strictEqual(params.cause_id, role_grant_id);
249
+ assert.isNotNull(params.offer.superseded_at);
250
+ }
251
+ finally {
252
+ await grantor_ws.close();
253
+ }
254
+ });
255
+ });
256
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAiB1B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAC,KAAK,mBAAmB,EAAC,MAAM,iCAAiC,CAAC;AACzE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxD;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,IA60CF,CAAC"}
1
+ {"version":3,"file":"integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAsB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAM9D,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAkB1B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAC,KAAK,mBAAmB,EAAC,MAAM,iCAAiC,CAAC;AACzE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxD;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC9C;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;;OASG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,kEAAkE;IAClE,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,mCAAmC,GAC/C,SAAS,8BAA8B,KACrC,IAw6CF,CAAC"}
@@ -23,6 +23,7 @@ import { ErrorCoverageCollector, assert_error_coverage, DEFAULT_INTEGRATION_ERRO
23
23
  import { is_public_auth } from '../http/auth_shape.js';
24
24
  import { account_verify_action_spec, account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_list_action_spec, account_token_revoke_action_spec, } from '../auth/account_action_specs.js';
25
25
  import { invite_create_action_spec } from '../auth/admin_action_specs.js';
26
+ import { LoginOutput, AccountStatusOutput } from '../auth/account_routes.js';
26
27
  import {} from './cross_backend/capabilities.js';
27
28
  import { DEFAULT_TEST_PASSWORD } from './app_server.js';
28
29
  /**
@@ -91,6 +92,14 @@ export const describe_standard_integration_tests = (options) => {
91
92
  });
92
93
  assert_error_coverage(error_collector, auth_routes.length > 0 ? auth_routes : route_specs, {
93
94
  min_coverage: options.error_coverage_min ?? DEFAULT_INTEGRATION_ERROR_COVERAGE,
95
+ // Authorization denials (403) on these scoped auth routes — the
96
+ // credential-channel gate on /logout + /password, the invite gate on
97
+ // /signup — are exercised by the conformance + attack-surface suites,
98
+ // not this lifecycle suite. Drop 403 from this collector's denominator
99
+ // (same spirit as the [401, 403, 429] ignore in the attack-surface
100
+ // tightness defaults); otherwise #10 adding /logout's 403 to the spine
101
+ // surface tips the ratio under threshold here though the gate is tested.
102
+ ignore_statuses: [403],
94
103
  });
95
104
  });
96
105
  // --- 1. Login/logout lifecycle ---
@@ -269,6 +278,75 @@ export const describe_standard_integration_tests = (options) => {
269
278
  assert.deepStrictEqual(wrong_pw_keys, no_user_keys, 'Response keys must be identical to prevent account enumeration');
270
279
  assert.strictEqual(wrong_pw_body.error, no_user_body.error, 'Error codes must be identical');
271
280
  });
281
+ // Wire-shape gate: the successful `POST /login` body must strict-parse
282
+ // against `LoginOutput` (`{ok: true}`). `.strictObject` rejects any
283
+ // extra field, so a backend leaking `username` / `account_id` (the
284
+ // Rust spine's old shape) fails here on either impl.
285
+ test('successful login body strict-parses against LoginOutput', async () => {
286
+ const fixture = await options.setup_test();
287
+ const login_route = find_auth_route(route_specs, '/login', 'POST');
288
+ assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
289
+ const res = await fixture.transport(login_route.path, {
290
+ method: 'POST',
291
+ headers: {
292
+ host: 'localhost',
293
+ origin: 'http://localhost:5173',
294
+ 'content-type': 'application/json',
295
+ },
296
+ body: JSON.stringify({
297
+ username: fixture.account.username,
298
+ password: DEFAULT_TEST_PASSWORD,
299
+ }),
300
+ });
301
+ assert.strictEqual(res.status, 200);
302
+ const body = await res.json();
303
+ // Throws on any extra or missing field — drift on either backend fails.
304
+ LoginOutput.parse(body);
305
+ });
306
+ });
307
+ // --- 1b. Account status body (strict schema) ---
308
+ describe('account status response body', () => {
309
+ // Wire-shape gate: the authenticated `GET /api/account/status` body
310
+ // must strict-parse against `AccountStatusOutput` — the full shape
311
+ // `{account: SessionAccountJson, actor: ActorSummaryJson | null,
312
+ // role_grants: RoleGrantSummaryJson[]}`. `.strictObject` rejects any
313
+ // extra or missing field on `account` / `actor` / each role_grant, so
314
+ // a backend returning the old narrow shape (Rust's `{account:{id,
315
+ // username}, role_grants:[{role}]}`) fails here on either impl. The
316
+ // fixture keeper is single-actor, so `actor` must be non-null and
317
+ // `role_grants` populated (keeper holds keeper + admin globally).
318
+ //
319
+ // `/status` is mounted at `create_app_server` time (it needs the
320
+ // `bootstrap_available` runtime state), not by `create_account_route_specs`
321
+ // and not listed in the declared surface, so we can't gate on `route_specs`.
322
+ // A backend that mounts only the account-route factory (e.g. a minimal
323
+ // in-process route set) doesn't serve it — probe at runtime and skip on
324
+ // 404. The full spine surfaces (in-process + cross-process) serve it, so
325
+ // the gate runs there. `find_auth_route` can't be used: `/status` isn't a
326
+ // `RestAuthRouteSuffix`.
327
+ test('authenticated status body strict-parses against AccountStatusOutput', async (ctx) => {
328
+ const fixture = await options.setup_test();
329
+ const login_route = find_auth_route(route_specs, '/login', 'POST');
330
+ assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
331
+ // `/status` is the sibling of `/login` under the same account prefix.
332
+ const status_path = login_route.path.replace(/\/login$/, '/status');
333
+ const res = await fixture.transport(status_path, {
334
+ method: 'GET',
335
+ headers: fixture.create_session_headers({ host: 'localhost' }),
336
+ });
337
+ if (res.status === 404) {
338
+ // Backend doesn't mount /status (minimal route set) — nothing to gate.
339
+ ctx.skip();
340
+ return;
341
+ }
342
+ assert.strictEqual(res.status, 200);
343
+ const body = await res.json();
344
+ // Throws on any extra/missing field across account/actor/role_grants.
345
+ const parsed = AccountStatusOutput.parse(body);
346
+ // Single-actor keeper: actor resolved, role_grants populated.
347
+ assert.ok(parsed.actor, 'single-actor keeper must resolve a non-null actor');
348
+ assert.ok(parsed.role_grants.length > 0, 'single-actor keeper must have populated role_grants');
349
+ });
272
350
  });
273
351
  // --- 2. Cookie attributes ---
274
352
  describe('cookie attributes', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.75.0",
3
+ "version": "0.77.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",
@@ -63,10 +63,10 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@electric-sql/pglite": "^0.4.5",
66
- "@fuzdev/blake3_wasm": "^0.1.0",
66
+ "@fuzdev/blake3_wasm": "^0.1.1",
67
67
  "@fuzdev/fuz_code": "^0.45.1",
68
- "@fuzdev/fuz_css": "^0.60.0",
69
- "@fuzdev/fuz_ui": "^0.195.1",
68
+ "@fuzdev/fuz_css": "^0.61.1",
69
+ "@fuzdev/fuz_ui": "^0.197.0",
70
70
  "@fuzdev/fuz_util": "^0.63.0",
71
71
  "@fuzdev/gro": "^0.200.0",
72
72
  "@hono/node-server": "^1.19.14",
@@ -94,7 +94,7 @@
94
94
  "prettier-plugin-svelte": "^3.5.1",
95
95
  "svelte": "^5.56.0",
96
96
  "svelte-check": "^4.4.5",
97
- "svelte-docinfo": "^0.2.0",
97
+ "svelte-docinfo": "^0.2.1",
98
98
  "svelte2tsx": "^0.7.52",
99
99
  "tslib": "^2.8.1",
100
100
  "typescript": "^5.9.3",