@luckystack/api 0.10.2 → 0.12.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/AGENTS.md +7 -0
- package/CHANGELOG.md +12 -1
- package/README.md +2 -103
- package/dist/_shared/apiTypes.d.ts +68 -0
- package/dist/_shared/applyApiRateLimits.d.ts +30 -0
- package/dist/_shared/backpressure.d.ts +4 -0
- package/dist/_shared/httpValidationStage.d.ts +14 -0
- package/dist/_shared/inputTypeWarning.d.ts +1 -0
- package/dist/_shared/logFlags.d.ts +4 -0
- package/dist/_shared/rateLimitIdentity.d.ts +1 -0
- package/dist/_shared/requestLifecycle.d.ts +14 -0
- package/dist/_shared/responseEnvelope.d.ts +9 -0
- package/dist/_shared/socketValidationStage.d.ts +13 -0
- package/dist/handleApiRequest.d.ts +9 -0
- package/dist/handleHttpApiRequest.d.ts +60 -0
- package/dist/index.d.ts +3 -72
- package/dist/index.js.map +1 -1
- package/docs/api-request-lifecycle.md +3 -276
- package/docs/auth-flow.md +3 -228
- package/docs/error-handling.md +3 -242
- package/docs/generated-types.md +3 -260
- package/docs/rate-limiting.md +3 -184
- package/docs/routing-conventions.md +3 -160
- package/package.json +5 -5
- package/CLAUDE.md +0 -111
package/docs/rate-limiting.md
CHANGED
|
@@ -1,186 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# API rate limiting
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Per-route and cross-route IP limits defend different abuse patterns; neither substitutes for authorization. An authenticated caller's per-route quota should be associated with a server-validated identity rather than the replaceable token string. Anonymous and global-IP quotas rely on trustworthy proxy configuration: a spoofable forwarded address defeats that control. See `../src/_shared/rateLimitIdentity.ts`, `../src/_shared/applyApiRateLimits.ts`, `../../core/src/resolveClientIp.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Rate-limit logic is identical on both transports — `handleApiRequest` and `handleHttpApiRequest` apply the same buckets in the same order. The only transport-specific difference is where the IP comes from: the socket transport reads `socket.handshake.address`; the HTTP transport takes a `requesterIp` parameter (the HTTP listener in `@luckystack/server` derives it from `req.socket.remoteAddress` + optional forwarded-header parsing).
|
|
8
|
-
|
|
9
|
-
Rejection responds with `api.rateLimitExceeded` + HTTP `429`, and includes `errorParams: [{ key: 'seconds', value: resetIn }]` so consumers can render a localized "try again in N seconds" message. The hook payload exposes the full key, scope, and counts for dashboards / alerting.
|
|
10
|
-
|
|
11
|
-
## The two buckets
|
|
12
|
-
|
|
13
|
-
### 1. Per-route bucket
|
|
14
|
-
|
|
15
|
-
Keyed per-caller per-route. Drops requests that exceed `apiEntry.rateLimit` (per route) or `rateLimiting.defaultApiLimit` (fallback) within `windowMs`.
|
|
16
|
-
|
|
17
|
-
**Key format:**
|
|
18
|
-
|
|
19
|
-
| Caller | Key |
|
|
20
|
-
| --- | --- |
|
|
21
|
-
| Authenticated (`token` present) | `token:<token>:api:<resolvedName>` |
|
|
22
|
-
| Anonymous (no token) | `ip:<requesterIp>:api:<resolvedName>` |
|
|
23
|
-
|
|
24
|
-
The `<resolvedName>` is the normalized route name returned by `parseTransportRouteName` (e.g. `settings/updateUser/v1`). Using the token (not user ID) as the key ensures the bucket sticks to the session even if multiple sessions exist for the same user under `session.perUser === 'multiple'`.
|
|
25
|
-
|
|
26
|
-
**Per-route override:** a route file can declare its own limit (or disable it):
|
|
27
|
-
|
|
28
|
-
```ts
|
|
29
|
-
// src/settings/_api/updateUser_v1.ts
|
|
30
|
-
export const rateLimit: number | false = 30; // 30 requests per window
|
|
31
|
-
// or:
|
|
32
|
-
export const rateLimit = false; // No per-route bucket
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
When `rateLimit === false` or `0`, the per-route bucket is skipped entirely — the global IP bucket still applies.
|
|
36
|
-
|
|
37
|
-
When `rateLimit === undefined` (the export is absent), the handler falls back to `rateLimiting.defaultApiLimit`. If that is also `false`, the per-route bucket is skipped.
|
|
38
|
-
|
|
39
|
-
### 2. Global per-IP bucket
|
|
40
|
-
|
|
41
|
-
Keyed only by IP, shared across every route. Drops requests from a single IP that exceed `rateLimiting.defaultIpLimit` in `windowMs`, regardless of which routes they hit.
|
|
42
|
-
|
|
43
|
-
**Key format:** `ip:<requesterIp>:api:all`
|
|
44
|
-
|
|
45
|
-
When `defaultIpLimit === false` or `0`, the global bucket is skipped entirely. Per-route limits still apply.
|
|
46
|
-
|
|
47
|
-
The global bucket runs **after** the per-route bucket — a route that has burned through its per-route allowance returns `api.rateLimitExceeded` without ever touching the global bucket, so the per-route violator does not also consume global capacity.
|
|
48
|
-
|
|
49
|
-
## Bucket evaluation order
|
|
50
|
-
|
|
51
|
-
Both transports execute exactly:
|
|
52
|
-
|
|
53
|
-
1. Compute `effectiveApiLimit = apiEntry.rateLimit ?? rateLimiting.defaultApiLimit`.
|
|
54
|
-
2. If `effectiveApiLimit !== false && effectiveApiLimit > 0`:
|
|
55
|
-
- Build the per-route key.
|
|
56
|
-
- `await checkRateLimit({ key, limit, windowMs })`.
|
|
57
|
-
- On `!allowed`, dispatch `rateLimitExceeded` with `scope: token ? 'user' : 'ip'` (the bucket is user-keyed only when a token is present; an anonymous per-route bucket is IP-keyed, so it reports `ip` — with `route` still set to distinguish it from the global `:api:all` IP bucket below), respond `api.rateLimitExceeded` + `429`, abort.
|
|
58
|
-
3. Read `defaultIpLimit = rateLimiting.defaultIpLimit`.
|
|
59
|
-
4. If `defaultIpLimit !== false && defaultIpLimit > 0`:
|
|
60
|
-
- Build the global IP key.
|
|
61
|
-
- `await checkRateLimit(...)`.
|
|
62
|
-
- On `!allowed`, dispatch `rateLimitExceeded` with `scope: 'ip'`, respond `api.rateLimitExceeded` + `429`, abort.
|
|
63
|
-
|
|
64
|
-
The rate-limit step sits between auth and validation. Auth runs first so unauthenticated probes can't burn through anonymous IP buckets enumerating routes. Validation runs after rate limit so a flood of malformed payloads from a single caller is throttled the same way as well-formed traffic.
|
|
65
|
-
|
|
66
|
-
## API Reference
|
|
67
|
-
|
|
68
|
-
### `applyApiRateLimits` (internal, socket transport)
|
|
69
|
-
|
|
70
|
-
**Signature** (paraphrased):
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
const applyApiRateLimits = async (params: {
|
|
74
|
-
apiEntry: RuntimeApiEntry;
|
|
75
|
-
resolvedName: string;
|
|
76
|
-
token: string | null;
|
|
77
|
-
socket: Socket;
|
|
78
|
-
user: SessionLayout | null;
|
|
79
|
-
emitApiError: EmitApiError;
|
|
80
|
-
}): Promise<boolean>;
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
| Param | Notes |
|
|
84
|
-
| --- | --- |
|
|
85
|
-
| `apiEntry` | Read `rateLimit` field. |
|
|
86
|
-
| `resolvedName` | Used to build the per-route key. |
|
|
87
|
-
| `token` | When non-null, drives `token:<token>:...` key + `scope: 'user'`. |
|
|
88
|
-
| `socket` | Read `socket.handshake.address` for the IP fallback. |
|
|
89
|
-
| `user` | Surfaced as `userId` in the `rateLimitExceeded` payload. |
|
|
90
|
-
| `emitApiError` | Closure that emits the localized error envelope on the response channel. |
|
|
91
|
-
|
|
92
|
-
**Returns**: `Promise<boolean>` — `true` if both buckets allowed the request, `false` if either rejected.
|
|
93
|
-
|
|
94
|
-
### Inline rate-limit block in `handleHttpApiRequest`
|
|
95
|
-
|
|
96
|
-
Same logic, inlined rather than extracted into a helper. Reads `requesterIp` (or `'anonymous'` for the per-route bucket key, `'unknown'` for the global bucket key) instead of `socket.handshake.address`.
|
|
97
|
-
|
|
98
|
-
### `checkRateLimit({ key, limit, windowMs })` (re-exported from `@luckystack/core`)
|
|
99
|
-
|
|
100
|
-
The underlying strategy call.
|
|
101
|
-
|
|
102
|
-
**Signature**:
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
async function checkRateLimit(params: {
|
|
106
|
-
key: string;
|
|
107
|
-
limit: number;
|
|
108
|
-
windowMs: number;
|
|
109
|
-
}): Promise<{ allowed: boolean; resetIn: number; current: number }>;
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
**Behavior**:
|
|
113
|
-
|
|
114
|
-
- Delegates to the registered strategy (`registerRateLimitStrategy`). Default strategy uses Redis when `rateLimiting.store === 'redis'`, otherwise an in-memory token-bucket.
|
|
115
|
-
- `resetIn` is seconds until the window resets, used as the `seconds` error param.
|
|
116
|
-
- Backend implementation lives in `@luckystack/core/src/rateLimiter.ts`; see its docs for sliding-window semantics and the `redisKeyPrefix` config option.
|
|
117
|
-
|
|
118
|
-
## Hooks dispatched
|
|
119
|
-
|
|
120
|
-
| Hook | When | Payload (key fields) |
|
|
121
|
-
| --- | --- | --- |
|
|
122
|
-
| `rateLimitExceeded` | A bucket rejects | `scope: 'user' \| 'route' \| 'ip'`, `key: string`, `limit: number`, `windowMs: number`, `count: number` (always `limit + 1` here), `route?: string`, `userId?: string`, `ip?: string` |
|
|
123
|
-
|
|
124
|
-
The hook fires before the error response is emitted, so handlers that want to escalate (alerting, IP-banning) can act on the same request. Handlers run inside `void dispatchHook(...)` — they cannot block or alter the rejection.
|
|
125
|
-
|
|
126
|
-
`scope` distinguishes the three cases:
|
|
127
|
-
|
|
128
|
-
- `'user'` — per-route bucket rejected an authenticated caller (`token` was non-null).
|
|
129
|
-
- `'route'` — per-route bucket rejected an anonymous caller (no token; keyed by IP).
|
|
130
|
-
- `'ip'` — global per-IP bucket rejected.
|
|
131
|
-
|
|
132
|
-
## Error response
|
|
133
|
-
|
|
134
|
-
Both transports return the same envelope on rejection:
|
|
135
|
-
|
|
136
|
-
```jsonc
|
|
137
|
-
{
|
|
138
|
-
"status": "error",
|
|
139
|
-
"httpStatus": 429,
|
|
140
|
-
"errorCode": "api.rateLimitExceeded",
|
|
141
|
-
"errorParams": [{ "key": "seconds", "value": 42 }],
|
|
142
|
-
"message": "Too many requests. Try again in 42 seconds."
|
|
143
|
-
// message comes from the registered localized normalizer using errorCode + errorParams
|
|
144
|
-
}
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
`resetIn` reflects the strategy's view of when the bucket frees up. The localized normalizer renders the message via the consumer's translate function — see [`./error-handling.md`](./error-handling.md).
|
|
148
|
-
|
|
149
|
-
## Config keys
|
|
150
|
-
|
|
151
|
-
All read at request time via `getProjectConfig()`.
|
|
152
|
-
|
|
153
|
-
| Key | Type | Effect |
|
|
154
|
-
| --- | --- | --- |
|
|
155
|
-
| `rateLimiting.defaultApiLimit` | `number \| false` | Fallback per-route limit when the route does not export `rateLimit`. `false` disables. |
|
|
156
|
-
| `rateLimiting.defaultIpLimit` | `number \| false` | Global per-IP limit across all routes. `false` disables. |
|
|
157
|
-
| `rateLimiting.windowMs` | `number` | Window size for both buckets, in ms. Default 60000 (one minute). |
|
|
158
|
-
| `rateLimiting.store` | `'memory' \| 'redis'` | Backend for the default strategy. `'redis'` is required for multi-instance consistency. |
|
|
159
|
-
| `rateLimiting.redisKeyPrefix` | `string` | Prefix prepended to keys when using the Redis backend. |
|
|
160
|
-
| `rateLimiting.enabled` | `boolean` | Master switch on the default strategy. When `false`, every `checkRateLimit` call returns `{ allowed: true }`. |
|
|
161
|
-
|
|
162
|
-
## Disabling rate limiting
|
|
163
|
-
|
|
164
|
-
| Scope | How |
|
|
165
|
-
| --- | --- |
|
|
166
|
-
| One route, per-route bucket only | `export const rateLimit = false;` in the route file. |
|
|
167
|
-
| One route, both buckets | `export const rateLimit = false;` + run the consumer behind a load balancer that absorbs the IP bucket. There is no per-route opt-out for the global bucket. |
|
|
168
|
-
| Globally, per-route bucket | `rateLimiting.defaultApiLimit: false` in `registerProjectConfig`. |
|
|
169
|
-
| Globally, IP bucket | `rateLimiting.defaultIpLimit: false`. |
|
|
170
|
-
| Globally, everything | `rateLimiting.enabled: false` (default strategy honors this and short-circuits). |
|
|
171
|
-
| Replace backend | `registerRateLimitStrategy(myStrategy)` from `@luckystack/core` — useful for edge-KV-backed limits, per-tier quotas, or no-op test strategies. |
|
|
172
|
-
|
|
173
|
-
## Edge cases
|
|
174
|
-
|
|
175
|
-
- **Anonymous request with no IP** — the socket transport falls back to `'unknown'` for `socket.handshake.address`; the HTTP transport falls back to `'anonymous'` for the per-route bucket and `'unknown'` for the global bucket. All anonymous callers that lack an IP share the same bucket. Front your server with a load balancer that always supplies `X-Forwarded-For` (parsed by `@luckystack/server`'s request adapter) to avoid this collision.
|
|
176
|
-
- **Token present but session deleted** — the bucket is still keyed by the token string; subsequent requests with the same expired token still throttle. Once the session is fully GC'd from `getSession`, the auth gate rejects with `auth.required` before rate-limit checks run (auth runs first).
|
|
177
|
-
- **Race within `windowMs`** — `checkRateLimit` is atomic on the strategy backend (`INCR` + `EXPIRE` for Redis, lock-free counter for memory). Two requests arriving at the same millisecond both count toward the limit.
|
|
178
|
-
- **Per-route `rateLimit = 0`** — equivalent to `false` (bucket skipped). The handler explicitly guards on `effectiveApiLimit > 0`.
|
|
179
|
-
- **Custom strategy returns `allowed: false` with `resetIn: 0`** — the response still echoes `seconds: 0`. Renderers should treat `seconds <= 0` as "right now" / "very soon".
|
|
180
|
-
|
|
181
|
-
## Related
|
|
182
|
-
|
|
183
|
-
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
184
|
-
- Error handling: [`./error-handling.md`](./error-handling.md)
|
|
185
|
-
- Rate-limit strategy: `@luckystack/core/docs/rate-limit-strategy.md`
|
|
186
|
-
- Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Rate Limiting")
|
|
5
|
+
Do not present a store failure as a successfully enforced quota. Consult `../../core/src/rateLimiter.ts` and `../../core/src/projectConfig.ts` for strategy and policy instead of copying limits or error envelopes here.
|
|
@@ -1,162 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# API routing conventions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Versioned route names allow client and server maps to agree as handlers evolve. Discovery is generated; keep declarations and generated artifacts aligned rather than maintaining a second route inventory. See `../../devkit/` and `../src/handleApiRequest.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
If a route name on the wire does not match the conventions below, both `handleApiRequest` and `handleHttpApiRequest` reject with `routing.invalidServiceRouteName` before any other side effect. If a name parses but does not match a registered route, both return `api.notFound`.
|
|
8
|
-
|
|
9
|
-
## Wire-level route names
|
|
10
|
-
|
|
11
|
-
Both transports accept the same shape:
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
api/<page>/<name>/v<N>
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
| Segment | Constraints |
|
|
18
|
-
| --- | --- |
|
|
19
|
-
| `api/` | Literal prefix. The HTTP transport accepts `name` with or without it and normalizes; the socket transport requires the prefix to be present in `msg.name`. |
|
|
20
|
-
| `<page>` | Slash-separated page path. May be deeply nested (`organization/settings`). Top-level system APIs use `system` as the page (e.g. `api/system/session/v1`). |
|
|
21
|
-
| `<name>` | Endpoint name. Free-form identifier; influences HTTP method inference (see below). |
|
|
22
|
-
| `v<N>` | Version token (`v1`, `v2`, ...). Required. Files that do not end in `_v<N>.ts` are skipped by the discovery walker. |
|
|
23
|
-
|
|
24
|
-
`parseTransportRouteName({ value, prefix: 'api' })` (exported from `@luckystack/core`) is the single source of truth. The returned `normalizedFullName` is what the handler uses for runtime-map lookup, rate-limit keying, and hook payloads.
|
|
25
|
-
|
|
26
|
-
## File-system convention
|
|
27
|
-
|
|
28
|
-
Discovery is owned by `@luckystack/devkit`. Files are picked up when they live under `src/` and match `**/_api/<name>_v<N>.ts` (or any subdirectory, including `_api/<sub>/<name>_v<N>.ts` for nested routes). The marker segment `_api` is configurable via `registerRoutingRules` but defaults to `_api`.
|
|
29
|
-
|
|
30
|
-
| Path | Resolved route |
|
|
31
|
-
| --- | --- |
|
|
32
|
-
| `src/_api/session_v1.ts` | `api/system/session/v1` (top-level APIs are mapped under the `system` service) |
|
|
33
|
-
| `src/settings/_api/updateUser_v1.ts` | `api/settings/updateUser/v1` |
|
|
34
|
-
| `src/organization/_api/settings/sendInvite_v1.ts` | `api/organization/settings/sendInvite/v1` |
|
|
35
|
-
| `src/playground/_api/streamCounter_v1.ts` | `api/playground/streamCounter/v1` |
|
|
36
|
-
|
|
37
|
-
Files that do not match the `_v<N>` suffix are not discovered. The version digit must be a positive integer. Versions are sorted numerically when emitted into the type map.
|
|
38
|
-
|
|
39
|
-
Folders prefixed with `_` are private — they may sit alongside `_api/`, `_sync/`, `_components/`, etc. without being treated as page routes by the frontend router.
|
|
40
|
-
|
|
41
|
-
## Per-route exports
|
|
42
|
-
|
|
43
|
-
Each route file declares its behavior via exports. The discovery walker captures these via the TypeScript compiler API and emits them into the runtime map.
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
// src/settings/_api/updateUser_v1.ts
|
|
47
|
-
import type { AuthProps } from '@luckystack/core';
|
|
48
|
-
import type { ApiResponse } from 'src/_sockets/apiTypes.generated';
|
|
49
|
-
|
|
50
|
-
export const rateLimit: number | false = 20;
|
|
51
|
-
export const httpMethod = 'POST' as const;
|
|
52
|
-
export const auth: AuthProps = { login: true };
|
|
53
|
-
|
|
54
|
-
export interface ApiParams {
|
|
55
|
-
data: { name: string };
|
|
56
|
-
user: SessionLayout;
|
|
57
|
-
functions: Functions;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const main = async ({ data, user, functions }: ApiParams): Promise<ApiResponse> => {
|
|
61
|
-
// ...
|
|
62
|
-
return { status: 'success', ok: true };
|
|
63
|
-
};
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
| Export | Type | Default | Effect |
|
|
67
|
-
| --- | --- | --- | --- |
|
|
68
|
-
| `main` | `(params) => Promise<ApiResponse>` | required | Handler invoked by `executeApiHandler` inside the `tryCatch` + registered error-tracker span. |
|
|
69
|
-
| `auth` | `AuthProps` | required | Auth gate (see [`./auth-flow.md`](./auth-flow.md)). |
|
|
70
|
-
| `rateLimit` | `number \| false` | `rateLimiting.defaultApiLimit` from config | Per-route requests-per-window cap. `false` disables the per-route bucket; the global IP bucket still applies. See [`./rate-limiting.md`](./rate-limiting.md). |
|
|
71
|
-
| `httpMethod` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` | inferred from name | Overrides `inferHttpMethod` for the HTTP transport. Socket transport ignores this field. |
|
|
72
|
-
| `ApiParams` interface | `{ data, user, functions, stream? }` | required | TypeScript-only. The `data` field's type is what `validateInputByType` checks at runtime via the generated Zod schema. |
|
|
73
|
-
| `validation` | `'strict' \| 'relaxed' \| { input: 'skip' \| 'strict' }` | `'strict'` | Socket-only escape hatch for public webhooks where the third-party payload is not modelable in TypeScript. See "Input validation" below. |
|
|
74
|
-
|
|
75
|
-
`ApiResponse` is exact: either `{ status: 'success'; httpStatus?: number; ...payload }` or `{ status: 'error'; errorCode: string; errorParams?: [...]; httpStatus?: number }`. Returning anything else is a runtime error (`api.invalidResponseStatus`).
|
|
76
|
-
|
|
77
|
-
## HTTP-method inference
|
|
78
|
-
|
|
79
|
-
Used by `handleHttpApiRequest` when the route file does not export `httpMethod`. Implemented in `inferHttpMethod` (from `@luckystack/core/src/httpApiUtils.ts`):
|
|
80
|
-
|
|
81
|
-
| Name prefix (case-insensitive, last `/`-segment) | Inferred method |
|
|
82
|
-
| --- | --- |
|
|
83
|
-
| `get*`, `fetch*`, `list*` | `GET` |
|
|
84
|
-
| `delete*`, `remove*` | `DELETE` |
|
|
85
|
-
| `update*`, `edit*`, `patch*` | `PUT` |
|
|
86
|
-
| Everything else (`create*`, `send*`, `sync*`, bare verbs) | `POST` |
|
|
87
|
-
|
|
88
|
-
The inference reads the last segment before the version token. `api/admin/users/getList/v1` infers `GET`; `api/admin/getUsers/v2` also infers `GET`.
|
|
89
|
-
|
|
90
|
-
The socket transport does not validate HTTP method — only the HTTP transport does. Mismatches return `api.methodNotAllowed` + `405` with `errorParams: [{ key: 'method', value: expectedMethod }]`.
|
|
91
|
-
|
|
92
|
-
## Input validation
|
|
93
|
-
|
|
94
|
-
The discovery walker reads the `data` field of the `ApiParams` interface (or whatever the `main` parameter's `data` member is named) and:
|
|
95
|
-
|
|
96
|
-
1. Emits the **text form** of the type into the runtime map (`apiEntry.inputType`).
|
|
97
|
-
2. Emits a **Zod schema** for the same type into `apiInputSchemas.generated.ts` for runtime checking + test-runner fuzz.
|
|
98
|
-
3. Emits the **structural type** into `apiTypes.generated.ts` so `apiRequest({ name, version, data })` on the client gets full autocomplete + a compile-time error if `data` does not match.
|
|
99
|
-
|
|
100
|
-
At request time, `validateInputByType({ typeText, value, rootKey: 'data', filePath })` matches the payload against the stored type. The validator lazy-loads `@luckystack/devkit`'s deep resolver in dev so imported / re-exported aliases expand correctly; in production it falls back to a static resolver.
|
|
101
|
-
|
|
102
|
-
The per-route `validation` flag turns the validator off:
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
// Public webhook receiving a payload from Stripe / Slack / GitHub
|
|
106
|
-
export const validation = 'relaxed' as const;
|
|
107
|
-
|
|
108
|
-
// Or, equivalent:
|
|
109
|
-
export const validation = { input: 'skip' } as const;
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Use sparingly. The skip suppresses both `api.invalidInputType` rejections and the dev-only "no inputType" warning. `postApiValidate` still fires with `{ status: 'success' }` so audit handlers see the skip.
|
|
113
|
-
|
|
114
|
-
## Wire-level route examples
|
|
115
|
-
|
|
116
|
-
```
|
|
117
|
-
api/system/session/v1 ← src/_api/session_v1.ts
|
|
118
|
-
api/system/logout/v1 ← src/_api/logout_v1.ts (built-in socket shortcut)
|
|
119
|
-
api/settings/updateUser/v1 ← src/settings/_api/updateUser_v1.ts
|
|
120
|
-
api/organization-settings/sendInvite/v1
|
|
121
|
-
← src/organization-settings/_api/sendInvite_v1.ts
|
|
122
|
-
api/admin/users/listAll/v3 ← src/admin/_api/users/listAll_v3.ts
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
## Reserved routes
|
|
126
|
-
|
|
127
|
-
| Route | Owner | Notes |
|
|
128
|
-
| --- | --- | --- |
|
|
129
|
-
| `api/system/logout/v1` | `handleApiRequest` (socket only) | Short-circuited before runtime map lookup. Calls `logout(...)` from `@luckystack/login`. HTTP requests for this route go through the normal pipeline. |
|
|
130
|
-
| `api/system/session/v1` | Project | Conventional location for the session-fetch endpoint. Not framework-reserved. |
|
|
131
|
-
|
|
132
|
-
Consumer routes whose final segment happens to be `logout` (`admin/logout/v1`) are NOT hijacked — the short-circuit matches the full normalized route `system/logout`, not just the trailing segment.
|
|
133
|
-
|
|
134
|
-
## Hooks dispatched
|
|
135
|
-
|
|
136
|
-
Discovery / route-naming hooks live in `@luckystack/devkit`. The runtime API handler does not emit routing-level hooks beyond the standard `pre/postApiValidate` / `pre/postApiExecute` / `pre/postApiRespond` / `transformApiResponse` / `rateLimitExceeded` set. See [`./api-request-lifecycle.md`](./api-request-lifecycle.md).
|
|
137
|
-
|
|
138
|
-
## Config keys
|
|
139
|
-
|
|
140
|
-
| Key | Effect |
|
|
141
|
-
| --- | --- |
|
|
142
|
-
| `paths.srcDir` | Root directory for `_api/` discovery (devkit). |
|
|
143
|
-
| `paths.generatedSocketTypes` | Output path for `apiTypes.generated.ts` (devkit). |
|
|
144
|
-
| `paths.generatedApiSchemas` | Output path for `apiInputSchemas.generated.ts` (devkit). |
|
|
145
|
-
| `paths.generatedApiDocs` | Output path for `apiDocs.generated.json` (devkit). |
|
|
146
|
-
|
|
147
|
-
No routing-specific keys are read at request time by `@luckystack/api`.
|
|
148
|
-
|
|
149
|
-
## Edge cases
|
|
150
|
-
|
|
151
|
-
- **Two files normalize to the same route key.** `assertNoDuplicateNormalizedRouteKeys` (devkit) throws at generation time. The runtime map will reflect whichever file was loaded last; treat the throw as a build-time guard.
|
|
152
|
-
- **File saved without a version suffix.** Discovery skips the file. The route never registers; `apiRequest` calls fail with `api.notFound` at runtime.
|
|
153
|
-
- **`auth` export missing.** TypeScript will not compile; if compilation is bypassed, the runtime map's `apiEntry.auth` is `undefined` and the auth gate treats it as `{ login: false }`. Do not rely on this — declare `auth` explicitly.
|
|
154
|
-
- **Non-`_api/` folders matching the regex.** Discovery only descends through folders; the marker segment `_api` is required, and folders without the underscore prefix are ignored.
|
|
155
|
-
|
|
156
|
-
## Related
|
|
157
|
-
|
|
158
|
-
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
159
|
-
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
160
|
-
- Architecture: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md)
|
|
161
|
-
- Devkit type-map: `@luckystack/devkit/docs/type-map-generation.md`
|
|
162
|
-
- Devkit loader pipeline: `@luckystack/devkit/docs/loader-pipeline.md`
|
|
5
|
+
An HTTP method is an HTTP transport constraint, not permission to invoke the handler: socket events have no method. Enforce caller identity and any route predicates in auth policy on both transports. Input-validation exceptions are deliberate reductions in defense and need a reason; avoid broad skips as a type-generator workaround. See `../src/handleHttpApiRequest.ts`, `./auth-flow.md`, `./generated-types.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"validation"
|
|
33
33
|
],
|
|
34
34
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
35
|
+
"node": ">=22.13.0"
|
|
36
36
|
},
|
|
37
37
|
"main": "./dist/index.js",
|
|
38
38
|
"types": "./dist/index.d.ts",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"files": [
|
|
46
46
|
"dist",
|
|
47
47
|
"README.md",
|
|
48
|
-
"
|
|
48
|
+
"AGENTS.md",
|
|
49
49
|
"docs",
|
|
50
50
|
"LICENSE",
|
|
51
51
|
"CHANGELOG.md"
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"test": "vitest run"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@luckystack/core": "^0.
|
|
60
|
-
"@luckystack/error-tracking": "^0.
|
|
59
|
+
"@luckystack/core": "^0.12.0",
|
|
60
|
+
"@luckystack/error-tracking": "^0.12.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@prisma/client": "^6.19.0",
|
package/CLAUDE.md
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
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**: `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).
|
|
86
|
-
- **Optional peer**: `@prisma/client@^6.19.0` — only needed when the active session/user provider uses the default Prisma client. Streaming uses an injected callback, not an external SSE library.
|
|
87
|
-
|
|
88
|
-
## Hooks consumed
|
|
89
|
-
|
|
90
|
-
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:
|
|
91
|
-
|
|
92
|
-
- `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'`.
|
|
93
|
-
- `preApiValidate` — before Zod validation runs.
|
|
94
|
-
- `postApiValidate` — after validation, with `validation: { status, message? }`.
|
|
95
|
-
- `preApiExecute` — may return a stop signal (`errorCode`, `httpStatus?`) to short-circuit.
|
|
96
|
-
- `postApiExecute` — observation point with `result`, `error`, `durationMs`.
|
|
97
|
-
- `preApiRespond` — last chance to mutate or swap `payload.response`; stop signal converts to localized error.
|
|
98
|
-
- `transformApiResponse` — purely mutative; runs between `preApiRespond` and socket/HTTP emit.
|
|
99
|
-
- `postApiRespond` — observation-only; response is already on the wire.
|
|
100
|
-
- `rateLimitExceeded` — fires when a bucket rejects (scope: `route` | `user` | `ip`).
|
|
101
|
-
- `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.
|
|
102
|
-
|
|
103
|
-
Deep behavior for each hook lives in `@luckystack/core`'s hook registry doc.
|
|
104
|
-
|
|
105
|
-
## Related
|
|
106
|
-
|
|
107
|
-
- Architecture deep dives: `/docs/ARCHITECTURE_API.md`, `/docs/ARCHITECTURE_ROUTING.md`.
|
|
108
|
-
- Consumer quickstart: `./README.md`.
|
|
109
|
-
- Generated-type contract: `/docs/ARCHITECTURE_ROUTING.md` (see "Generated maps") and repository-root `CLAUDE.md` rule 21 (no `unknown`/`any` casts).
|
|
110
|
-
- 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`.
|
|
111
|
-
- Runtime map source: `@luckystack/core` `getRuntimeApiMaps()` + `@luckystack/devkit` emitter.
|