@luckystack/api 0.6.7 → 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.
- package/CLAUDE.md +113 -113
- package/LICENSE +21 -21
- package/README.md +106 -106
- package/dist/index.js.map +1 -1
- package/docs/api-request-lifecycle.md +280 -280
- package/docs/auth-flow.md +232 -232
- package/docs/error-handling.md +246 -246
- package/docs/generated-types.md +264 -264
- package/docs/rate-limiting.md +188 -188
- package/docs/routing-conventions.md +164 -164
- package/package.json +3 -3
package/CLAUDE.md
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
|
-
# @luckystack/api
|
|
2
|
-
|
|
3
|
-
> AI summary + function INDEX. For deep specs see `docs/` next to this file.
|
|
4
|
-
|
|
5
|
-
## What this package does
|
|
6
|
-
|
|
7
|
-
`@luckystack/api` is the transport-agnostic API request layer for LuckyStack. It exposes two handler entry points — one for socket.io (`handleApiRequest`) and one for raw HTTP (`handleHttpApiRequest`) — that share an identical pipeline: route resolution against the generated runtime map, session lookup, auth check, rate limiting, input validation (Zod from generated types), hook dispatch, handler execution with `tryCatch`-wrapped error capture, and response normalization (localized error envelopes, default HTTP status inference, streaming via SSE on the HTTP side).
|
|
8
|
-
|
|
9
|
-
> ✅ **Runtime input validation now runs in production** (api F1 / core CORE-01). `validateInputByType` (in `@luckystack/core`) used to short-circuit to `{ status: 'success' }` in production; it now runs the structural validator against the build-time-resolved generated type text. The expensive dev-only TypeScript-compiler RESOLVER is still skipped in prod (it can't run there), but the pre-resolved type is validated directly — so a route typed `data: { userId: string }` rejects a malformed prod payload exactly as in dev. Gated by `validation.runtimeMode` (default `'enforce'`); set `validation.runtimeMode: 'off'` to restore the old prod no-op. If the generated type still carries an `__RUNTIME_UNRESOLVED__::` marker (artifacts not regenerated before shipping), validation surfaces it as a LOUD error (regenerate artifacts) rather than silently passing. `validation: 'relaxed'` / `{ input: 'skip' }` still skips validation per-route (now honored on BOTH transports).
|
|
10
|
-
|
|
11
|
-
Routes are discovered by `@luckystack/devkit` from `src/{page}/_api/{name}_v{N}.ts` files and surfaced at runtime via `getRuntimeApiMaps()` (from `@luckystack/core`). Consumers normally do not call these handlers directly — `@luckystack/server` wires them into the socket.io server and the HTTP request listener.
|
|
12
|
-
|
|
13
|
-
## When to USE this package
|
|
14
|
-
|
|
15
|
-
- You are building a custom transport (queue worker, gRPC bridge, Lambda adapter) and need to invoke the LuckyStack API pipeline for a registered route.
|
|
16
|
-
- You are extending the framework with a new hook in the API request lifecycle (`preApiValidate`, `preApiExecute`, `preApiRespond`, `transformApiResponse`, `postApiRespond`, etc.).
|
|
17
|
-
- You need to understand or document the exact ordering of auth -> rate limit -> validate -> execute -> respond.
|
|
18
|
-
|
|
19
|
-
## When to NOT suggest this (yet)
|
|
20
|
-
|
|
21
|
-
- Writing a new app-level API endpoint: create a file under `src/{page}/_api/{name}_v{N}.ts` instead. The handler in this package will pick it up automatically.
|
|
22
|
-
- Client-side request invocation: use `apiRequest` from `@luckystack/core` (client subpath), not anything from this package.
|
|
23
|
-
- Plain Express/Koa middleware: this package assumes the LuckyStack runtime maps + hook system. Do not graft it onto an unrelated server framework.
|
|
24
|
-
- Changing the request envelope shape: that contract lives in `@luckystack/core` (response normalization, error codes, locale extraction). Edit there, not here.
|
|
25
|
-
|
|
26
|
-
## Function Index
|
|
27
|
-
|
|
28
|
-
| Function / Export | One-liner | Deep doc |
|
|
29
|
-
| --- | --- | --- |
|
|
30
|
-
| `handleApiRequest({ msg, socket, token })` (default export) | Socket.io transport adapter. Validates `apiMessage`, resolves the route via `getRuntimeApiMaps`, runs the full pipeline, emits the response on the `buildApiResponseEventName(responseIndex)` channel. Also handles the built-in `system/logout` shortcut. | -> docs/api-request-lifecycle.md |
|
|
31
|
-
| `handleHttpApiRequest({ name, data, token, requesterIp?, xLanguageHeader?, acceptLanguageHeader?, method?, stream? })` | HTTP transport adapter for `/api/*` POST/GET/PUT/DELETE. Same pipeline as the socket variant plus HTTP-method validation (`inferHttpMethod` or route-declared `httpMethod`). Returns `ApiNetworkResponse` and optionally pipes stream events via the provided `stream` callback (SSE on the server side). | -> docs/api-request-lifecycle.md |
|
|
32
|
-
| Type: `ApiHttpStreamEvent` | Shape of stream payloads emitted by streaming HTTP endpoints (alias of `Record<string, unknown>` extended with route-specific keys). Consumers writing SSE bridges import this. | -> docs/api-request-lifecycle.md |
|
|
33
|
-
|
|
34
|
-
### Internal pipeline helpers (not exported, listed for AI context)
|
|
35
|
-
|
|
36
|
-
These live inside `handleApiRequest.ts` / `handleHttpApiRequest.ts` and are referenced in deep docs:
|
|
37
|
-
|
|
38
|
-
| Helper | Role |
|
|
39
|
-
| --- | --- |
|
|
40
|
-
| `validateApiMessage` | Rejects malformed socket envelopes (missing `responseIndex`, non-object/array data). |
|
|
41
|
-
| `checkApiAuth` | `auth.login` gate + null-safe `validateRequest` for additional auth predicates (an anonymous caller on a public route with `additional` predicates is cleanly forbidden, not a throw — api F4). Dispatches the `apiAuthRejected` hook (`void`) on every fail path (api F9). |
|
|
42
|
-
| `applyApiRateLimits` | Per-route bucket (`apiEntry.rateLimit` or `defaultApiLimit`, keyed on the validated `user.id` or the resolved IP — never the raw token; basis overridable via `rateLimiting.identity`) + global per-IP bucket (`defaultIpLimit`, skippable for loopback in dev via `rateLimiting.skipLoopbackInDev`). Fires `rateLimitExceeded` hook on reject. |
|
|
43
|
-
| `applyGlobalIpRateLimit` | Shared global per-IP `ip:<ip>:api:all` bucket helper; also applied to the built-in `system/logout` shortcut so it can't be spammed uncapped. |
|
|
44
|
-
| `executeApiHandler` | Wraps `apiEntry.main(...)` in `tryCatch`. Span open/close + identity propagation moved to `preApiExecute`/`postApiExecute` hook subscribers in `@luckystack/error-tracking` (no direct `startSpan` in this handler). |
|
|
45
|
-
| `normalizeApiResponse` (in `_shared/responseEnvelope.ts`) | Normalizes success/error result into the wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse`. |
|
|
46
|
-
| `emitApiResult` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
|
|
47
|
-
| `warnIfInputTypeMissing` (in `_shared/inputTypeWarning.ts`) | Dev-only one-shot warning when a strict-mode route has no generated `inputType` (Zod validation effectively disabled). |
|
|
48
|
-
|
|
49
|
-
## Pipeline order (authoritative)
|
|
50
|
-
|
|
51
|
-
Both transports execute the same sequence. Deviating is a breaking change.
|
|
52
|
-
|
|
53
|
-
1. Parse message / payload shape (`validateApiMessage` / inline checks; rejects arrays).
|
|
54
|
-
2. `readSession(token)` -> resolve `user` (may be null on public routes). Identity propagation to error trackers happens later via the `preApiExecute` hook subscriber, NOT a direct `setSentryUser` here.
|
|
55
|
-
3. `parseTransportRouteName` (rejects invalid `api/<page>/<name>/v<N>` shapes).
|
|
56
|
-
4. Built-in `system/logout` shortcut (socket transport only; now subject to the global per-IP bucket first).
|
|
57
|
-
5. `getRuntimeApiMaps()` -> reject with `api.notFound` if the route is unknown.
|
|
58
|
-
6. `checkApiAuth` (login + `validateRequest`).
|
|
59
|
-
7. `applyApiRateLimits` (per-route -> global IP).
|
|
60
|
-
8. **HTTP-only**: method check (`inferHttpMethod` vs `method`). The `httpMethod` route export and the `inferHttpMethod` heuristic are **HTTP-only**. The socket transport does NOT enforce the declared HTTP method — all routes are callable over WebSocket regardless of method declaration (socket.io has no method concept). Do not rely on `httpMethod` for authorization; use `auth` predicates or `preSocketMessage` for socket-specific gates.
|
|
61
|
-
9. `preApiValidate` hook -> `validateInputByType` (skippable on BOTH transports via `validation: 'relaxed'` or `{ input: 'skip' }`) -> `postApiValidate` hook. `validateInputByType` now runs the structural validator in production too (gated by `validation.runtimeMode`, default `'enforce'`); set `validation.runtimeMode: 'off'` to restore the old prod no-op. See the caveat at the top.
|
|
62
|
-
10. `preApiExecute` hook (stop-signal aborts with localized error).
|
|
63
|
-
11. `executeApiHandler` (wrapped in `tryCatch`; span/identity via the `pre/postApiExecute` hook subscribers, not a direct span here).
|
|
64
|
-
12. `postApiExecute` hook (gets `result`, `error`, `durationMs`).
|
|
65
|
-
13. `normalizeApiResponse` -> `preApiRespond` -> `transformApiResponse` -> emit -> `postApiRespond`.
|
|
66
|
-
|
|
67
|
-
## Config keys
|
|
68
|
-
|
|
69
|
-
Read at request time via `getProjectConfig()` (from `@luckystack/core`). All live under `projectConfig`:
|
|
70
|
-
|
|
71
|
-
- `logging.devLogs` — gate for verbose per-request `getLogger().debug/warn/error` output.
|
|
72
|
-
- `logging.stream` — gate for `emitStream` payload logging.
|
|
73
|
-
- `dev.warnOnMissingInputType` — toggles the one-shot `warnIfInputTypeMissing` developer hint.
|
|
74
|
-
- `rateLimiting.defaultApiLimit` — fallback per-route limit when an API file does not export `rateLimit`. `false` disables.
|
|
75
|
-
- `rateLimiting.defaultIpLimit` — global per-IP bucket across all API routes. `false` disables.
|
|
76
|
-
- `rateLimiting.windowMs` — window for both buckets.
|
|
77
|
-
- `rateLimiting.skipLoopbackInDev` — when `true` (default `false`), skips ONLY the global per-IP ABUSE bucket for loopback IPs in non-production (per-route bucket still applies). Lets a dev / the test runner hammer localhost without tripping the cross-route cap. Gated on this explicit flag (not inferred from `NODE_ENV` + a spoofable address) and on `isLoopbackIp(resolvedIp)`.
|
|
78
|
-
- `rateLimiting.identity` — optional `(params) => { scope: 'user'|'ip'|'custom'; id } | null` callback that overrides the per-route bucket BASIS (api-key / tenant). Returning `null` falls back to the default (validated `user.id`, else resolved IP — NEVER the raw token).
|
|
79
|
-
|
|
80
|
-
No project env vars or secrets are read directly by this package — only the ambient `process.env.NODE_ENV` (for the dev-only loopback rate-limit skip in `handleHttpApiRequest`); all other toggles travel through `@luckystack/core`'s config layer.
|
|
81
|
-
|
|
82
|
-
## Peer dependencies
|
|
83
|
-
|
|
84
|
-
- **Required (runtime deps)**: `@luckystack/core`, `@luckystack/error-tracking`. **`@luckystack/login` is NOT a dependency** (0.2.0 decoupling) — sessions resolve through core's `readSession` / session-provider registry, with login as the default *provider* (optional package), not a hard runtime dep.
|
|
85
|
-
- **Required (peer)**:
|
|
86
|
-
- `@prisma/client@^6.19.0` (transitively required via `@luckystack/core` session storage).
|
|
87
|
-
- `socket.io@^4.8.0` (only consumed for the `Socket` type — the HTTP entry point does not need a live socket.io instance at call time).
|
|
88
|
-
- **No optional peers.** Streaming uses an injected callback, not an external SSE library.
|
|
89
|
-
|
|
90
|
-
## Hooks consumed
|
|
91
|
-
|
|
92
|
-
This package only dispatches hooks (it does not register any). Consumers register handlers via `dispatchHook` / the hook registry in `@luckystack/core`. Hooks emitted, in order:
|
|
93
|
-
|
|
94
|
-
- `preSocketMessage` — (socket transport only) at the very top, before session lookup / route resolution / auth. Stop signal rejects the message. Transport-level seam mirroring `preHttpRequest`; `channel: 'api'`.
|
|
95
|
-
- `preApiValidate` — before Zod validation runs.
|
|
96
|
-
- `postApiValidate` — after validation, with `validation: { status, message? }`.
|
|
97
|
-
- `preApiExecute` — may return a stop signal (`errorCode`, `httpStatus?`) to short-circuit.
|
|
98
|
-
- `postApiExecute` — observation point with `result`, `error`, `durationMs`.
|
|
99
|
-
- `preApiRespond` — last chance to mutate or swap `payload.response`; stop signal converts to localized error.
|
|
100
|
-
- `transformApiResponse` — purely mutative; runs between `preApiRespond` and socket/HTTP emit.
|
|
101
|
-
- `postApiRespond` — observation-only; response is already on the wire.
|
|
102
|
-
- `rateLimitExceeded` — fires when a bucket rejects (scope: `route` | `user` | `ip`).
|
|
103
|
-
- `apiAuthRejected` — fires on every auth-fail path (login required, `additional[]` predicate failed, or a misconfigured predicate). Observational (`void`-dispatched, stop signal ignored); payload `{ routeName, reason, userId, ip?, transport?, failedKey? }`. Lets abuse-detection / audit subscribers see failed authorizations (e.g. credential-stuffing) without forking the handler.
|
|
104
|
-
|
|
105
|
-
Deep behavior for each hook lives in `@luckystack/core`'s hook registry doc.
|
|
106
|
-
|
|
107
|
-
## Related
|
|
108
|
-
|
|
109
|
-
- Architecture deep dives: `/docs/ARCHITECTURE_API.md`, `/docs/ARCHITECTURE_ROUTING.md`.
|
|
110
|
-
- Consumer quickstart: `./README.md`.
|
|
111
|
-
- Generated-type contract: `/docs/ARCHITECTURE_ROUTING.md` (see "Generated maps") and root `CLAUDE.md` rule 16 (no `unknown`/`any` casts).
|
|
112
|
-
- Session source: `@luckystack/core`'s session-provider registry — `readSession(token)` / `performLogout(...)` (see `docs/ARCHITECTURE_SESSION.md`). `@luckystack/login` is the DEFAULT provider when installed, not a hard dep. Shared types `BaseSessionLayout` / `AuthProps` come from `@luckystack/core`.
|
|
113
|
-
- Runtime map source: `@luckystack/core` `getRuntimeApiMaps()` + `@luckystack/devkit` emitter.
|
|
1
|
+
# @luckystack/api
|
|
2
|
+
|
|
3
|
+
> AI summary + function INDEX. For deep specs see `docs/` next to this file.
|
|
4
|
+
|
|
5
|
+
## What this package does
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` is the transport-agnostic API request layer for LuckyStack. It exposes two handler entry points — one for socket.io (`handleApiRequest`) and one for raw HTTP (`handleHttpApiRequest`) — that share an identical pipeline: route resolution against the generated runtime map, session lookup, auth check, rate limiting, input validation (Zod from generated types), hook dispatch, handler execution with `tryCatch`-wrapped error capture, and response normalization (localized error envelopes, default HTTP status inference, streaming via SSE on the HTTP side).
|
|
8
|
+
|
|
9
|
+
> ✅ **Runtime input validation now runs in production** (api F1 / core CORE-01). `validateInputByType` (in `@luckystack/core`) used to short-circuit to `{ status: 'success' }` in production; it now runs the structural validator against the build-time-resolved generated type text. The expensive dev-only TypeScript-compiler RESOLVER is still skipped in prod (it can't run there), but the pre-resolved type is validated directly — so a route typed `data: { userId: string }` rejects a malformed prod payload exactly as in dev. Gated by `validation.runtimeMode` (default `'enforce'`); set `validation.runtimeMode: 'off'` to restore the old prod no-op. If the generated type still carries an `__RUNTIME_UNRESOLVED__::` marker (artifacts not regenerated before shipping), validation surfaces it as a LOUD error (regenerate artifacts) rather than silently passing. `validation: 'relaxed'` / `{ input: 'skip' }` still skips validation per-route (now honored on BOTH transports).
|
|
10
|
+
|
|
11
|
+
Routes are discovered by `@luckystack/devkit` from `src/{page}/_api/{name}_v{N}.ts` files and surfaced at runtime via `getRuntimeApiMaps()` (from `@luckystack/core`). Consumers normally do not call these handlers directly — `@luckystack/server` wires them into the socket.io server and the HTTP request listener.
|
|
12
|
+
|
|
13
|
+
## When to USE this package
|
|
14
|
+
|
|
15
|
+
- You are building a custom transport (queue worker, gRPC bridge, Lambda adapter) and need to invoke the LuckyStack API pipeline for a registered route.
|
|
16
|
+
- You are extending the framework with a new hook in the API request lifecycle (`preApiValidate`, `preApiExecute`, `preApiRespond`, `transformApiResponse`, `postApiRespond`, etc.).
|
|
17
|
+
- You need to understand or document the exact ordering of auth -> rate limit -> validate -> execute -> respond.
|
|
18
|
+
|
|
19
|
+
## When to NOT suggest this (yet)
|
|
20
|
+
|
|
21
|
+
- Writing a new app-level API endpoint: create a file under `src/{page}/_api/{name}_v{N}.ts` instead. The handler in this package will pick it up automatically.
|
|
22
|
+
- Client-side request invocation: use `apiRequest` from `@luckystack/core` (client subpath), not anything from this package.
|
|
23
|
+
- Plain Express/Koa middleware: this package assumes the LuckyStack runtime maps + hook system. Do not graft it onto an unrelated server framework.
|
|
24
|
+
- Changing the request envelope shape: that contract lives in `@luckystack/core` (response normalization, error codes, locale extraction). Edit there, not here.
|
|
25
|
+
|
|
26
|
+
## Function Index
|
|
27
|
+
|
|
28
|
+
| Function / Export | One-liner | Deep doc |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `handleApiRequest({ msg, socket, token })` (default export) | Socket.io transport adapter. Validates `apiMessage`, resolves the route via `getRuntimeApiMaps`, runs the full pipeline, emits the response on the `buildApiResponseEventName(responseIndex)` channel. Also handles the built-in `system/logout` shortcut. | -> docs/api-request-lifecycle.md |
|
|
31
|
+
| `handleHttpApiRequest({ name, data, token, requesterIp?, xLanguageHeader?, acceptLanguageHeader?, method?, stream? })` | HTTP transport adapter for `/api/*` POST/GET/PUT/DELETE. Same pipeline as the socket variant plus HTTP-method validation (`inferHttpMethod` or route-declared `httpMethod`). Returns `ApiNetworkResponse` and optionally pipes stream events via the provided `stream` callback (SSE on the server side). | -> docs/api-request-lifecycle.md |
|
|
32
|
+
| Type: `ApiHttpStreamEvent` | Shape of stream payloads emitted by streaming HTTP endpoints (alias of `Record<string, unknown>` extended with route-specific keys). Consumers writing SSE bridges import this. | -> docs/api-request-lifecycle.md |
|
|
33
|
+
|
|
34
|
+
### Internal pipeline helpers (not exported, listed for AI context)
|
|
35
|
+
|
|
36
|
+
These live inside `handleApiRequest.ts` / `handleHttpApiRequest.ts` and are referenced in deep docs:
|
|
37
|
+
|
|
38
|
+
| Helper | Role |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `validateApiMessage` | Rejects malformed socket envelopes (missing `responseIndex`, non-object/array data). |
|
|
41
|
+
| `checkApiAuth` | `auth.login` gate + null-safe `validateRequest` for additional auth predicates (an anonymous caller on a public route with `additional` predicates is cleanly forbidden, not a throw — api F4). Dispatches the `apiAuthRejected` hook (`void`) on every fail path (api F9). |
|
|
42
|
+
| `applyApiRateLimits` | Per-route bucket (`apiEntry.rateLimit` or `defaultApiLimit`, keyed on the validated `user.id` or the resolved IP — never the raw token; basis overridable via `rateLimiting.identity`) + global per-IP bucket (`defaultIpLimit`, skippable for loopback in dev via `rateLimiting.skipLoopbackInDev`). Fires `rateLimitExceeded` hook on reject. |
|
|
43
|
+
| `applyGlobalIpRateLimit` | Shared global per-IP `ip:<ip>:api:all` bucket helper; also applied to the built-in `system/logout` shortcut so it can't be spammed uncapped. |
|
|
44
|
+
| `executeApiHandler` | Wraps `apiEntry.main(...)` in `tryCatch`. Span open/close + identity propagation moved to `preApiExecute`/`postApiExecute` hook subscribers in `@luckystack/error-tracking` (no direct `startSpan` in this handler). |
|
|
45
|
+
| `normalizeApiResponse` (in `_shared/responseEnvelope.ts`) | Normalizes success/error result into the wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse`. |
|
|
46
|
+
| `emitApiResult` | Runs `preApiRespond` / `transformApiResponse` / `postApiRespond` hooks and emits on the socket. |
|
|
47
|
+
| `warnIfInputTypeMissing` (in `_shared/inputTypeWarning.ts`) | Dev-only one-shot warning when a strict-mode route has no generated `inputType` (Zod validation effectively disabled). |
|
|
48
|
+
|
|
49
|
+
## Pipeline order (authoritative)
|
|
50
|
+
|
|
51
|
+
Both transports execute the same sequence. Deviating is a breaking change.
|
|
52
|
+
|
|
53
|
+
1. Parse message / payload shape (`validateApiMessage` / inline checks; rejects arrays).
|
|
54
|
+
2. `readSession(token)` -> resolve `user` (may be null on public routes). Identity propagation to error trackers happens later via the `preApiExecute` hook subscriber, NOT a direct `setSentryUser` here.
|
|
55
|
+
3. `parseTransportRouteName` (rejects invalid `api/<page>/<name>/v<N>` shapes).
|
|
56
|
+
4. Built-in `system/logout` shortcut (socket transport only; now subject to the global per-IP bucket first).
|
|
57
|
+
5. `getRuntimeApiMaps()` -> reject with `api.notFound` if the route is unknown.
|
|
58
|
+
6. `checkApiAuth` (login + `validateRequest`).
|
|
59
|
+
7. `applyApiRateLimits` (per-route -> global IP).
|
|
60
|
+
8. **HTTP-only**: method check (`inferHttpMethod` vs `method`). The `httpMethod` route export and the `inferHttpMethod` heuristic are **HTTP-only**. The socket transport does NOT enforce the declared HTTP method — all routes are callable over WebSocket regardless of method declaration (socket.io has no method concept). Do not rely on `httpMethod` for authorization; use `auth` predicates or `preSocketMessage` for socket-specific gates.
|
|
61
|
+
9. `preApiValidate` hook -> `validateInputByType` (skippable on BOTH transports via `validation: 'relaxed'` or `{ input: 'skip' }`) -> `postApiValidate` hook. `validateInputByType` now runs the structural validator in production too (gated by `validation.runtimeMode`, default `'enforce'`); set `validation.runtimeMode: 'off'` to restore the old prod no-op. See the caveat at the top.
|
|
62
|
+
10. `preApiExecute` hook (stop-signal aborts with localized error).
|
|
63
|
+
11. `executeApiHandler` (wrapped in `tryCatch`; span/identity via the `pre/postApiExecute` hook subscribers, not a direct span here).
|
|
64
|
+
12. `postApiExecute` hook (gets `result`, `error`, `durationMs`).
|
|
65
|
+
13. `normalizeApiResponse` -> `preApiRespond` -> `transformApiResponse` -> emit -> `postApiRespond`.
|
|
66
|
+
|
|
67
|
+
## Config keys
|
|
68
|
+
|
|
69
|
+
Read at request time via `getProjectConfig()` (from `@luckystack/core`). All live under `projectConfig`:
|
|
70
|
+
|
|
71
|
+
- `logging.devLogs` — gate for verbose per-request `getLogger().debug/warn/error` output.
|
|
72
|
+
- `logging.stream` — gate for `emitStream` payload logging.
|
|
73
|
+
- `dev.warnOnMissingInputType` — toggles the one-shot `warnIfInputTypeMissing` developer hint.
|
|
74
|
+
- `rateLimiting.defaultApiLimit` — fallback per-route limit when an API file does not export `rateLimit`. `false` disables.
|
|
75
|
+
- `rateLimiting.defaultIpLimit` — global per-IP bucket across all API routes. `false` disables.
|
|
76
|
+
- `rateLimiting.windowMs` — window for both buckets.
|
|
77
|
+
- `rateLimiting.skipLoopbackInDev` — when `true` (default `false`), skips ONLY the global per-IP ABUSE bucket for loopback IPs in non-production (per-route bucket still applies). Lets a dev / the test runner hammer localhost without tripping the cross-route cap. Gated on this explicit flag (not inferred from `NODE_ENV` + a spoofable address) and on `isLoopbackIp(resolvedIp)`.
|
|
78
|
+
- `rateLimiting.identity` — optional `(params) => { scope: 'user'|'ip'|'custom'; id } | null` callback that overrides the per-route bucket BASIS (api-key / tenant). Returning `null` falls back to the default (validated `user.id`, else resolved IP — NEVER the raw token).
|
|
79
|
+
|
|
80
|
+
No project env vars or secrets are read directly by this package — only the ambient `process.env.NODE_ENV` (for the dev-only loopback rate-limit skip in `handleHttpApiRequest`); all other toggles travel through `@luckystack/core`'s config layer.
|
|
81
|
+
|
|
82
|
+
## Peer dependencies
|
|
83
|
+
|
|
84
|
+
- **Required (runtime deps)**: `@luckystack/core`, `@luckystack/error-tracking`. **`@luckystack/login` is NOT a dependency** (0.2.0 decoupling) — sessions resolve through core's `readSession` / session-provider registry, with login as the default *provider* (optional package), not a hard runtime dep.
|
|
85
|
+
- **Required (peer)**:
|
|
86
|
+
- `@prisma/client@^6.19.0` (transitively required via `@luckystack/core` session storage).
|
|
87
|
+
- `socket.io@^4.8.0` (only consumed for the `Socket` type — the HTTP entry point does not need a live socket.io instance at call time).
|
|
88
|
+
- **No optional peers.** Streaming uses an injected callback, not an external SSE library.
|
|
89
|
+
|
|
90
|
+
## Hooks consumed
|
|
91
|
+
|
|
92
|
+
This package only dispatches hooks (it does not register any). Consumers register handlers via `dispatchHook` / the hook registry in `@luckystack/core`. Hooks emitted, in order:
|
|
93
|
+
|
|
94
|
+
- `preSocketMessage` — (socket transport only) at the very top, before session lookup / route resolution / auth. Stop signal rejects the message. Transport-level seam mirroring `preHttpRequest`; `channel: 'api'`.
|
|
95
|
+
- `preApiValidate` — before Zod validation runs.
|
|
96
|
+
- `postApiValidate` — after validation, with `validation: { status, message? }`.
|
|
97
|
+
- `preApiExecute` — may return a stop signal (`errorCode`, `httpStatus?`) to short-circuit.
|
|
98
|
+
- `postApiExecute` — observation point with `result`, `error`, `durationMs`.
|
|
99
|
+
- `preApiRespond` — last chance to mutate or swap `payload.response`; stop signal converts to localized error.
|
|
100
|
+
- `transformApiResponse` — purely mutative; runs between `preApiRespond` and socket/HTTP emit.
|
|
101
|
+
- `postApiRespond` — observation-only; response is already on the wire.
|
|
102
|
+
- `rateLimitExceeded` — fires when a bucket rejects (scope: `route` | `user` | `ip`).
|
|
103
|
+
- `apiAuthRejected` — fires on every auth-fail path (login required, `additional[]` predicate failed, or a misconfigured predicate). Observational (`void`-dispatched, stop signal ignored); payload `{ routeName, reason, userId, ip?, transport?, failedKey? }`. Lets abuse-detection / audit subscribers see failed authorizations (e.g. credential-stuffing) without forking the handler.
|
|
104
|
+
|
|
105
|
+
Deep behavior for each hook lives in `@luckystack/core`'s hook registry doc.
|
|
106
|
+
|
|
107
|
+
## Related
|
|
108
|
+
|
|
109
|
+
- Architecture deep dives: `/docs/ARCHITECTURE_API.md`, `/docs/ARCHITECTURE_ROUTING.md`.
|
|
110
|
+
- Consumer quickstart: `./README.md`.
|
|
111
|
+
- Generated-type contract: `/docs/ARCHITECTURE_ROUTING.md` (see "Generated maps") and root `CLAUDE.md` rule 16 (no `unknown`/`any` casts).
|
|
112
|
+
- Session source: `@luckystack/core`'s session-provider registry — `readSession(token)` / `performLogout(...)` (see `docs/ARCHITECTURE_SESSION.md`). `@luckystack/login` is the DEFAULT provider when installed, not a hard dep. Shared types `BaseSessionLayout` / `AuthProps` come from `@luckystack/core`.
|
|
113
|
+
- Runtime map source: `@luckystack/core` `getRuntimeApiMaps()` + `@luckystack/devkit` emitter.
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Mathijs van Melick
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mathijs van Melick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
# @luckystack/api
|
|
2
|
-
|
|
3
|
-
> Type-safe API request handlers for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2). WebSocket-first via socket.io with HTTP fallback. File-based routing, generated route map, integrated rate limiting, validation, hooks, and error-tracking tracing.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @luckystack/api @luckystack/core @luckystack/error-tracking socket.io
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Quickstart
|
|
12
|
-
|
|
13
|
-
API endpoints live in `src/{page}/_api/{name}_v{N}.ts` and are picked up by the file-based router. Each file exports `main`, plus optional metadata (`auth`, `method`, `rateLimit`).
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
// src/settings/_api/updateUser_v1.ts
|
|
17
|
-
import type { AuthProps } from '@luckystack/core';
|
|
18
|
-
import type { ApiResponse } from '@luckystack/core';
|
|
19
|
-
|
|
20
|
-
export const rateLimit: number | false = 20;
|
|
21
|
-
export const method = 'POST' as const;
|
|
22
|
-
export const auth: AuthProps = { login: true };
|
|
23
|
-
|
|
24
|
-
export interface ApiParams {
|
|
25
|
-
data: { name: string };
|
|
26
|
-
user: SessionLayout;
|
|
27
|
-
functions: Functions;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const main = async ({ data, user }: ApiParams): Promise<ApiResponse> => {
|
|
31
|
-
await prisma.user.update({ where: { id: user.id }, data: { name: data.name } });
|
|
32
|
-
return { status: 'success', result: { ok: true } };
|
|
33
|
-
};
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
The package exposes the two transport adapters that `@luckystack/server` wires into Socket.io and HTTP:
|
|
37
|
-
|
|
38
|
-
```ts
|
|
39
|
-
import handleApiRequest from '@luckystack/api';
|
|
40
|
-
import { handleHttpApiRequest } from '@luckystack/api';
|
|
41
|
-
|
|
42
|
-
io.on('connection', socket => {
|
|
43
|
-
socket.on('apiRequest', msg => handleApiRequest({ msg, socket, token }));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
httpServer.on('request', async (req, res) => {
|
|
47
|
-
if (req.url?.startsWith('/api/')) {
|
|
48
|
-
const result = await handleHttpApiRequest({ name: body.name, data: body.data, token });
|
|
49
|
-
res.end(JSON.stringify(result));
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
You typically don't call these yourself — `createLuckyStackServer` does. Use this package directly only when building a custom transport.
|
|
55
|
-
|
|
56
|
-
## How it integrates
|
|
57
|
-
|
|
58
|
-
1. **Authenticates** via `readSession` from `@luckystack/core` (the session-provider registry; `@luckystack/login` is the optional default provider, not a hard dependency) when `auth.login === true`. Auth runs first so unauthenticated probes can't enumerate routes or learn input shape from the validation message.
|
|
59
|
-
2. **Rate-limits** with two buckets via `checkRateLimit` from `@luckystack/core`: a per-route bucket keyed on the validated `user.id` (anonymous callers fall back to the resolved IP — never the raw session token, so re-login can't reset the limit; the basis is overridable via `rateLimiting.identity`) plus a global per-IP abuse bucket.
|
|
60
|
-
3. **Validates** the inbound payload against the Zod schema generated from your `ApiParams['data']` interface (via `@luckystack/devkit`).
|
|
61
|
-
4. **Dispatches** the `preApiExecute` hook (may abort with a stop signal).
|
|
62
|
-
5. **Calls** your `main(...)` and captures errors via `tryCatch` (auto-forwarded to Sentry).
|
|
63
|
-
6. **Dispatches** the `postApiExecute` hook with the result + duration.
|
|
64
|
-
7. **Returns** the response via socket `ack` or HTTP body / SSE stream.
|
|
65
|
-
|
|
66
|
-
## Generated types
|
|
67
|
-
|
|
68
|
-
`apiRequest` (in `@luckystack/core/client`) is fully typed against the route map emitted by `@luckystack/devkit` from your `_api/*` files (default location: `src/_sockets/apiTypes.generated.ts`). Use route-name + version literals so inference works:
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
const result = await apiRequest({
|
|
72
|
-
name: 'settings/updateUser',
|
|
73
|
-
version: 'v1',
|
|
74
|
-
data: { name: 'Alice' },
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (result.status === 'success') {
|
|
78
|
-
// result is typed from the matching `main` return type.
|
|
79
|
-
}
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Do not wrap `apiRequest` in `unknown` / `any` shims (see `.claude/CLAUDE.md` rule 16). If inference fails, fix the typing source or regenerate maps instead.
|
|
83
|
-
|
|
84
|
-
## Public API
|
|
85
|
-
|
|
86
|
-
| Export | Purpose |
|
|
87
|
-
| --- | --- |
|
|
88
|
-
| `handleApiRequest({ msg, socket, token })` | Socket.io request handler (default export). |
|
|
89
|
-
| `handleHttpApiRequest({ name, data, token, ... })` | HTTP fallback for `/api/*` routes; returns `Promise<ApiNetworkResponse>` and supports SSE streaming via a `stream` callback. |
|
|
90
|
-
| Type: `ApiHttpStreamEvent` | SSE event shape emitted by streaming endpoints. |
|
|
91
|
-
|
|
92
|
-
## Related architecture docs
|
|
93
|
-
|
|
94
|
-
- [`docs/ARCHITECTURE_API.md`](../../docs/ARCHITECTURE_API.md) — full request lifecycle, streaming, error contract.
|
|
95
|
-
- [`docs/ARCHITECTURE_ROUTING.md`](../../docs/ARCHITECTURE_ROUTING.md) — `_api/` file conventions and method inference.
|
|
96
|
-
|
|
97
|
-
## Dependencies
|
|
98
|
-
|
|
99
|
-
- Runtime: `@luckystack/core`, `@luckystack/error-tracking` (`@luckystack/login` is NOT a runtime dependency — sessions resolve through core's session-provider registry; login is the optional default provider)
|
|
100
|
-
- Peer (canonical ranges, standardized 2026-05-07):
|
|
101
|
-
- `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
|
|
102
|
-
- `socket.io@^4.8.0`
|
|
103
|
-
|
|
104
|
-
## License
|
|
105
|
-
|
|
106
|
-
MIT — see [LICENSE](../../LICENSE).
|
|
1
|
+
# @luckystack/api
|
|
2
|
+
|
|
3
|
+
> Type-safe API request handlers for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2). WebSocket-first via socket.io with HTTP fallback. File-based routing, generated route map, integrated rate limiting, validation, hooks, and error-tracking tracing.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @luckystack/api @luckystack/core @luckystack/error-tracking socket.io
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
API endpoints live in `src/{page}/_api/{name}_v{N}.ts` and are picked up by the file-based router. Each file exports `main`, plus optional metadata (`auth`, `method`, `rateLimit`).
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/settings/_api/updateUser_v1.ts
|
|
17
|
+
import type { AuthProps } from '@luckystack/core';
|
|
18
|
+
import type { ApiResponse } from '@luckystack/core';
|
|
19
|
+
|
|
20
|
+
export const rateLimit: number | false = 20;
|
|
21
|
+
export const method = 'POST' as const;
|
|
22
|
+
export const auth: AuthProps = { login: true };
|
|
23
|
+
|
|
24
|
+
export interface ApiParams {
|
|
25
|
+
data: { name: string };
|
|
26
|
+
user: SessionLayout;
|
|
27
|
+
functions: Functions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const main = async ({ data, user }: ApiParams): Promise<ApiResponse> => {
|
|
31
|
+
await prisma.user.update({ where: { id: user.id }, data: { name: data.name } });
|
|
32
|
+
return { status: 'success', result: { ok: true } };
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The package exposes the two transport adapters that `@luckystack/server` wires into Socket.io and HTTP:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import handleApiRequest from '@luckystack/api';
|
|
40
|
+
import { handleHttpApiRequest } from '@luckystack/api';
|
|
41
|
+
|
|
42
|
+
io.on('connection', socket => {
|
|
43
|
+
socket.on('apiRequest', msg => handleApiRequest({ msg, socket, token }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
httpServer.on('request', async (req, res) => {
|
|
47
|
+
if (req.url?.startsWith('/api/')) {
|
|
48
|
+
const result = await handleHttpApiRequest({ name: body.name, data: body.data, token });
|
|
49
|
+
res.end(JSON.stringify(result));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
You typically don't call these yourself — `createLuckyStackServer` does. Use this package directly only when building a custom transport.
|
|
55
|
+
|
|
56
|
+
## How it integrates
|
|
57
|
+
|
|
58
|
+
1. **Authenticates** via `readSession` from `@luckystack/core` (the session-provider registry; `@luckystack/login` is the optional default provider, not a hard dependency) when `auth.login === true`. Auth runs first so unauthenticated probes can't enumerate routes or learn input shape from the validation message.
|
|
59
|
+
2. **Rate-limits** with two buckets via `checkRateLimit` from `@luckystack/core`: a per-route bucket keyed on the validated `user.id` (anonymous callers fall back to the resolved IP — never the raw session token, so re-login can't reset the limit; the basis is overridable via `rateLimiting.identity`) plus a global per-IP abuse bucket.
|
|
60
|
+
3. **Validates** the inbound payload against the Zod schema generated from your `ApiParams['data']` interface (via `@luckystack/devkit`).
|
|
61
|
+
4. **Dispatches** the `preApiExecute` hook (may abort with a stop signal).
|
|
62
|
+
5. **Calls** your `main(...)` and captures errors via `tryCatch` (auto-forwarded to Sentry).
|
|
63
|
+
6. **Dispatches** the `postApiExecute` hook with the result + duration.
|
|
64
|
+
7. **Returns** the response via socket `ack` or HTTP body / SSE stream.
|
|
65
|
+
|
|
66
|
+
## Generated types
|
|
67
|
+
|
|
68
|
+
`apiRequest` (in `@luckystack/core/client`) is fully typed against the route map emitted by `@luckystack/devkit` from your `_api/*` files (default location: `src/_sockets/apiTypes.generated.ts`). Use route-name + version literals so inference works:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const result = await apiRequest({
|
|
72
|
+
name: 'settings/updateUser',
|
|
73
|
+
version: 'v1',
|
|
74
|
+
data: { name: 'Alice' },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result.status === 'success') {
|
|
78
|
+
// result is typed from the matching `main` return type.
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Do not wrap `apiRequest` in `unknown` / `any` shims (see `.claude/CLAUDE.md` rule 16). If inference fails, fix the typing source or regenerate maps instead.
|
|
83
|
+
|
|
84
|
+
## Public API
|
|
85
|
+
|
|
86
|
+
| Export | Purpose |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `handleApiRequest({ msg, socket, token })` | Socket.io request handler (default export). |
|
|
89
|
+
| `handleHttpApiRequest({ name, data, token, ... })` | HTTP fallback for `/api/*` routes; returns `Promise<ApiNetworkResponse>` and supports SSE streaming via a `stream` callback. |
|
|
90
|
+
| Type: `ApiHttpStreamEvent` | SSE event shape emitted by streaming endpoints. |
|
|
91
|
+
|
|
92
|
+
## Related architecture docs
|
|
93
|
+
|
|
94
|
+
- [`docs/ARCHITECTURE_API.md`](../../docs/ARCHITECTURE_API.md) — full request lifecycle, streaming, error contract.
|
|
95
|
+
- [`docs/ARCHITECTURE_ROUTING.md`](../../docs/ARCHITECTURE_ROUTING.md) — `_api/` file conventions and method inference.
|
|
96
|
+
|
|
97
|
+
## Dependencies
|
|
98
|
+
|
|
99
|
+
- Runtime: `@luckystack/core`, `@luckystack/error-tracking` (`@luckystack/login` is NOT a runtime dependency — sessions resolve through core's session-provider registry; login is the optional default provider)
|
|
100
|
+
- Peer (canonical ranges, standardized 2026-05-07):
|
|
101
|
+
- `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
|
|
102
|
+
- `socket.io@^4.8.0`
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT — see [LICENSE](../../LICENSE).
|