@orthacms/utils-server 0.4.2 → 0.5.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/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -1
- package/dist/lib/env.d.ts +166 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +249 -0
- package/dist/lib/filters/budgets.d.ts +79 -0
- package/dist/lib/filters/budgets.d.ts.map +1 -0
- package/dist/lib/filters/budgets.js +81 -0
- package/dist/lib/filters/filter-exceptions.d.ts +7 -0
- package/dist/lib/filters/filter-exceptions.d.ts.map +1 -1
- package/dist/lib/filters/filter-exceptions.js +8 -1
- package/dist/lib/filters/operator-support.d.ts.map +1 -1
- package/dist/lib/filters/operator-support.js +13 -1
- package/dist/lib/filters/parse-filter-tree.d.ts.map +1 -1
- package/dist/lib/filters/parse-filter-tree.js +7 -9
- package/dist/lib/filters/resolve-leaf.d.ts +14 -1
- package/dist/lib/filters/resolve-leaf.d.ts.map +1 -1
- package/dist/lib/filters/resolve-leaf.js +56 -6
- package/dist/lib/filters/scalar-op.d.ts.map +1 -1
- package/dist/lib/filters/scalar-op.js +26 -0
- package/dist/lib/filters/types.d.ts +39 -0
- package/dist/lib/filters/types.d.ts.map +1 -1
- package/dist/lib/filters/types.js +22 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export { parseFilterTree } from './lib/filters/parse-filter-tree';
|
|
2
|
+
export { FILTER_MAX_LENGTH } from './lib/filters/budgets';
|
|
2
3
|
export { applyFilterTree, type ApplyFilterTreeOptions, type FilterExtensionResolver } from './lib/filters/tree-to-drizzle';
|
|
3
4
|
export type { DbLike, TableLike } from './lib/filters/table-helpers';
|
|
4
|
-
export { FilterOperator, ScalarFieldType, RelationKind } from './lib/filters/types';
|
|
5
|
+
export { FilterOperator, ScalarFieldType, RelationKind, WithinLastUnit } from './lib/filters/types';
|
|
5
6
|
export { OPERATORS_BY_TYPE, operatorsFor } from './lib/filters/operator-support';
|
|
6
|
-
export type { ScalarFieldSchema, FieldSchema, RelationSchema, RelationScope, FilterSchema, ParsedRule, ParsedGroup, ParsedNode } from './lib/filters/types';
|
|
7
|
+
export type { WithinLastValue, ScalarFieldSchema, FieldSchema, RelationSchema, RelationScope, FilterSchema, ParsedRule, ParsedGroup, ParsedNode } from './lib/filters/types';
|
|
7
8
|
export { FilterException, FilterErrorCode, FilterSchemaException } from './lib/filters/filter-exceptions';
|
|
8
9
|
export { clampInt } from './lib/clamp-int';
|
|
9
10
|
export { isForeignKeyViolation, isUniqueViolation, violatedConstraint } from './lib/pg-errors';
|
|
11
|
+
export { readEnv, requireEnv, readPositiveInt, readOptionalPositiveInt, readList, readOptionalList, readFlag, readTrustProxy, readNodeEnv, isProduction, when, defined, NODE_ENVS, type NodeEnv } from './lib/env';
|
|
10
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EACH,eAAe,EACf,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAC/B,MAAM,+BAA+B,CAAC;AACvC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EACH,cAAc,EACd,eAAe,EACf,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EACH,eAAe,EACf,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAC/B,MAAM,+BAA+B,CAAC;AACvC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EACH,cAAc,EACd,eAAe,EACf,YAAY,EACZ,cAAc,EACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACH,iBAAiB,EACjB,YAAY,EACf,MAAM,gCAAgC,CAAC;AACxC,YAAY,EACR,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,aAAa,EACb,YAAY,EACZ,UAAU,EACV,WAAW,EACX,UAAU,EACb,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACH,eAAe,EACf,eAAe,EACf,qBAAqB,EACxB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EACH,qBAAqB,EACrB,iBAAiB,EACjB,kBAAkB,EACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACH,OAAO,EACP,UAAU,EACV,eAAe,EACf,uBAAuB,EACvB,QAAQ,EACR,gBAAgB,EAChB,QAAQ,EACR,cAAc,EACd,WAAW,EACX,YAAY,EACZ,IAAI,EACJ,OAAO,EACP,SAAS,EACT,KAAK,OAAO,EACf,MAAM,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.violatedConstraint = exports.isUniqueViolation = exports.isForeignKeyViolation = exports.clampInt = exports.FilterSchemaException = exports.FilterErrorCode = exports.FilterException = exports.operatorsFor = exports.OPERATORS_BY_TYPE = exports.RelationKind = exports.ScalarFieldType = exports.FilterOperator = exports.applyFilterTree = exports.parseFilterTree = void 0;
|
|
3
|
+
exports.NODE_ENVS = exports.defined = exports.when = exports.isProduction = exports.readNodeEnv = exports.readTrustProxy = exports.readFlag = exports.readOptionalList = exports.readList = exports.readOptionalPositiveInt = exports.readPositiveInt = exports.requireEnv = exports.readEnv = exports.violatedConstraint = exports.isUniqueViolation = exports.isForeignKeyViolation = exports.clampInt = exports.FilterSchemaException = exports.FilterErrorCode = exports.FilterException = exports.operatorsFor = exports.OPERATORS_BY_TYPE = exports.WithinLastUnit = exports.RelationKind = exports.ScalarFieldType = exports.FilterOperator = exports.applyFilterTree = exports.FILTER_MAX_LENGTH = exports.parseFilterTree = void 0;
|
|
4
4
|
var parse_filter_tree_1 = require("./lib/filters/parse-filter-tree");
|
|
5
5
|
Object.defineProperty(exports, "parseFilterTree", { enumerable: true, get: function () { return parse_filter_tree_1.parseFilterTree; } });
|
|
6
|
+
var budgets_1 = require("./lib/filters/budgets");
|
|
7
|
+
Object.defineProperty(exports, "FILTER_MAX_LENGTH", { enumerable: true, get: function () { return budgets_1.FILTER_MAX_LENGTH; } });
|
|
6
8
|
var tree_to_drizzle_1 = require("./lib/filters/tree-to-drizzle");
|
|
7
9
|
Object.defineProperty(exports, "applyFilterTree", { enumerable: true, get: function () { return tree_to_drizzle_1.applyFilterTree; } });
|
|
8
10
|
var types_1 = require("./lib/filters/types");
|
|
9
11
|
Object.defineProperty(exports, "FilterOperator", { enumerable: true, get: function () { return types_1.FilterOperator; } });
|
|
10
12
|
Object.defineProperty(exports, "ScalarFieldType", { enumerable: true, get: function () { return types_1.ScalarFieldType; } });
|
|
11
13
|
Object.defineProperty(exports, "RelationKind", { enumerable: true, get: function () { return types_1.RelationKind; } });
|
|
14
|
+
Object.defineProperty(exports, "WithinLastUnit", { enumerable: true, get: function () { return types_1.WithinLastUnit; } });
|
|
12
15
|
var operator_support_1 = require("./lib/filters/operator-support");
|
|
13
16
|
Object.defineProperty(exports, "OPERATORS_BY_TYPE", { enumerable: true, get: function () { return operator_support_1.OPERATORS_BY_TYPE; } });
|
|
14
17
|
Object.defineProperty(exports, "operatorsFor", { enumerable: true, get: function () { return operator_support_1.operatorsFor; } });
|
|
@@ -22,3 +25,17 @@ var pg_errors_1 = require("./lib/pg-errors");
|
|
|
22
25
|
Object.defineProperty(exports, "isForeignKeyViolation", { enumerable: true, get: function () { return pg_errors_1.isForeignKeyViolation; } });
|
|
23
26
|
Object.defineProperty(exports, "isUniqueViolation", { enumerable: true, get: function () { return pg_errors_1.isUniqueViolation; } });
|
|
24
27
|
Object.defineProperty(exports, "violatedConstraint", { enumerable: true, get: function () { return pg_errors_1.violatedConstraint; } });
|
|
28
|
+
var env_1 = require("./lib/env");
|
|
29
|
+
Object.defineProperty(exports, "readEnv", { enumerable: true, get: function () { return env_1.readEnv; } });
|
|
30
|
+
Object.defineProperty(exports, "requireEnv", { enumerable: true, get: function () { return env_1.requireEnv; } });
|
|
31
|
+
Object.defineProperty(exports, "readPositiveInt", { enumerable: true, get: function () { return env_1.readPositiveInt; } });
|
|
32
|
+
Object.defineProperty(exports, "readOptionalPositiveInt", { enumerable: true, get: function () { return env_1.readOptionalPositiveInt; } });
|
|
33
|
+
Object.defineProperty(exports, "readList", { enumerable: true, get: function () { return env_1.readList; } });
|
|
34
|
+
Object.defineProperty(exports, "readOptionalList", { enumerable: true, get: function () { return env_1.readOptionalList; } });
|
|
35
|
+
Object.defineProperty(exports, "readFlag", { enumerable: true, get: function () { return env_1.readFlag; } });
|
|
36
|
+
Object.defineProperty(exports, "readTrustProxy", { enumerable: true, get: function () { return env_1.readTrustProxy; } });
|
|
37
|
+
Object.defineProperty(exports, "readNodeEnv", { enumerable: true, get: function () { return env_1.readNodeEnv; } });
|
|
38
|
+
Object.defineProperty(exports, "isProduction", { enumerable: true, get: function () { return env_1.isProduction; } });
|
|
39
|
+
Object.defineProperty(exports, "when", { enumerable: true, get: function () { return env_1.when; } });
|
|
40
|
+
Object.defineProperty(exports, "defined", { enumerable: true, get: function () { return env_1.defined; } });
|
|
41
|
+
Object.defineProperty(exports, "NODE_ENVS", { enumerable: true, get: function () { return env_1.NODE_ENVS; } });
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `process.env` into typed configuration.
|
|
3
|
+
*
|
|
4
|
+
* A host's `ortha.config.ts` is the one file allowed to touch the environment,
|
|
5
|
+
* and every deployment writes the same handful of readers to do it: a required
|
|
6
|
+
* string, a bounded number, a comma list, `TRUST_PROXY`, `NODE_ENV`. They were
|
|
7
|
+
* duplicated between this repo's host and the scaffolder's template, where the
|
|
8
|
+
* copies had already drifted — the template's number reader had no
|
|
9
|
+
* "optional, no default" form, so a generated app could not express a ceiling
|
|
10
|
+
* that means "leave the plugin's own default alone".
|
|
11
|
+
*
|
|
12
|
+
* Every reader here **throws rather than guesses**. That is the whole point of
|
|
13
|
+
* the module: the failure mode of environment parsing is silence, and a config
|
|
14
|
+
* value that is quietly wrong is discovered a session TTL later, or by reading
|
|
15
|
+
* a `Set-Cookie` header, rather than at boot with the variable named.
|
|
16
|
+
*
|
|
17
|
+
* The last two exports — {@link when} and {@link defined} — read nothing. They
|
|
18
|
+
* are here because they are what a host does with the readers' `undefined`:
|
|
19
|
+
* plugins merge their settings as `{ ...DEFAULTS, ...config }`, so an unset
|
|
20
|
+
* value must become an *absent key* rather than an explicit `undefined`, which
|
|
21
|
+
* would erase the default it was meant to leave alone. Every host writing that
|
|
22
|
+
* as `...(x ? { key } : {})` at each call site is the duplication this module
|
|
23
|
+
* exists to end.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* A trimmed environment value, `undefined` when unset **or empty**.
|
|
27
|
+
*
|
|
28
|
+
* The one reader every other reader here is built on, so "empty means not
|
|
29
|
+
* configured" is decided once. That equivalence matters: `.env.example` ships
|
|
30
|
+
* keys with no value, and `ANTHROPIC_API_KEY=` has to leave a deployment with
|
|
31
|
+
* *no* Claude backend rather than one registered with an empty key — a backend
|
|
32
|
+
* that is in the picker and fails on the first message.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readEnv(name: string): string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Reads a value the app cannot run without, failing at load rather than
|
|
37
|
+
* several seconds into boot.
|
|
38
|
+
*
|
|
39
|
+
* Left to default to `''`, a missing `DATABASE_URL` reaches `pg` as "use the
|
|
40
|
+
* libpq defaults", and the first thing that touches the database fails with
|
|
41
|
+
* whatever the local libpq environment happens to produce (measured: `SASL:
|
|
42
|
+
* SCRAM-SERVER-FIRST-MESSAGE: client password must be a string`). The server
|
|
43
|
+
* does fail closed, which is the important half, but nothing in that message
|
|
44
|
+
* names the variable that was never set.
|
|
45
|
+
*
|
|
46
|
+
* @param name The environment variable to read.
|
|
47
|
+
* @param hint Appended to the error, for a deployment-specific instruction —
|
|
48
|
+
* which file to copy, which secret store to look in. Omitted, the message
|
|
49
|
+
* says only that there is no usable default.
|
|
50
|
+
*/
|
|
51
|
+
export declare function requireEnv(name: string, hint?: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Reads a numeric setting: the default when unset or empty, the value when it
|
|
54
|
+
* is a plain positive decimal integer, and an error otherwise.
|
|
55
|
+
*
|
|
56
|
+
* This replaces `Number(process.env[x]) || default`, which was wrong in three
|
|
57
|
+
* directions at once and silent in all of them. `0` is falsy, so it became the
|
|
58
|
+
* default — `LOGIN_RATE_LIMIT=0` ("block every login") quietly meant 10. A
|
|
59
|
+
* negative is truthy, so it was accepted — `SESSION_TTL_SECONDS=-1` issued
|
|
60
|
+
* every session already expired, login answering `201` and the very next
|
|
61
|
+
* request `401`. And exponent notation parsed, so `GRAPHQL_MAX_DEPTH=1e9`
|
|
62
|
+
* removed the cost budget that ADR-0008 calls GraphQL's replacement for REST's
|
|
63
|
+
* structural bound. Refusing to boot names the variable; the alternative was a
|
|
64
|
+
* deployment that looked configured and was not.
|
|
65
|
+
*
|
|
66
|
+
* Empty is deliberately *not* an error: `.env.example` ships several keys with
|
|
67
|
+
* no value, and a fresh clone must boot from it unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readPositiveInt(name: string, fallback: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* As {@link readPositiveInt}, but `undefined` when unset — no default to fall
|
|
72
|
+
* back to.
|
|
73
|
+
*
|
|
74
|
+
* The form a *ceiling* needs. A plugin that ships its own default cannot be
|
|
75
|
+
* handed `{ maxSteps: undefined }`, because spreading that overwrites the
|
|
76
|
+
* default with nothing; the caller conditionally spreads on this instead.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readOptionalPositiveInt(name: string): number | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* A comma-separated list setting, trimmed and emptied of blanks.
|
|
81
|
+
*
|
|
82
|
+
* An explicitly empty value yields an empty list rather than the fallback,
|
|
83
|
+
* because "allow no origins" is a setting somebody means.
|
|
84
|
+
*/
|
|
85
|
+
export declare function readList(name: string, fallback: string): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Express's `trust proxy` setting, in the three shapes it accepts.
|
|
88
|
+
*
|
|
89
|
+
* Checked in this order: a hop count (`'1'` — the recommended form, and the
|
|
90
|
+
* only one a client cannot forge past), a boolean (`'true'` trusts the entire
|
|
91
|
+
* `X-Forwarded-For` chain, `'false'` trusts none), or any other non-empty
|
|
92
|
+
* string, passed to Express verbatim as a subnet/preset list (`'loopback'`,
|
|
93
|
+
* `'10.0.0.0/8'`). Unset yields `undefined`, leaving Express's default of
|
|
94
|
+
* ignoring forwarded headers entirely.
|
|
95
|
+
*
|
|
96
|
+
* Returns the union structurally rather than importing `TrustProxySetting`
|
|
97
|
+
* from `@orthacms/bootstrap-server`: this is a leaf helper package, and the
|
|
98
|
+
* host importing it must not become a dependency of it. The host's own
|
|
99
|
+
* `trustProxy?: TrustProxySetting` field is what checks the two agree.
|
|
100
|
+
*/
|
|
101
|
+
export declare function readTrustProxy(name?: string): boolean | number | string | undefined;
|
|
102
|
+
/** The deployment modes an Ortha app recognises. */
|
|
103
|
+
export declare const NODE_ENVS: readonly ["development", "test", "production"];
|
|
104
|
+
/** One of {@link NODE_ENVS}. */
|
|
105
|
+
export type NodeEnv = (typeof NODE_ENVS)[number];
|
|
106
|
+
/**
|
|
107
|
+
* Reads `NODE_ENV`, rejecting a value that is neither recognised nor empty.
|
|
108
|
+
*
|
|
109
|
+
* `NODE_ENV !== 'production'` is the switch behind **two** protections at once
|
|
110
|
+
* — whether the API reference and GraphiQL are published, and whether the
|
|
111
|
+
* session cookie carries `Secure` — so any value that is not exactly
|
|
112
|
+
* `production` turns both off. Unset is a legitimate, and the common, local
|
|
113
|
+
* state; a *typo* is not, and it is indistinguishable from correct
|
|
114
|
+
* configuration until you read a `Set-Cookie` header. Measured: `produciton`
|
|
115
|
+
* serves `/reference/json` to an unauthenticated caller and drops `Secure`
|
|
116
|
+
* from the session cookie, exactly as if nothing had been set (ORT-137).
|
|
117
|
+
*
|
|
118
|
+
* Rejecting the typo costs a deployment that spells it right nothing, and
|
|
119
|
+
* turns a silent downgrade into a refusal to start.
|
|
120
|
+
*/
|
|
121
|
+
export declare function readNodeEnv(): NodeEnv | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* True only in a deployment that said `production`, with the spelling checked
|
|
124
|
+
* by {@link readNodeEnv}.
|
|
125
|
+
*/
|
|
126
|
+
export declare function isProduction(): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* As {@link readList}, but `undefined` when the variable is unset, so the
|
|
129
|
+
* consumer's own default list survives rather than being replaced by an empty
|
|
130
|
+
* one.
|
|
131
|
+
*
|
|
132
|
+
* The list counterpart to {@link readOptionalPositiveInt}, and needed for the
|
|
133
|
+
* same reason: a setting whose owner ships a default cannot be handed an empty
|
|
134
|
+
* array meaning "the deployment said nothing".
|
|
135
|
+
*/
|
|
136
|
+
export declare function readOptionalList(name: string): string[] | undefined;
|
|
137
|
+
/**
|
|
138
|
+
* A boolean setting: what the deployment said, or `fallback` when it said
|
|
139
|
+
* nothing.
|
|
140
|
+
*
|
|
141
|
+
* Anything other than `true` reads as false, so a typo turns a switch **off**
|
|
142
|
+
* rather than on. That direction is deliberate: the switches this reads are
|
|
143
|
+
* kill switches for surfaces that send content to a third party or open a door
|
|
144
|
+
* for an external agent, and a misspelling should not open one.
|
|
145
|
+
*/
|
|
146
|
+
export declare function readFlag(name: string, fallback: boolean): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* The value when the setting was configured, `undefined` when it was not.
|
|
149
|
+
*
|
|
150
|
+
* The counterpart to {@link defined}: together they replace
|
|
151
|
+
* `...(x ? { key: … } : {})` at a config site. `build` is a thunk so its body —
|
|
152
|
+
* often several further reads — runs only when it applies, which is what lets a
|
|
153
|
+
* whole provider block sit behind one credential check.
|
|
154
|
+
*/
|
|
155
|
+
export declare function when<T>(configured: unknown, build: () => T): T | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* The same object with every `undefined`-valued key removed.
|
|
158
|
+
*
|
|
159
|
+
* A plugin merges its own defaults as `{ ...DEFAULTS, ...config }`, so an
|
|
160
|
+
* explicit `{ maxSteps: undefined }` does not leave `DEFAULT_RUN_LIMITS.maxSteps`
|
|
161
|
+
* in place — it erases it. Dropping the key is what "the deployment did not set
|
|
162
|
+
* this" has to mean, and saying it once at the end of a builder is why a config
|
|
163
|
+
* file no longer needs a conditional per optional setting.
|
|
164
|
+
*/
|
|
165
|
+
export declare function defined<T extends object>(value: T): T;
|
|
166
|
+
//# sourceMappingURL=env.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/lib/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAcxE;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAKjE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC1B,IAAI,SAAgB,GACrB,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAavC;AAED,oDAAoD;AACpD,eAAO,MAAM,SAAS,gDAAiD,CAAC;AAExE,gCAAgC;AAChC,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,IAAI,OAAO,GAAG,SAAS,CAejD;AAED;;;GAGG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAEtC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAEnE;AAED;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAGjE;AAED;;;;;;;GAOG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,GAAG,SAAS,CAE1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAQrD"}
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reading `process.env` into typed configuration.
|
|
4
|
+
*
|
|
5
|
+
* A host's `ortha.config.ts` is the one file allowed to touch the environment,
|
|
6
|
+
* and every deployment writes the same handful of readers to do it: a required
|
|
7
|
+
* string, a bounded number, a comma list, `TRUST_PROXY`, `NODE_ENV`. They were
|
|
8
|
+
* duplicated between this repo's host and the scaffolder's template, where the
|
|
9
|
+
* copies had already drifted — the template's number reader had no
|
|
10
|
+
* "optional, no default" form, so a generated app could not express a ceiling
|
|
11
|
+
* that means "leave the plugin's own default alone".
|
|
12
|
+
*
|
|
13
|
+
* Every reader here **throws rather than guesses**. That is the whole point of
|
|
14
|
+
* the module: the failure mode of environment parsing is silence, and a config
|
|
15
|
+
* value that is quietly wrong is discovered a session TTL later, or by reading
|
|
16
|
+
* a `Set-Cookie` header, rather than at boot with the variable named.
|
|
17
|
+
*
|
|
18
|
+
* The last two exports — {@link when} and {@link defined} — read nothing. They
|
|
19
|
+
* are here because they are what a host does with the readers' `undefined`:
|
|
20
|
+
* plugins merge their settings as `{ ...DEFAULTS, ...config }`, so an unset
|
|
21
|
+
* value must become an *absent key* rather than an explicit `undefined`, which
|
|
22
|
+
* would erase the default it was meant to leave alone. Every host writing that
|
|
23
|
+
* as `...(x ? { key } : {})` at each call site is the duplication this module
|
|
24
|
+
* exists to end.
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.NODE_ENVS = void 0;
|
|
28
|
+
exports.readEnv = readEnv;
|
|
29
|
+
exports.requireEnv = requireEnv;
|
|
30
|
+
exports.readPositiveInt = readPositiveInt;
|
|
31
|
+
exports.readOptionalPositiveInt = readOptionalPositiveInt;
|
|
32
|
+
exports.readList = readList;
|
|
33
|
+
exports.readTrustProxy = readTrustProxy;
|
|
34
|
+
exports.readNodeEnv = readNodeEnv;
|
|
35
|
+
exports.isProduction = isProduction;
|
|
36
|
+
exports.readOptionalList = readOptionalList;
|
|
37
|
+
exports.readFlag = readFlag;
|
|
38
|
+
exports.when = when;
|
|
39
|
+
exports.defined = defined;
|
|
40
|
+
/**
|
|
41
|
+
* A trimmed environment value, `undefined` when unset **or empty**.
|
|
42
|
+
*
|
|
43
|
+
* The one reader every other reader here is built on, so "empty means not
|
|
44
|
+
* configured" is decided once. That equivalence matters: `.env.example` ships
|
|
45
|
+
* keys with no value, and `ANTHROPIC_API_KEY=` has to leave a deployment with
|
|
46
|
+
* *no* Claude backend rather than one registered with an empty key — a backend
|
|
47
|
+
* that is in the picker and fails on the first message.
|
|
48
|
+
*/
|
|
49
|
+
function readEnv(name) {
|
|
50
|
+
return process.env[name]?.trim() || undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads a value the app cannot run without, failing at load rather than
|
|
54
|
+
* several seconds into boot.
|
|
55
|
+
*
|
|
56
|
+
* Left to default to `''`, a missing `DATABASE_URL` reaches `pg` as "use the
|
|
57
|
+
* libpq defaults", and the first thing that touches the database fails with
|
|
58
|
+
* whatever the local libpq environment happens to produce (measured: `SASL:
|
|
59
|
+
* SCRAM-SERVER-FIRST-MESSAGE: client password must be a string`). The server
|
|
60
|
+
* does fail closed, which is the important half, but nothing in that message
|
|
61
|
+
* names the variable that was never set.
|
|
62
|
+
*
|
|
63
|
+
* @param name The environment variable to read.
|
|
64
|
+
* @param hint Appended to the error, for a deployment-specific instruction —
|
|
65
|
+
* which file to copy, which secret store to look in. Omitted, the message
|
|
66
|
+
* says only that there is no usable default.
|
|
67
|
+
*/
|
|
68
|
+
function requireEnv(name, hint) {
|
|
69
|
+
const raw = readEnv(name);
|
|
70
|
+
if (!raw) {
|
|
71
|
+
throw new Error(`Missing required environment variable ${name}. ` +
|
|
72
|
+
(hint ?? 'The server has no usable default for this value.'));
|
|
73
|
+
}
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reads a numeric setting: the default when unset or empty, the value when it
|
|
78
|
+
* is a plain positive decimal integer, and an error otherwise.
|
|
79
|
+
*
|
|
80
|
+
* This replaces `Number(process.env[x]) || default`, which was wrong in three
|
|
81
|
+
* directions at once and silent in all of them. `0` is falsy, so it became the
|
|
82
|
+
* default — `LOGIN_RATE_LIMIT=0` ("block every login") quietly meant 10. A
|
|
83
|
+
* negative is truthy, so it was accepted — `SESSION_TTL_SECONDS=-1` issued
|
|
84
|
+
* every session already expired, login answering `201` and the very next
|
|
85
|
+
* request `401`. And exponent notation parsed, so `GRAPHQL_MAX_DEPTH=1e9`
|
|
86
|
+
* removed the cost budget that ADR-0008 calls GraphQL's replacement for REST's
|
|
87
|
+
* structural bound. Refusing to boot names the variable; the alternative was a
|
|
88
|
+
* deployment that looked configured and was not.
|
|
89
|
+
*
|
|
90
|
+
* Empty is deliberately *not* an error: `.env.example` ships several keys with
|
|
91
|
+
* no value, and a fresh clone must boot from it unchanged.
|
|
92
|
+
*/
|
|
93
|
+
function readPositiveInt(name, fallback) {
|
|
94
|
+
return readOptionalPositiveInt(name) ?? fallback;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* As {@link readPositiveInt}, but `undefined` when unset — no default to fall
|
|
98
|
+
* back to.
|
|
99
|
+
*
|
|
100
|
+
* The form a *ceiling* needs. A plugin that ships its own default cannot be
|
|
101
|
+
* handed `{ maxSteps: undefined }`, because spreading that overwrites the
|
|
102
|
+
* default with nothing; the caller conditionally spreads on this instead.
|
|
103
|
+
*/
|
|
104
|
+
function readOptionalPositiveInt(name) {
|
|
105
|
+
const raw = readEnv(name);
|
|
106
|
+
if (!raw) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
// Plain decimal digits only. `Number` would also take `1e9`, `0x20` and
|
|
110
|
+
// `Infinity`, none of which anyone means to write in a `.env`.
|
|
111
|
+
if (!/^\d+$/.test(raw) || Number(raw) <= 0) {
|
|
112
|
+
throw new Error(`Environment variable ${name} must be a positive whole number ` +
|
|
113
|
+
`(got "${raw}").`);
|
|
114
|
+
}
|
|
115
|
+
return Number(raw);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A comma-separated list setting, trimmed and emptied of blanks.
|
|
119
|
+
*
|
|
120
|
+
* An explicitly empty value yields an empty list rather than the fallback,
|
|
121
|
+
* because "allow no origins" is a setting somebody means.
|
|
122
|
+
*/
|
|
123
|
+
function readList(name, fallback) {
|
|
124
|
+
return (process.env[name] ?? fallback)
|
|
125
|
+
.split(',')
|
|
126
|
+
.map((item) => item.trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Express's `trust proxy` setting, in the three shapes it accepts.
|
|
131
|
+
*
|
|
132
|
+
* Checked in this order: a hop count (`'1'` — the recommended form, and the
|
|
133
|
+
* only one a client cannot forge past), a boolean (`'true'` trusts the entire
|
|
134
|
+
* `X-Forwarded-For` chain, `'false'` trusts none), or any other non-empty
|
|
135
|
+
* string, passed to Express verbatim as a subnet/preset list (`'loopback'`,
|
|
136
|
+
* `'10.0.0.0/8'`). Unset yields `undefined`, leaving Express's default of
|
|
137
|
+
* ignoring forwarded headers entirely.
|
|
138
|
+
*
|
|
139
|
+
* Returns the union structurally rather than importing `TrustProxySetting`
|
|
140
|
+
* from `@orthacms/bootstrap-server`: this is a leaf helper package, and the
|
|
141
|
+
* host importing it must not become a dependency of it. The host's own
|
|
142
|
+
* `trustProxy?: TrustProxySetting` field is what checks the two agree.
|
|
143
|
+
*/
|
|
144
|
+
function readTrustProxy(name = 'TRUST_PROXY') {
|
|
145
|
+
const raw = readEnv(name);
|
|
146
|
+
if (!raw) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const hops = Number(raw);
|
|
150
|
+
if (Number.isInteger(hops) && hops >= 0) {
|
|
151
|
+
return hops;
|
|
152
|
+
}
|
|
153
|
+
if (raw === 'true' || raw === 'false') {
|
|
154
|
+
return raw === 'true';
|
|
155
|
+
}
|
|
156
|
+
return raw;
|
|
157
|
+
}
|
|
158
|
+
/** The deployment modes an Ortha app recognises. */
|
|
159
|
+
exports.NODE_ENVS = ['development', 'test', 'production'];
|
|
160
|
+
/**
|
|
161
|
+
* Reads `NODE_ENV`, rejecting a value that is neither recognised nor empty.
|
|
162
|
+
*
|
|
163
|
+
* `NODE_ENV !== 'production'` is the switch behind **two** protections at once
|
|
164
|
+
* — whether the API reference and GraphiQL are published, and whether the
|
|
165
|
+
* session cookie carries `Secure` — so any value that is not exactly
|
|
166
|
+
* `production` turns both off. Unset is a legitimate, and the common, local
|
|
167
|
+
* state; a *typo* is not, and it is indistinguishable from correct
|
|
168
|
+
* configuration until you read a `Set-Cookie` header. Measured: `produciton`
|
|
169
|
+
* serves `/reference/json` to an unauthenticated caller and drops `Secure`
|
|
170
|
+
* from the session cookie, exactly as if nothing had been set (ORT-137).
|
|
171
|
+
*
|
|
172
|
+
* Rejecting the typo costs a deployment that spells it right nothing, and
|
|
173
|
+
* turns a silent downgrade into a refusal to start.
|
|
174
|
+
*/
|
|
175
|
+
function readNodeEnv() {
|
|
176
|
+
const raw = readEnv('NODE_ENV');
|
|
177
|
+
if (!raw) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
if (!exports.NODE_ENVS.includes(raw)) {
|
|
181
|
+
throw new Error(`NODE_ENV is "${raw}", which this app does not recognise — expected ` +
|
|
182
|
+
`one of ${exports.NODE_ENVS.join(', ')}, or nothing at all for local ` +
|
|
183
|
+
'development. Anything else reads as "not production", which ' +
|
|
184
|
+
'publishes the API reference and drops `Secure` from the session ' +
|
|
185
|
+
'cookie.');
|
|
186
|
+
}
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* True only in a deployment that said `production`, with the spelling checked
|
|
191
|
+
* by {@link readNodeEnv}.
|
|
192
|
+
*/
|
|
193
|
+
function isProduction() {
|
|
194
|
+
return readNodeEnv() === 'production';
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* As {@link readList}, but `undefined` when the variable is unset, so the
|
|
198
|
+
* consumer's own default list survives rather than being replaced by an empty
|
|
199
|
+
* one.
|
|
200
|
+
*
|
|
201
|
+
* The list counterpart to {@link readOptionalPositiveInt}, and needed for the
|
|
202
|
+
* same reason: a setting whose owner ships a default cannot be handed an empty
|
|
203
|
+
* array meaning "the deployment said nothing".
|
|
204
|
+
*/
|
|
205
|
+
function readOptionalList(name) {
|
|
206
|
+
return when(readEnv(name), () => readList(name, ''));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A boolean setting: what the deployment said, or `fallback` when it said
|
|
210
|
+
* nothing.
|
|
211
|
+
*
|
|
212
|
+
* Anything other than `true` reads as false, so a typo turns a switch **off**
|
|
213
|
+
* rather than on. That direction is deliberate: the switches this reads are
|
|
214
|
+
* kill switches for surfaces that send content to a third party or open a door
|
|
215
|
+
* for an external agent, and a misspelling should not open one.
|
|
216
|
+
*/
|
|
217
|
+
function readFlag(name, fallback) {
|
|
218
|
+
const raw = readEnv(name);
|
|
219
|
+
return raw === undefined ? fallback : raw === 'true';
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The value when the setting was configured, `undefined` when it was not.
|
|
223
|
+
*
|
|
224
|
+
* The counterpart to {@link defined}: together they replace
|
|
225
|
+
* `...(x ? { key: … } : {})` at a config site. `build` is a thunk so its body —
|
|
226
|
+
* often several further reads — runs only when it applies, which is what lets a
|
|
227
|
+
* whole provider block sit behind one credential check.
|
|
228
|
+
*/
|
|
229
|
+
function when(configured, build) {
|
|
230
|
+
return configured ? build() : undefined;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The same object with every `undefined`-valued key removed.
|
|
234
|
+
*
|
|
235
|
+
* A plugin merges its own defaults as `{ ...DEFAULTS, ...config }`, so an
|
|
236
|
+
* explicit `{ maxSteps: undefined }` does not leave `DEFAULT_RUN_LIMITS.maxSteps`
|
|
237
|
+
* in place — it erases it. Dropping the key is what "the deployment did not set
|
|
238
|
+
* this" has to mean, and saying it once at the end of a builder is why a config
|
|
239
|
+
* file no longer needs a conditional per optional setting.
|
|
240
|
+
*/
|
|
241
|
+
function defined(value) {
|
|
242
|
+
const result = {};
|
|
243
|
+
for (const [key, item] of Object.entries(value)) {
|
|
244
|
+
if (item !== undefined) {
|
|
245
|
+
result[key] = item;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every ceiling the filter engine imposes, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The engine's job is to accept a tree a stranger wrote. What stops that tree
|
|
5
|
+
* from being unbounded work is this file — nothing else. Four of these budgets
|
|
6
|
+
* count *structure* (how many rules, how deep, how long an `IN` list); the
|
|
7
|
+
* fifth counts *text*, and it was missing.
|
|
8
|
+
*
|
|
9
|
+
* ## What the DTO cap covers, and what it does not
|
|
10
|
+
*
|
|
11
|
+
* {@link FILTER_MAX_LENGTH} is the cap a caller's DTO puts on the whole
|
|
12
|
+
* serialised filter — `@MaxLength(FILTER_MAX_LENGTH)` on `?filter=`. It lives
|
|
13
|
+
* here rather than in each plugin because it was declared separately in four of
|
|
14
|
+
* them and one copy had drifted to 8192, so "the filter cap" meant two numbers
|
|
15
|
+
* depending on which endpoint you asked.
|
|
16
|
+
*
|
|
17
|
+
* It is a **transport** guard, and it only guards the paths where the filter
|
|
18
|
+
* arrives as a string. It does not reach:
|
|
19
|
+
*
|
|
20
|
+
* - **Alarm rules.** `CreateAlarmRuleDto.filter` is an already-parsed object
|
|
21
|
+
* (`@IsObject()`), because the authority on a filter tree's shape is this
|
|
22
|
+
* parser rather than a second copy of the grammar in decorators. `@MaxLength`
|
|
23
|
+
* is a string decorator and could not be applied to it.
|
|
24
|
+
* - **A stored alarm rule replayed from its `jsonb` column** — by the outbox
|
|
25
|
+
* subscriber, the periodic sweep, or `POST /rules/:id/rescan`. Nothing
|
|
26
|
+
* re-serialises it on the way out of the database, so there is no string to
|
|
27
|
+
* measure.
|
|
28
|
+
* - **The copilot's `admin_content_search` tool.** Its `filter` argument is an
|
|
29
|
+
* object too, and the registry's input check deliberately ignores `anyOf`, so
|
|
30
|
+
* the tree's contents reach the parser unexamined.
|
|
31
|
+
* - **GraphQL's entry loader**, which builds an id-`in` tree in code and casts
|
|
32
|
+
* past the DTO.
|
|
33
|
+
*
|
|
34
|
+
* On those four paths the parser *is* the boundary. That is why
|
|
35
|
+
* {@link DEFAULT_MAX_VALUE_LENGTH} exists: a budget declared on the DTO
|
|
36
|
+
* protects the endpoints that happen to have one, and a budget declared here
|
|
37
|
+
* protects every caller there will ever be.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Max length of a serialised filter payload, for the `@MaxLength` a caller
|
|
41
|
+
* puts on its own `?filter=` / `filter` DTO field.
|
|
42
|
+
*
|
|
43
|
+
* A coarse first latch ahead of the engine's own budgets: it refuses an
|
|
44
|
+
* oversized payload before `JSON.parse` sees it. It is deliberately generous —
|
|
45
|
+
* roughly a hundred UUID clauses — because it is not the real bound; the node,
|
|
46
|
+
* depth, list and value budgets below are.
|
|
47
|
+
*
|
|
48
|
+
* **The one number, and every DTO imports it from here.** `activity`,
|
|
49
|
+
* `content` and `users` each declared their own `4096` and `alarms` a `8192`
|
|
50
|
+
* that was applied to nothing at all; four declarations of one rule is three
|
|
51
|
+
* chances to answer a caller differently on two endpoints for the same filter.
|
|
52
|
+
*/
|
|
53
|
+
export declare const FILTER_MAX_LENGTH = 4096;
|
|
54
|
+
/** Default `schema.maxDepth` — how many dotted relation hops a path may walk. */
|
|
55
|
+
export declare const DEFAULT_MAX_DEPTH = 3;
|
|
56
|
+
/** Default `schema.maxNodes` — how many groups + rules one tree may hold. */
|
|
57
|
+
export declare const DEFAULT_MAX_NODES = 50;
|
|
58
|
+
/** Default `schema.maxGroupDepth` — how deep `and`/`or` nesting may go. */
|
|
59
|
+
export declare const DEFAULT_MAX_GROUP_DEPTH = 5;
|
|
60
|
+
/** Default `schema.maxInListLength` — how many values one `in`/`nin` may name. */
|
|
61
|
+
export declare const DEFAULT_MAX_IN_LIST = 100;
|
|
62
|
+
/**
|
|
63
|
+
* Default `schema.maxValueLength` — how long one clause's **string** value may
|
|
64
|
+
* be, applied to each element of an `in` list as well as to a bare scalar.
|
|
65
|
+
*
|
|
66
|
+
* The number is {@link FILTER_MAX_LENGTH} on purpose, and the equality is the
|
|
67
|
+
* argument for it: a filter that arrives as a string is already capped at that
|
|
68
|
+
* length in total, so no value inside one can be longer. This budget therefore
|
|
69
|
+
* cannot refuse anything an HTTP caller could already send — it exists for the
|
|
70
|
+
* paths listed at the top of this file, which never meet the DTO at all, and
|
|
71
|
+
* where the alternative bound was the 1 MB body limit.
|
|
72
|
+
*
|
|
73
|
+
* What it stops is a single arbitrarily long `LIKE` pattern: `~~` is scanned
|
|
74
|
+
* against every candidate row, so the cost of one clause is the pattern's
|
|
75
|
+
* length times the table's, and nothing above the parser was measuring the
|
|
76
|
+
* first factor.
|
|
77
|
+
*/
|
|
78
|
+
export declare const DEFAULT_MAX_VALUE_LENGTH = 4096;
|
|
79
|
+
//# sourceMappingURL=budgets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"budgets.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/budgets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,2EAA2E;AAC3E,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,kFAAkF;AAClF,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,wBAAwB,OAAoB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Every ceiling the filter engine imposes, in one place.
|
|
4
|
+
*
|
|
5
|
+
* The engine's job is to accept a tree a stranger wrote. What stops that tree
|
|
6
|
+
* from being unbounded work is this file — nothing else. Four of these budgets
|
|
7
|
+
* count *structure* (how many rules, how deep, how long an `IN` list); the
|
|
8
|
+
* fifth counts *text*, and it was missing.
|
|
9
|
+
*
|
|
10
|
+
* ## What the DTO cap covers, and what it does not
|
|
11
|
+
*
|
|
12
|
+
* {@link FILTER_MAX_LENGTH} is the cap a caller's DTO puts on the whole
|
|
13
|
+
* serialised filter — `@MaxLength(FILTER_MAX_LENGTH)` on `?filter=`. It lives
|
|
14
|
+
* here rather than in each plugin because it was declared separately in four of
|
|
15
|
+
* them and one copy had drifted to 8192, so "the filter cap" meant two numbers
|
|
16
|
+
* depending on which endpoint you asked.
|
|
17
|
+
*
|
|
18
|
+
* It is a **transport** guard, and it only guards the paths where the filter
|
|
19
|
+
* arrives as a string. It does not reach:
|
|
20
|
+
*
|
|
21
|
+
* - **Alarm rules.** `CreateAlarmRuleDto.filter` is an already-parsed object
|
|
22
|
+
* (`@IsObject()`), because the authority on a filter tree's shape is this
|
|
23
|
+
* parser rather than a second copy of the grammar in decorators. `@MaxLength`
|
|
24
|
+
* is a string decorator and could not be applied to it.
|
|
25
|
+
* - **A stored alarm rule replayed from its `jsonb` column** — by the outbox
|
|
26
|
+
* subscriber, the periodic sweep, or `POST /rules/:id/rescan`. Nothing
|
|
27
|
+
* re-serialises it on the way out of the database, so there is no string to
|
|
28
|
+
* measure.
|
|
29
|
+
* - **The copilot's `admin_content_search` tool.** Its `filter` argument is an
|
|
30
|
+
* object too, and the registry's input check deliberately ignores `anyOf`, so
|
|
31
|
+
* the tree's contents reach the parser unexamined.
|
|
32
|
+
* - **GraphQL's entry loader**, which builds an id-`in` tree in code and casts
|
|
33
|
+
* past the DTO.
|
|
34
|
+
*
|
|
35
|
+
* On those four paths the parser *is* the boundary. That is why
|
|
36
|
+
* {@link DEFAULT_MAX_VALUE_LENGTH} exists: a budget declared on the DTO
|
|
37
|
+
* protects the endpoints that happen to have one, and a budget declared here
|
|
38
|
+
* protects every caller there will ever be.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.DEFAULT_MAX_VALUE_LENGTH = exports.DEFAULT_MAX_IN_LIST = exports.DEFAULT_MAX_GROUP_DEPTH = exports.DEFAULT_MAX_NODES = exports.DEFAULT_MAX_DEPTH = exports.FILTER_MAX_LENGTH = void 0;
|
|
42
|
+
/**
|
|
43
|
+
* Max length of a serialised filter payload, for the `@MaxLength` a caller
|
|
44
|
+
* puts on its own `?filter=` / `filter` DTO field.
|
|
45
|
+
*
|
|
46
|
+
* A coarse first latch ahead of the engine's own budgets: it refuses an
|
|
47
|
+
* oversized payload before `JSON.parse` sees it. It is deliberately generous —
|
|
48
|
+
* roughly a hundred UUID clauses — because it is not the real bound; the node,
|
|
49
|
+
* depth, list and value budgets below are.
|
|
50
|
+
*
|
|
51
|
+
* **The one number, and every DTO imports it from here.** `activity`,
|
|
52
|
+
* `content` and `users` each declared their own `4096` and `alarms` a `8192`
|
|
53
|
+
* that was applied to nothing at all; four declarations of one rule is three
|
|
54
|
+
* chances to answer a caller differently on two endpoints for the same filter.
|
|
55
|
+
*/
|
|
56
|
+
exports.FILTER_MAX_LENGTH = 4096;
|
|
57
|
+
/** Default `schema.maxDepth` — how many dotted relation hops a path may walk. */
|
|
58
|
+
exports.DEFAULT_MAX_DEPTH = 3;
|
|
59
|
+
/** Default `schema.maxNodes` — how many groups + rules one tree may hold. */
|
|
60
|
+
exports.DEFAULT_MAX_NODES = 50;
|
|
61
|
+
/** Default `schema.maxGroupDepth` — how deep `and`/`or` nesting may go. */
|
|
62
|
+
exports.DEFAULT_MAX_GROUP_DEPTH = 5;
|
|
63
|
+
/** Default `schema.maxInListLength` — how many values one `in`/`nin` may name. */
|
|
64
|
+
exports.DEFAULT_MAX_IN_LIST = 100;
|
|
65
|
+
/**
|
|
66
|
+
* Default `schema.maxValueLength` — how long one clause's **string** value may
|
|
67
|
+
* be, applied to each element of an `in` list as well as to a bare scalar.
|
|
68
|
+
*
|
|
69
|
+
* The number is {@link FILTER_MAX_LENGTH} on purpose, and the equality is the
|
|
70
|
+
* argument for it: a filter that arrives as a string is already capped at that
|
|
71
|
+
* length in total, so no value inside one can be longer. This budget therefore
|
|
72
|
+
* cannot refuse anything an HTTP caller could already send — it exists for the
|
|
73
|
+
* paths listed at the top of this file, which never meet the DTO at all, and
|
|
74
|
+
* where the alternative bound was the 1 MB body limit.
|
|
75
|
+
*
|
|
76
|
+
* What it stops is a single arbitrarily long `LIKE` pattern: `~~` is scanned
|
|
77
|
+
* against every candidate row, so the cost of one clause is the pattern's
|
|
78
|
+
* length times the table's, and nothing above the parser was measuring the
|
|
79
|
+
* first factor.
|
|
80
|
+
*/
|
|
81
|
+
exports.DEFAULT_MAX_VALUE_LENGTH = exports.FILTER_MAX_LENGTH;
|
|
@@ -39,6 +39,13 @@ export declare const FilterErrorCode: {
|
|
|
39
39
|
readonly EmptyInList: "FILTER_EMPTY_IN_LIST";
|
|
40
40
|
/** `in`/`nin` value list exceeded `schema.maxInListLength` (default 100). */
|
|
41
41
|
readonly MaxInListExceeded: "FILTER_MAX_IN_LIST_EXCEEDED";
|
|
42
|
+
/**
|
|
43
|
+
* A single clause's string value exceeded `schema.maxValueLength`
|
|
44
|
+
* (default 4096) — the engine's only budget over *text* rather than over
|
|
45
|
+
* the tree's structure. Reported per clause, naming the offending path, so
|
|
46
|
+
* a client can point at the rule rather than at the whole filter.
|
|
47
|
+
*/
|
|
48
|
+
readonly ValueTooLong: "FILTER_VALUE_TOO_LONG";
|
|
42
49
|
};
|
|
43
50
|
/** One of the {@link FilterErrorCode} values. */
|
|
44
51
|
export type FilterErrorCode = (typeof FilterErrorCode)[keyof typeof FilterErrorCode];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filter-exceptions.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/filter-exceptions.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,mBAAmB,EACnB,4BAA4B,EAC/B,MAAM,gBAAgB,CAAC;AAExB,sDAAsD;AACtD,eAAO,MAAM,eAAe;IACxB,2DAA2D;;IAE3D,mDAAmD;;IAEnD,iDAAiD;;IAEjD,sDAAsD;;IAEtD;;;;;;OAMG;;IAEH,8DAA8D;;IAE9D,oEAAoE;;IAEpE,uEAAuE;;IAEvE,uDAAuD;;IAEvD,2DAA2D;;IAE3D,oDAAoD;;IAEpD,iEAAiE;;IAEjE;;;;OAIG;;IAEH,6EAA6E;;
|
|
1
|
+
{"version":3,"file":"filter-exceptions.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/filter-exceptions.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,mBAAmB,EACnB,4BAA4B,EAC/B,MAAM,gBAAgB,CAAC;AAExB,sDAAsD;AACtD,eAAO,MAAM,eAAe;IACxB,2DAA2D;;IAE3D,mDAAmD;;IAEnD,iDAAiD;;IAEjD,sDAAsD;;IAEtD;;;;;;OAMG;;IAEH,8DAA8D;;IAE9D,oEAAoE;;IAEpE,uEAAuE;;IAEvE,uDAAuD;;IAEvD,2DAA2D;;IAE3D,oDAAoD;;IAEpD,iEAAiE;;IAEjE;;;;OAIG;;IAEH,6EAA6E;;IAE7E;;;;;OAKG;;CAEG,CAAC;AAEX,iDAAiD;AACjD,MAAM,MAAM,eAAe,GACvB,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,CAAC;AAE3D;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,eAAgB,SAAQ,mBAAmB;IACpD,uCAAuC;IACvC,SAAgB,IAAI,EAAE,eAAe,CAAC;IACtC,yDAAyD;IACzD,SAAgB,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;gBAGvD,IAAI,EAAE,eAAe,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;CAkB5C;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,qBAAsB,SAAQ,4BAA4B;IACnE,wEAAwE;IACxE,SAAgB,IAAI,2BAA2B;gBAEnC,OAAO,EAAE,MAAM;CAQ9B"}
|
|
@@ -41,7 +41,14 @@ exports.FilterErrorCode = {
|
|
|
41
41
|
*/
|
|
42
42
|
EmptyInList: 'FILTER_EMPTY_IN_LIST',
|
|
43
43
|
/** `in`/`nin` value list exceeded `schema.maxInListLength` (default 100). */
|
|
44
|
-
MaxInListExceeded: 'FILTER_MAX_IN_LIST_EXCEEDED'
|
|
44
|
+
MaxInListExceeded: 'FILTER_MAX_IN_LIST_EXCEEDED',
|
|
45
|
+
/**
|
|
46
|
+
* A single clause's string value exceeded `schema.maxValueLength`
|
|
47
|
+
* (default 4096) — the engine's only budget over *text* rather than over
|
|
48
|
+
* the tree's structure. Reported per clause, naming the offending path, so
|
|
49
|
+
* a client can point at the rule rather than at the whole filter.
|
|
50
|
+
*/
|
|
51
|
+
ValueTooLong: 'FILTER_VALUE_TOO_LONG'
|
|
45
52
|
};
|
|
46
53
|
/**
|
|
47
54
|
* Typed 400 exception thrown by the filter parser.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"operator-support.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/operator-support.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"operator-support.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/operator-support.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAmE1D;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAClC,eAAe,EACf,SAAS,cAAc,EAAE,CAQ5B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CACxB,IAAI,EAAE,eAAe,GACtB,SAAS,cAAc,EAAE,GAAG,SAAS,CAIvC"}
|
|
@@ -47,6 +47,18 @@ const COMPARABLE_OPERATORS = [
|
|
|
47
47
|
types_1.FilterOperator.Nin,
|
|
48
48
|
types_1.FilterOperator.Null
|
|
49
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* {@link COMPARABLE_OPERATORS} plus the relative window.
|
|
52
|
+
*
|
|
53
|
+
* `within_last` is date-only because it compares against `now()` — asking for
|
|
54
|
+
* "titles within the last 7 days" is not a narrower query, it is a category
|
|
55
|
+
* error, and Postgres would report it as a type mismatch rather than as
|
|
56
|
+
* something the client can fix.
|
|
57
|
+
*/
|
|
58
|
+
const DATE_OPERATORS = [
|
|
59
|
+
...COMPARABLE_OPERATORS,
|
|
60
|
+
types_1.FilterOperator.WithinLast
|
|
61
|
+
];
|
|
50
62
|
/** {@link COMPARABLE_OPERATORS} plus the text-only `~~` family. */
|
|
51
63
|
const TEXT_OPERATORS = [
|
|
52
64
|
...COMPARABLE_OPERATORS,
|
|
@@ -67,7 +79,7 @@ exports.OPERATORS_BY_TYPE = {
|
|
|
67
79
|
[types_1.ScalarFieldType.Number]: COMPARABLE_OPERATORS,
|
|
68
80
|
[types_1.ScalarFieldType.Boolean]: COMPARABLE_OPERATORS,
|
|
69
81
|
[types_1.ScalarFieldType.Uuid]: COMPARABLE_OPERATORS,
|
|
70
|
-
[types_1.ScalarFieldType.Date]:
|
|
82
|
+
[types_1.ScalarFieldType.Date]: DATE_OPERATORS
|
|
71
83
|
};
|
|
72
84
|
/**
|
|
73
85
|
* The operators `type` accepts, or `undefined` when the type is not one this
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse-filter-tree.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/parse-filter-tree.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"parse-filter-tree.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/parse-filter-tree.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAgB,UAAU,EAAE,MAAM,SAAS,CAAC;AAEtE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC3B,SAAS,EAAE,OAAO,EAClB,MAAM,EAAE,YAAY,GACrB,UAAU,GAAG,IAAI,CAqCnB"}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseFilterTree = parseFilterTree;
|
|
4
|
+
const budgets_1 = require("./budgets");
|
|
4
5
|
const filter_exceptions_1 = require("./filter-exceptions");
|
|
5
6
|
const resolve_leaf_1 = require("./resolve-leaf");
|
|
6
|
-
const DEFAULT_MAX_DEPTH = 3;
|
|
7
|
-
const DEFAULT_MAX_NODES = 50;
|
|
8
|
-
const DEFAULT_MAX_GROUP_DEPTH = 5;
|
|
9
|
-
const DEFAULT_MAX_IN_LIST = 100;
|
|
10
7
|
/**
|
|
11
8
|
* Parse a `filter` payload into a tree the translator can walk. Two
|
|
12
9
|
* input shapes are accepted:
|
|
@@ -44,10 +41,11 @@ function parseFilterTree(rawFilter, schema) {
|
|
|
44
41
|
return null;
|
|
45
42
|
const ctx = {
|
|
46
43
|
nodeCount: 0,
|
|
47
|
-
maxNodes: schema.maxNodes ?? DEFAULT_MAX_NODES,
|
|
48
|
-
maxGroupDepth: schema.maxGroupDepth ?? DEFAULT_MAX_GROUP_DEPTH,
|
|
49
|
-
maxDepth: schema.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
50
|
-
maxInListLength: schema.maxInListLength ?? DEFAULT_MAX_IN_LIST
|
|
44
|
+
maxNodes: schema.maxNodes ?? budgets_1.DEFAULT_MAX_NODES,
|
|
45
|
+
maxGroupDepth: schema.maxGroupDepth ?? budgets_1.DEFAULT_MAX_GROUP_DEPTH,
|
|
46
|
+
maxDepth: schema.maxDepth ?? budgets_1.DEFAULT_MAX_DEPTH,
|
|
47
|
+
maxInListLength: schema.maxInListLength ?? budgets_1.DEFAULT_MAX_IN_LIST,
|
|
48
|
+
maxValueLength: schema.maxValueLength ?? budgets_1.DEFAULT_MAX_VALUE_LENGTH
|
|
51
49
|
};
|
|
52
50
|
return walkNode(obj, schema, 0, ctx);
|
|
53
51
|
}
|
|
@@ -97,7 +95,7 @@ function walkNode(raw, schema, depth, ctx) {
|
|
|
97
95
|
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'rule `op` must be a non-empty string');
|
|
98
96
|
}
|
|
99
97
|
const path = field.split('.');
|
|
100
|
-
const leaf = (0, resolve_leaf_1.resolveLeaf)(path, op, node.value, schema, ctx
|
|
98
|
+
const leaf = (0, resolve_leaf_1.resolveLeaf)(path, op, node.value, schema, ctx);
|
|
101
99
|
return toRule(leaf);
|
|
102
100
|
}
|
|
103
101
|
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'tree node must have `and`, `or`, or (`field` + `op`)');
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import type { FilterSchema, ParsedFilter } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The per-leaf budgets, passed as one object rather than as a growing tail of
|
|
4
|
+
* positional numbers — three `number` parameters in a row is a call site where
|
|
5
|
+
* transposing two of them typechecks.
|
|
6
|
+
*/
|
|
7
|
+
export interface LeafLimits {
|
|
8
|
+
/** How many dotted relation hops the path may walk. */
|
|
9
|
+
maxDepth: number;
|
|
10
|
+
/** How many values one `in`/`nin` may name. */
|
|
11
|
+
maxInListLength: number;
|
|
12
|
+
/** How long one string value may be — see `budgets.ts`. */
|
|
13
|
+
maxValueLength: number;
|
|
14
|
+
}
|
|
2
15
|
/**
|
|
3
16
|
* Walk a single dotted path against the schema, validate the operator,
|
|
4
17
|
* and coerce the value to the declared type. Produces the `ParsedFilter`
|
|
5
18
|
* leaf shape the tree parser tags with `kind: 'rule'`.
|
|
6
19
|
*/
|
|
7
|
-
export declare function resolveLeaf(path: string[], op: string, value: unknown, schema: FilterSchema,
|
|
20
|
+
export declare function resolveLeaf(path: string[], op: string, value: unknown, schema: FilterSchema, limits: LeafLimits): ParsedFilter;
|
|
8
21
|
//# sourceMappingURL=resolve-leaf.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-leaf.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/resolve-leaf.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"resolve-leaf.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/resolve-leaf.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACR,YAAY,EACZ,YAAY,EAGf,MAAM,SAAS,CAAC;AAQjB;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACvB,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,eAAe,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,cAAc,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACvB,IAAI,EAAE,MAAM,EAAE,EACd,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,UAAU,GACnB,YAAY,CA6Dd"}
|
|
@@ -13,7 +13,8 @@ const UUID_CANONICAL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
|
|
|
13
13
|
* and coerce the value to the declared type. Produces the `ParsedFilter`
|
|
14
14
|
* leaf shape the tree parser tags with `kind: 'rule'`.
|
|
15
15
|
*/
|
|
16
|
-
function resolveLeaf(path, op, value, schema,
|
|
16
|
+
function resolveLeaf(path, op, value, schema, limits) {
|
|
17
|
+
const { maxDepth } = limits;
|
|
17
18
|
if (path.length === 0) {
|
|
18
19
|
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.EmptyPath, 'empty filter path');
|
|
19
20
|
}
|
|
@@ -37,7 +38,7 @@ function resolveLeaf(path, op, value, schema, maxDepth, maxInListLength) {
|
|
|
37
38
|
return {
|
|
38
39
|
path,
|
|
39
40
|
op: op,
|
|
40
|
-
value: coerce(value, field, op, path,
|
|
41
|
+
value: coerce(value, field, op, path, limits)
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
const rel = (0, own_property_1.own)(relations, seg);
|
|
@@ -69,8 +70,12 @@ function assertOperatorAllowed(field, op, path) {
|
|
|
69
70
|
}
|
|
70
71
|
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.OperatorNotAllowed, `operator "${op}" is not available on "${path.join('.')}" (a ${field.type} field)`, { path: path.join('.'), op, fieldType: field.type, allowed });
|
|
71
72
|
}
|
|
72
|
-
function coerce(raw, field, op, path,
|
|
73
|
+
function coerce(raw, field, op, path, limits) {
|
|
74
|
+
const { maxInListLength } = limits;
|
|
73
75
|
const pathStr = path.join('.');
|
|
76
|
+
if (op === types_1.FilterOperator.WithinLast) {
|
|
77
|
+
return withinLastValue(raw, pathStr);
|
|
78
|
+
}
|
|
74
79
|
if (op === types_1.FilterOperator.Null) {
|
|
75
80
|
if (raw === 'true' || raw === true)
|
|
76
81
|
return true;
|
|
@@ -96,11 +101,39 @@ function coerce(raw, field, op, path, maxInListLength) {
|
|
|
96
101
|
if (items.length > maxInListLength) {
|
|
97
102
|
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.MaxInListExceeded, `${op} value list exceeds max length ${maxInListLength}`, { path: pathStr, op, maxInListLength, length: items.length });
|
|
98
103
|
}
|
|
99
|
-
return items.map((v) => scalarOf(v, field, pathStr));
|
|
104
|
+
return items.map((v) => scalarOf(v, field, pathStr, limits));
|
|
100
105
|
}
|
|
101
|
-
return scalarOf(raw, field, pathStr);
|
|
106
|
+
return scalarOf(raw, field, pathStr, limits);
|
|
102
107
|
}
|
|
103
|
-
|
|
108
|
+
/** Units a `within_last` window may name, as a set for membership checks. */
|
|
109
|
+
const WITHIN_LAST_UNITS = new Set(Object.values(types_1.WithinLastUnit));
|
|
110
|
+
/**
|
|
111
|
+
* Coerce a `within_last` value — `{ n, unit }`, the only object-shaped value in
|
|
112
|
+
* the grammar.
|
|
113
|
+
*
|
|
114
|
+
* The bound on `n` is not decoration. `now() - make_interval(days => 1e9)`
|
|
115
|
+
* overflows Postgres' timestamp range and raises a `22008` the caller sees as a
|
|
116
|
+
* 500; a client asking for a window that long means to say "all of it", and
|
|
117
|
+
* should be told so with a 400 rather than a stack trace. Ten years of minutes
|
|
118
|
+
* is far past any real window and comfortably inside what a timestamp can hold.
|
|
119
|
+
*/
|
|
120
|
+
const MAX_WITHIN_LAST_N = 10_000_000;
|
|
121
|
+
function withinLastValue(raw, pathStr) {
|
|
122
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
123
|
+
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, 'within_last expects { n, unit }', { path: pathStr, op: types_1.FilterOperator.WithinLast });
|
|
124
|
+
}
|
|
125
|
+
const value = raw;
|
|
126
|
+
const n = Number(value['n']);
|
|
127
|
+
const unit = value['unit'];
|
|
128
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_WITHIN_LAST_N) {
|
|
129
|
+
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `within_last n must be an integer between 1 and ${MAX_WITHIN_LAST_N}`, { path: pathStr, op: types_1.FilterOperator.WithinLast, value: value['n'] });
|
|
130
|
+
}
|
|
131
|
+
if (typeof unit !== 'string' || !WITHIN_LAST_UNITS.has(unit)) {
|
|
132
|
+
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `within_last unit must be one of ${[...WITHIN_LAST_UNITS].join(', ')}`, { path: pathStr, op: types_1.FilterOperator.WithinLast, value: unit });
|
|
133
|
+
}
|
|
134
|
+
return { n, unit: unit };
|
|
135
|
+
}
|
|
136
|
+
function scalarOf(v, field, pathStr, limits) {
|
|
104
137
|
// A filter value has to be a scalar. `String(v)` on anything else produces
|
|
105
138
|
// a plausible-looking string that is then MATCHED AGAINST rather than
|
|
106
139
|
// rejected: `null` → `"null"`, a missing `value` key → `"undefined"`,
|
|
@@ -117,7 +150,24 @@ function scalarOf(v, field, pathStr) {
|
|
|
117
150
|
? 'value is required'
|
|
118
151
|
: 'value must be a string, number or boolean', { path: pathStr, expectedType: field.type });
|
|
119
152
|
}
|
|
153
|
+
// The only budget that counts *text*. Every other ceiling in this engine
|
|
154
|
+
// bounds the tree's structure — nodes, hops, group nesting, `in` list
|
|
155
|
+
// length — and a single clause can carry an arbitrarily long string through
|
|
156
|
+
// all of them. On the endpoints that own a DTO the `@MaxLength` on the
|
|
157
|
+
// whole serialised filter caps this first; on the paths that never meet one
|
|
158
|
+
// (an alarm rule replayed out of its `jsonb` column, the copilot's search
|
|
159
|
+
// tool, an internally-built tree) this check is the only bound there is,
|
|
160
|
+
// and the alternative was the 1 MB body limit. Applied to a `number` or
|
|
161
|
+
// `boolean` too, which is free: their `String(v)` is a handful of
|
|
162
|
+
// characters and can never trip it.
|
|
120
163
|
const s = typeof v === 'string' ? v : String(v);
|
|
164
|
+
if (s.length > limits.maxValueLength) {
|
|
165
|
+
throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.ValueTooLong, `value exceeds max length ${limits.maxValueLength}`, {
|
|
166
|
+
path: pathStr,
|
|
167
|
+
maxValueLength: limits.maxValueLength,
|
|
168
|
+
length: s.length
|
|
169
|
+
});
|
|
170
|
+
}
|
|
121
171
|
switch (field.type) {
|
|
122
172
|
case types_1.ScalarFieldType.String:
|
|
123
173
|
return s;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scalar-op.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/scalar-op.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"scalar-op.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/scalar-op.ts"],"names":[],"mappings":"AAAA,OAAO,EAgBH,KAAK,SAAS,EACd,KAAK,GAAG,EACX,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAkB,MAAM,SAAS,CAAC;AAkBzD;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAClB,GAAG,EAAE,SAAS,EACd,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,OAAO,GACf,GAAG,CAuCL"}
|
|
@@ -54,6 +54,8 @@ function scalar(col, op, value) {
|
|
|
54
54
|
return negative(col, (0, drizzle_orm_1.notIlike)(col, String(value)));
|
|
55
55
|
case types_1.FilterOperator.Null:
|
|
56
56
|
return value === true ? (0, drizzle_orm_1.isNull)(col) : (0, drizzle_orm_1.isNotNull)(col);
|
|
57
|
+
case types_1.FilterOperator.WithinLast:
|
|
58
|
+
return withinLast(col, value);
|
|
57
59
|
default:
|
|
58
60
|
// The parser validates `op` against the vocabulary, so this is
|
|
59
61
|
// unreachable — but falling out of the switch returned `undefined`,
|
|
@@ -64,3 +66,27 @@ function scalar(col, op, value) {
|
|
|
64
66
|
throw new filter_exceptions_1.FilterSchemaException(`no translation for operator "${String(op)}"`);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* `column >= now() - <n> <unit>`.
|
|
71
|
+
*
|
|
72
|
+
* The cutoff is computed by **Postgres, at query time** — which is the whole
|
|
73
|
+
* point of the operator surviving to the server. A cutoff computed here in
|
|
74
|
+
* JavaScript would be identical for one request and wrong for a stored filter
|
|
75
|
+
* replayed a month later.
|
|
76
|
+
*
|
|
77
|
+
* A `switch` over the three units rather than interpolating the unit into the
|
|
78
|
+
* SQL: `make_interval`'s argument names cannot be parameterised, so building
|
|
79
|
+
* the fragment from a variable would mean `sql.raw` on a value that came off
|
|
80
|
+
* the wire. The parser already restricts the unit to these three, and this way
|
|
81
|
+
* there is no path by which that could stop being true.
|
|
82
|
+
*/
|
|
83
|
+
function withinLast(col, window) {
|
|
84
|
+
switch (window.unit) {
|
|
85
|
+
case types_1.WithinLastUnit.Minutes:
|
|
86
|
+
return (0, drizzle_orm_1.gte)(col, (0, drizzle_orm_1.sql) `now() - make_interval(mins => ${window.n})`);
|
|
87
|
+
case types_1.WithinLastUnit.Hours:
|
|
88
|
+
return (0, drizzle_orm_1.gte)(col, (0, drizzle_orm_1.sql) `now() - make_interval(hours => ${window.n})`);
|
|
89
|
+
default:
|
|
90
|
+
return (0, drizzle_orm_1.gte)(col, (0, drizzle_orm_1.sql) `now() - make_interval(days => ${window.n})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -25,6 +25,20 @@ export declare const FilterOperator: {
|
|
|
25
25
|
readonly Ilike: "ilike";
|
|
26
26
|
readonly Nilike: "nilike";
|
|
27
27
|
readonly Null: "null";
|
|
28
|
+
/**
|
|
29
|
+
* `column >= now() - <n> <unit>` — a window measured from **query time**,
|
|
30
|
+
* not from the moment the filter was written.
|
|
31
|
+
*
|
|
32
|
+
* The distinction is invisible in a URL and decisive in a stored one. The
|
|
33
|
+
* admin's query builder resolves its own `within_last` into a concrete
|
|
34
|
+
* `gte` cutoff when it serialises a filter into a link, deliberately: a
|
|
35
|
+
* shared deep link should keep showing the same rows. A filter that is
|
|
36
|
+
* *stored and replayed* — an alarm rule — must mean the opposite, or
|
|
37
|
+
* "not updated in 90 days" silently becomes "not updated since the day the
|
|
38
|
+
* rule was written". So the operator survives to the server, and the caller
|
|
39
|
+
* chooses which meaning it wants.
|
|
40
|
+
*/
|
|
41
|
+
readonly WithinLast: "within_last";
|
|
28
42
|
};
|
|
29
43
|
/** Standard REST operator names. Translator maps each to a Drizzle helper. */
|
|
30
44
|
export type FilterOperator = (typeof FilterOperator)[keyof typeof FilterOperator];
|
|
@@ -188,6 +202,31 @@ export interface FilterSchema {
|
|
|
188
202
|
* elements inside one rule's value list).
|
|
189
203
|
*/
|
|
190
204
|
maxInListLength?: number;
|
|
205
|
+
/**
|
|
206
|
+
* Longest string one clause's value may be — applied to each element of an
|
|
207
|
+
* `in` list as well as to a bare scalar. Defaults to 4096.
|
|
208
|
+
*
|
|
209
|
+
* The engine's only ceiling over **text**. Every other budget here bounds
|
|
210
|
+
* the tree's *structure*, and a caller that never passes a DTO (an alarm
|
|
211
|
+
* rule replayed out of storage, the copilot's search tool) can carry a
|
|
212
|
+
* megabyte-long `LIKE` pattern through all of them.
|
|
213
|
+
*/
|
|
214
|
+
maxValueLength?: number;
|
|
215
|
+
}
|
|
216
|
+
/** Time units a {@link FilterOperator.WithinLast} window may be measured in. */
|
|
217
|
+
export declare const WithinLastUnit: {
|
|
218
|
+
readonly Minutes: "minutes";
|
|
219
|
+
readonly Hours: "hours";
|
|
220
|
+
readonly Days: "days";
|
|
221
|
+
};
|
|
222
|
+
/** One of the {@link WithinLastUnit} values. */
|
|
223
|
+
export type WithinLastUnit = (typeof WithinLastUnit)[keyof typeof WithinLastUnit];
|
|
224
|
+
/** The coerced value of a `within_last` leaf. */
|
|
225
|
+
export interface WithinLastValue {
|
|
226
|
+
/** How many units back the window reaches. A positive integer. */
|
|
227
|
+
n: number;
|
|
228
|
+
/** The unit `n` is counted in. */
|
|
229
|
+
unit: WithinLastUnit;
|
|
191
230
|
}
|
|
192
231
|
/** Parser output, one node per URL filter entry. */
|
|
193
232
|
export interface ParsedFilter {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,SAAS,KAAK,GAAG,GAAG,SAAS,CAAC;AAEnE,8EAA8E;AAC9E,eAAO,MAAM,cAAc;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.RelationKind = exports.ScalarFieldType = exports.FilterOperator = void 0;
|
|
3
|
+
exports.WithinLastUnit = exports.RelationKind = exports.ScalarFieldType = exports.FilterOperator = void 0;
|
|
4
4
|
/** Named constants for `FilterOperator` — use in switches and comparisons. */
|
|
5
5
|
exports.FilterOperator = {
|
|
6
6
|
Eq: 'eq',
|
|
@@ -14,7 +14,21 @@ exports.FilterOperator = {
|
|
|
14
14
|
Like: 'like',
|
|
15
15
|
Ilike: 'ilike',
|
|
16
16
|
Nilike: 'nilike',
|
|
17
|
-
Null: 'null'
|
|
17
|
+
Null: 'null',
|
|
18
|
+
/**
|
|
19
|
+
* `column >= now() - <n> <unit>` — a window measured from **query time**,
|
|
20
|
+
* not from the moment the filter was written.
|
|
21
|
+
*
|
|
22
|
+
* The distinction is invisible in a URL and decisive in a stored one. The
|
|
23
|
+
* admin's query builder resolves its own `within_last` into a concrete
|
|
24
|
+
* `gte` cutoff when it serialises a filter into a link, deliberately: a
|
|
25
|
+
* shared deep link should keep showing the same rows. A filter that is
|
|
26
|
+
* *stored and replayed* — an alarm rule — must mean the opposite, or
|
|
27
|
+
* "not updated in 90 days" silently becomes "not updated since the day the
|
|
28
|
+
* rule was written". So the operator survives to the server, and the caller
|
|
29
|
+
* chooses which meaning it wants.
|
|
30
|
+
*/
|
|
31
|
+
WithinLast: 'within_last'
|
|
18
32
|
};
|
|
19
33
|
/** Named constants for `ScalarFieldType` — use in switches. */
|
|
20
34
|
exports.ScalarFieldType = {
|
|
@@ -33,3 +47,9 @@ exports.RelationKind = {
|
|
|
33
47
|
ManyToMany: 'many-to-many',
|
|
34
48
|
SelfReferential: 'self-referential'
|
|
35
49
|
};
|
|
50
|
+
/** Time units a {@link FilterOperator.WithinLast} window may be measured in. */
|
|
51
|
+
exports.WithinLastUnit = {
|
|
52
|
+
Minutes: 'minutes',
|
|
53
|
+
Hours: 'hours',
|
|
54
|
+
Days: 'days'
|
|
55
|
+
};
|
package/package.json
CHANGED