@vantageos/mcp-boilerplate 0.2.4 → 0.4.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +182 -0
- package/README.fr.md +318 -15
- package/README.md +302 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +22 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +9 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +47 -0
- package/dist/locales/fr.json +47 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +124 -48
- package/dist/oauth/authorizationServer.js +63 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +5 -2
- package/dist/oauth/picker.js +34 -4
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/servedToolRecords.d.ts +18 -0
- package/dist/servedToolRecords.js +10 -0
- package/dist/serverManifest.d.ts +43 -0
- package/dist/serverManifest.js +90 -0
- package/dist/stdio.js +8 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +124 -0
- package/dist/toolRecord.js +114 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +5 -0
- package/mcp-server.json +14 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration extension point for a server derived from this boilerplate. The derived server
|
|
3
|
+
* DECLARES the environment variables it reads (name, type, required or optional, default, secret
|
|
4
|
+
* flag, bilingual description) and receives a typed, validated, frozen configuration object at
|
|
5
|
+
* start — or ONE refusal naming every missing or invalid variable.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // src/settings.ts — evaluated once per process
|
|
9
|
+
* import { defineVariables, loadVariables } from "@vantageos/mcp-boilerplate";
|
|
10
|
+
*
|
|
11
|
+
* export const declared = defineVariables({
|
|
12
|
+
* apiBaseUrl: { env: "UPSTREAM_API_BASE_URL", type: "url", description: { en: "…", fr: "…" } },
|
|
13
|
+
* readKey: { env: "LAKE_READ_KEY", type: "string", secret: true, description: { en: "…", fr: "…" } },
|
|
14
|
+
* pageSize: { env: "UPSTREAM_PAGE_SIZE", type: "integer", min: 1, max: 500, required: false,
|
|
15
|
+
* default: 100, description: { en: "…", fr: "…" } },
|
|
16
|
+
* fieldMap: { env: "FIELD_MAPPINGS", type: "json", description: { en: "…", fr: "…" } },
|
|
17
|
+
* });
|
|
18
|
+
* export const settings = loadVariables(declared); // throws ConfigurationError, or is fully typed
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The variable names above are examples: the derived server chooses them, nothing here hardcodes one.
|
|
22
|
+
*
|
|
23
|
+
* TYPES. The set is derived from the readers `src/envKnobs.ts` already provides — `rawKnob`
|
|
24
|
+
* (string), `integerKnob` (integer), `flagKnob` (boolean), `originListKnob` (originList) — which
|
|
25
|
+
* are reused here, not reimplemented, plus the three a derived server reading an upstream API
|
|
26
|
+
* needs: `url` (an endpoint: https, http on loopback only — the rule of `src/oauth/settings.ts`
|
|
27
|
+
* and `src/upstream/clientCredentials.ts`), `enum` (a closed set of values) and `json` (an object
|
|
28
|
+
* or array, e.g. a field-mapping table). `VARIABLE_TYPES` is that list; anything else is refused.
|
|
29
|
+
*
|
|
30
|
+
* GUARANTEES.
|
|
31
|
+
* - Reading: unset or EMPTY is "missing" (the `rawKnob` rule). A required variable that is missing
|
|
32
|
+
* and every variable that is set but invalid are collected — ALL of them — before ONE
|
|
33
|
+
* `ConfigurationError` is thrown. Its `problems` list `{ key, env, reason, expected }`; its
|
|
34
|
+
* message (English / French in one string, like `src/serverManifest.ts`) names each variable
|
|
35
|
+
* and the expected form. A VALUE never appears in it, secret or not: a value in a log is a leak
|
|
36
|
+
* waiting for the next variable. A set-but-invalid optional variable is refused, never replaced
|
|
37
|
+
* by its default (the `envKnobs.ts` rule).
|
|
38
|
+
* - Result: frozen (lists and json values deeply). The returned object is a Proxy: reading a key
|
|
39
|
+
* that was not declared throws, naming it — at runtime as at compile time, where the type is
|
|
40
|
+
* inferred from the declaration (required -> value, optional without default -> `| undefined`,
|
|
41
|
+
* enum -> union of its literals) and an undeclared key does not compile. Declared divergence:
|
|
42
|
+
* symbol keys and `then` read through as `undefined`, so the object can be returned from an
|
|
43
|
+
* async function; the serialisation keys of `PROTOCOL_KEYS` read through too (below); no other
|
|
44
|
+
* undeclared string key does.
|
|
45
|
+
* - Serialisation: `JSON.stringify`, `String()`, template literals and `util.inspect` (hence
|
|
46
|
+
* `console.log`) never throw and render every declared key, a `secret: true` one as the fixed
|
|
47
|
+
* `SECRET_MASK` (never the value, never its length). A declared key named like a protocol key
|
|
48
|
+
* wins over it; a secret stays masked or hidden either way.
|
|
49
|
+
* - Enumeration: a `secret: true` value is NON-enumerable, so `{ ...config }`, `Object.entries`,
|
|
50
|
+
* `Object.keys`, `Object.assign` and `for…in` never copy it. A secret is read by name only
|
|
51
|
+
* (`config.readKey`); an operator who wants the value asks for it explicitly. Declared
|
|
52
|
+
* divergence: an explicit reflective read (`Object.getOwnPropertyDescriptor`, or `util.inspect`
|
|
53
|
+
* with `customInspect: false` and `showHidden: true`) is asking by name, and shows it.
|
|
54
|
+
* - Declaration: `defineVariables` refuses, when it is called, a duplicate env name, an unknown
|
|
55
|
+
* type, an unknown property (a typo such as `requried` would otherwise be silently ignored), a
|
|
56
|
+
* default that does not itself validate, a default on a required variable (`required` defaults
|
|
57
|
+
* to true: a default needs `required: false`), and any field it cannot read. Its errors
|
|
58
|
+
* (`VariableDeclarationError`, English: they are programmer errors, raised while the code is
|
|
59
|
+
* written) name the key and the env name, never a default value.
|
|
60
|
+
* - `describeVariables` lists the contract (`--help`, docs) with no value and no default.
|
|
61
|
+
*
|
|
62
|
+
* DECLARED DIVERGENCE, NOT DONE HERE: the boilerplate's own readers — `OAUTH_ENV` and
|
|
63
|
+
* `readAuthorizeSettings` (`src/oauth/settings.ts`), the knobs read through `src/envKnobs.ts`, and
|
|
64
|
+
* `clientCredentialsFromEnv` (`src/upstream/clientCredentials.ts`) — are candidates to migrate
|
|
65
|
+
* onto this registry. They are left untouched in this delivery; the https-or-loopback URL rule is
|
|
66
|
+
* therefore held in three places until that migration.
|
|
67
|
+
*/
|
|
68
|
+
/** Every type a variable can declare. Derived from `envKnobs.ts` readers + url, enum, json. */
|
|
69
|
+
export declare const VARIABLE_TYPES: readonly ["string", "integer", "boolean", "url", "originList", "enum", "json"];
|
|
70
|
+
export type VariableType = (typeof VARIABLE_TYPES)[number];
|
|
71
|
+
export interface BilingualText {
|
|
72
|
+
readonly en: string;
|
|
73
|
+
readonly fr: string;
|
|
74
|
+
}
|
|
75
|
+
/** A parsed `json` value: an object or an array, frozen. */
|
|
76
|
+
export type JsonContainer = {
|
|
77
|
+
readonly [key: string]: unknown;
|
|
78
|
+
} | readonly unknown[];
|
|
79
|
+
interface VariableBase {
|
|
80
|
+
/** The environment variable name, e.g. `UPSTREAM_API_BASE_URL`. Unique within one declaration. */
|
|
81
|
+
readonly env: string;
|
|
82
|
+
/** Default true. A variable with a `default` must say `required: false`. */
|
|
83
|
+
readonly required?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Marks a credential. It is masked when the configuration is serialised, non-enumerable (spread
|
|
86
|
+
* and `Object.entries` skip it, read it by name), and flagged by `describeVariables`.
|
|
87
|
+
*/
|
|
88
|
+
readonly secret?: boolean;
|
|
89
|
+
readonly description: BilingualText;
|
|
90
|
+
}
|
|
91
|
+
export interface StringVariable extends VariableBase {
|
|
92
|
+
readonly type: "string";
|
|
93
|
+
readonly default?: string;
|
|
94
|
+
}
|
|
95
|
+
export interface IntegerVariable extends VariableBase {
|
|
96
|
+
readonly type: "integer";
|
|
97
|
+
/** Default 0. Non-negative: the `integerKnob` reader accepts digits only. */
|
|
98
|
+
readonly min?: number;
|
|
99
|
+
readonly max?: number;
|
|
100
|
+
readonly default?: number;
|
|
101
|
+
}
|
|
102
|
+
/** Exactly "0" or "1" in the environment (the `flagKnob` rule). */
|
|
103
|
+
export interface BooleanVariable extends VariableBase {
|
|
104
|
+
readonly type: "boolean";
|
|
105
|
+
readonly default?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** https, or http on a loopback host only. */
|
|
108
|
+
export interface UrlVariable extends VariableBase {
|
|
109
|
+
readonly type: "url";
|
|
110
|
+
readonly default?: string;
|
|
111
|
+
}
|
|
112
|
+
/** Comma-separated origins (the `originListKnob` rule). */
|
|
113
|
+
export interface OriginListVariable extends VariableBase {
|
|
114
|
+
readonly type: "originList";
|
|
115
|
+
readonly default?: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
export interface EnumVariable extends VariableBase {
|
|
118
|
+
readonly type: "enum";
|
|
119
|
+
readonly values: readonly string[];
|
|
120
|
+
readonly default?: string;
|
|
121
|
+
}
|
|
122
|
+
/** A JSON document whose top level is an object or an array. */
|
|
123
|
+
export interface JsonVariable extends VariableBase {
|
|
124
|
+
readonly type: "json";
|
|
125
|
+
readonly default?: JsonContainer;
|
|
126
|
+
}
|
|
127
|
+
export type VariableSpec = StringVariable | IntegerVariable | BooleanVariable | UrlVariable | OriginListVariable | EnumVariable | JsonVariable;
|
|
128
|
+
export type VariablesSpec = {
|
|
129
|
+
readonly [key: string]: VariableSpec;
|
|
130
|
+
};
|
|
131
|
+
type ValueOf<V> = V extends {
|
|
132
|
+
type: "string" | "url";
|
|
133
|
+
} ? string : V extends {
|
|
134
|
+
type: "integer";
|
|
135
|
+
} ? number : V extends {
|
|
136
|
+
type: "boolean";
|
|
137
|
+
} ? boolean : V extends {
|
|
138
|
+
type: "originList";
|
|
139
|
+
} ? readonly string[] : V extends {
|
|
140
|
+
type: "enum";
|
|
141
|
+
values: readonly (infer E)[];
|
|
142
|
+
} ? E : V extends {
|
|
143
|
+
type: "json";
|
|
144
|
+
} ? JsonContainer : never;
|
|
145
|
+
type Resolved<V> = V extends {
|
|
146
|
+
required: false;
|
|
147
|
+
} ? V extends {
|
|
148
|
+
default: unknown;
|
|
149
|
+
} ? ValueOf<V> : ValueOf<V> | undefined : ValueOf<V>;
|
|
150
|
+
/** The configuration object inferred from a declaration. Read-only; undeclared keys do not exist. */
|
|
151
|
+
export type VariablesOf<S extends VariablesSpec> = {
|
|
152
|
+
readonly [K in keyof S]: Resolved<S[K]>;
|
|
153
|
+
};
|
|
154
|
+
/** A declaration validated by `defineVariables`. Only such a value is accepted by the readers. */
|
|
155
|
+
export interface DeclaredVariables<S extends VariablesSpec> {
|
|
156
|
+
readonly spec: S;
|
|
157
|
+
}
|
|
158
|
+
export type ProblemReason = "missing" | "invalid";
|
|
159
|
+
export interface ConfigurationProblem {
|
|
160
|
+
/** The logical key in the declaration. */
|
|
161
|
+
readonly key: string;
|
|
162
|
+
/** The environment variable name. */
|
|
163
|
+
readonly env: string;
|
|
164
|
+
readonly reason: ProblemReason;
|
|
165
|
+
readonly expected: VariableType;
|
|
166
|
+
}
|
|
167
|
+
export interface VariableDescription {
|
|
168
|
+
readonly key: string;
|
|
169
|
+
readonly env: string;
|
|
170
|
+
readonly type: VariableType;
|
|
171
|
+
readonly required: boolean;
|
|
172
|
+
readonly secret: boolean;
|
|
173
|
+
readonly hasDefault: boolean;
|
|
174
|
+
readonly description: BilingualText;
|
|
175
|
+
}
|
|
176
|
+
/** A broken declaration, raised by `defineVariables`. Names the key and env, never a default. */
|
|
177
|
+
export declare class VariableDeclarationError extends Error {
|
|
178
|
+
constructor(message: string);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The environment does not satisfy the declaration. `problems` lists every one of them; the
|
|
182
|
+
* message (built by `loadVariables`) names each variable and its expected form. No value, ever.
|
|
183
|
+
*/
|
|
184
|
+
export declare class ConfigurationError extends Error {
|
|
185
|
+
readonly problems: readonly ConfigurationProblem[];
|
|
186
|
+
constructor(message: string, problems: readonly ConfigurationProblem[]);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* https, or http on a loopback host. Same rule as `src/oauth/settings.ts` (`originOf`). Exported
|
|
190
|
+
* for the package's own modules (src/lake/convex.ts), not from the package root.
|
|
191
|
+
*/
|
|
192
|
+
export declare function isSecureUrl(raw: string): boolean;
|
|
193
|
+
/**
|
|
194
|
+
* Validates a declaration and returns it, typed. Throws `VariableDeclarationError` on anything it
|
|
195
|
+
* cannot read: a broken declaration fails here, not silently when the environment is read.
|
|
196
|
+
*/
|
|
197
|
+
export declare function defineVariables<const S extends VariablesSpec>(spec: S): DeclaredVariables<S>;
|
|
198
|
+
/**
|
|
199
|
+
* Reads every declared variable from `env`. Returns the frozen, typed configuration, or throws ONE
|
|
200
|
+
* `ConfigurationError` listing every missing or invalid variable — names and expected types only.
|
|
201
|
+
*/
|
|
202
|
+
export declare function loadVariables<S extends VariablesSpec>(declared: DeclaredVariables<S>, env?: NodeJS.ProcessEnv): VariablesOf<S>;
|
|
203
|
+
/** The configuration contract, for `--help` or documentation. Carries no value and no default. */
|
|
204
|
+
export declare function describeVariables<S extends VariablesSpec>(declared: DeclaredVariables<S>): readonly VariableDescription[];
|
|
205
|
+
export {};
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration extension point for a server derived from this boilerplate. The derived server
|
|
3
|
+
* DECLARES the environment variables it reads (name, type, required or optional, default, secret
|
|
4
|
+
* flag, bilingual description) and receives a typed, validated, frozen configuration object at
|
|
5
|
+
* start — or ONE refusal naming every missing or invalid variable.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // src/settings.ts — evaluated once per process
|
|
9
|
+
* import { defineVariables, loadVariables } from "@vantageos/mcp-boilerplate";
|
|
10
|
+
*
|
|
11
|
+
* export const declared = defineVariables({
|
|
12
|
+
* apiBaseUrl: { env: "UPSTREAM_API_BASE_URL", type: "url", description: { en: "…", fr: "…" } },
|
|
13
|
+
* readKey: { env: "LAKE_READ_KEY", type: "string", secret: true, description: { en: "…", fr: "…" } },
|
|
14
|
+
* pageSize: { env: "UPSTREAM_PAGE_SIZE", type: "integer", min: 1, max: 500, required: false,
|
|
15
|
+
* default: 100, description: { en: "…", fr: "…" } },
|
|
16
|
+
* fieldMap: { env: "FIELD_MAPPINGS", type: "json", description: { en: "…", fr: "…" } },
|
|
17
|
+
* });
|
|
18
|
+
* export const settings = loadVariables(declared); // throws ConfigurationError, or is fully typed
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The variable names above are examples: the derived server chooses them, nothing here hardcodes one.
|
|
22
|
+
*
|
|
23
|
+
* TYPES. The set is derived from the readers `src/envKnobs.ts` already provides — `rawKnob`
|
|
24
|
+
* (string), `integerKnob` (integer), `flagKnob` (boolean), `originListKnob` (originList) — which
|
|
25
|
+
* are reused here, not reimplemented, plus the three a derived server reading an upstream API
|
|
26
|
+
* needs: `url` (an endpoint: https, http on loopback only — the rule of `src/oauth/settings.ts`
|
|
27
|
+
* and `src/upstream/clientCredentials.ts`), `enum` (a closed set of values) and `json` (an object
|
|
28
|
+
* or array, e.g. a field-mapping table). `VARIABLE_TYPES` is that list; anything else is refused.
|
|
29
|
+
*
|
|
30
|
+
* GUARANTEES.
|
|
31
|
+
* - Reading: unset or EMPTY is "missing" (the `rawKnob` rule). A required variable that is missing
|
|
32
|
+
* and every variable that is set but invalid are collected — ALL of them — before ONE
|
|
33
|
+
* `ConfigurationError` is thrown. Its `problems` list `{ key, env, reason, expected }`; its
|
|
34
|
+
* message (English / French in one string, like `src/serverManifest.ts`) names each variable
|
|
35
|
+
* and the expected form. A VALUE never appears in it, secret or not: a value in a log is a leak
|
|
36
|
+
* waiting for the next variable. A set-but-invalid optional variable is refused, never replaced
|
|
37
|
+
* by its default (the `envKnobs.ts` rule).
|
|
38
|
+
* - Result: frozen (lists and json values deeply). The returned object is a Proxy: reading a key
|
|
39
|
+
* that was not declared throws, naming it — at runtime as at compile time, where the type is
|
|
40
|
+
* inferred from the declaration (required -> value, optional without default -> `| undefined`,
|
|
41
|
+
* enum -> union of its literals) and an undeclared key does not compile. Declared divergence:
|
|
42
|
+
* symbol keys and `then` read through as `undefined`, so the object can be returned from an
|
|
43
|
+
* async function; the serialisation keys of `PROTOCOL_KEYS` read through too (below); no other
|
|
44
|
+
* undeclared string key does.
|
|
45
|
+
* - Serialisation: `JSON.stringify`, `String()`, template literals and `util.inspect` (hence
|
|
46
|
+
* `console.log`) never throw and render every declared key, a `secret: true` one as the fixed
|
|
47
|
+
* `SECRET_MASK` (never the value, never its length). A declared key named like a protocol key
|
|
48
|
+
* wins over it; a secret stays masked or hidden either way.
|
|
49
|
+
* - Enumeration: a `secret: true` value is NON-enumerable, so `{ ...config }`, `Object.entries`,
|
|
50
|
+
* `Object.keys`, `Object.assign` and `for…in` never copy it. A secret is read by name only
|
|
51
|
+
* (`config.readKey`); an operator who wants the value asks for it explicitly. Declared
|
|
52
|
+
* divergence: an explicit reflective read (`Object.getOwnPropertyDescriptor`, or `util.inspect`
|
|
53
|
+
* with `customInspect: false` and `showHidden: true`) is asking by name, and shows it.
|
|
54
|
+
* - Declaration: `defineVariables` refuses, when it is called, a duplicate env name, an unknown
|
|
55
|
+
* type, an unknown property (a typo such as `requried` would otherwise be silently ignored), a
|
|
56
|
+
* default that does not itself validate, a default on a required variable (`required` defaults
|
|
57
|
+
* to true: a default needs `required: false`), and any field it cannot read. Its errors
|
|
58
|
+
* (`VariableDeclarationError`, English: they are programmer errors, raised while the code is
|
|
59
|
+
* written) name the key and the env name, never a default value.
|
|
60
|
+
* - `describeVariables` lists the contract (`--help`, docs) with no value and no default.
|
|
61
|
+
*
|
|
62
|
+
* DECLARED DIVERGENCE, NOT DONE HERE: the boilerplate's own readers — `OAUTH_ENV` and
|
|
63
|
+
* `readAuthorizeSettings` (`src/oauth/settings.ts`), the knobs read through `src/envKnobs.ts`, and
|
|
64
|
+
* `clientCredentialsFromEnv` (`src/upstream/clientCredentials.ts`) — are candidates to migrate
|
|
65
|
+
* onto this registry. They are left untouched in this delivery; the https-or-loopback URL rule is
|
|
66
|
+
* therefore held in three places until that migration.
|
|
67
|
+
*/
|
|
68
|
+
import { flagKnob, integerKnob, originListKnob, rawKnob } from "./envKnobs.js";
|
|
69
|
+
import { t } from "./i18n.js";
|
|
70
|
+
/** Every type a variable can declare. Derived from `envKnobs.ts` readers + url, enum, json. */
|
|
71
|
+
export const VARIABLE_TYPES = [
|
|
72
|
+
"string",
|
|
73
|
+
"integer",
|
|
74
|
+
"boolean",
|
|
75
|
+
"url",
|
|
76
|
+
"originList",
|
|
77
|
+
"enum",
|
|
78
|
+
"json",
|
|
79
|
+
];
|
|
80
|
+
/** A broken declaration, raised by `defineVariables`. Names the key and env, never a default. */
|
|
81
|
+
export class VariableDeclarationError extends Error {
|
|
82
|
+
constructor(message) {
|
|
83
|
+
super(`declared variables: ${message}`);
|
|
84
|
+
this.name = "VariableDeclarationError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The environment does not satisfy the declaration. `problems` lists every one of them; the
|
|
89
|
+
* message (built by `loadVariables`) names each variable and its expected form. No value, ever.
|
|
90
|
+
*/
|
|
91
|
+
export class ConfigurationError extends Error {
|
|
92
|
+
problems;
|
|
93
|
+
constructor(message, problems) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "ConfigurationError";
|
|
96
|
+
this.problems = Object.freeze(problems.map((p) => Object.freeze({ ...p })));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Declarations that passed `defineVariables`, with their normalized entries. */
|
|
100
|
+
const REGISTRIES = new WeakMap();
|
|
101
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
102
|
+
const COMMON_PROPERTIES = ["env", "type", "required", "secret", "description", "default"];
|
|
103
|
+
const TYPE_PROPERTIES = {
|
|
104
|
+
string: [],
|
|
105
|
+
integer: ["min", "max"],
|
|
106
|
+
boolean: [],
|
|
107
|
+
url: [],
|
|
108
|
+
originList: [],
|
|
109
|
+
enum: ["values"],
|
|
110
|
+
json: [],
|
|
111
|
+
};
|
|
112
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
113
|
+
/** Stands for a `secret: true` value in every rendering. Fixed: never the value, nor its length. */
|
|
114
|
+
const SECRET_MASK = "[secret]";
|
|
115
|
+
/** Node's `util.inspect.custom`: a registered symbol, so `node:util` need not be imported. */
|
|
116
|
+
const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
117
|
+
/**
|
|
118
|
+
* The undeclared keys the language and Node read on their own to serialise an object, allowed
|
|
119
|
+
* through the undeclared-read trap and answered with the masked form. Where each comes from:
|
|
120
|
+
* - `toJSON`: read by `JSON.stringify` (SerializeJSONProperty).
|
|
121
|
+
* - `Symbol.toPrimitive`: read first by `String(config)`, template literals and `"" + config`
|
|
122
|
+
* (ToPrimitive). Since it is defined, ToPrimitive never falls back to `toString` / `valueOf`.
|
|
123
|
+
* - `toString`: an explicit `config.toString()` call.
|
|
124
|
+
* - `nodejs.util.inspect.custom`: read by `util.inspect`, hence `console.log`. Node reads it on the
|
|
125
|
+
* proxy TARGET, not through the trap, so it lives on the target's prototype like the others.
|
|
126
|
+
*/
|
|
127
|
+
const PROTOCOL_KEYS = new Set([
|
|
128
|
+
"toJSON",
|
|
129
|
+
Symbol.toPrimitive,
|
|
130
|
+
"toString",
|
|
131
|
+
INSPECT_CUSTOM,
|
|
132
|
+
]);
|
|
133
|
+
function isRecord(value) {
|
|
134
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
135
|
+
}
|
|
136
|
+
function isVariableType(value) {
|
|
137
|
+
return typeof value === "string" && VARIABLE_TYPES.includes(value);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* https, or http on a loopback host. Same rule as `src/oauth/settings.ts` (`originOf`). Exported
|
|
141
|
+
* for the package's own modules (src/lake/convex.ts), not from the package root.
|
|
142
|
+
*/
|
|
143
|
+
export function isSecureUrl(raw) {
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(raw);
|
|
146
|
+
if (url.protocol === "https:")
|
|
147
|
+
return true;
|
|
148
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function isJsonContainer(value) {
|
|
155
|
+
return typeof value === "object" && value !== null;
|
|
156
|
+
}
|
|
157
|
+
function deepFreeze(value) {
|
|
158
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
159
|
+
for (const child of Object.values(value))
|
|
160
|
+
deepFreeze(child);
|
|
161
|
+
Object.freeze(value);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Parses one SET (non-empty) raw value. Returns `{ ok: false }` when it is invalid; the envKnobs
|
|
167
|
+
* readers signal invalid by throwing, and their message is dropped here (the problem list carries
|
|
168
|
+
* the name and the expected type; a reader message is never forwarded).
|
|
169
|
+
*/
|
|
170
|
+
function parse(entry, raw) {
|
|
171
|
+
const env = { [entry.env]: raw };
|
|
172
|
+
try {
|
|
173
|
+
switch (entry.type) {
|
|
174
|
+
case "string":
|
|
175
|
+
return { ok: true, value: raw };
|
|
176
|
+
case "integer":
|
|
177
|
+
return { ok: true, value: integerKnob(env, entry.env, entry.min, entry.max) };
|
|
178
|
+
case "boolean":
|
|
179
|
+
return { ok: true, value: flagKnob(env, entry.env) };
|
|
180
|
+
case "url":
|
|
181
|
+
return isSecureUrl(raw) ? { ok: true, value: raw } : { ok: false };
|
|
182
|
+
case "originList":
|
|
183
|
+
return { ok: true, value: Object.freeze(originListKnob(env, entry.env)) };
|
|
184
|
+
case "enum":
|
|
185
|
+
return entry.values.includes(raw) ? { ok: true, value: raw } : { ok: false };
|
|
186
|
+
case "json": {
|
|
187
|
+
const value = JSON.parse(raw);
|
|
188
|
+
return isJsonContainer(value) ? { ok: true, value: deepFreeze(value) } : { ok: false };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { ok: false };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Validates a declared default against the same rules as a value read from the environment. */
|
|
197
|
+
function defaultIsValid(entry, value) {
|
|
198
|
+
switch (entry.type) {
|
|
199
|
+
case "string":
|
|
200
|
+
case "url":
|
|
201
|
+
case "enum":
|
|
202
|
+
return typeof value === "string" && value !== "" && parse(entry, value).ok;
|
|
203
|
+
case "integer":
|
|
204
|
+
return typeof value === "number" && parse(entry, String(value)).ok;
|
|
205
|
+
case "boolean":
|
|
206
|
+
return typeof value === "boolean";
|
|
207
|
+
case "originList": {
|
|
208
|
+
if (!Array.isArray(value) || !value.every((o) => typeof o === "string"))
|
|
209
|
+
return false;
|
|
210
|
+
if (value.length === 0)
|
|
211
|
+
return true;
|
|
212
|
+
const read = parse(entry, value.join(","));
|
|
213
|
+
return (read.ok &&
|
|
214
|
+
Array.isArray(read.value) &&
|
|
215
|
+
read.value.length === value.length &&
|
|
216
|
+
read.value.every((o, i) => o === value[i]));
|
|
217
|
+
}
|
|
218
|
+
case "json":
|
|
219
|
+
return isJsonContainer(value);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function normalize(key, raw) {
|
|
223
|
+
if (!isRecord(raw))
|
|
224
|
+
throw new VariableDeclarationError(`"${key}" is not an object`);
|
|
225
|
+
const { env } = raw;
|
|
226
|
+
if (typeof env !== "string" || !ENV_NAME.test(env)) {
|
|
227
|
+
throw new VariableDeclarationError(`"${key}": env must be an environment variable name ([A-Za-z_][A-Za-z0-9_]*)`);
|
|
228
|
+
}
|
|
229
|
+
const where = `"${key}" (${env})`;
|
|
230
|
+
const { type } = raw;
|
|
231
|
+
if (!isVariableType(type)) {
|
|
232
|
+
throw new VariableDeclarationError(`${where}: unknown type; expected one of ${VARIABLE_TYPES.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
const allowed = new Set([...COMMON_PROPERTIES, ...TYPE_PROPERTIES[type]]);
|
|
235
|
+
for (const property of Object.keys(raw)) {
|
|
236
|
+
if (!allowed.has(property)) {
|
|
237
|
+
throw new VariableDeclarationError(`${where}: unknown property "${property}" for type ${type}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (raw.required !== undefined && typeof raw.required !== "boolean") {
|
|
241
|
+
throw new VariableDeclarationError(`${where}: required must be a boolean`);
|
|
242
|
+
}
|
|
243
|
+
if (raw.secret !== undefined && typeof raw.secret !== "boolean") {
|
|
244
|
+
throw new VariableDeclarationError(`${where}: secret must be a boolean`);
|
|
245
|
+
}
|
|
246
|
+
const en = isRecord(raw.description) ? raw.description.en : undefined;
|
|
247
|
+
const fr = isRecord(raw.description) ? raw.description.fr : undefined;
|
|
248
|
+
if (typeof en !== "string" || en === "" || typeof fr !== "string" || fr === "") {
|
|
249
|
+
throw new VariableDeclarationError(`${where}: description must carry non-empty "en" and "fr" strings`);
|
|
250
|
+
}
|
|
251
|
+
let min = 0;
|
|
252
|
+
let max = Number.MAX_SAFE_INTEGER;
|
|
253
|
+
if (type === "integer") {
|
|
254
|
+
if (raw.min !== undefined) {
|
|
255
|
+
if (!Number.isSafeInteger(raw.min) || raw.min < 0) {
|
|
256
|
+
throw new VariableDeclarationError(`${where}: min must be a non-negative integer`);
|
|
257
|
+
}
|
|
258
|
+
min = raw.min;
|
|
259
|
+
}
|
|
260
|
+
if (raw.max !== undefined) {
|
|
261
|
+
if (!Number.isSafeInteger(raw.max) || raw.max < 0) {
|
|
262
|
+
throw new VariableDeclarationError(`${where}: max must be a non-negative integer`);
|
|
263
|
+
}
|
|
264
|
+
max = raw.max;
|
|
265
|
+
}
|
|
266
|
+
if (min > max)
|
|
267
|
+
throw new VariableDeclarationError(`${where}: min is greater than max`);
|
|
268
|
+
}
|
|
269
|
+
let values = [];
|
|
270
|
+
if (type === "enum") {
|
|
271
|
+
const declared = raw.values;
|
|
272
|
+
if (!Array.isArray(declared) ||
|
|
273
|
+
declared.length === 0 ||
|
|
274
|
+
!declared.every((v) => typeof v === "string" && v !== "") ||
|
|
275
|
+
new Set(declared).size !== declared.length) {
|
|
276
|
+
throw new VariableDeclarationError(`${where}: values must be a non-empty list of distinct non-empty strings`);
|
|
277
|
+
}
|
|
278
|
+
values = Object.freeze([...declared]);
|
|
279
|
+
}
|
|
280
|
+
const required = raw.required ?? true;
|
|
281
|
+
const hasDefault = raw.default !== undefined;
|
|
282
|
+
const entry = {
|
|
283
|
+
key,
|
|
284
|
+
env,
|
|
285
|
+
type,
|
|
286
|
+
required: required,
|
|
287
|
+
secret: (raw.secret ?? false),
|
|
288
|
+
hasDefault,
|
|
289
|
+
default: undefined,
|
|
290
|
+
min,
|
|
291
|
+
max,
|
|
292
|
+
values,
|
|
293
|
+
description: Object.freeze({ en, fr }),
|
|
294
|
+
};
|
|
295
|
+
if (hasDefault && entry.required) {
|
|
296
|
+
throw new VariableDeclarationError(`${where}: a default is declared on a required variable; set required: false (required defaults to true)`);
|
|
297
|
+
}
|
|
298
|
+
if (hasDefault && !defaultIsValid(entry, raw.default)) {
|
|
299
|
+
throw new VariableDeclarationError(`${where}: the default does not validate as ${type} (value not shown)`);
|
|
300
|
+
}
|
|
301
|
+
return Object.freeze({
|
|
302
|
+
...entry,
|
|
303
|
+
default: hasDefault ? deepFreeze(structuredClone(raw.default)) : undefined,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Validates a declaration and returns it, typed. Throws `VariableDeclarationError` on anything it
|
|
308
|
+
* cannot read: a broken declaration fails here, not silently when the environment is read.
|
|
309
|
+
*/
|
|
310
|
+
export function defineVariables(spec) {
|
|
311
|
+
if (!isRecord(spec))
|
|
312
|
+
throw new VariableDeclarationError("the declaration is not an object");
|
|
313
|
+
const entries = [];
|
|
314
|
+
const byKey = new Map();
|
|
315
|
+
const keyByEnv = new Map();
|
|
316
|
+
for (const [key, raw] of Object.entries(spec)) {
|
|
317
|
+
const entry = normalize(key, raw);
|
|
318
|
+
const previous = keyByEnv.get(entry.env);
|
|
319
|
+
if (previous !== undefined) {
|
|
320
|
+
throw new VariableDeclarationError(`${entry.env} is declared twice, by "${previous}" and by "${key}"`);
|
|
321
|
+
}
|
|
322
|
+
keyByEnv.set(entry.env, key);
|
|
323
|
+
entries.push(entry);
|
|
324
|
+
byKey.set(key, entry);
|
|
325
|
+
}
|
|
326
|
+
const declared = Object.freeze({ spec });
|
|
327
|
+
REGISTRIES.set(declared, { entries: Object.freeze(entries), byKey });
|
|
328
|
+
return declared;
|
|
329
|
+
}
|
|
330
|
+
function registryOf(declared) {
|
|
331
|
+
const registry = typeof declared === "object" && declared !== null ? REGISTRIES.get(declared) : undefined;
|
|
332
|
+
if (!registry) {
|
|
333
|
+
throw new VariableDeclarationError("not a declaration produced by defineVariables (refused: it was never validated)");
|
|
334
|
+
}
|
|
335
|
+
return registry;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Reads every declared variable from `env`. Returns the frozen, typed configuration, or throws ONE
|
|
339
|
+
* `ConfigurationError` listing every missing or invalid variable — names and expected types only.
|
|
340
|
+
*/
|
|
341
|
+
export function loadVariables(declared, env = process.env) {
|
|
342
|
+
const registry = registryOf(declared);
|
|
343
|
+
const problems = [];
|
|
344
|
+
const values = {};
|
|
345
|
+
for (const entry of registry.entries) {
|
|
346
|
+
const raw = rawKnob(env, entry.env);
|
|
347
|
+
if (raw === undefined) {
|
|
348
|
+
if (entry.required) {
|
|
349
|
+
problems.push({ key: entry.key, env: entry.env, reason: "missing", expected: entry.type });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
values[entry.key] = entry.default;
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const read = parse(entry, raw);
|
|
357
|
+
if (read.ok) {
|
|
358
|
+
values[entry.key] = read.value;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
problems.push({ key: entry.key, env: entry.env, reason: "invalid", expected: entry.type });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (problems.length > 0) {
|
|
365
|
+
throw new ConfigurationError(refusalMessage(problems, registry.byKey), problems);
|
|
366
|
+
}
|
|
367
|
+
// The masked view: every declared key, a secret replaced by SECRET_MASK. A fresh plain object.
|
|
368
|
+
const masked = () => {
|
|
369
|
+
const view = {};
|
|
370
|
+
for (const entry of registry.entries) {
|
|
371
|
+
view[entry.key] = entry.secret ? SECRET_MASK : values[entry.key];
|
|
372
|
+
}
|
|
373
|
+
return view;
|
|
374
|
+
};
|
|
375
|
+
const rendered = () => JSON.stringify(masked());
|
|
376
|
+
// Non-enumerable, read-only: `for…in` and spread never see them.
|
|
377
|
+
const protocol = Object.freeze(Object.create(Object.prototype, {
|
|
378
|
+
toJSON: { value: masked },
|
|
379
|
+
[Symbol.toPrimitive]: { value: rendered },
|
|
380
|
+
toString: { value: rendered },
|
|
381
|
+
[INSPECT_CUSTOM]: { value: masked },
|
|
382
|
+
}));
|
|
383
|
+
// A secret is non-enumerable: readable by name, never copied by spread or Object.entries.
|
|
384
|
+
const frozen = Object.create(protocol);
|
|
385
|
+
for (const entry of registry.entries) {
|
|
386
|
+
Object.defineProperty(frozen, entry.key, {
|
|
387
|
+
value: values[entry.key],
|
|
388
|
+
enumerable: !entry.secret,
|
|
389
|
+
writable: false,
|
|
390
|
+
configurable: false,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return new Proxy(Object.freeze(frozen), {
|
|
394
|
+
get(target, property, receiver) {
|
|
395
|
+
if (PROTOCOL_KEYS.has(property) ||
|
|
396
|
+
typeof property === "symbol" ||
|
|
397
|
+
property === "then" ||
|
|
398
|
+
registry.byKey.has(property)) {
|
|
399
|
+
return Reflect.get(target, property, receiver);
|
|
400
|
+
}
|
|
401
|
+
throw new TypeError(`configuration: "${property}" is not a declared variable (declared: ${[...registry.byKey.keys()].join(", ")})`);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/** The configuration contract, for `--help` or documentation. Carries no value and no default. */
|
|
406
|
+
export function describeVariables(declared) {
|
|
407
|
+
return Object.freeze(registryOf(declared).entries.map((entry) => Object.freeze({
|
|
408
|
+
key: entry.key,
|
|
409
|
+
env: entry.env,
|
|
410
|
+
type: entry.type,
|
|
411
|
+
required: entry.required,
|
|
412
|
+
secret: entry.secret,
|
|
413
|
+
hasDefault: entry.hasDefault,
|
|
414
|
+
description: entry.description,
|
|
415
|
+
})));
|
|
416
|
+
}
|
|
417
|
+
/** `{name}` placeholders filled literally (a function replacer: no `$` pattern is interpreted). */
|
|
418
|
+
function fill(text, params) {
|
|
419
|
+
return Object.entries(params).reduce((out, [name, value]) => out.replaceAll(`{${name}}`, () => value), text);
|
|
420
|
+
}
|
|
421
|
+
/** The expected form of a variable, in one language. Declaration content only, never a value. */
|
|
422
|
+
function expectedText(locale, entry) {
|
|
423
|
+
switch (entry.type) {
|
|
424
|
+
case "integer":
|
|
425
|
+
return entry.max === Number.MAX_SAFE_INTEGER
|
|
426
|
+
? fill(t(locale, "config.expect.integerAtLeast"), { min: String(entry.min) })
|
|
427
|
+
: fill(t(locale, "config.expect.integer"), {
|
|
428
|
+
min: String(entry.min),
|
|
429
|
+
max: String(entry.max),
|
|
430
|
+
});
|
|
431
|
+
case "enum":
|
|
432
|
+
return fill(t(locale, "config.expect.enum"), { values: entry.values.join(", ") });
|
|
433
|
+
default:
|
|
434
|
+
return t(locale, `config.expect.${entry.type}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The refusal, English / French in one string as `tBoth` renders it (a start-time failure has no
|
|
439
|
+
* caller locale). Built from names and declaration content only: `problems` carries no value.
|
|
440
|
+
*/
|
|
441
|
+
function refusalMessage(problems, byKey) {
|
|
442
|
+
const render = (locale) => fill(t(locale, "config.refused"), {
|
|
443
|
+
problems: problems
|
|
444
|
+
.map((p) => fill(t(locale, `config.${p.reason}`), {
|
|
445
|
+
env: p.env,
|
|
446
|
+
expected: expectedText(locale, byKey.get(p.key)),
|
|
447
|
+
}))
|
|
448
|
+
.join("; "),
|
|
449
|
+
});
|
|
450
|
+
return `${render("en")} / ${render("fr")}`;
|
|
451
|
+
}
|
package/dist/exampleItems.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* stored `orgId` of the organisation that owns it — the row is selected by comparing that
|
|
4
4
|
* stored id with the caller's verified organisation (tenancy.ts `isRowInTenant`), never by
|
|
5
5
|
* a creator name. `createdBy` / `namespace` stay as display fields only.
|
|
6
|
+
*
|
|
7
|
+
* The collection is keyed by `id` and kept sorted by it (the `list_items` range reads rely on it).
|
|
6
8
|
*/
|
|
7
9
|
export interface ExampleItem {
|
|
8
10
|
id: string;
|
|
@@ -12,3 +14,16 @@ export interface ExampleItem {
|
|
|
12
14
|
namespace: string;
|
|
13
15
|
}
|
|
14
16
|
export declare const EXAMPLE_ITEMS: ExampleItem[];
|
|
17
|
+
/**
|
|
18
|
+
* The rows strictly after `afterKey` (all rows when it is undefined), at most `limit` of them,
|
|
19
|
+
* from a collection SORTED by `id`: a binary search for the first key above `afterKey`, then a
|
|
20
|
+
* slice of the page. That is the `indexed-range` mechanism the list tools declare
|
|
21
|
+
* (src/toolRecord.ts): the cost follows the page, not the collection. It requires sorted input;
|
|
22
|
+
* `EXAMPLE_ITEMS` is sorted above and scoping by tenant (a filter) preserves the order.
|
|
23
|
+
*
|
|
24
|
+
* In this in-memory seed the tenant scoping that precedes it is a filter over the rows. A fork
|
|
25
|
+
* backed by a store reads the same range from a compound (orgId, id) index instead.
|
|
26
|
+
*/
|
|
27
|
+
export declare function rangeAfter<T extends {
|
|
28
|
+
id: string;
|
|
29
|
+
}>(sortedRows: readonly T[], afterKey: string | undefined, limit?: number): T[];
|