@ultimat3/http 24.0.0 → 25.1.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 +39 -21
- package/README.md +64 -25
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/config.ts +27 -29
- package/src/context.ts +10 -10
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/deadline.ts +2 -5
- package/src/error-map-tier-0.ts +2 -0
- package/src/error-map-tier-1.ts +6 -0
- package/src/error-map-tier-2.ts +2 -0
- package/src/error-map-tier-3.ts +8 -0
- package/src/error-map-tier-4.ts +24 -0
- package/src/error-page.ts +2 -2
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +58 -94
- package/src/forwarded.ts +20 -5
- package/src/health-disclosure.ts +0 -3
- package/src/hooks.ts +6 -5
- package/src/index.ts +31 -21
- package/src/navigation.ts +14 -2
- package/src/pipeline.ts +12 -8
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-postgres.ts +3 -3
- package/src/rate-limit-stage.ts +13 -4
- package/src/rate-limit.ts +22 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +2 -2
- package/src/response.ts +2 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +16 -1
- package/src/server.ts +74 -24
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +15 -9
- package/src/validate.ts +2 -2
- package/src/webhook-verify.ts +17 -1
package/src/errors.ts
CHANGED
|
@@ -1,46 +1,18 @@
|
|
|
1
1
|
// The HTTP layer's stable error codes. Every throw in this package goes through a
|
|
2
2
|
// factory here so a code, a cause and an exact fix always travel together — the
|
|
3
3
|
// terminal, the dev overlay and `--json` all render the same three strings.
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'X_METHOD_NOT_ALLOWED',
|
|
17
|
-
'X_PATH_INVALID',
|
|
18
|
-
'X_BODY_INVALID',
|
|
19
|
-
'X_RATE_LIMITED',
|
|
20
|
-
'X_BUILD_SKEW',
|
|
21
|
-
'X_ROUTE_CONFLICT',
|
|
22
|
-
'X_SERVER_NOT_STARTED',
|
|
23
|
-
'X_PIPELINE_NO_RESPONSE',
|
|
24
|
-
'X_PIPELINE_FINALIZE_FAILED',
|
|
25
|
-
'X_NO_REQUEST',
|
|
26
|
-
'X_ERROR_STATUS_INVALID',
|
|
27
|
-
'X_PROBLEM_META_INVALID',
|
|
28
|
-
'X_CORS_CONFIG_INVALID',
|
|
29
|
-
'X_CSP_DIRECTIVE_INVALID',
|
|
30
|
-
'X_RATE_LIMIT_NOT_SHARED',
|
|
31
|
-
'X_RATE_LIMIT_BUCKET_CONFLICT',
|
|
32
|
-
'X_RATE_LIMIT_BUCKET_UNBOUND',
|
|
33
|
-
'X_RATE_LIMIT_SCOPE_UNSET',
|
|
34
|
-
'X_RATE_LIMIT_INVALID',
|
|
35
|
-
'X_RATE_LIMIT_STORE_UNAVAILABLE',
|
|
36
|
-
'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
|
|
37
|
-
'X_TRUST_PROXY_UNSET',
|
|
38
|
-
'X_OVERLOADED',
|
|
39
|
-
'X_CSRF_BLOCKED',
|
|
40
|
-
'X_WEBHOOK_SIGNATURE_INVALID',
|
|
41
|
-
'X_WEBHOOK_SIGNATURE_STALE',
|
|
42
|
-
'X_BEARER_MOUNT_INVALID',
|
|
43
|
-
] as const;
|
|
4
|
+
import { deniedCallerFix, renderFixShellArg, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
5
|
+
// The owned code list, its titles and its retry classes are `error-titles.ts` — the one module
|
|
6
|
+
// `sideEffects` lists, imported bare here and from the barrel — so this module is side-effect-free
|
|
7
|
+
// and a chunk keeps only the classes it constructs. Re-exported: every name still reads from here.
|
|
8
|
+
import './error-titles';
|
|
9
|
+
import { HTTP_OWNED_ERROR_CODES } from './error-titles';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
HTTP_ERROR_TITLES,
|
|
13
|
+
HTTP_OWNED_ERROR_CODES,
|
|
14
|
+
type HttpOwnedErrorCode,
|
|
15
|
+
} from './error-titles';
|
|
44
16
|
|
|
45
17
|
/**
|
|
46
18
|
* Codes this package throws but does NOT own. `X_FORBIDDEN` belongs to `@ultimat3/policy` and
|
|
@@ -71,60 +43,8 @@ export const HTTP_BORROWED_ERROR_CODES = [
|
|
|
71
43
|
/** Every code http can throw: the ones it owns plus the two it borrows. */
|
|
72
44
|
export const HTTP_ERROR_CODES = [...HTTP_OWNED_ERROR_CODES, ...HTTP_BORROWED_ERROR_CODES] as const;
|
|
73
45
|
|
|
74
|
-
export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
|
|
75
46
|
export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
|
|
76
47
|
|
|
77
|
-
/** Human title per owned code. Kept next to the codes so one edit updates every surface. */
|
|
78
|
-
export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
79
|
-
X_ROUTE_NOT_FOUND: 'no route matches this request',
|
|
80
|
-
X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
|
|
81
|
-
X_PATH_INVALID: 'a path segment is not valid percent-encoding',
|
|
82
|
-
X_BODY_INVALID: 'request body failed its schema',
|
|
83
|
-
X_RATE_LIMITED: 'rate limit exhausted for this key',
|
|
84
|
-
X_BUILD_SKEW: 'client build id does not match the server build id',
|
|
85
|
-
X_ROUTE_CONFLICT: 'two routes claim the same path',
|
|
86
|
-
X_SERVER_NOT_STARTED: 'server handle used before start()',
|
|
87
|
-
X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
|
|
88
|
-
X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
|
|
89
|
-
X_NO_REQUEST: 'the inbound request is not in scope here',
|
|
90
|
-
X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
|
|
91
|
-
X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
|
|
92
|
-
X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
|
|
93
|
-
X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
|
|
94
|
-
X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
|
|
95
|
-
X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
|
|
96
|
-
X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
|
|
97
|
-
X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
|
|
98
|
-
X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
|
|
99
|
-
X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
|
|
100
|
-
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
|
|
101
|
-
X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
|
|
102
|
-
X_OVERLOADED: 'in-flight requests are at the configured ceiling',
|
|
103
|
-
X_CSRF_BLOCKED: 'an unsafe request that did not prove same-origin',
|
|
104
|
-
X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
|
|
105
|
-
X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
|
|
106
|
-
X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// Registered at module load, unconditionally, in one call, so core's registry renders OUR title
|
|
110
|
-
// everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
|
|
111
|
-
// presence guard, a package that claimed one of these first would silently keep its own title.
|
|
112
|
-
registerErrorCodes(
|
|
113
|
-
Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The two codes this package throws that a client is SUPPOSED to come back from, and both say
|
|
118
|
-
* when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
|
|
119
|
-
* right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
|
|
120
|
-
* a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
|
|
121
|
-
* `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
|
|
122
|
-
*/
|
|
123
|
-
registerErrorRetry({
|
|
124
|
-
X_RATE_LIMITED: 'retry-after',
|
|
125
|
-
X_OVERLOADED: 'retry-after',
|
|
126
|
-
});
|
|
127
|
-
|
|
128
48
|
// No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
|
|
129
49
|
// `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
130
50
|
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
|
|
@@ -274,7 +194,7 @@ export const serverNotStarted = (member: string): HttpError =>
|
|
|
274
194
|
new HttpError({
|
|
275
195
|
code: 'X_SERVER_NOT_STARTED',
|
|
276
196
|
cause: `${member} was read before start() bound a socket`,
|
|
277
|
-
fix: 'call
|
|
197
|
+
fix: 'call httpServer({ ... }).start() before reading url()',
|
|
278
198
|
});
|
|
279
199
|
|
|
280
200
|
export const pipelineNoResponse = (stage: string): HttpError =>
|
|
@@ -297,7 +217,7 @@ export const finalizeFailed = (stage: string, cause: unknown): HttpError =>
|
|
|
297
217
|
// runs a `Proxy`'s `getPrototypeOf` trap and `.message` runs a getter, so both reads go
|
|
298
218
|
// through core's total `renderThrowable` — the fast path was the last unguarded one here.
|
|
299
219
|
cause: `the "${stage}" stage threw while finishing the response: ${renderThrowable(cause)}`,
|
|
300
|
-
fix: 'return a Response built here —
|
|
220
|
+
fix: 'return a Response built here — jsonResponse(), textResponse(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
|
|
301
221
|
});
|
|
302
222
|
|
|
303
223
|
/**
|
|
@@ -379,6 +299,36 @@ export const trustProxyUnset = (): HttpError =>
|
|
|
379
299
|
fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
|
|
380
300
|
});
|
|
381
301
|
|
|
302
|
+
/**
|
|
303
|
+
* `http.drainTimeoutMs`, deleted in 25.0.0. It was a second knob for the budget `drain.deadlineMs`
|
|
304
|
+
* owns: applied after `lifecycleForRole` on the web role only, so the web role and every other
|
|
305
|
+
* role could drain on different budgets from one app config. Refused by key PRESENCE — a JS caller
|
|
306
|
+
* or a spread still handing it over gets the edit, never a silently ignored number.
|
|
307
|
+
*/
|
|
308
|
+
export const drainTimeoutDeleted = (): HttpError =>
|
|
309
|
+
new HttpError({
|
|
310
|
+
code: 'X_CONFIG_INVALID',
|
|
311
|
+
cause:
|
|
312
|
+
'http.drainTimeoutMs was deleted in 25.0.0: drain.deadlineMs is the one drain budget, for every role, and a second key for the web role alone is two answers to how long SIGTERM waits',
|
|
313
|
+
fix: 'drain: { deadlineMs: 25_000 } # in app.config.ts: drain.deadlineMs is the one budget every role, web included, drains on — and delete drainTimeoutMs from configureHttp({ … })',
|
|
314
|
+
meta: { option: 'drainTimeoutMs', replacement: 'drain.deadlineMs' },
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `http.buildIdHeader`, deleted in 25.0.0. The pipeline read the client's build claim from it and
|
|
319
|
+
* stamped its own back under it, while the typed client, the service worker and `DEFAULT_CORS`
|
|
320
|
+
* all spelled core's `BUILD_ID_HEADER` — so a renamed header was a server listening for a header
|
|
321
|
+
* no client of it sent, and skew detection off in silence. Refused by key PRESENCE.
|
|
322
|
+
*/
|
|
323
|
+
export const buildIdHeaderDeleted = (): HttpError =>
|
|
324
|
+
new HttpError({
|
|
325
|
+
code: 'X_CONFIG_INVALID',
|
|
326
|
+
cause:
|
|
327
|
+
'http.buildIdHeader was deleted in 25.0.0: the build-id header is core BUILD_ID_HEADER (x-ultimate-build), which every client sends, and a renamed one is a header no client of this server sends',
|
|
328
|
+
fix: "import { BUILD_ID_HEADER } from '@ultimat3/core' # the one build-id header, not configurable — and delete buildIdHeader from configureHttp({ … })",
|
|
329
|
+
meta: { option: 'buildIdHeader', replacement: 'BUILD_ID_HEADER' },
|
|
330
|
+
});
|
|
331
|
+
|
|
382
332
|
/**
|
|
383
333
|
* A numeric knob that is not a count, refused where `app.config.ts` still names it.
|
|
384
334
|
*
|
|
@@ -497,3 +447,17 @@ export const webhookSignatureStale = (
|
|
|
497
447
|
fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
|
|
498
448
|
meta: { skewMs, toleranceMs },
|
|
499
449
|
});
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The receiver holds no secret. Not a weak key — NO key: an HMAC under '' is a mac anyone can
|
|
453
|
+
* compute, so verifying against it accepts every forged delivery. `X_CONFIG_INVALID` because the
|
|
454
|
+
* repair is the route's configuration and nothing a sender does can fix it. `shape` is `typeof`
|
|
455
|
+
* or "an empty string" — never the value, which is a credential when it is one.
|
|
456
|
+
*/
|
|
457
|
+
export const webhookSecretMissing = (shape: string): HttpError =>
|
|
458
|
+
new HttpError({
|
|
459
|
+
code: 'X_CONFIG_INVALID',
|
|
460
|
+
cause: `verifyWebhookSignature was given ${shape} as its secret, and a mac under an empty key is one anyone can compute — every forged delivery would verify`,
|
|
461
|
+
fix: "verifyWebhookSignature(request, { secret: process.env.WEBHOOK_SECRET ?? '' }) in the api/ route receiving it, with WEBHOOK_SECRET set in the deployment environment to the signing secret the sender shows for this endpoint — an unset variable is then refused here rather than verified against",
|
|
462
|
+
meta: { option: 'webhook secret' },
|
|
463
|
+
});
|
package/src/forwarded.ts
CHANGED
|
@@ -43,10 +43,25 @@ export const forwardedElement = (
|
|
|
43
43
|
return list[index];
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
/** The plain-list form: `x-forwarded-for
|
|
46
|
+
/** The plain-list form: `x-forwarded-for`. */
|
|
47
47
|
export const forwardedValue = (header: string | null, hops: number): string | undefined =>
|
|
48
48
|
forwardedElement(header, hops);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* `x-forwarded-proto`, which proxies OVERWRITE far more often than they append (nginx's `$scheme`,
|
|
52
|
+
* an ALB, Traefik): behind two hops it holds one value while `x-forwarded-for` holds two. A full
|
|
53
|
+
* chain is read exactly like an address. A SHORTER one is not a forgery here: an overwrite erased
|
|
54
|
+
* everything client-ward of it, so every entry left was written by a trusted hop, and the
|
|
55
|
+
* outermost of them — index 0 — is the one that saw the client's leg. A client value survives only
|
|
56
|
+
* when no trusted hop writes the header at all, the same exposure `hops: 1` has always had.
|
|
57
|
+
*/
|
|
58
|
+
export const forwardedProto = (header: string | null, hops: number): string | undefined => {
|
|
59
|
+
if (header === null || hops < 1) return undefined;
|
|
60
|
+
const list = plainSplit(header, ',');
|
|
61
|
+
if (list.length === 0) return undefined;
|
|
62
|
+
return list[Math.max(0, list.length - hops)];
|
|
63
|
+
};
|
|
64
|
+
|
|
50
65
|
/**
|
|
51
66
|
* `1.2.3.4:5678` and `[::1]:443` are both legal in an `x-forwarded-for` entry; the port is the
|
|
52
67
|
* proxy's bookkeeping and would make every connection its own rate-limit bucket.
|
|
@@ -91,12 +106,12 @@ export const clientAddress = (input: ForwardedInput): string | null =>
|
|
|
91
106
|
forwardedClientAddress(input.headers, input.config) ?? input.socketAddress;
|
|
92
107
|
|
|
93
108
|
/**
|
|
94
|
-
* Whether the CLIENT's leg of the connection was TLS.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
109
|
+
* Whether the CLIENT's leg of the connection was TLS. Hop-indexed like the address, because
|
|
110
|
+
* `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether a
|
|
111
|
+
* two-year `includeSubDomains` HSTS policy goes out. `forwardedProto` says why a short list counts.
|
|
97
112
|
*/
|
|
98
113
|
export const clientUsedHttps = (input: ForwardedInput): boolean => {
|
|
99
|
-
const forwarded =
|
|
114
|
+
const forwarded = forwardedProto(
|
|
100
115
|
input.headers.get(FORWARDED_PROTO),
|
|
101
116
|
input.config.trustedProxyHops,
|
|
102
117
|
);
|
package/src/health-disclosure.ts
CHANGED
|
@@ -12,9 +12,6 @@ import type { HttpConfig } from './config';
|
|
|
12
12
|
import { HttpError } from './errors';
|
|
13
13
|
import { forwardedClientAddress } from './forwarded';
|
|
14
14
|
|
|
15
|
-
/** Core's, named here for the config that defaults to it — never a second copy of the list. */
|
|
16
|
-
export { DEFAULT_HEALTH_DETAIL_PEERS } from '@ultimat3/core';
|
|
17
|
-
|
|
18
15
|
/** Every class an entry may name. A `Record` so a class core adds is a build error here. */
|
|
19
16
|
const ADDRESS_CLASSES = Object.freeze<Record<AddressClass, true>>({
|
|
20
17
|
loopback: true,
|
package/src/hooks.ts
CHANGED
|
@@ -40,12 +40,13 @@ export interface ServerHooks {
|
|
|
40
40
|
/**
|
|
41
41
|
* The app's OWN error page for a status, served byte for byte, or `undefined` to render the
|
|
42
42
|
* framework's. A seam and not a config value because the answer lives on a disk this package
|
|
43
|
-
* cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html
|
|
44
|
-
* dropped into a running server takes effect without a restart
|
|
45
|
-
* already follows
|
|
43
|
+
* cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html` — per request under
|
|
44
|
+
* `x dev`, so a file dropped into a running server takes effect without a restart (the rule
|
|
45
|
+
* `/favicon.ico` already follows), and once per status in a container.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Asked on the HTML path only — an agent gets the problem document in both environments. In
|
|
48
|
+
* production for every status; in dev for a 4xx alone (the page a visitor will see), with the
|
|
49
|
+
* overlay when it answers `undefined`. A dev 5xx is always the overlay and never asks.
|
|
49
50
|
*/
|
|
50
51
|
readonly errorPage?: (
|
|
51
52
|
status: number,
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// The public surface of @ultimat3/http. Explicit, never `export *`: what is not
|
|
2
2
|
// listed here is an implementation detail and may change without a major bump.
|
|
3
3
|
|
|
4
|
+
// Imported bare, and the one module `sideEffects` lists: the codes' titles and the `retry-after`
|
|
5
|
+
// class of a 429 and a 503 are read by `UltimateError`, which never imports this module — so a
|
|
6
|
+
// server realm that reaches the barrel registers them whatever it constructs. `SIDE_EFFECTS_ANCHORS`
|
|
7
|
+
// carries the argument and `bun run side-effects` enforces it.
|
|
8
|
+
import './error-titles';
|
|
9
|
+
|
|
4
10
|
export type { PgExecutor, RenderMode } from '@ultimat3/core';
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
export {
|
|
9
|
-
escapeHtml,
|
|
10
|
-
isCanonicalWebhookField,
|
|
11
|
-
readCookie,
|
|
12
|
-
WEBHOOK_ID_HEADER,
|
|
13
|
-
WEBHOOK_SIGNATURE_HEADER,
|
|
14
|
-
WEBHOOK_SIGNATURE_VERSION,
|
|
15
|
-
WEBHOOK_TOPIC_HEADER,
|
|
16
|
-
} from '@ultimat3/core';
|
|
11
|
+
// No core VALUE is re-exported here (`X_HELPER_COPY`, `bun run flight-copies`): the webhook wire
|
|
12
|
+
// format (`WEBHOOK_*`, `isCanonicalWebhookField`), `escapeHtml` and `readCookie` are imported from
|
|
13
|
+
// `@ultimat3/core`, their one home, by every caller.
|
|
17
14
|
export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
|
|
18
15
|
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
|
|
19
16
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
@@ -30,8 +27,8 @@ export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
|
30
27
|
export {
|
|
31
28
|
actorView,
|
|
32
29
|
asCtx,
|
|
33
|
-
createRequestContext,
|
|
34
30
|
elapsedMs,
|
|
31
|
+
requestContext,
|
|
35
32
|
useRequestBodyBytes,
|
|
36
33
|
useRequestContext,
|
|
37
34
|
useRequestCookie,
|
|
@@ -44,7 +41,7 @@ export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } fro
|
|
|
44
41
|
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
45
42
|
export { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
|
|
46
43
|
export type { Deadline } from './deadline';
|
|
47
|
-
export {
|
|
44
|
+
export { resolveTimeoutMs, startDeadline } from './deadline';
|
|
48
45
|
export type { ErrorFacts, ProblemDocument } from './error-facts';
|
|
49
46
|
export {
|
|
50
47
|
factsOf,
|
|
@@ -125,7 +122,7 @@ export { OVERLAY_STYLE } from './overlay-style';
|
|
|
125
122
|
export type { PeerIdentity } from './peer-identity';
|
|
126
123
|
export { peerIdentity } from './peer-identity';
|
|
127
124
|
export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
|
|
128
|
-
export {
|
|
125
|
+
export { httpPipeline, PIPELINE_STAGES } from './pipeline';
|
|
129
126
|
export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
|
|
130
127
|
export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
|
|
131
128
|
export type {
|
|
@@ -142,11 +139,11 @@ export type {
|
|
|
142
139
|
} from './rate-limit';
|
|
143
140
|
export {
|
|
144
141
|
assertRateLimitScope,
|
|
145
|
-
createRateLimiter,
|
|
146
142
|
DEFAULT_MAX_RATE_LIMIT_KEYS,
|
|
147
143
|
DEFAULT_RATE_LIMIT,
|
|
148
144
|
memoryRateLimitStore,
|
|
149
145
|
rateLimitDecision,
|
|
146
|
+
rateLimiter,
|
|
150
147
|
rateLimitSpends,
|
|
151
148
|
resolveRateLimitConfig,
|
|
152
149
|
toBucket,
|
|
@@ -162,6 +159,15 @@ export {
|
|
|
162
159
|
rateLimitStoreUnavailable,
|
|
163
160
|
tenantBucketUnknown,
|
|
164
161
|
} from './rate-limit-errors';
|
|
162
|
+
export { publishRateLimit, rateLimitHeaders } from './rate-limit-headers';
|
|
163
|
+
export type { AdoptRateLimitStoreOptions } from './rate-limit-installed';
|
|
164
|
+
export {
|
|
165
|
+
adoptRateLimitStore,
|
|
166
|
+
assertInstalledRateLimitScope,
|
|
167
|
+
installedRateLimitStore,
|
|
168
|
+
installRateLimitStore,
|
|
169
|
+
resetRateLimitStore,
|
|
170
|
+
} from './rate-limit-installed';
|
|
165
171
|
export type { RateLimitPeek } from './rate-limit-peek';
|
|
166
172
|
export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
167
173
|
export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
|
|
@@ -172,18 +178,20 @@ export {
|
|
|
172
178
|
export { setRedirect, takeRedirect } from './redirect';
|
|
173
179
|
export type { QueryValues } from './request';
|
|
174
180
|
export { UltimateRequest } from './request';
|
|
181
|
+
export type { RequestFacts, RequestFactsInput } from './request-facts';
|
|
182
|
+
export { requestFacts } from './request-facts';
|
|
175
183
|
export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
|
|
176
184
|
export {
|
|
177
185
|
applyCacheHeaders,
|
|
178
186
|
cacheControl,
|
|
179
187
|
html,
|
|
180
|
-
|
|
188
|
+
jsonResponse,
|
|
181
189
|
NO_STORE,
|
|
182
190
|
noContent,
|
|
183
191
|
problem,
|
|
184
192
|
redirect,
|
|
185
193
|
stream,
|
|
186
|
-
|
|
194
|
+
textResponse,
|
|
187
195
|
withHeaders,
|
|
188
196
|
} from './response';
|
|
189
197
|
export type {
|
|
@@ -198,9 +206,9 @@ export type {
|
|
|
198
206
|
RouteTable,
|
|
199
207
|
} from './router';
|
|
200
208
|
export {
|
|
201
|
-
createRouter,
|
|
202
209
|
describeRoutes,
|
|
203
210
|
HTTP_METHODS,
|
|
211
|
+
httpRouter,
|
|
204
212
|
matchRoute,
|
|
205
213
|
} from './router';
|
|
206
214
|
export type { SecurityConfig } from './security-headers';
|
|
@@ -212,12 +220,14 @@ export type {
|
|
|
212
220
|
UpgradeTarget,
|
|
213
221
|
WebSocketMount,
|
|
214
222
|
} from './server';
|
|
215
|
-
export {
|
|
223
|
+
export { httpServer } from './server';
|
|
224
|
+
export type { DeleteCookieOptions } from './set-cookie';
|
|
225
|
+
export { deleteCookie, setCookie } from './set-cookie';
|
|
216
226
|
// The stage vocabulary comes from its declaration site, beside the fourteen implementations it
|
|
217
227
|
// names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
|
|
218
228
|
export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
|
|
219
229
|
export type { InferOutput, Schema, ValidationOutcome } from './validate';
|
|
220
|
-
export { formatIssue,
|
|
230
|
+
export { formatIssue, validateBody, validateSync } from './validate';
|
|
221
231
|
export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
|
|
222
232
|
export {
|
|
223
233
|
DEFAULT_WEBHOOK_BODY_LIMIT,
|
package/src/navigation.ts
CHANGED
|
@@ -39,7 +39,8 @@ const WEB_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
|
39
39
|
* whenever a redirect target is caller-influenced. Such a target — and one that will not parse —
|
|
40
40
|
* answers the REQUESTED url instead, as a path: a document load of it re-runs the route, and the
|
|
41
41
|
* browser's own redirect handling refuses what this refused. Total, never a throw: it runs in the
|
|
42
|
-
* `response` stage, after the handler.
|
|
42
|
+
* `response` stage, after the handler. A same-origin pathname is never emitted starting `//`
|
|
43
|
+
* (`hostlessPath`).
|
|
43
44
|
*/
|
|
44
45
|
export function locationFor(target: string, base: URL): string {
|
|
45
46
|
const requested = `${base.pathname}${base.search}${base.hash}`;
|
|
@@ -47,7 +48,18 @@ export function locationFor(target: string, base: URL): string {
|
|
|
47
48
|
const resolved = new URL(target, base);
|
|
48
49
|
if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
|
|
49
50
|
if (resolved.origin !== base.origin) return target;
|
|
50
|
-
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
51
|
+
return `${hostlessPath(resolved.pathname)}${resolved.search}${resolved.hash}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A pathname that starts `//` — `/.//evil.test`, `/..//evil.test`, a request for `//evil.test` —
|
|
56
|
+
* is a path on THIS origin only while the URL keeps its host. Emitted alone it is a
|
|
57
|
+
* scheme-relative reference, and the router resolves it to another host and assigns that to
|
|
58
|
+
* `window.location`. `/.` in front is how WHATWG URL serialisation keeps a host-less `//` path a
|
|
59
|
+
* path; it resolves back to the same pathname, so nothing legitimate changes.
|
|
60
|
+
*/
|
|
61
|
+
function hostlessPath(pathname: string): string {
|
|
62
|
+
return pathname.startsWith('//') ? `/.${pathname}` : pathname;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** "Load this with a real navigation" — never stored anywhere, it answers one request. */
|
package/src/pipeline.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// `finalize.ts` owns the promise that the tail always answers rather than rejecting.
|
|
6
6
|
import { beginWork, recordRequest, runWithContext, withSpan } from '@ultimat3/core';
|
|
7
7
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
8
|
-
import { asCtx,
|
|
8
|
+
import { asCtx, elapsedMs, type RequestContext, requestContext } from './context';
|
|
9
9
|
import { readCorrelation } from './correlation';
|
|
10
10
|
import { type Deadline, startDeadline } from './deadline';
|
|
11
11
|
import { pipelineNoResponse } from './errors';
|
|
@@ -14,8 +14,9 @@ import { clientAddress, clientUsedHttps } from './forwarded';
|
|
|
14
14
|
import type { ServerHooks } from './hooks';
|
|
15
15
|
import type { Middleware } from './middleware';
|
|
16
16
|
import { peerIdentity } from './peer-identity';
|
|
17
|
-
import { assertRateLimitScope,
|
|
17
|
+
import { assertRateLimitScope, type RateLimiter, rateLimiter } from './rate-limit';
|
|
18
18
|
import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
19
|
+
import { assertInstalledRateLimitScope } from './rate-limit-installed';
|
|
19
20
|
import { UltimateRequest } from './request';
|
|
20
21
|
import { problem } from './response';
|
|
21
22
|
import type { RouteTable } from './router';
|
|
@@ -109,7 +110,7 @@ export interface PipelineDeps {
|
|
|
109
110
|
readonly hooks?: ServerHooks;
|
|
110
111
|
readonly middleware?: readonly Middleware[];
|
|
111
112
|
/**
|
|
112
|
-
* A limiter built elsewhere — `
|
|
113
|
+
* A limiter built elsewhere — `httpServer({ rateLimitStore })` is the one supported way, and
|
|
113
114
|
* it hands over a limiter built from the SAME merged config this constructor would have built.
|
|
114
115
|
* One passed from anywhere else resolves bucket names against the table IT closed over, which
|
|
115
116
|
* is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
|
|
@@ -135,16 +136,19 @@ export interface Pipeline {
|
|
|
135
136
|
handle(request: Request, init: HandleInit): Promise<Response>;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export const
|
|
139
|
+
export const httpPipeline = (deps: PipelineDeps): Pipeline => {
|
|
139
140
|
// Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
|
|
140
141
|
// bucket a route declares only becomes real at the construction that holds both. Idempotent, so
|
|
141
|
-
// a config `
|
|
142
|
+
// a config `httpServer` already merged passes through unchanged.
|
|
142
143
|
const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
|
|
143
|
-
const limiter = deps.limiter ??
|
|
144
|
-
// Here rather than in `
|
|
144
|
+
const limiter = deps.limiter ?? rateLimiter({ config: config.rateLimit });
|
|
145
|
+
// Here rather than in `httpServer`: this is the one construction path every server, test and
|
|
145
146
|
// embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
|
|
146
147
|
// Two halves of one question — where the counters live, and which buckets the limiter holds.
|
|
147
148
|
assertRateLimitScope(config.rateLimit, limiter);
|
|
149
|
+
// The third half: where an action's or a query's declared `rateLimit:` is counted. Under a
|
|
150
|
+
// `'shared'` declaration that store is held to the same promise as this pipeline's own.
|
|
151
|
+
assertInstalledRateLimitScope(config.rateLimit);
|
|
148
152
|
assertRouteBuckets(limiter, deps.table.routes);
|
|
149
153
|
const run = stageRunners({
|
|
150
154
|
table: deps.table,
|
|
@@ -243,7 +247,7 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
243
247
|
socketAddress: init.ip ?? null,
|
|
244
248
|
urlProtocol: url.protocol,
|
|
245
249
|
};
|
|
246
|
-
const ctx =
|
|
250
|
+
const ctx = requestContext({
|
|
247
251
|
url,
|
|
248
252
|
method: raw.method,
|
|
249
253
|
role: init.role,
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import type { HttpConfig } from './config';
|
|
7
7
|
import type { Bucket, RateLimiter } from './rate-limit';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
rateLimitBucketConflict,
|
|
10
|
+
rateLimitBucketForPrimitive,
|
|
11
|
+
rateLimitBucketUnbound,
|
|
12
|
+
} from './rate-limit-errors';
|
|
9
13
|
import type { Route } from './router';
|
|
10
14
|
|
|
11
15
|
const same = (a: Bucket, b: Bucket): boolean =>
|
|
@@ -20,10 +24,30 @@ const same = (a: Bucket, b: Bucket): boolean =>
|
|
|
20
24
|
* number an author read, an OpenAPI document published and nothing enforced, which is the exact
|
|
21
25
|
* failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
|
|
22
26
|
*
|
|
23
|
-
* Idempotent, so both construction paths (`
|
|
27
|
+
* Idempotent, so both construction paths (`httpServer`, and `httpPipeline` under it) can apply
|
|
24
28
|
* it: a second pass compares each bucket against the copy the first pass registered.
|
|
25
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* A configured bucket keyed by a mounted primitive's NAME, which since 25.0.0 limits nothing.
|
|
32
|
+
* `default`, the tenant bucket and any name a route really selects are live, so they pass.
|
|
33
|
+
*/
|
|
34
|
+
const assertNoPrimitiveBuckets = (config: HttpConfig, routes: readonly Route[]): void => {
|
|
35
|
+
const selected = new Set<string>(['default']);
|
|
36
|
+
if (config.rateLimit.tenantBucket !== null) selected.add(config.rateLimit.tenantBucket);
|
|
37
|
+
for (const route of routes) {
|
|
38
|
+
if (route.meta.rateLimit !== undefined) selected.add(route.meta.rateLimit);
|
|
39
|
+
}
|
|
40
|
+
for (const route of routes) {
|
|
41
|
+
const primitive = route.meta.primitive;
|
|
42
|
+
const name = route.meta.name;
|
|
43
|
+
if (primitive === undefined || selected.has(name)) continue;
|
|
44
|
+
if (!Object.hasOwn(config.rateLimit.buckets, name)) continue;
|
|
45
|
+
throw rateLimitBucketForPrimitive({ bucket: name, primitive });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
26
49
|
export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
|
|
50
|
+
assertNoPrimitiveBuckets(config, routes);
|
|
27
51
|
const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
|
|
28
52
|
for (const route of routes) {
|
|
29
53
|
const bucket = route.meta.rateLimitBucket;
|
package/src/rate-limit-errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration
|
|
2
2
|
// faults the boot refuses. Split from `errors.ts` at the 500-line ceiling, on the seam it already
|
|
3
|
-
// had. The codes
|
|
4
|
-
// must see them all in a single call.
|
|
3
|
+
// had. The codes, their TITLES and the `retry-after` class are `error-titles.ts`'s, the one
|
|
4
|
+
// registry — `registerErrorCodes` must see them all in a single call.
|
|
5
5
|
import { HttpError } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -19,19 +19,24 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* At `
|
|
22
|
+
* At `httpServer`/`httpPipeline`, never on the request. `replicas: 3` behind one config means
|
|
23
23
|
* each process holds its own counters, so every configured number is enforced three times over —
|
|
24
24
|
* a green `x verify` and a limit that is not the limit. The declaration is the app's because the
|
|
25
25
|
* framework cannot see its replica count, and a framework that guessed would guess wrong.
|
|
26
26
|
*/
|
|
27
|
-
export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
|
|
27
|
+
export const rateLimitNotShared = (found: 'process' | 'disabled' | 'installed'): HttpError =>
|
|
28
28
|
new HttpError({
|
|
29
29
|
code: 'X_RATE_LIMIT_NOT_SHARED',
|
|
30
30
|
cause:
|
|
31
31
|
found === 'disabled'
|
|
32
32
|
? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
|
|
33
|
-
:
|
|
34
|
-
|
|
33
|
+
: found === 'installed'
|
|
34
|
+
? "http.rateLimit.scope is 'shared' but the store actions and queries spend their declared rateLimit from keeps its counters in this process, so each replica would enforce every declared limit on its own"
|
|
35
|
+
: "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
|
|
36
|
+
fix:
|
|
37
|
+
found === 'installed'
|
|
38
|
+
? 'installRateLimitStore(rateLimitStore) at boot, with the same postgresRateLimitStore({ executor }) passed to httpServer({ routes, rateLimitStore })'
|
|
39
|
+
: "httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
/**
|
|
@@ -46,7 +51,28 @@ interface BucketNumbers {
|
|
|
46
51
|
const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
|
-
*
|
|
54
|
+
* `rateLimit.buckets.<name>` where `<name>` is a mounted action's or query's own name. Since
|
|
55
|
+
* 25.0.0 a primitive's route carries no `meta.rateLimit` — the primitive spends its own declared
|
|
56
|
+
* limit on every surface — so that bucket limits nothing. Refused rather than kept: a number an
|
|
57
|
+
* operator reads in `configureHttp()` and nothing enforces is the failure every 12a deletion turns
|
|
58
|
+
* into a code. `X_CONFIG_INVALID`, borrowed, because it is a config key the framework cannot honour.
|
|
59
|
+
*/
|
|
60
|
+
export const rateLimitBucketForPrimitive = (input: {
|
|
61
|
+
bucket: string;
|
|
62
|
+
primitive: 'action' | 'query';
|
|
63
|
+
}): HttpError =>
|
|
64
|
+
new HttpError({
|
|
65
|
+
code: 'X_CONFIG_INVALID',
|
|
66
|
+
cause: `rateLimit.buckets.${input.bucket} is named after the mounted ${input.primitive} "${input.bucket}", and since 25.0.0 an ${input.primitive}'s route spends no named bucket — the ${input.primitive} spends its own declared rateLimit on every surface — so this bucket would limit nothing`,
|
|
67
|
+
fix:
|
|
68
|
+
input.primitive === 'query'
|
|
69
|
+
? `query({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits a query since 25.0.0 — delete it from configureHttp() and declare the limit on the query`
|
|
70
|
+
: `action({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits an action since 25.0.0 — delete it from configureHttp() and declare the limit on the action`,
|
|
71
|
+
meta: { option: `rateLimit.buckets.${input.bucket}`, primitive: input.primitive },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Two declarations of one bucket, at `httpServer`/`httpPipeline`. Neither wins: an app that
|
|
50
76
|
* configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
|
|
51
77
|
* disagree about what is enforced, and whichever a merge picked would leave the other a number
|
|
52
78
|
* someone read and nothing applies — the failure this seam exists to end. The message speaks
|
|
@@ -84,7 +110,7 @@ export const rateLimitBucketConflict = (input: {
|
|
|
84
110
|
|
|
85
111
|
/**
|
|
86
112
|
* A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
|
|
87
|
-
* `
|
|
113
|
+
* `httpPipeline`, never on the request. `rateLimiter` closes over the config it was built
|
|
88
114
|
* with, so a limiter constructed before the routes existed resolves the route's bucket name
|
|
89
115
|
* through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
|
|
90
116
|
* requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
|
|
@@ -109,7 +135,7 @@ export const rateLimitBucketUnbound = (input: {
|
|
|
109
135
|
? 'does not hold that bucket, so the route would run on the default one'
|
|
110
136
|
: `holds ${numbers(input.found)} for it`
|
|
111
137
|
}`,
|
|
112
|
-
fix: 'pass the STORE and let the pipeline build the limiter —
|
|
138
|
+
fix: 'pass the STORE and let the pipeline build the limiter — httpServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
|
|
113
139
|
});
|
|
114
140
|
|
|
115
141
|
/**
|
|
@@ -125,7 +151,7 @@ export const rateLimitScopeUnset = (): HttpError =>
|
|
|
125
151
|
code: 'X_RATE_LIMIT_SCOPE_UNSET',
|
|
126
152
|
cause:
|
|
127
153
|
'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
|
|
128
|
-
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus
|
|
154
|
+
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
|
|
129
155
|
});
|
|
130
156
|
|
|
131
157
|
/**
|