@fuzdev/fuz_app 0.6.0 → 0.7.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.
@@ -19,7 +19,7 @@ import type { PasswordHashDeps } from '../auth/password.js';
19
19
  import { type SessionOptions } from '../auth/session_cookie.js';
20
20
  import type { AppBackend } from '../server/app_backend.js';
21
21
  import { type AppServerOptions, type AppServerContext } from '../server/app_server.js';
22
- import type { AppSurface } from '../http/surface.js';
22
+ import type { AppSurface, AppSurfaceSpec } from '../http/surface.js';
23
23
  import type { RouteSpec } from '../http/route_spec.js';
24
24
  /**
25
25
  * Fast password stub for tests that don't exercise login/password flows.
@@ -137,6 +137,7 @@ export interface TestAccount {
137
137
  export interface TestApp {
138
138
  app: Hono;
139
139
  backend: TestAppServer;
140
+ surface_spec: AppSurfaceSpec;
140
141
  surface: AppSurface;
141
142
  route_specs: Array<RouteSpec>;
142
143
  /** Build request headers with the bootstrapped session cookie. */
@@ -1 +1 @@
1
- {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,MAAM,CAAC;AAK/B,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAE1E,OAAO,KAAK,EAAC,EAAE,EAAE,MAAM,EAAC,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAU1D,OAAO,EAA8B,KAAK,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAG3F,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,0BAA0B,CAAC;AACzD,OAAO,EAEN,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAKrD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,gBAIhC,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,QAAiB,CAAC;AASjD;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC3C,EAAE,EAAE,EAAE,CAAC;IACP,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GAClC,SAAS,2BAA2B,KAClC,OAAO,CAAC;IACV,OAAO,EAAE;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACxC,KAAK,EAAE;QAAC,EAAE,EAAE,MAAM,CAAA;KAAC,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACvB,CAyCA,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,aAAc,SAAQ,UAAU;IAChD,gCAAgC;IAChC,OAAO,EAAE;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACxC,uCAAuC;IACvC,KAAK,EAAE;QAAC,EAAE,EAAE,MAAM,CAAA;KAAC,CAAC;IACpB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,mDAAmD;IACnD,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kGAAkG;IAClG,EAAE,CAAC,EAAE,EAAE,CAAC;IACR,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yHAAyH;IACzH,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gDAAgD;IAChD,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAqBD,eAAO,MAAM,sBAAsB,GAClC,SAAS,oBAAoB,KAC3B,OAAO,CAAC,aAAa,CAsFvB,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,oBAAqB,SAAQ,oBAAoB;IACjE,yEAAyE;IACzE,kBAAkB,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IACpE,gHAAgH;IAChH,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;CACF;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACxC,KAAK,EAAE;QAAC,EAAE,EAAE,MAAM,CAAA;KAAC,CAAC;IACpB,mCAAmC;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnF,8DAA8D;IAC9D,qBAAqB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClF;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACvB,GAAG,EAAE,IAAI,CAAC;IACV,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,UAAU,CAAC;IACpB,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnF,gEAAgE;IAChE,qBAAqB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClF,qDAAqD;IACrD,cAAc,EAAE,CAAC,OAAO,CAAC,EAAE;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;KACtB,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC3B,8DAA8D;IAC9D,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,GAAU,SAAS,oBAAoB,KAAG,OAAO,CAAC,OAAO,CA+EpF,CAAC"}
1
+ {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
@@ -230,6 +230,7 @@ export const create_test_app = async (options) => {
230
230
  return {
231
231
  app,
232
232
  backend: test_server,
233
+ surface_spec,
233
234
  surface: surface_spec.surface,
234
235
  route_specs: surface_spec.route_specs,
235
236
  create_session_headers,
@@ -0,0 +1,23 @@
1
+ import './assert_dev_env.js';
2
+ import type { AppSurfaceSpec } from '../http/surface.js';
3
+ /** Options for `describe_rpc_attack_surface_tests`. */
4
+ export interface RpcAttackSurfaceOptions {
5
+ /** Build the app surface bundle (surface + route specs + middleware specs + rpc_endpoints). */
6
+ build: () => AppSurfaceSpec;
7
+ /** All roles in the app (e.g. `['admin', 'keeper']`). */
8
+ roles: Array<string>;
9
+ }
10
+ /**
11
+ * Run the standard RPC attack surface test suite.
12
+ *
13
+ * Generates 3 test groups:
14
+ * 1. Auth enforcement — per-method auth checks via JSON-RPC envelopes
15
+ * 2. Adversarial envelopes — malformed JSON-RPC requests
16
+ * 3. Adversarial params — schema-invalid params per method
17
+ *
18
+ * Skips silently when `surface.rpc_endpoints` is empty.
19
+ *
20
+ * @param options - the test configuration
21
+ */
22
+ export declare const describe_rpc_attack_surface_tests: (options: RpcAttackSurfaceOptions) => void;
23
+ //# sourceMappingURL=rpc_attack_surface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc_attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAkB7B,OAAO,KAAK,EAA6C,cAAc,EAAC,MAAM,oBAAoB,CAAC;AAenG,uDAAuD;AACvD,MAAM,WAAW,uBAAuB;IACvC,+FAA+F;IAC/F,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACrB;AAkaD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,uBAAuB,KAAG,IAOpF,CAAC"}
@@ -0,0 +1,376 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * Composable RPC attack surface test suite.
4
+ *
5
+ * Three test groups for JSON-RPC 2.0 endpoints:
6
+ * 1. **Auth enforcement** — per-method auth inside the dispatcher
7
+ * 2. **Adversarial envelopes** — malformed JSON-RPC requests
8
+ * 3. **Adversarial params** — schema-invalid params per method
9
+ *
10
+ * Uses the same `{build, roles}` config as `describe_adversarial_auth`
11
+ * and `describe_adversarial_input`. No DB needed — uses stub deps.
12
+ *
13
+ * @module
14
+ */
15
+ import { test, assert, describe } from 'vitest';
16
+ import { JSONRPC_ERROR_CODES } from '../http/jsonrpc_errors.js';
17
+ import { create_auth_test_apps, select_auth_app } from './auth_apps.js';
18
+ import { generate_input_test_cases } from './adversarial_input.js';
19
+ import { ERROR_INVALID_JSON_BODY } from '../http/error_schemas.js';
20
+ import { create_rpc_post_init, create_rpc_get_url, assert_jsonrpc_error_response, } from './rpc_helpers.js';
21
+ // --- Helpers ---
22
+ /** Filter RPC methods that require any form of authentication. */
23
+ const filter_protected_rpc_methods = (endpoint) => endpoint.methods.filter((m) => m.auth.type !== 'none');
24
+ /** Filter RPC methods that require a specific role. */
25
+ const filter_role_rpc_methods = (endpoint) => endpoint.methods.filter((m) => m.auth.type === 'role');
26
+ /** Find the `RpcAction` source spec for a surface method. */
27
+ const find_rpc_action = (rpc_endpoint_specs, endpoint_path, method_name) => {
28
+ const ep = rpc_endpoint_specs.find((e) => e.path === endpoint_path);
29
+ return ep?.actions.find((a) => a.spec.method === method_name);
30
+ };
31
+ // --- Auth enforcement ---
32
+ /**
33
+ * Generate adversarial auth enforcement tests for RPC endpoints.
34
+ *
35
+ * For each endpoint, iterates methods with auth requirements and fires
36
+ * JSON-RPC envelopes with wrong/missing credentials. Auth errors are
37
+ * JSON-RPC format: `{jsonrpc, id, error: {code, message}}`.
38
+ *
39
+ * Describe blocks:
40
+ * - unauthenticated → error code -32001 — every protected method
41
+ * - wrong role → error code -32002 — every role method with non-matching roles
42
+ * - authenticated without role → -32002 — every role method, no-role context
43
+ * - keeper routes reject session credential → -32002
44
+ * - correct auth passes — every protected method, assert not 401/403
45
+ */
46
+ const describe_rpc_auth = (options) => {
47
+ const { build, roles } = options;
48
+ const { surface, route_specs } = build();
49
+ if (surface.rpc_endpoints.length === 0)
50
+ return;
51
+ const apps = create_auth_test_apps(route_specs, roles);
52
+ describe('RPC auth enforcement', () => {
53
+ for (const endpoint of surface.rpc_endpoints) {
54
+ const protected_methods = filter_protected_rpc_methods(endpoint);
55
+ if (protected_methods.length === 0)
56
+ continue;
57
+ const role_methods = filter_role_rpc_methods(endpoint);
58
+ describe(endpoint.path, () => {
59
+ describe('unauthenticated → JSON-RPC error', () => {
60
+ for (const method of protected_methods) {
61
+ test(`${method.name} (${format_auth(method.auth)})`, async () => {
62
+ const res = await apps.public.request(endpoint.path, create_rpc_post_init(method.name));
63
+ assert.strictEqual(res.status, 401, `${method.name} should return 401`);
64
+ const body = await res.json();
65
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.unauthenticated);
66
+ });
67
+ }
68
+ });
69
+ if (role_methods.length > 0) {
70
+ describe('wrong role → forbidden', () => {
71
+ for (const method of role_methods) {
72
+ const wrong_roles = roles.filter((r) => r !== method.auth.role);
73
+ for (const wrong_role of wrong_roles) {
74
+ test(`${method.name} (${wrong_role} instead of ${method.auth.role})`, async () => {
75
+ const app = apps.by_role.get(wrong_role);
76
+ if (!app)
77
+ throw new Error(`No test app for role '${wrong_role}'`);
78
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name));
79
+ assert.strictEqual(res.status, 403, `${method.name} should return 403`);
80
+ const body = await res.json();
81
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.forbidden);
82
+ });
83
+ }
84
+ }
85
+ });
86
+ describe('authenticated without role → forbidden', () => {
87
+ for (const method of role_methods) {
88
+ test(`${method.name} (${method.auth.role})`, async () => {
89
+ const res = await apps.authed.request(endpoint.path, create_rpc_post_init(method.name));
90
+ assert.strictEqual(res.status, 403, `${method.name} should return 403`);
91
+ const body = await res.json();
92
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.forbidden);
93
+ });
94
+ }
95
+ });
96
+ }
97
+ // NOTE: no "keeper rejects session credential" test for RPC — the RPC
98
+ // dispatcher's check_action_auth only checks role, not credential type.
99
+ // Credential type enforcement is a REST middleware concern (require_keeper).
100
+ describe('correct auth passes', () => {
101
+ for (const method of protected_methods) {
102
+ test(method.name, async () => {
103
+ const app = select_auth_app(apps, method.auth);
104
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name));
105
+ // handler may error (500, 404 from stub deps) — that's fine
106
+ assert.notStrictEqual(res.status, 401, 'should not be 401');
107
+ assert.notStrictEqual(res.status, 403, 'should not be 403');
108
+ });
109
+ }
110
+ });
111
+ // also test GET for read methods with auth
112
+ const protected_reads = protected_methods.filter((m) => !m.side_effects);
113
+ if (protected_reads.length > 0) {
114
+ describe('GET unauthenticated → JSON-RPC error', () => {
115
+ for (const method of protected_reads) {
116
+ test(`${method.name} (GET)`, async () => {
117
+ const url = create_rpc_get_url(endpoint.path, method.name);
118
+ const res = await apps.public.request(url);
119
+ assert.strictEqual(res.status, 401, `GET ${method.name} should return 401`);
120
+ const body = await res.json();
121
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.unauthenticated);
122
+ });
123
+ }
124
+ });
125
+ }
126
+ });
127
+ }
128
+ });
129
+ };
130
+ // --- Adversarial envelopes ---
131
+ /**
132
+ * Generate adversarial envelope tests for RPC endpoints.
133
+ *
134
+ * Fixed set of malformation cases that exercise the dispatcher's
135
+ * envelope parsing (step 1) and method lookup (step 2).
136
+ */
137
+ const describe_rpc_adversarial_envelopes = (options) => {
138
+ const { build, roles } = options;
139
+ const { surface, route_specs } = build();
140
+ if (surface.rpc_endpoints.length === 0)
141
+ return;
142
+ // public app for envelope errors (happen before auth checks)
143
+ const apps = create_auth_test_apps(route_specs, []);
144
+ // authed apps for the GET mutation test (needs correct auth to reach the side_effects check)
145
+ const authed_apps = create_auth_test_apps(route_specs, roles);
146
+ describe('RPC adversarial envelopes', () => {
147
+ for (const endpoint of surface.rpc_endpoints) {
148
+ // find a mutation method for GET-restriction testing
149
+ const mutation_method = endpoint.methods.find((m) => m.side_effects);
150
+ describe(endpoint.path, () => {
151
+ // --- POST envelope malformation ---
152
+ test('non-JSON body → parse_error', async () => {
153
+ const res = await apps.public.request(endpoint.path, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: 'not-json',
157
+ });
158
+ assert.strictEqual(res.status, 400);
159
+ const body = await res.json();
160
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.parse_error);
161
+ });
162
+ test('wrong jsonrpc version → invalid_request', async () => {
163
+ const res = await apps.public.request(endpoint.path, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({
167
+ jsonrpc: '1.0',
168
+ id: 'test',
169
+ method: endpoint.methods[0]?.name ?? 'any',
170
+ }),
171
+ });
172
+ assert.strictEqual(res.status, 400);
173
+ const body = await res.json();
174
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
175
+ });
176
+ test('missing jsonrpc field → invalid_request', async () => {
177
+ const res = await apps.public.request(endpoint.path, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({
181
+ id: 'test',
182
+ method: endpoint.methods[0]?.name ?? 'any',
183
+ }),
184
+ });
185
+ assert.strictEqual(res.status, 400);
186
+ const body = await res.json();
187
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
188
+ });
189
+ test('missing method field → invalid_request', async () => {
190
+ const res = await apps.public.request(endpoint.path, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify({ jsonrpc: '2.0', id: 'test' }),
194
+ });
195
+ assert.strictEqual(res.status, 400);
196
+ const body = await res.json();
197
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
198
+ });
199
+ test('missing id field → invalid_request', async () => {
200
+ const res = await apps.public.request(endpoint.path, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({
204
+ jsonrpc: '2.0',
205
+ method: endpoint.methods[0]?.name ?? 'any',
206
+ }),
207
+ });
208
+ assert.strictEqual(res.status, 400);
209
+ const body = await res.json();
210
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
211
+ });
212
+ test('batch (array) body → invalid_request', async () => {
213
+ const res = await apps.public.request(endpoint.path, {
214
+ method: 'POST',
215
+ headers: { 'Content-Type': 'application/json' },
216
+ body: JSON.stringify([
217
+ { jsonrpc: '2.0', id: '1', method: endpoint.methods[0]?.name ?? 'any' },
218
+ ]),
219
+ });
220
+ assert.strictEqual(res.status, 400);
221
+ const body = await res.json();
222
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
223
+ assert.strictEqual(body.id, null, 'batch has no extractable id');
224
+ });
225
+ test('unknown method name → method_not_found', async () => {
226
+ const res = await apps.public.request(endpoint.path, create_rpc_post_init('__nonexistent_method__'));
227
+ assert.strictEqual(res.status, 404);
228
+ const body = await res.json();
229
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.method_not_found);
230
+ });
231
+ // --- GET envelope malformation ---
232
+ test('GET missing method → invalid_request', async () => {
233
+ const res = await apps.public.request(`${endpoint.path}?id=test`);
234
+ assert.strictEqual(res.status, 400);
235
+ const body = await res.json();
236
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
237
+ });
238
+ test('GET missing id → invalid_request', async () => {
239
+ const first_method = endpoint.methods[0]?.name ?? 'any';
240
+ const res = await apps.public.request(`${endpoint.path}?method=${first_method}`);
241
+ assert.strictEqual(res.status, 400);
242
+ const body = await res.json();
243
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
244
+ });
245
+ test('GET invalid JSON params → invalid_params', async () => {
246
+ const read_method = endpoint.methods.find((m) => !m.side_effects);
247
+ // skip if no read methods exist
248
+ if (!read_method)
249
+ return;
250
+ const res = await apps.public.request(`${endpoint.path}?method=${read_method.name}&id=test&params=not-json`);
251
+ assert.strictEqual(res.status, 400);
252
+ const body = await res.json();
253
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_params);
254
+ });
255
+ test('GET non-object params → error', async () => {
256
+ const read_method = endpoint.methods.find((m) => !m.side_effects);
257
+ // skip if no read methods exist
258
+ if (!read_method)
259
+ return;
260
+ // valid JSON but not an object — hits dispatcher's params validation
261
+ const res = await apps.public.request(`${endpoint.path}?method=${read_method.name}&id=test&params=42`);
262
+ // should reject: either invalid_params (step 4) or auth error (step 3)
263
+ assert.ok(res.status >= 400, `expected error status for non-object params, got ${res.status}`);
264
+ const body = await res.json();
265
+ assert_jsonrpc_error_response(body);
266
+ });
267
+ if (mutation_method) {
268
+ test('GET mutation method → invalid_request (side effects)', async () => {
269
+ const url = create_rpc_get_url(endpoint.path, mutation_method.name);
270
+ // need correct auth to reach the side_effects check
271
+ const app = select_auth_app(authed_apps, mutation_method.auth);
272
+ const res = await app.request(url);
273
+ assert.strictEqual(res.status, 400);
274
+ const body = await res.json();
275
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_request);
276
+ });
277
+ }
278
+ });
279
+ }
280
+ });
281
+ };
282
+ // --- Adversarial params ---
283
+ /**
284
+ * Generate adversarial params validation tests for RPC endpoints.
285
+ *
286
+ * For each method with a non-null input schema, generates test cases
287
+ * from the schema (wrong types, missing fields, format violations)
288
+ * and wraps them in valid JSON-RPC envelopes. Reuses
289
+ * `generate_input_test_cases` from `adversarial_input.ts`.
290
+ */
291
+ const describe_rpc_adversarial_params = (options) => {
292
+ const { build, roles } = options;
293
+ const { surface, route_specs, rpc_endpoints: rpc_endpoint_specs } = build();
294
+ if (surface.rpc_endpoints.length === 0)
295
+ return;
296
+ const apps = create_auth_test_apps(route_specs, roles);
297
+ let total_cases = 0;
298
+ describe('RPC adversarial params', () => {
299
+ for (const endpoint of surface.rpc_endpoints) {
300
+ const methods_with_input = endpoint.methods.filter((m) => m.input_schema !== null);
301
+ if (methods_with_input.length === 0)
302
+ continue;
303
+ describe(endpoint.path, () => {
304
+ for (const method of methods_with_input) {
305
+ // look up the source RpcAction for the Zod schema
306
+ const action = find_rpc_action(rpc_endpoint_specs, endpoint.path, method.name);
307
+ if (!action) {
308
+ test(`${method.name} — missing RpcAction source spec`, () => {
309
+ assert.fail(`surface has method '${method.name}' but no matching RpcAction in rpc_endpoints`);
310
+ });
311
+ continue;
312
+ }
313
+ // filter out structural cases (non-object body) — those fail at
314
+ // envelope validation (invalid_request) not params validation (invalid_params).
315
+ // Envelope-level structural errors are covered by adversarial envelopes.
316
+ const test_cases = generate_input_test_cases(action.spec.input).filter((tc) => tc.expected_error !== ERROR_INVALID_JSON_BODY);
317
+ if (test_cases.length === 0)
318
+ continue;
319
+ total_cases += test_cases.length;
320
+ const app = select_auth_app(apps, method.auth);
321
+ describe(method.name, () => {
322
+ for (const tc of test_cases) {
323
+ test(tc.label, async () => {
324
+ const res = await app.request(endpoint.path, create_rpc_post_init(method.name, tc.body));
325
+ assert.strictEqual(res.status, 400, `Expected 400 for ${method.name} [${tc.label}], got ${res.status}`);
326
+ const body = await res.json();
327
+ assert_jsonrpc_error_response(body, JSONRPC_ERROR_CODES.invalid_params);
328
+ });
329
+ }
330
+ });
331
+ }
332
+ });
333
+ }
334
+ test('generated RPC params test cases', () => {
335
+ // soft check — methods with only null-input schemas produce 0 cases
336
+ if (surface.rpc_endpoints.some((ep) => ep.methods.some((m) => m.input_schema !== null))) {
337
+ assert.ok(total_cases > 0, 'No RPC params test cases generated — schema walking may be broken');
338
+ }
339
+ });
340
+ });
341
+ };
342
+ // --- Helpers (formatting) ---
343
+ /** Format a `RouteAuth` as a human-readable label. */
344
+ const format_auth = (auth) => {
345
+ switch (auth.type) {
346
+ case 'none':
347
+ return 'public';
348
+ case 'authenticated':
349
+ return 'authenticated';
350
+ case 'role':
351
+ return `role: ${auth.role}`;
352
+ case 'keeper':
353
+ return 'keeper';
354
+ }
355
+ };
356
+ // --- Public API ---
357
+ /**
358
+ * Run the standard RPC attack surface test suite.
359
+ *
360
+ * Generates 3 test groups:
361
+ * 1. Auth enforcement — per-method auth checks via JSON-RPC envelopes
362
+ * 2. Adversarial envelopes — malformed JSON-RPC requests
363
+ * 3. Adversarial params — schema-invalid params per method
364
+ *
365
+ * Skips silently when `surface.rpc_endpoints` is empty.
366
+ *
367
+ * @param options - the test configuration
368
+ */
369
+ export const describe_rpc_attack_surface_tests = (options) => {
370
+ const { surface } = options.build();
371
+ if (surface.rpc_endpoints.length === 0)
372
+ return;
373
+ describe_rpc_auth(options);
374
+ describe_rpc_adversarial_envelopes(options);
375
+ describe_rpc_adversarial_params(options);
376
+ };
@@ -0,0 +1,44 @@
1
+ import './assert_dev_env.js';
2
+ import { z } from 'zod';
3
+ import type { JsonrpcErrorCode } from '../http/jsonrpc_errors.js';
4
+ /**
5
+ * Create a `RequestInit` for a JSON-RPC POST request.
6
+ *
7
+ * @param method - JSON-RPC method name
8
+ * @param params - params object (omit for null-input methods)
9
+ * @param id - request id (default `'test'`)
10
+ * @returns a `RequestInit` with the JSON-RPC envelope as body
11
+ */
12
+ export declare const create_rpc_post_init: (method: string, params?: unknown, id?: string | number) => RequestInit;
13
+ /**
14
+ * Build a GET URL with JSON-RPC query parameters.
15
+ *
16
+ * @param endpoint_path - the RPC endpoint path (e.g., `/api/rpc`)
17
+ * @param method - JSON-RPC method name
18
+ * @param params - params object (omit for null-input methods)
19
+ * @param id - request id (default `'test'`)
20
+ * @returns the full URL with query string
21
+ */
22
+ export declare const create_rpc_get_url: (endpoint_path: string, method: string, params?: unknown, id?: string | number) => string;
23
+ /**
24
+ * Assert that a response body is a valid JSON-RPC error response.
25
+ *
26
+ * Validates the structure matches `JsonrpcErrorResponse` and optionally
27
+ * checks the error code.
28
+ *
29
+ * @param body - parsed response body
30
+ * @param expected_code - optional error code to assert
31
+ */
32
+ export declare const assert_jsonrpc_error_response: (body: unknown, expected_code?: JsonrpcErrorCode) => void;
33
+ /**
34
+ * Assert that a response body is a valid JSON-RPC success response.
35
+ *
36
+ * Validates the structure matches `JsonrpcResponse`. When `output_schema`
37
+ * is provided, also validates the `result` field against the declared
38
+ * output schema — matching the REST round-trip's `assert_response_matches_spec`.
39
+ *
40
+ * @param body - parsed response body
41
+ * @param output_schema - optional Zod schema to validate the `result` field against
42
+ */
43
+ export declare const assert_jsonrpc_success_response: (body: unknown, output_schema?: z.ZodType) => void;
44
+ //# sourceMappingURL=rpc_helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc_helpers.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_helpers.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAW7B,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAGtB,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,QAAQ,MAAM,EACd,SAAS,OAAO,EAChB,KAAI,MAAM,GAAG,MAAe,KAC1B,WAID,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,eAAe,MAAM,EACrB,QAAQ,MAAM,EACd,SAAS,OAAO,EAChB,KAAI,MAAM,GAAG,MAAe,KAC1B,MAMF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,OAAO,EACb,gBAAgB,gBAAgB,KAC9B,IAUF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,+BAA+B,GAAI,MAAM,OAAO,EAAE,gBAAgB,CAAC,CAAC,OAAO,KAAG,IAU1F,CAAC"}
@@ -0,0 +1,74 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * JSON-RPC request construction and response assertion helpers.
4
+ *
5
+ * Shared by `rpc_attack_surface.ts` and `rpc_round_trip.ts`.
6
+ *
7
+ * @module
8
+ */
9
+ import { assert } from 'vitest';
10
+ import { z } from 'zod';
11
+ import { JSONRPC_VERSION, JsonrpcErrorResponse, JsonrpcResponse } from '../http/jsonrpc.js';
12
+ /**
13
+ * Create a `RequestInit` for a JSON-RPC POST request.
14
+ *
15
+ * @param method - JSON-RPC method name
16
+ * @param params - params object (omit for null-input methods)
17
+ * @param id - request id (default `'test'`)
18
+ * @returns a `RequestInit` with the JSON-RPC envelope as body
19
+ */
20
+ export const create_rpc_post_init = (method, params, id = 'test') => ({
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ jsonrpc: JSONRPC_VERSION, method, params, id }),
24
+ });
25
+ /**
26
+ * Build a GET URL with JSON-RPC query parameters.
27
+ *
28
+ * @param endpoint_path - the RPC endpoint path (e.g., `/api/rpc`)
29
+ * @param method - JSON-RPC method name
30
+ * @param params - params object (omit for null-input methods)
31
+ * @param id - request id (default `'test'`)
32
+ * @returns the full URL with query string
33
+ */
34
+ export const create_rpc_get_url = (endpoint_path, method, params, id = 'test') => {
35
+ const search = new URLSearchParams({ method, id: String(id) });
36
+ if (params !== undefined && params !== null) {
37
+ search.set('params', JSON.stringify(params));
38
+ }
39
+ return `${endpoint_path}?${search.toString()}`;
40
+ };
41
+ /**
42
+ * Assert that a response body is a valid JSON-RPC error response.
43
+ *
44
+ * Validates the structure matches `JsonrpcErrorResponse` and optionally
45
+ * checks the error code.
46
+ *
47
+ * @param body - parsed response body
48
+ * @param expected_code - optional error code to assert
49
+ */
50
+ export const assert_jsonrpc_error_response = (body, expected_code) => {
51
+ const result = JsonrpcErrorResponse.safeParse(body);
52
+ assert.ok(result.success, `not a valid JSON-RPC error response: ${JSON.stringify(body)}`);
53
+ if (expected_code !== undefined) {
54
+ assert.strictEqual(result.data.error.code, expected_code, `expected error code ${expected_code}, got ${result.data.error.code}`);
55
+ }
56
+ };
57
+ /**
58
+ * Assert that a response body is a valid JSON-RPC success response.
59
+ *
60
+ * Validates the structure matches `JsonrpcResponse`. When `output_schema`
61
+ * is provided, also validates the `result` field against the declared
62
+ * output schema — matching the REST round-trip's `assert_response_matches_spec`.
63
+ *
64
+ * @param body - parsed response body
65
+ * @param output_schema - optional Zod schema to validate the `result` field against
66
+ */
67
+ export const assert_jsonrpc_success_response = (body, output_schema) => {
68
+ const result = JsonrpcResponse.safeParse(body);
69
+ assert.ok(result.success, `not a valid JSON-RPC success response: ${JSON.stringify(body)}`);
70
+ if (output_schema) {
71
+ const output_result = output_schema.safeParse(result.data.result);
72
+ assert.ok(output_result.success, `JSON-RPC result does not match output schema: ${JSON.stringify(output_result.error?.issues)}`);
73
+ }
74
+ };
@@ -0,0 +1,41 @@
1
+ import './assert_dev_env.js';
2
+ import type { RouteSpec } from '../http/route_spec.js';
3
+ import type { AppServerContext, AppServerOptions } from '../server/app_server.js';
4
+ import type { SessionOptions } from '../auth/session_cookie.js';
5
+ import { type DbFactory } from './db.js';
6
+ import type { RpcEndpointSpec } from '../http/surface.js';
7
+ /** Options for `describe_rpc_round_trip_tests`. */
8
+ export interface RpcRoundTripTestOptions {
9
+ /** Session config for cookie-based auth. */
10
+ session_options: SessionOptions<string>;
11
+ /** Route spec factory — same one used in production. */
12
+ create_route_specs: (ctx: AppServerContext) => Array<RouteSpec>;
13
+ /** RPC endpoint specs — the source `RpcAction` arrays for params generation. */
14
+ rpc_endpoints: Array<RpcEndpointSpec>;
15
+ /** Optional overrides for `AppServerOptions`. */
16
+ app_options?: Partial<Omit<AppServerOptions, 'backend' | 'session_options' | 'create_route_specs'>>;
17
+ /** Database factories to run tests against. Default: pglite only. */
18
+ db_factories?: Array<DbFactory>;
19
+ /** Methods to skip, by name (e.g., `'tx_plan'`). */
20
+ skip_methods?: Array<string>;
21
+ /** Override generated params for specific methods (method name → params). */
22
+ input_overrides?: Map<string, Record<string, unknown>>;
23
+ }
24
+ /**
25
+ * Run schema-driven round-trip validation for RPC endpoints.
26
+ *
27
+ * For each method:
28
+ * 1. Generate valid params from the action's input schema
29
+ * 2. Fire a POST request with JSON-RPC envelope
30
+ * 3. For `side_effects: false` methods, also fire a GET request
31
+ * 4. Validate response is well-formed JSON-RPC; successful responses are
32
+ * also validated against the method's declared output schema
33
+ *
34
+ * Error responses (from missing DB state, etc.) are expected and validated
35
+ * as well-formed JSON-RPC errors. Successful responses are validated against
36
+ * `action.spec.output`.
37
+ *
38
+ * @param options - round-trip test configuration
39
+ */
40
+ export declare const describe_rpc_round_trip_tests: (options: RpcRoundTripTestOptions) => void;
41
+ //# sourceMappingURL=rpc_round_trip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rpc_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAe7B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAG9D,OAAO,EAAwB,KAAK,SAAS,EAAC,MAAM,SAAS,CAAC;AAK9D,OAAO,KAAK,EAAC,eAAe,EAAsB,MAAM,oBAAoB,CAAC;AAQ7E,mDAAmD;AACnD,MAAM,WAAW,uBAAuB;IACvC,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,gFAAgF;IAChF,aAAa,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;IACtC,iDAAiD;IACjD,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;IACF,qEAAqE;IACrE,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAChC,oDAAoD;IACpD,YAAY,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,6EAA6E;IAC7E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AA2BD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,6BAA6B,GAAI,SAAS,uBAAuB,KAAG,IAsIhF,CAAC"}
@@ -0,0 +1,163 @@
1
+ import './assert_dev_env.js';
2
+ /**
3
+ * Schema-driven round-trip validation for RPC endpoints.
4
+ *
5
+ * For every RPC method, generates valid params and fires JSON-RPC requests
6
+ * (POST for all methods, GET for reads), validating that responses are
7
+ * well-formed JSON-RPC. Successful responses are validated against the
8
+ * method's declared output schema. DB-backed via `create_test_app`.
9
+ *
10
+ * @module
11
+ */
12
+ import { describe, test, beforeAll, afterAll } from 'vitest';
13
+ import { ROLE_ADMIN } from '../auth/role_schema.js';
14
+ import { create_test_app } from './app_server.js';
15
+ import { create_pglite_factory } from './db.js';
16
+ import { generate_valid_body } from './schema_generators.js';
17
+ import { run_migrations } from '../db/migrate.js';
18
+ import { AUTH_MIGRATION_NS } from '../auth/migrations.js';
19
+ import { create_rpc_post_init, create_rpc_get_url, assert_jsonrpc_error_response, assert_jsonrpc_success_response, } from './rpc_helpers.js';
20
+ /**
21
+ * Pick auth headers matching an RPC method's auth requirement.
22
+ */
23
+ const pick_rpc_auth_headers = (method, test_app, authed_account, admin_account) => {
24
+ switch (method.auth.type) {
25
+ case 'none':
26
+ return { host: 'localhost', origin: 'http://localhost:5173' };
27
+ case 'authenticated':
28
+ return authed_account.create_session_headers();
29
+ case 'role':
30
+ if (method.auth.role === ROLE_ADMIN) {
31
+ return admin_account.create_session_headers();
32
+ }
33
+ // keeper role uses the bootstrapped account
34
+ return test_app.create_session_headers();
35
+ case 'keeper':
36
+ return test_app.create_bearer_headers();
37
+ }
38
+ };
39
+ /**
40
+ * Run schema-driven round-trip validation for RPC endpoints.
41
+ *
42
+ * For each method:
43
+ * 1. Generate valid params from the action's input schema
44
+ * 2. Fire a POST request with JSON-RPC envelope
45
+ * 3. For `side_effects: false` methods, also fire a GET request
46
+ * 4. Validate response is well-formed JSON-RPC; successful responses are
47
+ * also validated against the method's declared output schema
48
+ *
49
+ * Error responses (from missing DB state, etc.) are expected and validated
50
+ * as well-formed JSON-RPC errors. Successful responses are validated against
51
+ * `action.spec.output`.
52
+ *
53
+ * @param options - round-trip test configuration
54
+ */
55
+ export const describe_rpc_round_trip_tests = (options) => {
56
+ const skip_set = new Set(options.skip_methods);
57
+ const init_schema = async (db) => {
58
+ await run_migrations(db, [AUTH_MIGRATION_NS]);
59
+ };
60
+ const factories = options.db_factories ?? [create_pglite_factory(init_schema)];
61
+ for (const factory of factories) {
62
+ describe(`RPC round-trip validation (${factory.name})`, () => {
63
+ if (factory.skip)
64
+ return;
65
+ let test_app;
66
+ let authed_account;
67
+ let admin_account;
68
+ let db;
69
+ beforeAll(async () => {
70
+ db = await factory.create();
71
+ test_app = await create_test_app({
72
+ session_options: options.session_options,
73
+ create_route_specs: options.create_route_specs,
74
+ db,
75
+ app_options: {
76
+ rpc_endpoints: options.rpc_endpoints,
77
+ ...options.app_options,
78
+ },
79
+ });
80
+ authed_account = await test_app.create_account({
81
+ username: 'rpc_round_trip_authed',
82
+ roles: [],
83
+ });
84
+ admin_account = await test_app.create_account({
85
+ username: 'rpc_round_trip_admin',
86
+ roles: [ROLE_ADMIN],
87
+ });
88
+ });
89
+ afterAll(async () => {
90
+ await test_app.cleanup();
91
+ await factory.close(db);
92
+ });
93
+ test('all RPC methods produce valid JSON-RPC responses (POST)', async () => {
94
+ for (const ep_spec of options.rpc_endpoints) {
95
+ const surface_ep = test_app.surface_spec.surface.rpc_endpoints.find((e) => e.path === ep_spec.path);
96
+ if (!surface_ep)
97
+ continue;
98
+ for (const action of ep_spec.actions) {
99
+ if (skip_set.has(action.spec.method))
100
+ continue;
101
+ const surface_method = surface_ep.methods.find((m) => m.name === action.spec.method);
102
+ if (!surface_method)
103
+ continue;
104
+ // generate or override params
105
+ const override = options.input_overrides?.get(action.spec.method);
106
+ const params = override ?? generate_valid_body(action.spec.input) ?? null;
107
+ // pick auth
108
+ const headers = pick_rpc_auth_headers(surface_method, test_app, authed_account, admin_account);
109
+ const init = create_rpc_post_init(action.spec.method, params);
110
+ // merge auth headers into init
111
+ Object.assign(init.headers, headers);
112
+ const res = await test_app.app.request(ep_spec.path, init); // eslint-disable-line no-await-in-loop
113
+ const body = await res.json(); // eslint-disable-line no-await-in-loop
114
+ // validate well-formed JSON-RPC; successful responses also checked against output schema
115
+ try {
116
+ if (res.ok) {
117
+ assert_jsonrpc_success_response(body, action.spec.output);
118
+ }
119
+ else {
120
+ assert_jsonrpc_error_response(body);
121
+ }
122
+ }
123
+ catch (e) {
124
+ throw new Error(`RPC round-trip POST failed for ${action.spec.method} (status ${res.status}): ${e.message}`);
125
+ }
126
+ }
127
+ }
128
+ });
129
+ test('all read RPC methods produce valid JSON-RPC responses (GET)', async () => {
130
+ for (const ep_spec of options.rpc_endpoints) {
131
+ const surface_ep = test_app.surface_spec.surface.rpc_endpoints.find((e) => e.path === ep_spec.path);
132
+ if (!surface_ep)
133
+ continue;
134
+ const read_actions = ep_spec.actions.filter((a) => !a.spec.side_effects);
135
+ for (const action of read_actions) {
136
+ if (skip_set.has(action.spec.method))
137
+ continue;
138
+ const surface_method = surface_ep.methods.find((m) => m.name === action.spec.method);
139
+ if (!surface_method)
140
+ continue;
141
+ const override = options.input_overrides?.get(action.spec.method);
142
+ const params = override ?? generate_valid_body(action.spec.input) ?? undefined;
143
+ const headers = pick_rpc_auth_headers(surface_method, test_app, authed_account, admin_account);
144
+ const url = create_rpc_get_url(ep_spec.path, action.spec.method, params);
145
+ const res = await test_app.app.request(url, { headers }); // eslint-disable-line no-await-in-loop
146
+ const body = await res.json(); // eslint-disable-line no-await-in-loop
147
+ try {
148
+ if (res.ok) {
149
+ assert_jsonrpc_success_response(body, action.spec.output);
150
+ }
151
+ else {
152
+ assert_jsonrpc_error_response(body);
153
+ }
154
+ }
155
+ catch (e) {
156
+ throw new Error(`RPC round-trip GET failed for ${action.spec.method} (status ${res.status}): ${e.message}`);
157
+ }
158
+ }
159
+ }
160
+ });
161
+ });
162
+ }
163
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",