@cripty2001/utils 0.0.213 → 0.0.215

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.
package/llms/appserver.md CHANGED
@@ -1,548 +1,548 @@
1
- # Appserver API guide
2
-
3
- Covers both sides of the RPC system: the server (`AppserverDispatcher` from `@cripty2001/utils/appserver/server`) and the client (`Client` from `@cripty2001/utils/appserver/client`). Self-contained. A worked example covering every common case is in §15.
4
-
5
- ---
6
-
7
- ## Part I — Server
8
-
9
- ---
10
-
11
- ## 1. What the API layer is
12
-
13
- - A **typed RPC dispatcher**. Every endpoint is reached via a single `POST` to a bound Express path (e.g. `/exec`). The body contains `{ action, payload }` where `action` is a slash-routed string identifying the handler and `payload` is the input object.
14
- - Wire format is **MessagePack** (`Content-Type: application/vnd.msgpack`). A JSON request is rejected with `REQUEST_INVALID_TYPE_HEADER`. You never touch MessagePack yourself — the dispatcher decodes the request body and encodes your return value. Binary payloads (`Uint8Array`) flow in and out natively with no base64.
15
- - The dispatcher is `AppserverDispatcher`. It binds to an existing Express app; it does not create one itself.
16
-
17
- **You write `{ schema, handler }` module objects. The dispatcher handles validation, routing, and error mapping.**
18
-
19
- ---
20
-
21
- ## 2. Tech stack
22
-
23
- | Layer | Choice |
24
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | Language | TypeScript, strict mode (`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`) |
26
- | Dispatcher | `AppserverDispatcher` from `@cripty2001/utils/appserver/server` |
27
- | Schema / validation | `@sinclair/typebox`, re-exported as `Type`, `Static`, `TSchema`, `Value` from the appserver module — **always import from there**, never from typebox directly |
28
- | Wire format | MessagePack (handled by the dispatcher — not imported by handlers) |
29
-
30
- ---
31
-
32
- ## 3. Directory layout
33
-
34
- ```
35
- src/
36
- index.ts # Bootstrap: creates Express app, AppserverDispatcher, calls bind/autoload
37
- api/
38
- <segment>/
39
- <segment>.ts # One file per endpoint. Path mirrors action string.
40
- ```
41
-
42
- **Rules for `src/api/`:**
43
-
44
- 1. **Folder/file path IS the action string.** `src/api/foo/bar.ts` compiles to `foo/bar.js` and is autoloaded as action `foo/bar`.
45
- 2. **Every path segment is a single lowercase alphanumeric word.** The autoloader enforces regex `^([a-z0-9]+\/)*[a-z0-9]+$`. Hyphens, underscores, uppercase, and multi-word segments throw at startup. Use a deeper folder (`user/cert/revoke`) or a sharper verb instead.
46
- 3. **One endpoint per file.** The autoloader registers the file's default export as one action.
47
- 4. **No barrel / `index.ts` files inside `src/api/`.** Each file is standalone.
48
- 5. **Autoload points at compiled output, not source.** The autoloader scans for `.js` files. Point it at `dist/api` (or equivalent), not `src/api`.
49
-
50
- ---
51
-
52
- ## 4. The endpoint module shape
53
-
54
- Every endpoint file must have a **default export** satisfying `AppserverModule`:
55
-
56
- ```ts
57
- import { Type } from '@cripty2001/utils/appserver/server';
58
- import type { AppserverModule } from '@cripty2001/utils/appserver/server';
59
-
60
- // Export the input schema so clients can import the type.
61
- export const myActionInputSchema = Type.Object({
62
- field: Type.String(),
63
- });
64
-
65
- const mod: AppserverModule<typeof myActionInputSchema> = {
66
- schema: myActionInputSchema,
67
- handler: async (input, auth) => {
68
- // input is typed and pre-validated.
69
- // auth is the raw Bearer token string, or undefined if absent.
70
- return { /* AppserverData-compatible value */ };
71
- },
72
- };
73
-
74
- export default mod;
75
- ```
76
-
77
- The autoloader checks `mod.default.handler` (must be a function) and `mod.default.schema` (must be truthy) at startup. A file that does not satisfy this throws immediately.
78
-
79
- ### Naming conventions
80
-
81
- | Thing | Rule | Example |
82
- | ------------- | ------------------------------------------------------------ | --------------------------------- |
83
- | File path | `src/api/<seg>/<seg>.ts` | `src/api/version/ios.ts` |
84
- | Action string | `<seg>/<seg>` (matches file path without `.ts` extension) | `version/ios` |
85
- | Schema const | `<camelCasePath>InputSchema` | `versionIosInputSchema` |
86
- | Segments | single lowercase alphanumeric word, no hyphens, no camelCase | `version`, `ios`, `cert`, `utils` |
87
-
88
- ---
89
-
90
- ## 5. Input and output types
91
-
92
- ### Input — declared, exported, TypeBox
93
-
94
- - Always `Type.Object({ ... })` at the top level, even for empty inputs (`Type.Object({})`). Never a bare string, number, or array at the top.
95
- - Export the schema constant so clients can import it.
96
- - The handler's `input` parameter is typed automatically from the schema. Do not annotate it manually.
97
- - Internally the input type is modeled as `Static<typeof schema> & AppserverData`:
98
- - `Static<typeof schema>` gives you the TypeScript type derived from the TypeBox schema.
99
- - `& AppserverData` ensures that whatever the schema describes is also representable on the wire (msgpack-safe / JSON-like: primitives, arrays, objects, `Uint8Array`, `null`).
100
- - Validation runs before the handler. Invalid input returns `422` with TypeBox errors. Your handler never sees malformed data. Validate semantics only (e.g. "does this ID exist") — not shape.
101
-
102
- ### Output — inferred, no schema
103
-
104
- - The return type is inferred. There is no output schema, intentionally.
105
- - Must be `AppserverData`:
106
- ```ts
107
- type AppserverData =
108
- | null | boolean | number | string | Uint8Array
109
- | AppserverData[]
110
- | { [key: string]: AppserverData };
111
- ```
112
- No `Date`, `undefined`, class instances, `bigint`, `Map`, or `Set`. Use ISO strings for dates. Use `null` instead of `undefined`.
113
- - **Discriminated unions** (`{ ok: true, ... } | { ok: false, ... }`) are idiomatic for expected business branching. For genuine errors, throw — see §7.
114
-
115
- ---
116
-
117
- ## 6. Authentication
118
-
119
- The dispatcher passes the raw Bearer token to every handler as `auth: string | undefined`. **There is no automatic auth enforcement.** Auth is fully manual in the handler.
120
-
121
- - If `Authorization: Bearer <token>` is present, `auth` is the token string (after stripping `Bearer `).
122
- - If absent or malformed, `auth` is `undefined`.
123
- - If your endpoint requires auth, check `auth`, resolve the token yourself, and throw `AppserverAuthError` if unauthorized.
124
-
125
- ```ts
126
- handler: async (input, auth) => {
127
- if (!auth) throw new AppserverAuthError();
128
- const user = await resolveToken(auth);
129
- if (!user) throw new AppserverAuthError();
130
- // ...
131
- }
132
- ```
133
-
134
- ---
135
-
136
- ## 7. Error handling
137
-
138
- Two categories of throwable errors, plus one for auth.
139
-
140
- ### 7.1 Handled errors — `AppserverHandledError`
141
-
142
- For failures the caller **can and must react to**: "certificate already revoked", "organization not found", "fiscal code malformed".
143
-
144
- ```ts
145
- import { AppserverHandledError } from '@cripty2001/utils/appserver/server';
146
-
147
- throw new AppserverHandledError(
148
- 'CERTIFICATE_ALREADY_REVOKED', // code — SCREAMING_SNAKE_CASE, stable, treat as public API
149
- 'This certificate has already been revoked.', // user-facing message, no internals
150
- { revokedAt: '2026-04-10T12:00:00Z' }, // optional AppserverData payload
151
- );
152
- ```
153
-
154
- - Maps to HTTP `500` with body `{ error, code, payload }`.
155
- - `code` is a contract — clients `switch` on it. Don't rename casually.
156
- - Message is user-facing: plain language, no stack info, no DB field names.
157
- - Extra context goes in `payload`, not interpolated into the message.
158
-
159
- ### 7.2 Auth errors — `AppserverAuthError`
160
-
161
- For missing or invalid credentials.
162
-
163
- ```ts
164
- import { AppserverAuthError } from '@cripty2001/utils/appserver/server';
165
-
166
- throw new AppserverAuthError();
167
- // or:
168
- throw new AppserverAuthError('Session expired', 'SESSION_EXPIRED');
169
- // constructor: (message?, code?) — both optional
170
- ```
171
-
172
- - Maps to HTTP `403` with code `PERMISSION_DENIED` by default.
173
- - Use only for auth/permission failures, not for business logic rejections.
174
-
175
- ### 7.3 Unhandled errors — plain `Error`
176
-
177
- For **"this should not have happened"** bugs: violated invariants, upstream crashes, unexpected nulls.
178
-
179
- ```ts
180
- if (cert === null)
181
- throw new Error(`Invariant: cert ${id} fetched but returned null`);
182
- ```
183
-
184
- - Dispatcher logs server-side and returns generic `{ error: 'Internal server error', code: 'INTERNAL_SERVERERROR' }` with HTTP `500`. Caller sees nothing useful — on purpose.
185
- - Never catch-and-rethrow as `AppserverHandledError` to surface a nicer message. That hides bugs.
186
-
187
- ### 7.4 Decision rule
188
-
189
- _"Is this failure something the caller did, or can act on?"_
190
- - Yes → `AppserverHandledError`
191
- - It's an auth/permission problem → `AppserverAuthError`
192
- - No, it's an internal bug → plain `throw new Error(...)`
193
-
194
- When unsure, use plain `Error`. Easier to promote later than to un-ship a handled error clients are keyed on.
195
-
196
- ---
197
-
198
- ## 8. Bootstrap (server)
199
-
200
- ```ts
201
- import express from 'express';
202
- import { AppserverDispatcher } from '@cripty2001/utils/appserver/server';
203
-
204
- const app = express();
205
- const dispatcher = new AppserverDispatcher();
206
-
207
- await dispatcher.autoload('./dist/api'); // must be awaited before bind/listen
208
-
209
- dispatcher.bind(app, '/exec'); // all registered actions route through this POST path
210
-
211
- app.listen(3000);
212
- ```
213
-
214
- `autoload` throws if any file has an invalid action path, a missing/malformed default export, or a duplicate action. For manual registration (testing, dynamic cases):
215
-
216
- ```ts
217
- dispatcher.register('action/path', { schema: mySchema, handler: myHandler });
218
- ```
219
-
220
- Keep the bootstrap as wiring only. No business logic.
221
-
222
- ---
223
-
224
- ## 9. HTTP-level error map (server)
225
-
226
- | Status | Trigger | Body shape |
227
- | ------ | ------------------------------------------ | --------------------------------------------- |
228
- | `200` | Handler returned successfully | Handler return value (msgpack-encoded) |
229
- | `400` | Wrong Content-Type, missing/invalid action | `{ error, code }` |
230
- | `403` | `AppserverAuthError` thrown | `{ error, code, payload }` |
231
- | `404` | Action not in registry | `{ error, code }` |
232
- | `422` | Input failed schema validation | `{ errors: string, received }` |
233
- | `500` | `AppserverHandledError` thrown | `{ error, code, payload }` |
234
- | `500` | Plain `Error` thrown | `{ error, code: 'INTERNAL_SERVERERROR' }` |
235
-
236
- Note: `errors` in the `422` body is a JSON-serialized string of TypeBox errors (from `JSON.stringify(Value.Errors(...))`), not a parsed object. The client receives it as a string; parse it if you need the structure.
237
-
238
- ---
239
-
240
- ## 10. Adding a new endpoint — checklist
241
-
242
- 1. **Path.** Every segment must match `[a-z0-9]+`. `user/cert/revoke` ✅. `user/revoke-cert` ❌.
243
- 2. **Create** `src/api/<seg>/<seg>.ts`.
244
- 3. **Write the input schema**, exported as `<camelCasePath>InputSchema`. Top-level `Type.Object({})`.
245
- 4. **Write the module** as a default export `{ schema, handler }`.
246
- 5. **Handle auth** if needed: check `auth`, resolve the token, throw `AppserverAuthError` if not authorized.
247
- 6. **Map failure modes** (§7): actionable → `AppserverHandledError`, auth → `AppserverAuthError`, bug → plain `Error`.
248
- 7. **Return a concrete shape.** No `undefined`, no class instances.
249
- 8. **Build & type-check.** Strict mode is non-negotiable.
250
- 9. **Verify autoload.** Compiled file must appear under the autoload base with the correct path.
251
-
252
- ---
253
-
254
- ## 11. Anti-patterns (server)
255
-
256
- - Path segment with hyphen, underscore, or uppercase — autoloader throws.
257
- - Not exporting a default `AppserverModule` — autoloader throws.
258
- - Importing `Type` from `@sinclair/typebox` directly instead of the appserver module.
259
- - Not checking `auth` in a handler that requires it — the dispatcher does not enforce this.
260
- - Catching every exception and returning `{ ok: false, message: e.message }` — turns bugs into handled errors.
261
- - Returning `err.message` in a response payload.
262
- - Declaring an output schema — output type inference is intentional.
263
- - Mutating `input` inside the handler.
264
- - Pointing `autoload` at `src/api` (source) instead of compiled output.
265
- - Business logic in the bootstrap.
266
-
267
- ---
268
-
269
- ## Part II — Client
270
-
271
- ---
272
-
273
- ## 12. What the client is
274
-
275
- `Client` from `@cripty2001/utils/appserver/client` is a typed MessagePack RPC client that mirrors the server's dispatcher. It:
276
-
277
- - Manages a Bearer token reactively via `Whispr<string | null>` from `@cripty2001/whispr`.
278
- - Encodes requests as `{ action, payload }` in MessagePack and decodes responses.
279
- - Maps HTTP status codes to typed error classes.
280
- - On auth errors (`401` or `403`), **automatically clears the token and suspends the in-flight call** until a new token is provided via `login()`, then retries — enabling transparent re-authentication.
281
- - Accepts per-call error handlers keyed by server error code, for side effects before rethrow.
282
-
283
- ---
284
-
285
- ## 13. Client API
286
-
287
- ### Construction
288
-
289
- ```ts
290
- import { Client } from '@cripty2001/utils/appserver/client';
291
-
292
- const client = Client.create(url, { rpcMount });
293
- ```
294
-
295
- `Client.create` is the only constructor. Do not call `new Client(...)`.
296
-
297
- | Parameter | Type | Description |
298
- | ------------------ | -------- | ---------------------------------------------------------------------------------------------- |
299
- | `url` | `string` | Base URL of the server, e.g. `'https://api.example.com'`. No trailing slash needed. |
300
- | `options.rpcMount` | `string` | Path to the dispatcher's bound route, e.g. `'/exec'`. **Must start with `/`.** |
301
-
302
- All requests are sent as `POST` to `url + rpcMount`.
303
-
304
- ### Token management
305
-
306
- ```ts
307
- await client.login(token: string): Promise<boolean>
308
- client.logout(): void
309
- client.loggedIn: Whispr<boolean>
310
- ```
311
-
312
- - **`login(token)`** — stores the token in the reactive `Whispr`. Always resolves `true`. Makes **no network call** — it does not validate the token against the server. The token is sent as `Authorization: Bearer <token>` on all subsequent `exec` calls.
313
- - **`logout()`** — synchronously clears the token.
314
- - **`loggedIn`** — a `Whispr<boolean>` reactive observable. Derives to `true` when a token is set, `false` when cleared. Not a plain boolean — read synchronously via `.value`, or subscribe reactively. Useful for binding to UI visibility state.
315
-
316
- ### Executing actions
317
-
318
- ```ts
319
- await client.exec<I, O>(
320
- action: string,
321
- input: I,
322
- onError?: Record<string, (payload: AppserverData) => Promise<void>>,
323
- ): Promise<O>
324
- ```
325
-
326
- | Parameter | Type | Description |
327
- | --------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
328
- | `action` | `string` | The action string, e.g. `'user/cert/revoke'`. Must match a registered server action exactly. |
329
- | `input` | `I extends AppserverData` | The input payload. Validated server-side against the endpoint's schema. |
330
- | `onError` | `Record<string, (payload: AppserverData) => Promise<void>>` | Optional. Map of server error `code` → async callback. Called **before** the error is rethrown. Use for side effects on known codes (toasts, cache invalidation, analytics). Does not suppress the throw. |
331
-
332
- **Return value:** the handler's return value, decoded from MessagePack as `O`. `O` is not verified at runtime — it is a cast. You are responsible for knowing the server's return shape.
333
-
334
- ### Auth retry loop
335
-
336
- When `exec` encounters a `ClientAuthError` (HTTP `401` or `403`):
337
-
338
- 1. Calls `logout()` — clears the token.
339
- 2. Calls `await this.token.load()` — **suspends** until the `Whispr<string | null>` token changes to a non-null value, i.e. until `login(newToken)` is called from somewhere (a login modal, an OAuth callback, etc.).
340
- 3. Retries the original request with the new token.
341
-
342
- This means **`exec` can suspend indefinitely** if `login()` is never called. Design accordingly: a `ClientAuthError` should trigger a login prompt; `login()` must be called on success to unblock. If the user navigates away without logging in, the suspended promise is simply abandoned.
343
-
344
- The retry only fires for `ClientAuthError`. All other errors throw immediately (after running any matching `onError` handler).
345
-
346
- ---
347
-
348
- ## 14. Error classes (client)
349
-
350
- All extend `ClientError extends Error`.
351
-
352
- | Class | Triggered by | Key fields |
353
- | ---------------------- | -------------------- | --------------------------------------------------------------------------- |
354
- | `ClientAuthError` | HTTP `401` or `403` | `.code: string`, `.payload: AppserverData` |
355
- | `ClientServerError` | HTTP `400`, `404`, `500` | `.code: string`, `.payload: AppserverData` |
356
- | `ClientValidationError`| HTTP `422` | `.errors: unknown` — the raw JSON string from the server; parse if needed |
357
- | `ClientError` | Unexpected status | `.message: string` |
358
-
359
- `ClientAuthError` also triggers `logout()` automatically inside `unsafeExec` before being caught by the retry loop. Do not catch `ClientAuthError` yourself unless you want to short-circuit the retry.
360
-
361
- `ClientServerError.code` maps directly to the `code` field of `AppserverHandledError` on the server. This is the cross-side contract — `switch` on it.
362
-
363
- ---
364
-
365
- ## 15. Complete worked example
366
-
367
- ### Server side
368
-
369
- `src/api/user/cert/revoke.ts`
370
-
371
- ```ts
372
- import {
373
- Type,
374
- AppserverHandledError,
375
- AppserverAuthError,
376
- } from '@cripty2001/utils/appserver/server';
377
- import type { AppserverModule } from '@cripty2001/utils/appserver/server';
378
-
379
- export const userCertRevokeInputSchema = Type.Object({
380
- certId: Type.String(),
381
- reason: Type.Optional(
382
- Type.Union([
383
- Type.Literal('COMPROMISED'),
384
- Type.Literal('SUPERSEDED'),
385
- Type.Literal('OTHER'),
386
- ]),
387
- ),
388
- notes: Type.Union([Type.String(), Type.Null()]),
389
- witnesses: Type.Array(Type.String()),
390
- });
391
-
392
- declare function resolveToken(token: string): Promise<{ id: string } | null>;
393
- declare function loadCert(id: string): Promise<{ id: string; ownerId: string; revokedAt: string | null } | null>;
394
- declare function persistRevocation(certId: string, reason: string): Promise<void>;
395
-
396
- const mod: AppserverModule<typeof userCertRevokeInputSchema> = {
397
- schema: userCertRevokeInputSchema,
398
- handler: async (input, auth) => {
399
- // Auth: manual — dispatcher does not enforce this
400
- if (!auth) throw new AppserverAuthError();
401
- const user = await resolveToken(auth);
402
- if (!user) throw new AppserverAuthError();
403
-
404
- // Semantic validation → AppserverHandledError
405
- const cert = await loadCert(input.certId);
406
- if (!cert)
407
- throw new AppserverHandledError('CERT_NOT_FOUND', `No certificate with id ${input.certId}.`, { certId: input.certId });
408
-
409
- if (cert.ownerId !== user.id)
410
- throw new AppserverHandledError('CERT_NOT_OWNED', 'You do not own this certificate.');
411
-
412
- if (cert.revokedAt !== null)
413
- throw new AppserverHandledError('CERT_ALREADY_REVOKED', 'This certificate has already been revoked.', { revokedAt: cert.revokedAt });
414
-
415
- await persistRevocation(cert.id, input.reason ?? 'OTHER');
416
-
417
- // Defensive re-check: if this fails it is an internal bug, not a caller error
418
- const after = await loadCert(cert.id);
419
- if (!after || after.revokedAt === null)
420
- throw new Error(`Invariant: cert ${cert.id} was not persisted as revoked`);
421
-
422
- return {
423
- certId: after.id,
424
- revokedAt: after.revokedAt,
425
- reason: input.reason ?? 'OTHER',
426
- witnessCount: input.witnesses.length,
427
- };
428
- },
429
- };
430
-
431
- export default mod;
432
- ```
433
-
434
- `src/index.ts`
435
-
436
- ```ts
437
- import express from 'express';
438
- import { AppserverDispatcher } from '@cripty2001/utils/appserver/server';
439
-
440
- const app = express();
441
- const dispatcher = new AppserverDispatcher();
442
-
443
- await dispatcher.autoload('./dist/api');
444
- dispatcher.bind(app, '/exec');
445
-
446
- app.listen(3000);
447
- ```
448
-
449
- ### Client side
450
-
451
- ```ts
452
- import {
453
- Client,
454
- ClientAuthError,
455
- ClientServerError,
456
- ClientValidationError,
457
- } from '@cripty2001/utils/appserver/client';
458
-
459
- // Construct once, reuse across the app.
460
- const client = Client.create('https://api.example.com', { rpcMount: '/exec' });
461
-
462
- // Store the token. No network call is made here.
463
- await client.login(bearerTokenFromYourAuthFlow);
464
-
465
- // Reactive auth state — bind to UI if needed.
466
- console.log(client.loggedIn.value); // true
467
-
468
- try {
469
- const result = await client.exec(
470
- 'user/cert/revoke',
471
- {
472
- certId: 'abc123',
473
- notes: null,
474
- witnesses: ['alice', 'bob'],
475
- // reason is optional, omit freely
476
- },
477
- {
478
- // onError: side-effect callbacks keyed by server error code.
479
- // Runs before the error is rethrown — use for toasts, logging, cache busting, etc.
480
- 'CERT_ALREADY_REVOKED': async (payload) => {
481
- console.warn('Already revoked at', payload);
482
- },
483
- },
484
- );
485
-
486
- console.log(result.certId, result.revokedAt, result.witnessCount);
487
-
488
- } catch (e) {
489
- if (e instanceof ClientValidationError) {
490
- // HTTP 422 — input did not match the server's schema.
491
- // e.errors is the raw JSON string from the server.
492
- const errors = JSON.parse(e.errors as string);
493
- console.error('Validation failed:', errors);
494
-
495
- } else if (e instanceof ClientServerError) {
496
- // AppserverHandledError from the server, or 400/404.
497
- // Switch on e.code — it maps directly to the server's error code.
498
- switch (e.code) {
499
- case 'CERT_NOT_FOUND':
500
- // e.payload.certId available
501
- break;
502
- case 'CERT_NOT_OWNED':
503
- break;
504
- case 'CERT_ALREADY_REVOKED':
505
- // e.payload.revokedAt available
506
- // onError above also ran before this catch
507
- break;
508
- case 'ACTION_NOT_FOUND':
509
- // Mismatched action string — likely a client/server version skew
510
- break;
511
- case 'INTERNAL_SERVERERROR':
512
- // Bug on the server — show generic failure
513
- break;
514
- }
515
-
516
- } else if (e instanceof ClientAuthError) {
517
- // HTTP 401/403. Normally the retry loop handles this transparently.
518
- // Only reaches here if the retry loop itself encounters another auth failure
519
- // (e.g. the new token is also invalid).
520
- }
521
- }
522
-
523
- client.logout();
524
- console.log(client.loggedIn.value); // false
525
- ```
526
-
527
- ### Auth retry flow (narrative)
528
-
529
- ```
530
- client.exec('user/cert/revoke', ...) called — token is null
531
- → server returns 403
532
- → client clears token, suspends, awaits token.load()
533
- [your UI shows a login modal]
534
- → user authenticates, you call client.login(newToken)
535
- → token.load() resolves
536
- → exec retries automatically with the new token
537
- → returns result normally
538
- ```
539
-
540
- If `login()` is never called, `exec` suspends indefinitely. Ensure your auth UI always either calls `login()` or the outstanding promise is abandoned (e.g. component unmounts, navigation occurs).
541
-
542
- ---
543
-
544
- ## 16. When in doubt
545
-
546
- - If you need to bypass the dispatcher (custom Express routes, JSON endpoints), you are building a parallel code path without validation or typed errors. Reshape the endpoint instead.
547
- - If you think you need to catch `ClientAuthError` yourself, you are probably fighting the retry loop. Let it run; only intercept it if you need to abort (navigate away without waiting for re-login).
1
+ # Appserver API guide
2
+
3
+ Covers both sides of the RPC system: the server (`AppserverDispatcher` from `@cripty2001/utils/appserver/server`) and the client (`Client` from `@cripty2001/utils/appserver/client`). Self-contained. A worked example covering every common case is in §15.
4
+
5
+ ---
6
+
7
+ ## Part I — Server
8
+
9
+ ---
10
+
11
+ ## 1. What the API layer is
12
+
13
+ - A **typed RPC dispatcher**. Every endpoint is reached via a single `POST` to a bound Express path (e.g. `/exec`). The body contains `{ action, payload }` where `action` is a slash-routed string identifying the handler and `payload` is the input object.
14
+ - Wire format is **MessagePack** (`Content-Type: application/vnd.msgpack`). A JSON request is rejected with `REQUEST_INVALID_TYPE_HEADER`. You never touch MessagePack yourself — the dispatcher decodes the request body and encodes your return value. Binary payloads (`Uint8Array`) flow in and out natively with no base64.
15
+ - The dispatcher is `AppserverDispatcher`. It binds to an existing Express app; it does not create one itself.
16
+
17
+ **You write `{ schema, handler }` module objects. The dispatcher handles validation, routing, and error mapping.**
18
+
19
+ ---
20
+
21
+ ## 2. Tech stack
22
+
23
+ | Layer | Choice |
24
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | Language | TypeScript, strict mode (`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`) |
26
+ | Dispatcher | `AppserverDispatcher` from `@cripty2001/utils/appserver/server` |
27
+ | Schema / validation | `@sinclair/typebox`, re-exported as `Type`, `Static`, `TSchema`, `Value` from the appserver module — **always import from there**, never from typebox directly |
28
+ | Wire format | MessagePack (handled by the dispatcher — not imported by handlers) |
29
+
30
+ ---
31
+
32
+ ## 3. Directory layout
33
+
34
+ ```
35
+ src/
36
+ index.ts # Bootstrap: creates Express app, AppserverDispatcher, calls bind/autoload
37
+ api/
38
+ <segment>/
39
+ <segment>.ts # One file per endpoint. Path mirrors action string.
40
+ ```
41
+
42
+ **Rules for `src/api/`:**
43
+
44
+ 1. **Folder/file path IS the action string.** `src/api/foo/bar.ts` compiles to `foo/bar.js` and is autoloaded as action `foo/bar`.
45
+ 2. **Every path segment is a single lowercase alphanumeric word.** The autoloader enforces regex `^([a-z0-9]+\/)*[a-z0-9]+$`. Hyphens, underscores, uppercase, and multi-word segments throw at startup. Use a deeper folder (`user/cert/revoke`) or a sharper verb instead.
46
+ 3. **One endpoint per file.** The autoloader registers the file's default export as one action.
47
+ 4. **No barrel / `index.ts` files inside `src/api/`.** Each file is standalone.
48
+ 5. **Autoload points at compiled output, not source.** The autoloader scans for `.js` files. Point it at `dist/api` (or equivalent), not `src/api`.
49
+
50
+ ---
51
+
52
+ ## 4. The endpoint module shape
53
+
54
+ Every endpoint file must have a **default export** satisfying `AppserverModule`:
55
+
56
+ ```ts
57
+ import { Type } from '@cripty2001/utils/appserver/server';
58
+ import type { AppserverModule } from '@cripty2001/utils/appserver/server';
59
+
60
+ // Export the input schema so clients can import the type.
61
+ export const myActionInputSchema = Type.Object({
62
+ field: Type.String(),
63
+ });
64
+
65
+ const mod: AppserverModule<typeof myActionInputSchema> = {
66
+ schema: myActionInputSchema,
67
+ handler: async (input, auth) => {
68
+ // input is typed and pre-validated.
69
+ // auth is the raw Bearer token string, or undefined if absent.
70
+ return { /* AppserverData-compatible value */ };
71
+ },
72
+ };
73
+
74
+ export default mod;
75
+ ```
76
+
77
+ The autoloader checks `mod.default.handler` (must be a function) and `mod.default.schema` (must be truthy) at startup. A file that does not satisfy this throws immediately.
78
+
79
+ ### Naming conventions
80
+
81
+ | Thing | Rule | Example |
82
+ | ------------- | ------------------------------------------------------------ | --------------------------------- |
83
+ | File path | `src/api/<seg>/<seg>.ts` | `src/api/version/ios.ts` |
84
+ | Action string | `<seg>/<seg>` (matches file path without `.ts` extension) | `version/ios` |
85
+ | Schema const | `<camelCasePath>InputSchema` | `versionIosInputSchema` |
86
+ | Segments | single lowercase alphanumeric word, no hyphens, no camelCase | `version`, `ios`, `cert`, `utils` |
87
+
88
+ ---
89
+
90
+ ## 5. Input and output types
91
+
92
+ ### Input — declared, exported, TypeBox
93
+
94
+ - Always `Type.Object({ ... })` at the top level, even for empty inputs (`Type.Object({})`). Never a bare string, number, or array at the top.
95
+ - Export the schema constant so clients can import it.
96
+ - The handler's `input` parameter is typed automatically from the schema. Do not annotate it manually.
97
+ - Internally the input type is modeled as `Static<typeof schema> & AppserverData`:
98
+ - `Static<typeof schema>` gives you the TypeScript type derived from the TypeBox schema.
99
+ - `& AppserverData` ensures that whatever the schema describes is also representable on the wire (msgpack-safe / JSON-like: primitives, arrays, objects, `Uint8Array`, `null`).
100
+ - Validation runs before the handler. Invalid input returns `422` with TypeBox errors. Your handler never sees malformed data. Validate semantics only (e.g. "does this ID exist") — not shape.
101
+
102
+ ### Output — inferred, no schema
103
+
104
+ - The return type is inferred. There is no output schema, intentionally.
105
+ - Must be `AppserverData`:
106
+ ```ts
107
+ type AppserverData =
108
+ | null | boolean | number | string | Uint8Array
109
+ | AppserverData[]
110
+ | { [key: string]: AppserverData };
111
+ ```
112
+ No `Date`, `undefined`, class instances, `bigint`, `Map`, or `Set`. Use ISO strings for dates. Use `null` instead of `undefined`.
113
+ - **Discriminated unions** (`{ ok: true, ... } | { ok: false, ... }`) are idiomatic for expected business branching. For genuine errors, throw — see §7.
114
+
115
+ ---
116
+
117
+ ## 6. Authentication
118
+
119
+ The dispatcher passes the raw Bearer token to every handler as `auth: string | undefined`. **There is no automatic auth enforcement.** Auth is fully manual in the handler.
120
+
121
+ - If `Authorization: Bearer <token>` is present, `auth` is the token string (after stripping `Bearer `).
122
+ - If absent or malformed, `auth` is `undefined`.
123
+ - If your endpoint requires auth, check `auth`, resolve the token yourself, and throw `AppserverAuthError` if unauthorized.
124
+
125
+ ```ts
126
+ handler: async (input, auth) => {
127
+ if (!auth) throw new AppserverAuthError();
128
+ const user = await resolveToken(auth);
129
+ if (!user) throw new AppserverAuthError();
130
+ // ...
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## 7. Error handling
137
+
138
+ Two categories of throwable errors, plus one for auth.
139
+
140
+ ### 7.1 Handled errors — `AppserverHandledError`
141
+
142
+ For failures the caller **can and must react to**: "certificate already revoked", "organization not found", "fiscal code malformed".
143
+
144
+ ```ts
145
+ import { AppserverHandledError } from '@cripty2001/utils/appserver/server';
146
+
147
+ throw new AppserverHandledError(
148
+ 'CERTIFICATE_ALREADY_REVOKED', // code — SCREAMING_SNAKE_CASE, stable, treat as public API
149
+ 'This certificate has already been revoked.', // user-facing message, no internals
150
+ { revokedAt: '2026-04-10T12:00:00Z' }, // optional AppserverData payload
151
+ );
152
+ ```
153
+
154
+ - Maps to HTTP `500` with body `{ error, code, payload }`.
155
+ - `code` is a contract — clients `switch` on it. Don't rename casually.
156
+ - Message is user-facing: plain language, no stack info, no DB field names.
157
+ - Extra context goes in `payload`, not interpolated into the message.
158
+
159
+ ### 7.2 Auth errors — `AppserverAuthError`
160
+
161
+ For missing or invalid credentials.
162
+
163
+ ```ts
164
+ import { AppserverAuthError } from '@cripty2001/utils/appserver/server';
165
+
166
+ throw new AppserverAuthError();
167
+ // or:
168
+ throw new AppserverAuthError('Session expired', 'SESSION_EXPIRED');
169
+ // constructor: (message?, code?) — both optional
170
+ ```
171
+
172
+ - Maps to HTTP `403` with code `PERMISSION_DENIED` by default.
173
+ - Use only for auth/permission failures, not for business logic rejections.
174
+
175
+ ### 7.3 Unhandled errors — plain `Error`
176
+
177
+ For **"this should not have happened"** bugs: violated invariants, upstream crashes, unexpected nulls.
178
+
179
+ ```ts
180
+ if (cert === null)
181
+ throw new Error(`Invariant: cert ${id} fetched but returned null`);
182
+ ```
183
+
184
+ - Dispatcher logs server-side and returns generic `{ error: 'Internal server error', code: 'INTERNAL_SERVERERROR' }` with HTTP `500`. Caller sees nothing useful — on purpose.
185
+ - Never catch-and-rethrow as `AppserverHandledError` to surface a nicer message. That hides bugs.
186
+
187
+ ### 7.4 Decision rule
188
+
189
+ _"Is this failure something the caller did, or can act on?"_
190
+ - Yes → `AppserverHandledError`
191
+ - It's an auth/permission problem → `AppserverAuthError`
192
+ - No, it's an internal bug → plain `throw new Error(...)`
193
+
194
+ When unsure, use plain `Error`. Easier to promote later than to un-ship a handled error clients are keyed on.
195
+
196
+ ---
197
+
198
+ ## 8. Bootstrap (server)
199
+
200
+ ```ts
201
+ import express from 'express';
202
+ import { AppserverDispatcher } from '@cripty2001/utils/appserver/server';
203
+
204
+ const app = express();
205
+ const dispatcher = new AppserverDispatcher();
206
+
207
+ await dispatcher.autoload('./dist/api'); // must be awaited before bind/listen
208
+
209
+ dispatcher.bind(app, '/exec'); // all registered actions route through this POST path
210
+
211
+ app.listen(3000);
212
+ ```
213
+
214
+ `autoload` throws if any file has an invalid action path, a missing/malformed default export, or a duplicate action. For manual registration (testing, dynamic cases):
215
+
216
+ ```ts
217
+ dispatcher.register('action/path', { schema: mySchema, handler: myHandler });
218
+ ```
219
+
220
+ Keep the bootstrap as wiring only. No business logic.
221
+
222
+ ---
223
+
224
+ ## 9. HTTP-level error map (server)
225
+
226
+ | Status | Trigger | Body shape |
227
+ | ------ | ------------------------------------------ | --------------------------------------------- |
228
+ | `200` | Handler returned successfully | Handler return value (msgpack-encoded) |
229
+ | `400` | Wrong Content-Type, missing/invalid action | `{ error, code }` |
230
+ | `403` | `AppserverAuthError` thrown | `{ error, code, payload }` |
231
+ | `404` | Action not in registry | `{ error, code }` |
232
+ | `422` | Input failed schema validation | `{ errors: string, received }` |
233
+ | `500` | `AppserverHandledError` thrown | `{ error, code, payload }` |
234
+ | `500` | Plain `Error` thrown | `{ error, code: 'INTERNAL_SERVERERROR' }` |
235
+
236
+ Note: `errors` in the `422` body is a JSON-serialized string of TypeBox errors (from `JSON.stringify(Value.Errors(...))`), not a parsed object. The client receives it as a string; parse it if you need the structure.
237
+
238
+ ---
239
+
240
+ ## 10. Adding a new endpoint — checklist
241
+
242
+ 1. **Path.** Every segment must match `[a-z0-9]+`. `user/cert/revoke` ✅. `user/revoke-cert` ❌.
243
+ 2. **Create** `src/api/<seg>/<seg>.ts`.
244
+ 3. **Write the input schema**, exported as `<camelCasePath>InputSchema`. Top-level `Type.Object({})`.
245
+ 4. **Write the module** as a default export `{ schema, handler }`.
246
+ 5. **Handle auth** if needed: check `auth`, resolve the token, throw `AppserverAuthError` if not authorized.
247
+ 6. **Map failure modes** (§7): actionable → `AppserverHandledError`, auth → `AppserverAuthError`, bug → plain `Error`.
248
+ 7. **Return a concrete shape.** No `undefined`, no class instances.
249
+ 8. **Build & type-check.** Strict mode is non-negotiable.
250
+ 9. **Verify autoload.** Compiled file must appear under the autoload base with the correct path.
251
+
252
+ ---
253
+
254
+ ## 11. Anti-patterns (server)
255
+
256
+ - Path segment with hyphen, underscore, or uppercase — autoloader throws.
257
+ - Not exporting a default `AppserverModule` — autoloader throws.
258
+ - Importing `Type` from `@sinclair/typebox` directly instead of the appserver module.
259
+ - Not checking `auth` in a handler that requires it — the dispatcher does not enforce this.
260
+ - Catching every exception and returning `{ ok: false, message: e.message }` — turns bugs into handled errors.
261
+ - Returning `err.message` in a response payload.
262
+ - Declaring an output schema — output type inference is intentional.
263
+ - Mutating `input` inside the handler.
264
+ - Pointing `autoload` at `src/api` (source) instead of compiled output.
265
+ - Business logic in the bootstrap.
266
+
267
+ ---
268
+
269
+ ## Part II — Client
270
+
271
+ ---
272
+
273
+ ## 12. What the client is
274
+
275
+ `Client` from `@cripty2001/utils/appserver/client` is a typed MessagePack RPC client that mirrors the server's dispatcher. It:
276
+
277
+ - Manages a Bearer token reactively via `Whispr<string | null>` from `@cripty2001/whispr`.
278
+ - Encodes requests as `{ action, payload }` in MessagePack and decodes responses.
279
+ - Maps HTTP status codes to typed error classes.
280
+ - On auth errors (`401` or `403`), **automatically clears the token and suspends the in-flight call** until a new token is provided via `login()`, then retries — enabling transparent re-authentication.
281
+ - Accepts per-call error handlers keyed by server error code, for side effects before rethrow.
282
+
283
+ ---
284
+
285
+ ## 13. Client API
286
+
287
+ ### Construction
288
+
289
+ ```ts
290
+ import { Client } from '@cripty2001/utils/appserver/client';
291
+
292
+ const client = Client.create(url, { rpcMount });
293
+ ```
294
+
295
+ `Client.create` is the only constructor. Do not call `new Client(...)`.
296
+
297
+ | Parameter | Type | Description |
298
+ | ------------------ | -------- | ---------------------------------------------------------------------------------------------- |
299
+ | `url` | `string` | Base URL of the server, e.g. `'https://api.example.com'`. No trailing slash needed. |
300
+ | `options.rpcMount` | `string` | Path to the dispatcher's bound route, e.g. `'/exec'`. **Must start with `/`.** |
301
+
302
+ All requests are sent as `POST` to `url + rpcMount`.
303
+
304
+ ### Token management
305
+
306
+ ```ts
307
+ await client.login(token: string): Promise<boolean>
308
+ client.logout(): void
309
+ client.loggedIn: Whispr<boolean>
310
+ ```
311
+
312
+ - **`login(token)`** — stores the token in the reactive `Whispr`. Always resolves `true`. Makes **no network call** — it does not validate the token against the server. The token is sent as `Authorization: Bearer <token>` on all subsequent `exec` calls.
313
+ - **`logout()`** — synchronously clears the token.
314
+ - **`loggedIn`** — a `Whispr<boolean>` reactive observable. Derives to `true` when a token is set, `false` when cleared. Not a plain boolean — read synchronously via `.value`, or subscribe reactively. Useful for binding to UI visibility state.
315
+
316
+ ### Executing actions
317
+
318
+ ```ts
319
+ await client.exec<I, O>(
320
+ action: string,
321
+ input: I,
322
+ onError?: Record<string, (payload: AppserverData) => Promise<void>>,
323
+ ): Promise<O>
324
+ ```
325
+
326
+ | Parameter | Type | Description |
327
+ | --------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
328
+ | `action` | `string` | The action string, e.g. `'user/cert/revoke'`. Must match a registered server action exactly. |
329
+ | `input` | `I extends AppserverData` | The input payload. Validated server-side against the endpoint's schema. |
330
+ | `onError` | `Record<string, (payload: AppserverData) => Promise<void>>` | Optional. Map of server error `code` → async callback. Called **before** the error is rethrown. Use for side effects on known codes (toasts, cache invalidation, analytics). Does not suppress the throw. |
331
+
332
+ **Return value:** the handler's return value, decoded from MessagePack as `O`. `O` is not verified at runtime — it is a cast. You are responsible for knowing the server's return shape.
333
+
334
+ ### Auth retry loop
335
+
336
+ When `exec` encounters a `ClientAuthError` (HTTP `401` or `403`):
337
+
338
+ 1. Calls `logout()` — clears the token.
339
+ 2. Calls `await this.token.load()` — **suspends** until the `Whispr<string | null>` token changes to a non-null value, i.e. until `login(newToken)` is called from somewhere (a login modal, an OAuth callback, etc.).
340
+ 3. Retries the original request with the new token.
341
+
342
+ This means **`exec` can suspend indefinitely** if `login()` is never called. Design accordingly: a `ClientAuthError` should trigger a login prompt; `login()` must be called on success to unblock. If the user navigates away without logging in, the suspended promise is simply abandoned.
343
+
344
+ The retry only fires for `ClientAuthError`. All other errors throw immediately (after running any matching `onError` handler).
345
+
346
+ ---
347
+
348
+ ## 14. Error classes (client)
349
+
350
+ All extend `ClientError extends Error`.
351
+
352
+ | Class | Triggered by | Key fields |
353
+ | ---------------------- | -------------------- | --------------------------------------------------------------------------- |
354
+ | `ClientAuthError` | HTTP `401` or `403` | `.code: string`, `.payload: AppserverData` |
355
+ | `ClientServerError` | HTTP `400`, `404`, `500` | `.code: string`, `.payload: AppserverData` |
356
+ | `ClientValidationError`| HTTP `422` | `.errors: unknown` — the raw JSON string from the server; parse if needed |
357
+ | `ClientError` | Unexpected status | `.message: string` |
358
+
359
+ `ClientAuthError` also triggers `logout()` automatically inside `unsafeExec` before being caught by the retry loop. Do not catch `ClientAuthError` yourself unless you want to short-circuit the retry.
360
+
361
+ `ClientServerError.code` maps directly to the `code` field of `AppserverHandledError` on the server. This is the cross-side contract — `switch` on it.
362
+
363
+ ---
364
+
365
+ ## 15. Complete worked example
366
+
367
+ ### Server side
368
+
369
+ `src/api/user/cert/revoke.ts`
370
+
371
+ ```ts
372
+ import {
373
+ Type,
374
+ AppserverHandledError,
375
+ AppserverAuthError,
376
+ } from '@cripty2001/utils/appserver/server';
377
+ import type { AppserverModule } from '@cripty2001/utils/appserver/server';
378
+
379
+ export const userCertRevokeInputSchema = Type.Object({
380
+ certId: Type.String(),
381
+ reason: Type.Optional(
382
+ Type.Union([
383
+ Type.Literal('COMPROMISED'),
384
+ Type.Literal('SUPERSEDED'),
385
+ Type.Literal('OTHER'),
386
+ ]),
387
+ ),
388
+ notes: Type.Union([Type.String(), Type.Null()]),
389
+ witnesses: Type.Array(Type.String()),
390
+ });
391
+
392
+ declare function resolveToken(token: string): Promise<{ id: string } | null>;
393
+ declare function loadCert(id: string): Promise<{ id: string; ownerId: string; revokedAt: string | null } | null>;
394
+ declare function persistRevocation(certId: string, reason: string): Promise<void>;
395
+
396
+ const mod: AppserverModule<typeof userCertRevokeInputSchema> = {
397
+ schema: userCertRevokeInputSchema,
398
+ handler: async (input, auth) => {
399
+ // Auth: manual — dispatcher does not enforce this
400
+ if (!auth) throw new AppserverAuthError();
401
+ const user = await resolveToken(auth);
402
+ if (!user) throw new AppserverAuthError();
403
+
404
+ // Semantic validation → AppserverHandledError
405
+ const cert = await loadCert(input.certId);
406
+ if (!cert)
407
+ throw new AppserverHandledError('CERT_NOT_FOUND', `No certificate with id ${input.certId}.`, { certId: input.certId });
408
+
409
+ if (cert.ownerId !== user.id)
410
+ throw new AppserverHandledError('CERT_NOT_OWNED', 'You do not own this certificate.');
411
+
412
+ if (cert.revokedAt !== null)
413
+ throw new AppserverHandledError('CERT_ALREADY_REVOKED', 'This certificate has already been revoked.', { revokedAt: cert.revokedAt });
414
+
415
+ await persistRevocation(cert.id, input.reason ?? 'OTHER');
416
+
417
+ // Defensive re-check: if this fails it is an internal bug, not a caller error
418
+ const after = await loadCert(cert.id);
419
+ if (!after || after.revokedAt === null)
420
+ throw new Error(`Invariant: cert ${cert.id} was not persisted as revoked`);
421
+
422
+ return {
423
+ certId: after.id,
424
+ revokedAt: after.revokedAt,
425
+ reason: input.reason ?? 'OTHER',
426
+ witnessCount: input.witnesses.length,
427
+ };
428
+ },
429
+ };
430
+
431
+ export default mod;
432
+ ```
433
+
434
+ `src/index.ts`
435
+
436
+ ```ts
437
+ import express from 'express';
438
+ import { AppserverDispatcher } from '@cripty2001/utils/appserver/server';
439
+
440
+ const app = express();
441
+ const dispatcher = new AppserverDispatcher();
442
+
443
+ await dispatcher.autoload('./dist/api');
444
+ dispatcher.bind(app, '/exec');
445
+
446
+ app.listen(3000);
447
+ ```
448
+
449
+ ### Client side
450
+
451
+ ```ts
452
+ import {
453
+ Client,
454
+ ClientAuthError,
455
+ ClientServerError,
456
+ ClientValidationError,
457
+ } from '@cripty2001/utils/appserver/client';
458
+
459
+ // Construct once, reuse across the app.
460
+ const client = Client.create('https://api.example.com', { rpcMount: '/exec' });
461
+
462
+ // Store the token. No network call is made here.
463
+ await client.login(bearerTokenFromYourAuthFlow);
464
+
465
+ // Reactive auth state — bind to UI if needed.
466
+ console.log(client.loggedIn.value); // true
467
+
468
+ try {
469
+ const result = await client.exec(
470
+ 'user/cert/revoke',
471
+ {
472
+ certId: 'abc123',
473
+ notes: null,
474
+ witnesses: ['alice', 'bob'],
475
+ // reason is optional, omit freely
476
+ },
477
+ {
478
+ // onError: side-effect callbacks keyed by server error code.
479
+ // Runs before the error is rethrown — use for toasts, logging, cache busting, etc.
480
+ 'CERT_ALREADY_REVOKED': async (payload) => {
481
+ console.warn('Already revoked at', payload);
482
+ },
483
+ },
484
+ );
485
+
486
+ console.log(result.certId, result.revokedAt, result.witnessCount);
487
+
488
+ } catch (e) {
489
+ if (e instanceof ClientValidationError) {
490
+ // HTTP 422 — input did not match the server's schema.
491
+ // e.errors is the raw JSON string from the server.
492
+ const errors = JSON.parse(e.errors as string);
493
+ console.error('Validation failed:', errors);
494
+
495
+ } else if (e instanceof ClientServerError) {
496
+ // AppserverHandledError from the server, or 400/404.
497
+ // Switch on e.code — it maps directly to the server's error code.
498
+ switch (e.code) {
499
+ case 'CERT_NOT_FOUND':
500
+ // e.payload.certId available
501
+ break;
502
+ case 'CERT_NOT_OWNED':
503
+ break;
504
+ case 'CERT_ALREADY_REVOKED':
505
+ // e.payload.revokedAt available
506
+ // onError above also ran before this catch
507
+ break;
508
+ case 'ACTION_NOT_FOUND':
509
+ // Mismatched action string — likely a client/server version skew
510
+ break;
511
+ case 'INTERNAL_SERVERERROR':
512
+ // Bug on the server — show generic failure
513
+ break;
514
+ }
515
+
516
+ } else if (e instanceof ClientAuthError) {
517
+ // HTTP 401/403. Normally the retry loop handles this transparently.
518
+ // Only reaches here if the retry loop itself encounters another auth failure
519
+ // (e.g. the new token is also invalid).
520
+ }
521
+ }
522
+
523
+ client.logout();
524
+ console.log(client.loggedIn.value); // false
525
+ ```
526
+
527
+ ### Auth retry flow (narrative)
528
+
529
+ ```
530
+ client.exec('user/cert/revoke', ...) called — token is null
531
+ → server returns 403
532
+ → client clears token, suspends, awaits token.load()
533
+ [your UI shows a login modal]
534
+ → user authenticates, you call client.login(newToken)
535
+ → token.load() resolves
536
+ → exec retries automatically with the new token
537
+ → returns result normally
538
+ ```
539
+
540
+ If `login()` is never called, `exec` suspends indefinitely. Ensure your auth UI always either calls `login()` or the outstanding promise is abandoned (e.g. component unmounts, navigation occurs).
541
+
542
+ ---
543
+
544
+ ## 16. When in doubt
545
+
546
+ - If you need to bypass the dispatcher (custom Express routes, JSON endpoints), you are building a parallel code path without validation or typed errors. Reshape the endpoint instead.
547
+ - If you think you need to catch `ClientAuthError` yourself, you are probably fighting the retry loop. Let it run; only intercept it if you need to abort (navigate away without waiting for re-login).
548
548
  - If the inferred `O` in `exec<I, O>` feels wrong, supply it explicitly: `client.exec<MyInput, MyOutput>(...)`. The ground truth is the server handler's return type — keep both sides in sync.