@cripty2001/utils 0.0.211 → 0.0.212

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.
@@ -1,622 +0,0 @@
1
- # Appserver API guide
2
-
3
- This document is the entry point for anyone — human or agent — writing API handlers in this codebase. It is a deep dive on **how APIs are built on top of the `Appserver` class from `@cripty2001/utils`**: the file layout, the registration pattern, input/output typing, authentication, and (most importantly) the error model.
4
-
5
- It is intentionally self-contained: you should not need to read any existing endpoint to understand how to add a new one. A full worked example covering every common case lives at the end (§13).
6
-
7
- Read it before making changes. The conventions here are not suggestions — deviations cause friction and should be justified, not accidental.
8
-
9
- ---
10
-
11
- ## 1. What the API layer is
12
-
13
- - A **typed RPC runner**, not a REST/HTTP-verbs-and-resources API. Every endpoint is a `POST` to `/exec/<action>` that takes one input object and returns one output object.
14
- - The wire format is **MessagePack** (`Content-Type: application/vnd.msgpack`), not JSON. This is enforced by the framework — a JSON request will be rejected with `REQUEST_INVALID_TYPE_HEADER`. You never touch MessagePack yourself: the framework decodes the request and encodes your return value. The only reason to care is that MessagePack supports `Uint8Array` natively, so binary payloads (signatures, certificates, images) flow in and out as plain `Uint8Array` with no base64 dance.
15
- - Transport, framing, validation, auth, and error mapping are all handled by the shared `Appserver` class from `@cripty2001/utils/appserver/server`. The matching client is `@cripty2001/utils/appserver/client`.
16
-
17
- **You do not write HTTP routes, Request/Response handlers, or fetch logic.** You write pure functions `(input) => output` or `(input, user) => output`, declare an input schema, and register them. The framework does the rest.
18
-
19
- ---
20
-
21
- ## 2. Tech stack for the API layer
22
-
23
- | Layer | Choice |
24
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | Language | TypeScript, strict mode (`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`) |
26
- | Server framework | `@cripty2001/utils/appserver/server` (internal) |
27
- | Schema / validation | `@sinclair/typebox`, re-exported as `Type` from the appserver module — **always import from there**, never from typebox directly, to avoid version skew |
28
- | Wire format | MessagePack (handled by the framework — not imported by handlers) |
29
-
30
- ---
31
-
32
- ## 3. Directory layout for APIs
33
-
34
- ```
35
- src/
36
- index.ts # Bootstrap: constructs Appserver, registers every API
37
- api/
38
- <segment1>/
39
- <segment2>.ts # One file per endpoint. Path mirrors the exposed URL.
40
- ```
41
-
42
- **Rules for the `src/api/` tree — these are not suggestions:**
43
-
44
- 1. **The folder path IS the URL path.** `src/api/foo/bar.ts` is exposed at `/exec/foo/bar`. Never break this mapping. Renaming a file moves the endpoint.
45
- 2. **Every path segment is a single lowercase word.** `version`, `ios`, `utils`, `identicon` — not `version-check`, not `userProfile`, not `user_profile`. If you feel you need two words in a segment, rethink the shape: usually it means a deeper folder (`user/cert/revoke` instead of `user/revokeCert`) or a sharper verb. Single-word segments keep URLs, folders, and register-function names readable and unambiguous.
46
- 3. **One endpoint per file.** Don't register two actions from the same file. This keeps the path-to-file mapping trivial and files short.
47
- 4. **No barrel / `index.ts` files inside `src/api/`.** Each endpoint file is imported directly by the bootstrap.
48
-
49
- ---
50
-
51
- ## 4. The API file template
52
-
53
- Every endpoint file has the same shape:
54
-
55
- ```ts
56
- import type { Appserver } from '@cripty2001/utils/appserver/server';
57
- import { Type } from '@cripty2001/utils/appserver/server';
58
-
59
- // 1. Input schema — declared with TypeBox, EXPORTED so clients can import it.
60
- export const <camelCasePath>InputSchema = Type.Object({
61
- field: Type.String(),
62
- // ...
63
- });
64
-
65
- // 2. Register function — named register<PascalCasePath>, takes the server, returns void.
66
- export function register<PascalCasePath>(server: Appserver<null>): void {
67
- server.registerPublic( // or registerPrivate — see §6
68
- '<segment1>/<segment2>', // action path — MUST match this file's folder/filename
69
- <camelCasePath>InputSchema,
70
- async (input) => {
71
- // 3. Handler: pure async function. `input` is already validated and typed.
72
- // Return an AppserverData-compatible value — that IS the output type.
73
- return { /* ... */ };
74
- },
75
- );
76
- }
77
- ```
78
-
79
- Then wire it up in the bootstrap (`src/index.ts`):
80
-
81
- ```ts
82
- import { register<PascalCasePath> } from './api/<segment1>/<segment2>.js';
83
- // ...
84
- register<PascalCasePath>(server);
85
- ```
86
-
87
- The `.js` extension on the relative import is required by the `Bundler` moduleResolution + `verbatimModuleSyntax` TS combo, even though source files are `.ts`.
88
-
89
- ### Naming conventions (strict)
90
-
91
- | Thing | Rule | Example |
92
- | ------------------ | --------------------------------------------------------------------------------- | ---------------------------------------- |
93
- | File path | `src/api/<seg>/<seg>.ts` | `src/api/version/ios.ts` |
94
- | URL path | `<seg>/<seg>` (no leading slash in `register*` call; framework prepends `/exec/`) | `version/ios` → `POST /exec/version/ios` |
95
- | Register fn | `register<PascalCasePath>` | `registerVersionIos` |
96
- | Input schema const | `<camelCasePath>InputSchema` | `versionIosInputSchema` |
97
- | Path segments | single lowercase word, no hyphens, no camelCase | `version`, `ios`, `utils`, `identicon` |
98
-
99
- ---
100
-
101
- ## 5. Input and output types
102
-
103
- ### Input — declared, exported, TypeBox
104
-
105
- - Every endpoint declares an input schema with `Type.Object({ ... })`. **Always an object at the top level**, even for single-field inputs. Never take a bare string, number, or array at the top.
106
- - The schema constant is **exported** so the client can import it for shared type inference.
107
- - The handler's `input` parameter is automatically typed from the schema via `Static<ISchema>` — don't annotate it manually.
108
- - The framework validates the input **before** your handler runs. Invalid input returns HTTP `422` with the list of TypeBox errors; your handler never sees malformed data. Don't re-validate shape in the handler; validate _semantics_ only (e.g. "this ID exists in the DB", "this version is in the supported list").
109
-
110
- ### Output — inferred, no schema
111
-
112
- - The return type of the handler is the output type. **There is no output schema, on purpose** — output shapes evolve freely and are inferred by TS, not validated at runtime.
113
- - The output must be `AppserverData`:
114
-
115
- ```ts
116
- type AppserverData =
117
- | null
118
- | boolean
119
- | number
120
- | string
121
- | Uint8Array
122
- | AppserverData[]
123
- | { [key: string]: AppserverData };
124
- ```
125
-
126
- i.e. JSON-like, **plus `Uint8Array`** for binary payloads. No `Date`, no `undefined`, no class instances, no `bigint`, no `Map`/`Set`. Convert at the boundary (ISO strings for dates, etc.).
127
-
128
- - The client gets the return value back as-is (msgpack-encoded then decoded). Whatever shape you return is what callers consume.
129
- - **Discriminated unions** (`{ ok: true, ... } | { ok: false, ... }`) are idiomatic for "success or structured failure that the client must branch on" — but **only** when the "failure" is a normal, expected business outcome (e.g. "version not supported, here's the upgrade link"). For genuine errors, throw — see §7.
130
-
131
- ---
132
-
133
- ## 6. Public vs. Private endpoints
134
-
135
- The framework exposes two registration methods:
136
-
137
- | Method | Auth | Handler signature | When to use |
138
- | ----------------------------------------------- | -------- | ------------------------- | ------------------------------------------------------------------------------- |
139
- | `server.registerPublic(path, schema, handler)` | None | `(input) => output` | Anonymous endpoints: health, version checks, public reads, identicon generation |
140
- | `server.registerPrivate(path, schema, handler)` | Required | `(input, user) => output` | Anything that needs a logged-in user |
141
-
142
- ### Auth flow
143
-
144
- - Clients send `Authorization: Bearer <token>`.
145
- - The token is passed to the `parseUser` function provided to `new Appserver(...)` in the bootstrap. That function returns the resolved user (any `AppserverData` shape) or `null` if the token is invalid / expired / absent.
146
- - For `registerPrivate`, a `null` user automatically yields HTTP `403` with code `PERMISSION_DENIED` (via the internal `AppserverAuthError`) — **you don't write that check**.
147
- - For `registerPublic`, the user is implicitly unused; the handler takes only `input`.
148
- - There is also a built-in `POST /auth/whoami` endpoint (registered by the framework itself) that returns the currently authenticated user, stripped of keys starting with `_` (use `_`-prefixed keys for internal, non-exposed fields like password hashes or session nonces).
149
-
150
- ### The `Appserver<U>` generic
151
-
152
- `Appserver<U>` is parameterized by the user type. Every `register<Name>` function should type its parameter as `Appserver<U>` matching the bootstrap's `U`. When `U` changes (e.g. auth lands), the bootstrap changes in one place and every private handler sees the new user type automatically.
153
-
154
- If the project has no real user model yet, use `Appserver<null>` and a `parseUser` stub that returns `null`. Every `registerPrivate` endpoint will reject calls until `parseUser` is implemented, but endpoints stay as-is.
155
-
156
- ---
157
-
158
- ## 7. Error handling — the important distinction
159
-
160
- There are **two categories of errors**. Getting this right matters because it's the difference between a useful, actionable error and a leaked stack trace.
161
-
162
- ### 7.1 Handled errors — `AppserverHandledError`
163
-
164
- Use when something went wrong **in a way the caller needs to know about and can reasonably react to**. Examples: "this certificate is already revoked", "this fiscal code is malformed", "this organization doesn't exist".
165
-
166
- ```ts
167
- import { AppserverHandledError } from "@cripty2001/utils/appserver/server";
168
-
169
- throw new AppserverHandledError(
170
- "CERTIFICATE_ALREADY_REVOKED", // code — SCREAMING_SNAKE_CASE, stable, machine-readable
171
- "This certificate has already been revoked.", // human-readable message
172
- { revokedAt: "2026-04-10T12:00:00Z" }, // optional structured payload (AppserverData)
173
- );
174
- ```
175
-
176
- - The client receives `{ error, code, payload }` with HTTP `500` (the framework's default status for handled errors). On the client side this arrives as a `ClientServerError` with matching `.code`, `.message`, `.payload`.
177
- - Pick codes that are **actionable**: the client should be able to `switch` on `code` to drive the right UI (a specific message, a retry button, a redirect).
178
- - Keep messages user-facing: plain language, no internal jargon, no stack info, no DB field names. The message is what ends up in a toast or dialog.
179
- - Put any extra context (IDs, timestamps, hints) in `payload`, not interpolated into the message — clients format the message themselves.
180
- - The `code` is the contract; treat it like a public API. Don't rename it casually, because clients key off it.
181
-
182
- ### 7.2 Unhandled errors — plain `Error` / exceptions
183
-
184
- Use for **"this shouldn't have happened" bugs**: invariants violated, upstream service crashed, a `null` that should never be `null`. The user should not see the details.
185
-
186
- ```ts
187
- if (cert === null) {
188
- // Not an AppserverHandledError — the caller can't act on this.
189
- throw new Error(`Invariant: cert ${id} was fetched but came back null`);
190
- }
191
- ```
192
-
193
- - The framework logs it server-side (`console.log("Unhandled server error:", e)`) and returns a generic `{ error: 'Internal server error', code: 'INTERNAL_SERVERERROR' }` with HTTP `500`. The client sees nothing useful — on purpose.
194
- - Never throw a plain `Error` with a message you'd be embarrassed to show to someone. Assume logs may end up shared with customers or on-call engineers outside your team.
195
- - **Don't catch-and-rethrow as `AppserverHandledError` just to get a nicer message out.** If the problem isn't the caller's to solve, let it fall through. Promoting a bug to a handled error hides the bug.
196
-
197
- ### 7.3 Decision rule
198
-
199
- Ask: _"Is this failure something the caller did, or something they can act on?"_
200
-
201
- - **Yes** → `AppserverHandledError` with a specific code, a user-facing message, and optional payload.
202
- - **No** → throw a regular `Error` with enough detail for server-side debugging.
203
-
204
- If you're unsure, err on the side of plain `Error`. It's easier to promote an internal error to a handled one later than to remove a misleading handled error that clients are already keyed on.
205
-
206
- ### 7.4 Framework errors you should _not_ throw manually
207
-
208
- - `AppserverAuthError` — thrown automatically by `registerPrivate` when the user is `null`. Don't instantiate it.
209
- - `HTTP 422` with TypeBox errors — emitted automatically when the input fails schema validation. Your handler never runs in that case.
210
-
211
- ---
212
-
213
- ## 8. Bootstrap: the entry file
214
-
215
- The bootstrap (typically `src/index.ts`) does two things, and only two things:
216
-
217
- 1. Construct the `Appserver`, passing `parseUser`, `getMetrics`, and allowed CORS origins.
218
- 2. Import and call every `register<Name>(server)` function.
219
-
220
- An express server is automatically created, started and wired up to all the registered routes.
221
-
222
- ```ts
223
- import { Appserver } from "@cripty2001/utils/appserver/server";
224
- import { registerVersionIos } from "./api/version/ios.js";
225
- import { registerUtilsIdenticon } from "./api/utils/identicon.js";
226
-
227
- const PORT = 3000;
228
- const parseUser = async (_token: string) => null; // replace with real token resolution
229
- const getMetrics = () => ({
230
- /* name: number */
231
- });
232
- const origins = ["http://localhost:5173"];
233
-
234
- const server = new Appserver(PORT, parseUser, getMetrics, origins);
235
-
236
- registerVersionIos(server);
237
- registerUtilsIdenticon(server);
238
- ```
239
-
240
- Adding an endpoint = one `import` line and one `register<Name>(server)` call here. Group registrations by folder and alphabetize within each group — it stays readable as the list grows.
241
-
242
- `getMetrics` returns `Record<string, number>` and is exposed at `GET /metrics` in Prometheus format (keys are lowercased, non-alphanumerics collapsed to `_`, then prefixed with `app_`). Values must be finite numbers; non-finite values cause the metrics endpoint to emit an error comment.
243
-
244
- **Keep the bootstrap as wiring only.** No business logic, no per-route conditionals, no ad-hoc Express middleware. If you're tempted to add logic here, it belongs in an endpoint file or a shared module.
245
-
246
- ---
247
-
248
- ## 9. Adding a new API — checklist
249
-
250
- 1. **Path.** Confirm each segment is a single lowercase word. `user/revoke` ✅. `user/revoke-cert` ❌. `user/revokeCert` ❌. If two words feel necessary, use a deeper path (`user/cert/revoke`) or a sharper verb.
251
- 2. **Create the file** at `src/api/<seg>/<seg>.ts`. `mkdir` the folder if it doesn't exist.
252
- 3. **Write the input schema** with TypeBox, exported as `<camelCasePath>InputSchema`. Top-level must be `Type.Object({ ... })`.
253
- 4. **Write the register function** named `register<PascalCasePath>`. Use `registerPrivate` if auth is required, `registerPublic` otherwise.
254
- 5. **Map each failure mode** to a category (§7):
255
- - Caller's fault / actionable → `AppserverHandledError('SPECIFIC_CODE', 'message', { payload })`
256
- - "Shouldn't happen" / internal → plain `throw new Error(...)`
257
- 6. **Return a concrete output shape.** Prefer narrow, explicit types (not `any`, not `unknown`). For "success or expected failure", use a discriminated union with `ok: true | false`.
258
- 7. **Register in the bootstrap:** import and call `register<PascalCasePath>(server)`.
259
- 8. **Build & type-check.** Strict mode is not negotiable; fix every TS error, don't silence.
260
- 9. **Test locally** using the `@cripty2001/utils/appserver/client` client — don't hand-roll fetch + msgpack.
261
-
262
- ---
263
-
264
- ## 10. Conventions and gotchas
265
-
266
- - **Always import `Type`, `Static`, `TSchema`, `Value` from the appserver module**, not from `@sinclair/typebox` directly. The appserver re-exports them to keep everyone on the same TypeBox version; a direct import risks subtle type-identity mismatches.
267
- - **Don't do work at module top-level** beyond declaring schemas and constants. The file is imported once by the bootstrap; don't trigger side effects at import time (no DB connections, no timers, no fetches).
268
- - **Don't reach into `req`/`res`.** You don't have them. If you think you need headers, cookies, or custom status codes, you're fighting the framework — reshape the endpoint instead.
269
- - **Binary data** (PDFs, signatures, DER blobs, images) travels as `Uint8Array`, in and out. Don't base64-encode — msgpack handles binary natively.
270
- - **No `undefined` in outputs.** Use `null` or omit the key. `exactOptionalPropertyTypes` is on, so most of these get caught by the type checker.
271
- - **`.js` extensions on relative imports** are required even though source files are `.ts`.
272
- - **CORS origins** are a constructor arg to `Appserver`. Adjust in the bootstrap; don't add per-route CORS.
273
- - **Input is frozen.** Treat the `input` argument as immutable. If you need to transform, make a copy.
274
- - **Comments:** document _why_, not _what_. A well-named identifier beats any comment.
275
-
276
- ---
277
-
278
- ## 11. Anti-patterns — do not do these
279
-
280
- - A path segment with a hyphen, underscore, or camelCase: `user-certs`, `user_certs`, `userCerts`. Use a nested folder or rename.
281
- - Two endpoints registered from the same file.
282
- - Importing `Type` from `@sinclair/typebox` directly.
283
- - Returning the raw exception or `err.message` inside a response payload.
284
- - Catching every exception and returning `{ ok: false, message: e.message }` — you're turning bugs into handled errors and hiding them.
285
- - Adding a JSON endpoint, an Express route, or middleware by hand.
286
- - Parsing `req.body` yourself.
287
- - Declaring a schema for the _output_ — inference is deliberate.
288
- - Mutating `input` inside the handler.
289
- - Using an `AppserverHandledError` for something the caller genuinely can't act on (infrastructure failure, programmer bug).
290
- - Using a plain `Error` for something the caller must react to (the client will receive a useless generic message).
291
- - Putting business logic in the bootstrap file. That file is wiring only.
292
-
293
- ---
294
-
295
- ## 12. HTTP-level error map (for client-side debugging)
296
-
297
- | Status | Meaning | Body shape |
298
- | ------------------------------------ | ---------------------------------------------------- | -------------------------------------- |
299
- | `200` | Success | Handler return value (msgpack-encoded) |
300
- | `400` | Bad request format (not msgpack, unparseable body) | `{ error, code }` |
301
- | `401` | Authentication required (private endpoint, no token) | `{ error, code }` |
302
- | `403` (`code: PERMISSION_DENIED`) | `AppserverAuthError` | `{ error, code, payload }` |
303
- | `422` | Input failed schema validation | `{ errors, received }` |
304
- | `500` (`code: INTERNAL_SERVERERROR`) | Unhandled server exception | `{ error, code }` |
305
- | `500` (other `code`) | `AppserverHandledError` thrown by handler | `{ error, code, payload }` |
306
-
307
- On the client (`@cripty2001/utils/appserver/client`) these surface as:
308
-
309
- - `200` → handler return value
310
- - `401` / `403` → `ClientAuthError` (plus an internal token reset in `Client.exec`)
311
- - `422` → `ClientValidationError` with `.errors`
312
- - `400` / `500` → `ClientServerError` with `.code`, `.message`, `.payload`
313
- - anything else → `ClientError`
314
-
315
- ---
316
-
317
- ## 13. Complete worked example (cookbook)
318
-
319
- A self-contained walkthrough exercising every pattern you're likely to hit: public endpoint, private endpoint, discriminated-union output, handled error with payload, unhandled error, binary input/output, optional/nullable fields, arrays, nested objects, and the bootstrap that wires them together.
320
-
321
- Assume a fictional user type:
322
-
323
- ```ts
324
- type AppUser = {
325
- id: string;
326
- fullName: string;
327
- _passwordHash: string; // '_' prefix → stripped by built-in /auth/whoami
328
- };
329
- ```
330
-
331
- ### 13.1 Public endpoint — simple input/output
332
-
333
- `src/api/health/ping.ts`
334
-
335
- ```ts
336
- import type { Appserver } from "@cripty2001/utils/appserver/server";
337
- import { Type } from "@cripty2001/utils/appserver/server";
338
-
339
- // Even a "no real input" endpoint takes an object at the top level.
340
- // Prefer Type.Object({}) over omitting the schema.
341
- export const healthPingInputSchema = Type.Object({});
342
-
343
- export function registerHealthPing(server: Appserver<AppUser>): void {
344
- server.registerPublic(
345
- "health/ping",
346
- healthPingInputSchema,
347
- async (_input) => {
348
- // Output shape is inferred from this return — no schema needed.
349
- return {
350
- ok: true,
351
- ts: new Date().toISOString(), // Date → string at the boundary
352
- };
353
- },
354
- );
355
- }
356
- ```
357
-
358
- ### 13.2 Public endpoint — discriminated-union output
359
-
360
- `src/api/version/ios.ts`
361
-
362
- ```ts
363
- import type { Appserver } from "@cripty2001/utils/appserver/server";
364
- import { Type } from "@cripty2001/utils/appserver/server";
365
-
366
- // The business outcome is "supported" or "not supported with upgrade info".
367
- // That's not an error — it's normal, expected branching — so we model it in the return type.
368
- type SupportedVersionData =
369
- | { ok: true }
370
- | {
371
- ok: false;
372
- title: string;
373
- message: string;
374
- link: { title: string; url: string } | null;
375
- };
376
-
377
- const SUPPORTED_VERSIONS: Record<string, SupportedVersionData> = {
378
- "1.0.0": { ok: true },
379
- "0.9.0": {
380
- ok: false,
381
- title: "Update required",
382
- message: "Please update to 1.0.0.",
383
- link: { title: "App Store", url: "https://apps.apple.com/..." },
384
- },
385
- };
386
-
387
- export const versionIosInputSchema = Type.Object({
388
- version: Type.String(),
389
- });
390
-
391
- export function registerVersionIos(server: Appserver<AppUser>): void {
392
- server.registerPublic("version/ios", versionIosInputSchema, async (input) => {
393
- const data = SUPPORTED_VERSIONS[input.version];
394
- if (!data)
395
- // Unknown version is an expected negative outcome, not an error.
396
- // Client shows "please upgrade"; no throw.
397
- return {
398
- ok: false,
399
- title: "Version not supported",
400
- message: `Version ${input.version} is not supported.`,
401
- link: null,
402
- };
403
- return data;
404
- });
405
- }
406
- ```
407
-
408
- ### 13.3 Private endpoint — handled error, unhandled error, nested schema
409
-
410
- `src/api/user/cert/revoke.ts`
411
-
412
- ```ts
413
- import type { Appserver } from "@cripty2001/utils/appserver/server";
414
- import {
415
- Type,
416
- AppserverHandledError,
417
- } from "@cripty2001/utils/appserver/server";
418
-
419
- // Nested objects, optional fields, nullable fields, arrays — all via TypeBox.
420
- export const userCertRevokeInputSchema = Type.Object({
421
- certId: Type.String(),
422
- reason: Type.Optional(
423
- // optional = key may be absent
424
- Type.Union([
425
- Type.Literal("COMPROMISED"),
426
- Type.Literal("SUPERSEDED"),
427
- Type.Literal("OTHER"),
428
- ]),
429
- ),
430
- notes: Type.Union([Type.String(), Type.Null()]), // nullable = required key, value may be null
431
- witnesses: Type.Array(Type.String()), // array of strings, required
432
- });
433
-
434
- // Fake storage layer — illustrative only.
435
- declare function loadCert(id: string): Promise<{
436
- id: string;
437
- ownerId: string;
438
- revokedAt: string | null;
439
- } | null>;
440
- declare function persistRevocation(
441
- certId: string,
442
- reason: string,
443
- ): Promise<void>;
444
-
445
- export function registerUserCertRevoke(server: Appserver<AppUser>): void {
446
- server.registerPrivate(
447
- "user/cert/revoke",
448
- userCertRevokeInputSchema,
449
- async (input, user) => {
450
- // --- 1) Semantic validation: map to AppserverHandledError ---
451
- const cert = await loadCert(input.certId);
452
- if (cert === null)
453
- throw new AppserverHandledError(
454
- "CERT_NOT_FOUND",
455
- `No certificate with id ${input.certId}.`,
456
- { certId: input.certId },
457
- );
458
-
459
- if (cert.ownerId !== user.id)
460
- throw new AppserverHandledError(
461
- "CERT_NOT_OWNED",
462
- "You do not own this certificate.",
463
- );
464
-
465
- if (cert.revokedAt !== null)
466
- throw new AppserverHandledError(
467
- "CERT_ALREADY_REVOKED",
468
- "This certificate has already been revoked.",
469
- { revokedAt: cert.revokedAt },
470
- );
471
-
472
- // --- 2) Do the work. If this throws, it's an internal bug. ---
473
- await persistRevocation(cert.id, input.reason ?? "OTHER");
474
-
475
- // --- 3) Defensive re-check to illustrate an UNHANDLED error ---
476
- const after = await loadCert(cert.id);
477
- if (after === null || after.revokedAt === null)
478
- // The client can't do anything with this — it's our bug.
479
- // Generic 500 INTERNAL_SERVERERROR is correct.
480
- throw new Error(
481
- `Invariant: cert ${cert.id} was not persisted as revoked`,
482
- );
483
-
484
- // --- 4) Return an explicit shape. No undefined. ---
485
- return {
486
- certId: after.id,
487
- revokedAt: after.revokedAt,
488
- reason: input.reason ?? "OTHER",
489
- witnessCount: input.witnesses.length,
490
- };
491
- },
492
- );
493
- }
494
- ```
495
-
496
- ### 13.4 Binary in, binary out
497
-
498
- `src/api/utils/thumbnail.ts`
499
-
500
- ```ts
501
- import type { Appserver } from "@cripty2001/utils/appserver/server";
502
- import { Type } from "@cripty2001/utils/appserver/server";
503
-
504
- // Uint8Array is a first-class TypeBox type AND a first-class msgpack type.
505
- // No base64 anywhere.
506
- export const utilsThumbnailInputSchema = Type.Object({
507
- image: Type.Uint8Array(),
508
- maxWidth: Type.Integer({ minimum: 1, maximum: 4096 }),
509
- });
510
-
511
- declare function resizeImage(
512
- bytes: Uint8Array,
513
- maxWidth: number,
514
- ): Promise<Uint8Array>;
515
-
516
- export function registerUtilsThumbnail(server: Appserver<AppUser>): void {
517
- server.registerPublic(
518
- "utils/thumbnail",
519
- utilsThumbnailInputSchema,
520
- async (input) => {
521
- const resized = await resizeImage(input.image, input.maxWidth);
522
- return {
523
- mime: "image/webp",
524
- bytes: resized, // returned as Uint8Array — client receives Uint8Array
525
- };
526
- },
527
- );
528
- }
529
- ```
530
-
531
- ### 13.5 The bootstrap
532
-
533
- `src/index.ts`
534
-
535
- ```ts
536
- import { Appserver } from "@cripty2001/utils/appserver/server";
537
-
538
- // One import per endpoint, grouped by folder, alphabetical within group.
539
- import { registerHealthPing } from "./api/health/ping.js";
540
- import { registerUserCertRevoke } from "./api/user/cert/revoke.js";
541
- import { registerUtilsThumbnail } from "./api/utils/thumbnail.js";
542
- import { registerVersionIos } from "./api/version/ios.js";
543
-
544
- const PORT = 3000;
545
-
546
- // Resolves the bearer token to your user shape, or null if invalid/absent.
547
- // Keys starting with '_' are stripped by the built-in /auth/whoami endpoint.
548
- const parseUser = async (_token: string): Promise<AppUser | null> => {
549
- // TODO: look up the token, return the user or null
550
- return null;
551
- };
552
-
553
- // Prometheus gauges exposed at GET /metrics.
554
- const getMetrics = () => ({
555
- active_sessions: 0,
556
- queued_jobs: 0,
557
- });
558
-
559
- // CORS allowlist for browser clients.
560
- const origins = ["http://localhost:5173", "https://app.example.com"];
561
-
562
- const server = new Appserver<AppUser>(PORT, parseUser, getMetrics, origins);
563
-
564
- registerHealthPing(server);
565
- registerUserCertRevoke(server);
566
- registerUtilsThumbnail(server);
567
- registerVersionIos(server);
568
-
569
- type AppUser = {
570
- id: string;
571
- fullName: string;
572
- _passwordHash: string;
573
- };
574
- ```
575
-
576
- ### 13.6 What the client sees
577
-
578
- Using `@cripty2001/utils/appserver/client`:
579
-
580
- ```ts
581
- import {
582
- Client,
583
- ClientServerError,
584
- ClientValidationError,
585
- } from "@cripty2001/utils/appserver/client";
586
-
587
- const client = Client.create("https://api.example.com");
588
- await client.login(bearerToken);
589
-
590
- try {
591
- const res = await client.exec("user/cert/revoke", {
592
- certId: "abc",
593
- notes: null,
594
- witnesses: [],
595
- // reason omitted — it's optional
596
- });
597
- // res is the handler's return value, typed as { certId, revokedAt, reason, witnessCount }
598
- } catch (e) {
599
- if (e instanceof ClientValidationError) {
600
- // 422: schema mismatch (e.errors is the TypeBox error list)
601
- } else if (e instanceof ClientServerError) {
602
- switch (e.code) {
603
- case "CERT_NOT_FOUND":
604
- /* show "not found" */ break;
605
- case "CERT_NOT_OWNED":
606
- /* show "access denied" */ break;
607
- case "CERT_ALREADY_REVOKED":
608
- /* show "already revoked at " + e.payload.revokedAt */ break;
609
- case "INTERNAL_SERVERERROR":
610
- /* show generic failure, file a bug */ break;
611
- }
612
- }
613
- }
614
- ```
615
-
616
- This is the full surface area. Everything else is a variation on these patterns.
617
-
618
- ---
619
-
620
- ## 14. When in doubt
621
-
622
- If you're about to do something the framework doesn't support, **change the endpoint shape**, don't bypass the framework. Bypasses metastasize: one handcrafted route becomes two, then a whole parallel code path, and suddenly half the API doesn't have validation or typed errors. The constraint is the feature.