@kindgi/env-schema 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts ADDED
@@ -0,0 +1,622 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Machine-readable registry of every operator-facing env var the Kindgi
6
+ * runtime understands. Used for:
7
+ *
8
+ * - Boot-time validation (`validateEnvForTarget`).
9
+ * - `.env.example` scaffolding (`renderEnvExample`).
10
+ * - Tools and coding agents that author packs —
11
+ * they `import { KINDGI_ENV_SCHEMA }` to introspect the current
12
+ * deployment-config surface.
13
+ *
14
+ * **Adding a new env var:** append an entry to `KINDGI_ENV_SCHEMA`. A
15
+ * `KINDGI_*` var that is not declared here is invisible to
16
+ * `envVarsForTarget`, `validateEnvForTarget` and `renderEnvExample`.
17
+ *
18
+ * **Every name carries the `KINDGI_` prefix — no exceptions.** Kindgi
19
+ * often shares one `.env` with the application it is embedded in
20
+ * (a pack added to an existing repository). The prefix is what keeps the two
21
+ * apart: Kindgi's runtime reads only `KINDGI_*`, and every un-prefixed
22
+ * name belongs to the application and the pack's agents. An un-prefixed
23
+ * runtime var (`DATABASE_URL`) would silently pick up the host app's
24
+ * value.
25
+ *
26
+ * **What lives here:** every var an operator sets in their deployment
27
+ * config (Docker Compose, K8s ConfigMap, .env file). Internal opt-in
28
+ * switches that are not deployment config are not declared here.
29
+ */
30
+
31
+ /**
32
+ * Caller's deployment-target choices. Each field narrows the set of
33
+ * env vars that apply. Fields are all optional so callers can query
34
+ * "what vars exist regardless of target" (pass `{}`) or "what vars
35
+ * apply given `postgres + gcp`" (pass both fields).
36
+ *
37
+ * New target axes get added as optional fields — a var's `appliesTo`
38
+ * predicate stays valid because it only reads the axes it cares about.
39
+ */
40
+ export interface EnvTarget {
41
+ /**
42
+ * Which process reads the env: the Kindgi `server` (the default when
43
+ * absent), or the `pack-service`, the separate process that runs a
44
+ * pack's code (its tools and guardrail checks).
45
+ */
46
+ readonly component?: 'server' | 'pack-service';
47
+ readonly secretsBackend?: 'none' | 'postgres' | 'secret-manager' | 'dotenv';
48
+ readonly secretsBackendKms?: 'gcp' | 'aws' | 'libsodium' | 'vault';
49
+ /**
50
+ * How the server reaches the pack service: `http` when
51
+ * `KINDGI_PACK_SERVICE_URL` is set. Absent: the server has no pack
52
+ * service. Server only.
53
+ */
54
+ readonly packTransport?: 'http';
55
+ }
56
+
57
+ /**
58
+ * Declarative shape of a single env var. `appliesTo` is a predicate
59
+ * over the target so vars that span multiple targets (e.g.
60
+ * `KINDGI_SECRETS_AAD_KEY_PATH` applies to `postgres + any KMS`)
61
+ * declare one broad predicate instead of one entry per target.
62
+ */
63
+ export interface EnvVarSpec {
64
+ /** Full env var name, e.g. `KINDGI_SECRETS_GCP_PROJECT_ID`. */
65
+ readonly name: string;
66
+ /** One-line description used in `.env.example` comments + docs. */
67
+ readonly description: string;
68
+ /**
69
+ * Realistic example value for `.env.example`. Never a real secret —
70
+ * placeholders like `my-proj`, `us-central1`, `/etc/kindgi/aad.key`.
71
+ * Empty string means "no useful example; caller must fill in."
72
+ */
73
+ readonly example: string;
74
+ /**
75
+ * `true` → `validateEnvForTarget` reports missing when the var
76
+ * `appliesTo(target)` but the env doesn't set it.
77
+ * `false` → optional; a default applies or the var is passthrough.
78
+ */
79
+ readonly required: boolean;
80
+ /**
81
+ * Predicate: does this var apply given the caller's target choices?
82
+ * Vars that apply universally return `true` unconditionally.
83
+ */
84
+ readonly appliesTo: (target: EnvTarget) => boolean;
85
+ /**
86
+ * Optional group tag — used by `renderEnvExample` to cluster related
87
+ * vars in the output. Groups appear in order of first appearance
88
+ * (for `KINDGI_ENV_SCHEMA`: `core`, `secrets`, then vendor-specific
89
+ * groups); ungrouped vars come last.
90
+ */
91
+ readonly group?: string;
92
+ /**
93
+ * Optional enum of valid string values. When set, tools can offer
94
+ * autocomplete. Declarative only — `validateEnvForTarget` does not
95
+ * check membership.
96
+ */
97
+ readonly allowedValues?: readonly string[];
98
+ }
99
+
100
+ // ---------------------------------------------------------------------
101
+ // Group ordering — output convention for `.env.example`.
102
+ // ---------------------------------------------------------------------
103
+
104
+ export const ENV_GROUPS = {
105
+ core: 'Core server config',
106
+ secrets: 'Secrets backend selection',
107
+ gcp: 'GCP vendor config (postgres + gcp KMS)',
108
+ 'pack-service': 'Pack service (runs the pack code: tools and guardrail checks)',
109
+ 'image-registry': "Image registry (where deployments' images are read from)",
110
+ dev: 'Development (`kindgi dev`; each needs `KINDGI_DEV=true`)',
111
+ } as const;
112
+
113
+ // ---------------------------------------------------------------------
114
+ // The registry.
115
+ // ---------------------------------------------------------------------
116
+
117
+ /** The server's own vars: every target but the pack service's. */
118
+ const appliesToServer = (t: EnvTarget): boolean => t.component !== 'pack-service';
119
+
120
+ const appliesToPostgresBackend = (t: EnvTarget): boolean =>
121
+ appliesToServer(t) && t.secretsBackend === 'postgres';
122
+
123
+ const appliesToDotenvBackend = (t: EnvTarget): boolean =>
124
+ appliesToServer(t) && t.secretsBackend === 'dotenv';
125
+
126
+ const appliesToPostgresGcp = (t: EnvTarget): boolean =>
127
+ appliesToPostgresBackend(t) && t.secretsBackendKms === 'gcp';
128
+
129
+ const appliesToPackService = (t: EnvTarget): boolean => t.component === 'pack-service';
130
+
131
+ /** The server, calling a pack service over HTTP. */
132
+ const appliesToServerHttpPackTransport = (t: EnvTarget): boolean =>
133
+ appliesToServer(t) && t.packTransport === 'http';
134
+
135
+ /**
136
+ * Every operator-facing env var Kindgi understands, keyed by name.
137
+ * Declarative source of truth. Consumers filter this via
138
+ * `envVarsForTarget` / `validateEnvForTarget`.
139
+ */
140
+ export const KINDGI_ENV_SCHEMA: readonly EnvVarSpec[] = [
141
+ // ---- core server ------------------------------------------------
142
+ {
143
+ name: 'KINDGI_API_PORT',
144
+ description: 'HTTP port the Kindgi API server listens on. Default 4000.',
145
+ example: '4000',
146
+ required: false,
147
+ appliesTo: appliesToServer,
148
+ group: 'core',
149
+ },
150
+ {
151
+ name: 'KINDGI_API_HOST',
152
+ description:
153
+ 'Address the API server binds. Default: all interfaces (what a container platform such as Cloud Run needs). `127.0.0.1` keeps it off the network; `kindgi dev` sets that on Linux, where the runtime container shares the host network.',
154
+ example: '127.0.0.1',
155
+ required: false,
156
+ appliesTo: appliesToServer,
157
+ group: 'core',
158
+ },
159
+ {
160
+ name: 'KINDGI_DATABASE_URL',
161
+ description:
162
+ 'Postgres connection string. Default `postgres://localhost:5432/kindgi` (dev only).',
163
+ example: 'postgres://user:pass@postgres.internal:5432/kindgi',
164
+ required: false,
165
+ appliesTo: appliesToServer,
166
+ group: 'core',
167
+ },
168
+ {
169
+ name: 'KINDGI_OPENFGA_API_URL',
170
+ description:
171
+ 'OpenFGA HTTP API URL. Set → per-tenant FGA stores are bootstrapped and authorization is enforced; unset → routes mount without enforcement (dev only; production MUST set it).',
172
+ example: 'http://openfga.internal:8080',
173
+ required: false,
174
+ appliesTo: appliesToServer,
175
+ group: 'core',
176
+ },
177
+ {
178
+ name: 'KINDGI_TENANT_ID',
179
+ description: 'Tenant UUID to seed / reuse. Default: fresh UUID printed at boot.',
180
+ example: '00000000-0000-4000-8000-000000000001',
181
+ required: false,
182
+ appliesTo: appliesToServer,
183
+ group: 'core',
184
+ },
185
+ {
186
+ name: 'KINDGI_API_TOKEN',
187
+ description: 'Bearer token to seed / reuse. Default: fresh `kgi_bt_...` token per boot.',
188
+ example: 'kgi_bt_my_stable_local_token',
189
+ required: false,
190
+ appliesTo: appliesToServer,
191
+ group: 'core',
192
+ },
193
+ {
194
+ name: 'KINDGI_SEED_USER_ID',
195
+ description:
196
+ 'Seed user UUID. Pinning this across restarts keeps the FGA admin@tenant tuple stable.',
197
+ example: '00000000-0000-4000-8000-000000000002',
198
+ required: false,
199
+ appliesTo: appliesToServer,
200
+ group: 'core',
201
+ },
202
+ {
203
+ name: 'KINDGI_DOCS',
204
+ description: 'Mount `/docs` (Scalar API reference). `true` (default) | `false`.',
205
+ example: 'true',
206
+ required: false,
207
+ appliesTo: appliesToServer,
208
+ group: 'core',
209
+ allowedValues: ['true', 'false', '1', '0'],
210
+ },
211
+ {
212
+ name: 'KINDGI_DEV',
213
+ description:
214
+ 'Development mode: `true` enables development-only settings (the `dotenv` secrets backend, `KINDGI_PACK_DIR`, `KINDGI_DEV_CONSOLE_LOGIN`, `KINDGI_DEV_HOST_ALIAS`). The server refuses those settings when this is off. Never set it in production.',
215
+ example: 'false',
216
+ required: false,
217
+ appliesTo: appliesToServer,
218
+ group: 'core',
219
+ allowedValues: ['true', 'false', '1', '0'],
220
+ },
221
+ {
222
+ name: 'KINDGI_TENANT_HOST_ACCESS',
223
+ description:
224
+ "How far what tenants register may reach into the server's own host. `deployed` refuses a `stdio` MCP endpoint (a command the server would run in its container, as its user) at registration (`403 host-access-denied`) and at connect; run MCP servers over HTTP instead. It also refuses the server's connections to the cloud metadata endpoints (link-local addresses) and to its own host (loopback) when a tenant chose the host: an MCP endpoint, a model provider's base URL, an HTTP tool, an image reference. Private networks stay reachable. `local` allows all of it: only for a machine where every token holder may run commands. Unset: `local` with `KINDGI_DEV`, `deployed` otherwise; a value set here always wins.",
225
+ example: 'deployed',
226
+ required: false,
227
+ appliesTo: appliesToServer,
228
+ group: 'core',
229
+ allowedValues: ['local', 'deployed'],
230
+ },
231
+ {
232
+ name: 'KINDGI_LICENSE_KEY',
233
+ description:
234
+ 'The commercial license key (`kgi_lk_...`) Kindgi issues: signed, checked offline at startup, with no call to Kindgi. Required outside development mode: without a valid key the server refuses to start, naming this variable. `KINDGI_DEV=true` needs none. A production key comes with a commercial license; a free non-production key covers staging and CI. A secret: give it from your secrets store.',
235
+ example: '',
236
+ required: false,
237
+ appliesTo: appliesToServer,
238
+ group: 'core',
239
+ },
240
+ {
241
+ name: 'KINDGI_ENV',
242
+ description:
243
+ 'The env this runtime serves. Secrets a tool declares by name (`needsSpec.secrets`) resolve under this env name. Unset: `local` in development mode (the `.env` and `.env.local` files); otherwise a tool that declares secrets fails its calls, naming this variable.',
244
+ example: 'production',
245
+ required: false,
246
+ appliesTo: appliesToServer,
247
+ group: 'core',
248
+ },
249
+ {
250
+ name: 'KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH',
251
+ description:
252
+ 'Absolute path to the Ed25519 private key (PKCS#8 PEM, mode 0600) that signs public run tokens (`kgi_pt_…`): the short-lived, read-only tokens a browser uses to follow a run. Use a key for this alone (`openssl genpkey -algorithm ed25519`). Unset: in development mode the server signs with a key generated at startup (tokens end at restart); otherwise public run tokens are off.',
253
+ example: '/etc/kindgi/public-token-signing.pem',
254
+ required: false,
255
+ appliesTo: appliesToServer,
256
+ group: 'core',
257
+ },
258
+ {
259
+ name: 'KINDGI_PUBLIC_TOKEN_SIGNING_KEY',
260
+ description:
261
+ "The same key's PEM file, base64 (`base64 < key.pem`): for platforms that give secrets as environment variables (Cloud Run with Secret Manager), where a key file's mode can't be 0600. Set this or `KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH`, not both.",
262
+ example: '',
263
+ required: false,
264
+ appliesTo: appliesToServer,
265
+ group: 'core',
266
+ },
267
+ {
268
+ name: 'KINDGI_CORS_ORIGINS',
269
+ description:
270
+ 'Comma-separated browser origins allowed to call, cross-origin, the routes a public run token can use (`GET /v1/runs/{runId}/progress` and its stream). Exact origins, no wildcards. Unset: no CORS headers on any route.',
271
+ example: 'https://app.example.com',
272
+ required: false,
273
+ appliesTo: appliesToServer,
274
+ group: 'core',
275
+ },
276
+
277
+ // ---- secrets backend --------------------------------------------
278
+ {
279
+ name: 'KINDGI_SECRETS_BACKEND',
280
+ description:
281
+ 'Where secret bytes live. `none` (default; /v1/secrets/* unmounted) | `postgres` (envelope-encrypted, needs KMS) | `dotenv` (`.env` files in a directory; development only, needs `KINDGI_DEV=true`) | `secret-manager` (reserved; not supported through this variable).',
282
+ example: 'postgres',
283
+ required: false,
284
+ appliesTo: appliesToServer,
285
+ group: 'secrets',
286
+ allowedValues: ['none', 'postgres', 'dotenv', 'secret-manager'],
287
+ },
288
+ {
289
+ name: 'KINDGI_SECRETS_DOTENV_DIR',
290
+ description:
291
+ 'Absolute path of the directory whose `.env` files hold secrets (`dotenv` backend). Mount the project directory here; secrets written through the API land in its files.',
292
+ example: '/pack',
293
+ required: true,
294
+ appliesTo: appliesToDotenvBackend,
295
+ group: 'secrets',
296
+ },
297
+ {
298
+ name: 'KINDGI_SECRETS_DOTENV_FILES',
299
+ description:
300
+ 'Comma-separated `.env` files read for secrets (`dotenv` backend), relative to `KINDGI_SECRETS_DOTENV_DIR`, lowest precedence first. Default `.env,.env.local`.',
301
+ example: '.env,.env.local',
302
+ required: false,
303
+ appliesTo: appliesToDotenvBackend,
304
+ group: 'secrets',
305
+ },
306
+ {
307
+ name: 'KINDGI_SECRETS_BACKEND_KMS',
308
+ description:
309
+ 'Which KMS vendor wraps DEKs (postgres backend only). Currently supported: `gcp`. Reserved: `aws`, `libsodium`.',
310
+ example: 'gcp',
311
+ required: true,
312
+ appliesTo: appliesToPostgresBackend,
313
+ group: 'secrets',
314
+ allowedValues: ['gcp', 'aws', 'libsodium'],
315
+ },
316
+ {
317
+ name: 'KINDGI_SECRETS_AAD_KEY_PATH',
318
+ description:
319
+ 'Absolute path to a 32-byte AAD/HMAC key file (mode 0600). Same key across every replica; protects AEAD associated data over every secret row. The postgres backend needs this or `KINDGI_SECRETS_AAD_KEY`, not both.',
320
+ example: '/etc/kindgi/secrets-aad.key',
321
+ required: false,
322
+ appliesTo: appliesToPostgresBackend,
323
+ group: 'secrets',
324
+ },
325
+ {
326
+ name: 'KINDGI_SECRETS_AAD_KEY',
327
+ description:
328
+ "The 32-byte AAD/HMAC key itself, base64: for platforms that give secrets as environment variables (Cloud Run with Secret Manager), where a key file's mode can't be 0600. The postgres backend needs this or `KINDGI_SECRETS_AAD_KEY_PATH`, not both.",
329
+ example: '',
330
+ required: false,
331
+ appliesTo: appliesToPostgresBackend,
332
+ group: 'secrets',
333
+ },
334
+
335
+ // ---- GCP vendor -------------------------------------------------
336
+ {
337
+ name: 'KINDGI_SECRETS_GCP_PROJECT_ID',
338
+ description: 'GCP project id owning the KMS keyring + key.',
339
+ example: 'my-proj',
340
+ required: true,
341
+ appliesTo: appliesToPostgresGcp,
342
+ group: 'gcp',
343
+ },
344
+ {
345
+ name: 'KINDGI_SECRETS_GCP_LOCATION_ID',
346
+ description: 'KMS location, e.g. `us-central1`, `europe-west1`, `global`.',
347
+ example: 'us-central1',
348
+ required: true,
349
+ appliesTo: appliesToPostgresGcp,
350
+ group: 'gcp',
351
+ },
352
+ {
353
+ name: 'KINDGI_SECRETS_GCP_KEY_RING_ID',
354
+ description: 'KMS keyring id within the location.',
355
+ example: 'kindgi',
356
+ required: true,
357
+ appliesTo: appliesToPostgresGcp,
358
+ group: 'gcp',
359
+ },
360
+ {
361
+ name: 'KINDGI_SECRETS_GCP_KEY_ID',
362
+ description: 'CryptoKey id within the keyring (symmetric GOOGLE_SYMMETRIC_ENCRYPTION).',
363
+ example: 'secrets-kek',
364
+ required: true,
365
+ appliesTo: appliesToPostgresGcp,
366
+ group: 'gcp',
367
+ },
368
+
369
+ // ---- pack service -----------------------------------------------
370
+ // The server's side: where the pack service is, and how long a call
371
+ // may take.
372
+ {
373
+ name: 'KINDGI_PACK_SERVICE_URL',
374
+ description:
375
+ 'Base URL of the pack service, the separate process that runs the pack code (its tools and guardrail checks), e.g. `http://pack-service:8080` or its internal Cloud Run URL. Set: the server calls it over HTTP for every pack tool and check, and needs `KINDGI_PACK_SERVICE_TOKEN`. Unset: no pack code runs, and a pack tool fails saying no pack service is wired. `kindgi dev` runs its own.',
376
+ example: 'http://pack-service:8080',
377
+ required: false,
378
+ appliesTo: appliesToServer,
379
+ group: 'pack-service',
380
+ },
381
+ {
382
+ name: 'KINDGI_PACK_CALL_TIMEOUT_MS',
383
+ description:
384
+ 'How long one pack call (a tool or a guardrail check) may take, in milliseconds. Default 120000. The deadline travels with the call, and the pack service aborts the handler when it passes.',
385
+ example: '120000',
386
+ required: false,
387
+ appliesTo: appliesToServerHttpPackTransport,
388
+ group: 'pack-service',
389
+ },
390
+ {
391
+ name: 'KINDGI_PACK_SERVICE_AUTH',
392
+ description:
393
+ "How the server proves itself to the pack service besides the pack token. `token` (default): the pack token alone. `google-id-token`: also a Google ID token for the pack service URL, from the server's own identity, for a pack service on Cloud Run behind IAM (`--no-allow-unauthenticated`); the URL must be https. The identity is a service account: on Cloud Run the server's own; elsewhere, impersonate one (a person's own credentials can't mint an ID token for a service).",
394
+ example: 'token',
395
+ required: false,
396
+ appliesTo: appliesToServerHttpPackTransport,
397
+ group: 'pack-service',
398
+ allowedValues: ['token', 'google-id-token'],
399
+ },
400
+ // Both sides: the shared token.
401
+ {
402
+ name: 'KINDGI_PACK_SERVICE_TOKEN',
403
+ description:
404
+ 'Shared secret between the server and the pack service. The pack service refuses any call without it, and the server sends it with every call. Required by the pack service, and by the server when `KINDGI_PACK_SERVICE_URL` is set. Use a long random value (`openssl rand -base64 32`). The pack service removes it from its environment before it loads the pack code.',
405
+ example: '',
406
+ required: true,
407
+ appliesTo: (t) => appliesToPackService(t) || appliesToServerHttpPackTransport(t),
408
+ group: 'pack-service',
409
+ },
410
+ // The pack service's side. It also listens on `PORT` (default 8080),
411
+ // the platform convention, which is not a Kindgi variable.
412
+ {
413
+ name: 'KINDGI_PACK_INDEX',
414
+ description:
415
+ "Path of the pack's `index.json` in the pack service's image. Default `/app/index.json`, where `kindgi build` puts it; the `--index` flag overrides it.",
416
+ example: '/app/index.json',
417
+ required: false,
418
+ appliesTo: appliesToPackService,
419
+ group: 'pack-service',
420
+ },
421
+ {
422
+ name: 'KINDGI_PACK_SERVICE_MAX_CONCURRENCY',
423
+ description:
424
+ "How many calls the pack service runs at once. Past it, calls get 503 (`overloaded`), which the server retries. Default 32. On Cloud Run, set the service's concurrency to the same number.",
425
+ example: '32',
426
+ required: false,
427
+ appliesTo: appliesToPackService,
428
+ group: 'pack-service',
429
+ },
430
+ {
431
+ name: 'KINDGI_PACK_ENV_CHECK',
432
+ description:
433
+ "What the pack service does when a name the pack's `env.required` declares is unset or empty in its environment. `strict` (default): it isn't ready, and `/readyz` and every call answer 503 naming the missing names. `warn`: it serves, and names them in its log and `/v1/info`. `kindgi dev` uses `warn`.",
434
+ example: 'strict',
435
+ required: false,
436
+ appliesTo: appliesToPackService,
437
+ group: 'pack-service',
438
+ allowedValues: ['strict', 'warn'],
439
+ },
440
+
441
+ // ---- image registry ---------------------------------------------
442
+ // How the server reads a deployment's image to verify it (`POST
443
+ // /v1/deployments`): anonymous unless credentials are set.
444
+ {
445
+ name: 'KINDGI_IMAGE_REGISTRY_HOST',
446
+ description:
447
+ 'The registry host the credentials below are for, with its port when it has one (e.g. `registry.example`, `ghcr.io`). Other registries are read anonymously.',
448
+ example: 'registry.example',
449
+ required: false,
450
+ appliesTo: appliesToServer,
451
+ group: 'image-registry',
452
+ },
453
+ {
454
+ name: 'KINDGI_IMAGE_REGISTRY_USERNAME',
455
+ description:
456
+ "Username for `KINDGI_IMAGE_REGISTRY_HOST`. Used for the registry's token flow, or as basic auth.",
457
+ example: 'kindgi-reader',
458
+ required: false,
459
+ appliesTo: appliesToServer,
460
+ group: 'image-registry',
461
+ },
462
+ {
463
+ name: 'KINDGI_IMAGE_REGISTRY_PASSWORD',
464
+ description:
465
+ 'Password or access token for `KINDGI_IMAGE_REGISTRY_HOST`. Read-only (pull) access is enough.',
466
+ example: '',
467
+ required: false,
468
+ appliesTo: appliesToServer,
469
+ group: 'image-registry',
470
+ },
471
+ {
472
+ name: 'KINDGI_IMAGE_REGISTRY_INSECURE_HOSTS',
473
+ description:
474
+ 'Comma-separated registry hosts reached over plain HTTP instead of HTTPS, e.g. a local `registry:2`. Loopback hosts (`localhost`, `127.0.0.1`) always are.',
475
+ example: 'registry.internal:5000',
476
+ required: false,
477
+ appliesTo: appliesToServer,
478
+ group: 'image-registry',
479
+ },
480
+ {
481
+ name: 'KINDGI_IMAGE_REGISTRY_AUTH',
482
+ description:
483
+ "How the server signs in to `KINDGI_IMAGE_REGISTRY_HOST`. `static` (default): `KINDGI_IMAGE_REGISTRY_USERNAME` and `_PASSWORD`. `google`: the server's own Google identity (Application Default Credentials: the service's identity on Cloud Run), for Artifact Registry; no username or password is set, and Kindgi keeps no key file.",
484
+ example: 'static',
485
+ required: false,
486
+ appliesTo: appliesToServer,
487
+ group: 'image-registry',
488
+ allowedValues: ['static', 'google'],
489
+ },
490
+
491
+ // ---- development (`kindgi dev`) ---------------------------------
492
+ {
493
+ name: 'KINDGI_PACK_DIR',
494
+ description:
495
+ "Development only: the pack directory `kindgi dev` runs. The server reads the pack's tools, guardrails, agents and flows from the index `kindgi dev` writes there (`.kindgi/dev/index.json`), on every change, instead of from Postgres; signed deployments are off. Mount the directory, not the file: the index is replaced by rename.",
496
+ example: '/pack',
497
+ required: false,
498
+ appliesTo: appliesToServer,
499
+ group: 'dev',
500
+ },
501
+ {
502
+ name: 'KINDGI_DEV_CONSOLE_LOGIN',
503
+ description:
504
+ 'Development only: `true` lets the console log in by itself. `GET /__dev/bearer` hands it the API token, answering only requests addressed to a loopback host (`localhost`, `127.0.0.1`, `[::1]`). Needs the console.',
505
+ example: 'true',
506
+ required: false,
507
+ appliesTo: appliesToServer,
508
+ group: 'dev',
509
+ allowedValues: ['true', 'false', '1', '0'],
510
+ },
511
+ {
512
+ name: 'KINDGI_DEV_HOST_ALIAS',
513
+ description:
514
+ "Development only: where the server's outbound calls to a loopback address (`localhost`, `127.0.0.1`, `[::1]`) connect instead. This covers webhooks to the app, HTTP MCP endpoints, model providers and the pack service. Inside a container, loopback is the container itself; `kindgi dev` sets `host.docker.internal` on Docker Desktop. The URL, the `Host` header and the TLS server name stay as written; only the connection goes to the alias.",
515
+ example: 'host.docker.internal',
516
+ required: false,
517
+ appliesTo: appliesToServer,
518
+ group: 'dev',
519
+ },
520
+ ];
521
+
522
+ // ---------------------------------------------------------------------
523
+ // Helper API — the three consumer entry points.
524
+ // ---------------------------------------------------------------------
525
+
526
+ /**
527
+ * Filter the schema down to just vars that apply to the caller's
528
+ * target. Preserves declaration order (which drives `.env.example`
529
+ * ordering).
530
+ */
531
+ export function envVarsForTarget(
532
+ target: EnvTarget,
533
+ schema: readonly EnvVarSpec[] = KINDGI_ENV_SCHEMA,
534
+ ): readonly EnvVarSpec[] {
535
+ return schema.filter((v) => v.appliesTo(target));
536
+ }
537
+
538
+ /**
539
+ * Render vars as a `.env.example` file body. Each group listed in
540
+ * `ENV_GROUPS` gets a section-header comment; groups appear in order of
541
+ * first appearance, ungrouped vars last. Each var gets a description
542
+ * comment, its allowed-values enum (when set), and the example
543
+ * assignment. Optional vars are prefixed with `# ` so operators
544
+ * explicitly opt in.
545
+ */
546
+ export function renderEnvExample(vars: readonly EnvVarSpec[]): string {
547
+ const groups = new Map<string, EnvVarSpec[]>();
548
+ const ungrouped: EnvVarSpec[] = [];
549
+ for (const v of vars) {
550
+ if (v.group === undefined) {
551
+ ungrouped.push(v);
552
+ continue;
553
+ }
554
+ const list = groups.get(v.group);
555
+ if (list !== undefined) {
556
+ list.push(v);
557
+ } else {
558
+ groups.set(v.group, [v]);
559
+ }
560
+ }
561
+
562
+ const lines: string[] = [
563
+ '# Generated by `kindgi env init` — Kindgi deployment env vars.',
564
+ '# See @kindgi/env-schema for the source of truth.',
565
+ '',
566
+ ];
567
+
568
+ const emitGroup = (group: string | undefined, entries: readonly EnvVarSpec[]): void => {
569
+ if (entries.length === 0) return;
570
+ const header = group !== undefined && ENV_GROUPS[group as keyof typeof ENV_GROUPS];
571
+ if (typeof header === 'string') {
572
+ lines.push(`# ---- ${header} ----`);
573
+ lines.push('');
574
+ }
575
+ for (const v of entries) {
576
+ lines.push(`# ${v.description}`);
577
+ if (v.allowedValues !== undefined) {
578
+ lines.push(`# Allowed values: ${v.allowedValues.join(' | ')}`);
579
+ }
580
+ const prefix = v.required ? '' : '# ';
581
+ lines.push(`${prefix}${v.name}=${v.example}`);
582
+ lines.push('');
583
+ }
584
+ };
585
+
586
+ for (const [group, entries] of groups) {
587
+ emitGroup(group, entries);
588
+ }
589
+ emitGroup(undefined, ungrouped);
590
+
591
+ return lines.join('\n');
592
+ }
593
+
594
+ /**
595
+ * Check whether every required var for the target is set in `env`.
596
+ * Returns `{ ok: true }` on success, `{ ok: false, missing }` with a
597
+ * complete list of missing var names on failure. Callers can render
598
+ * this into an operator-actionable error message.
599
+ *
600
+ * Only checks REQUIRED vars — optional vars that are unset are fine
601
+ * (defaults apply, or the var is passthrough).
602
+ *
603
+ * Does not enforce `allowedValues` — that's a declarative hint for
604
+ * tooling; code that reads a var validates its value.
605
+ */
606
+ export function validateEnvForTarget(
607
+ env: Readonly<Record<string, string | undefined>>,
608
+ target: EnvTarget,
609
+ schema: readonly EnvVarSpec[] = KINDGI_ENV_SCHEMA,
610
+ ): { readonly ok: true } | { readonly ok: false; readonly missing: readonly string[] } {
611
+ const applicable = envVarsForTarget(target, schema);
612
+ const missing: string[] = [];
613
+ for (const v of applicable) {
614
+ if (!v.required) continue;
615
+ const value = env[v.name];
616
+ if (value === undefined || value === '') {
617
+ missing.push(v.name);
618
+ }
619
+ }
620
+ if (missing.length === 0) return { ok: true };
621
+ return { ok: false, missing };
622
+ }
package/src/values.ts ADDED
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Value checks for variables whose shape the schema can't express.
6
+ * Shared by the server, which reads them at startup, and the CLI, which
7
+ * checks them before it starts anything: both refuse the same values
8
+ * with the same message.
9
+ */
10
+
11
+ /** The Ed25519 private key file that signs public run tokens. */
12
+ export const PUBLIC_TOKEN_KEY_PATH_VAR = 'KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH';
13
+
14
+ /** The same key's PEM file, base64: the alternative to the path. */
15
+ export const PUBLIC_TOKEN_KEY_VAR = 'KINDGI_PUBLIC_TOKEN_SIGNING_KEY';
16
+
17
+ /** The license key the server checks at startup outside development mode. */
18
+ export const LICENSE_KEY_VAR = 'KINDGI_LICENSE_KEY';
19
+
20
+ /** The browser origins allowed on the routes a public run token opens. */
21
+ export const CORS_ORIGINS_VAR = 'KINDGI_CORS_ORIGINS';
22
+
23
+ /**
24
+ * Parse `KINDGI_CORS_ORIGINS`: comma-separated exact origins
25
+ * (`https://app.example.com`, `http://localhost:3000`): a scheme, a host
26
+ * and an optional port; no path, no trailing slash, no wildcard. Unset or
27
+ * empty: none. Throws on anything else, so a typo fails at startup rather
28
+ * than as a browser's CORS error.
29
+ */
30
+ export function parseCorsOrigins(raw: string | undefined): readonly string[] {
31
+ if (raw === undefined || raw.trim() === '') return [];
32
+ const origins = raw
33
+ .split(',')
34
+ .map((entry) => entry.trim())
35
+ .filter((entry) => entry !== '');
36
+ const invalid = origins.filter((origin) => !isExactOrigin(origin));
37
+ if (invalid.length > 0) {
38
+ throw new Error(
39
+ `${CORS_ORIGINS_VAR} entries must be exact origins like https://app.example.com ` +
40
+ `(scheme, host, optional port; no path or wildcard). Got: ${invalid.join(', ')}.`,
41
+ );
42
+ }
43
+ return [...new Set(origins)];
44
+ }
45
+
46
+ function isExactOrigin(value: string): boolean {
47
+ let url: URL;
48
+ try {
49
+ url = new URL(value);
50
+ } catch {
51
+ return false;
52
+ }
53
+ // `https://*.example.com` parses (as a literal `*` host): refuse it.
54
+ return (
55
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
56
+ url.origin === value &&
57
+ !value.includes('*')
58
+ );
59
+ }