@vantageos/mcp-boilerplate 0.2.4 → 0.3.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +5 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. 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
+ }
@@ -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[];