@checkstack/backend-api 0.20.0 → 0.21.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 (51) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/package.json +12 -11
  3. package/src/auth-strategy.ts +6 -3
  4. package/src/bearer-token.ts +13 -0
  5. package/src/collector-strategy.ts +9 -0
  6. package/src/config-versioning.test.ts +227 -0
  7. package/src/config-versioning.ts +172 -0
  8. package/src/core-services.ts +14 -0
  9. package/src/esm-script-runner.test.ts +55 -16
  10. package/src/esm-script-runner.ts +212 -55
  11. package/src/index.ts +3 -0
  12. package/src/render-templatable-config.test.ts +168 -0
  13. package/src/render-templatable-config.ts +193 -0
  14. package/src/schema-utils.ts +3 -0
  15. package/src/script-sandbox/capabilities.test.ts +122 -0
  16. package/src/script-sandbox/capabilities.ts +372 -0
  17. package/src/script-sandbox/capped-output.test.ts +116 -0
  18. package/src/script-sandbox/capped-output.ts +172 -0
  19. package/src/script-sandbox/env-guard.test.ts +105 -0
  20. package/src/script-sandbox/env-guard.ts +129 -0
  21. package/src/script-sandbox/filesystem.test.ts +437 -0
  22. package/src/script-sandbox/filesystem.ts +514 -0
  23. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  24. package/src/script-sandbox/global-default.test.ts +161 -0
  25. package/src/script-sandbox/global-default.ts +100 -0
  26. package/src/script-sandbox/index.ts +14 -0
  27. package/src/script-sandbox/network.test.ts +356 -0
  28. package/src/script-sandbox/network.ts +373 -0
  29. package/src/script-sandbox/observability.test.ts +210 -0
  30. package/src/script-sandbox/observability.ts +168 -0
  31. package/src/script-sandbox/output-truncation.test.ts +53 -0
  32. package/src/script-sandbox/output-truncation.ts +69 -0
  33. package/src/script-sandbox/policy.test.ts +189 -0
  34. package/src/script-sandbox/policy.ts +220 -0
  35. package/src/script-sandbox/provider.test.ts +61 -0
  36. package/src/script-sandbox/provider.ts +134 -0
  37. package/src/script-sandbox/readiness.test.ts +80 -0
  38. package/src/script-sandbox/readiness.ts +117 -0
  39. package/src/script-sandbox/report.ts +88 -0
  40. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  41. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  42. package/src/script-sandbox/rootless-egress.ts +218 -0
  43. package/src/script-sandbox/shell-quote.test.ts +32 -0
  44. package/src/script-sandbox/shell-quote.ts +10 -0
  45. package/src/script-sandbox/wrapper.test.ts +1194 -0
  46. package/src/script-sandbox/wrapper.ts +714 -0
  47. package/src/shell-script-runner.test.ts +243 -0
  48. package/src/shell-script-runner.ts +210 -45
  49. package/src/zod-config.test.ts +60 -0
  50. package/src/zod-config.ts +38 -14
  51. package/tsconfig.json +3 -0
@@ -1,9 +1,23 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
+ import { realpathSync } from "node:fs";
2
3
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import path from "node:path";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { pathToFileURL } from "node:url";
8
+ import { detectSandboxCapabilities } from "./script-sandbox/capabilities";
9
+ import { readCappedOutput } from "./script-sandbox/capped-output";
10
+ import { pickSafeEnv } from "./script-sandbox/env-guard";
11
+ import { truncateCapturedOutput } from "./script-sandbox/output-truncation";
12
+ import {
13
+ FAIL_CLOSED_DOWNGRADE_REASON,
14
+ resolveActiveSandboxPolicy,
15
+ } from "./script-sandbox/provider";
16
+ import {
17
+ type EffectiveSandbox,
18
+ SandboxUnavailableError,
19
+ } from "./script-sandbox/report";
20
+ import { buildSpawnHardening } from "./script-sandbox/wrapper";
7
21
 
8
22
  /**
9
23
  * Shared sandbox for executing user-authored TypeScript / JavaScript
@@ -61,6 +75,14 @@ export interface EsmScriptRunResult {
61
75
  stderr: string;
62
76
  /** True if the timeout fired before the subprocess exited. */
63
77
  timedOut: boolean;
78
+ /** True if captured output exceeded the sandbox `maxOutputBytes` cap and was trimmed. */
79
+ outputTruncated?: boolean;
80
+ /**
81
+ * What the OS-level sandbox actually enforced / degraded for this run.
82
+ * Always present: the runner resolves the active GLOBAL policy itself and
83
+ * reports the result so callers can surface downgrades.
84
+ */
85
+ sandbox?: EffectiveSandbox;
64
86
  }
65
87
 
66
88
  export interface EsmScriptRunOptions {
@@ -78,9 +100,9 @@ export interface EsmScriptRunOptions {
78
100
  * to point at that file. Skipped if either field is omitted.
79
101
  *
80
102
  * @example
81
- * helperModuleName: "@checkstack/healthcheck"
103
+ * helperModuleName: "@checkstack/sdk/healthcheck"
82
104
  * helperFunctionName: "defineHealthCheck"
83
- * // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
105
+ * // editor: import { defineHealthCheck } from "@checkstack/sdk/healthcheck"
84
106
  * // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
85
107
  */
86
108
  helperModuleName?: string;
@@ -112,6 +134,10 @@ export interface EsmScriptRunOptions {
112
134
  *
113
135
  * The user's script reads these as `process.env.ENV_NAME`. On a key
114
136
  * collision with a safe var, the injected value wins.
137
+ *
138
+ * Note: forbidden keys (`LD_PRELOAD`, `NODE_OPTIONS`, ...) are dropped from
139
+ * these overrides by the shared env denylist whenever the active sandbox
140
+ * policy is enabled.
115
141
  */
116
142
  env?: Record<string, string>;
117
143
  }
@@ -125,41 +151,6 @@ export interface EsmScriptRunner {
125
151
  run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
126
152
  }
127
153
 
128
- // =============================================================================
129
- // INTERNALS
130
- // =============================================================================
131
-
132
- /**
133
- * Vars passed through to the subprocess. We intentionally do NOT
134
- * forward the satellite's full env so backend secrets (DB URLs, API
135
- * tokens, signing keys) never reach user-authored scripts. PATH / HOME
136
- * / LANG / ... are kept so `node:child_process`, `node:fs`, and
137
- * locale-sensitive APIs behave normally.
138
- */
139
- const SAFE_ENV_VARS = [
140
- "PATH",
141
- "HOME",
142
- "USER",
143
- "LANG",
144
- "LC_ALL",
145
- "LC_CTYPE",
146
- "TZ",
147
- "TMPDIR",
148
- "HOSTNAME",
149
- "SHELL",
150
- ];
151
-
152
- function pickSafeEnv(): Record<string, string> {
153
- const env: Record<string, string> = {};
154
- for (const key of SAFE_ENV_VARS) {
155
- const value = process.env[key];
156
- if (value !== undefined) {
157
- env[key] = value;
158
- }
159
- }
160
- return env;
161
- }
162
-
163
154
  // =============================================================================
164
155
  // USER-SCRIPT NORMALISATION
165
156
  // =============================================================================
@@ -332,6 +323,22 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
332
323
  resolutionRoot,
333
324
  env: injectedEnv,
334
325
  }) {
326
+ // Reconcile the requested policy against this host's capabilities BEFORE
327
+ // any filesystem work or spawn. When `onUnavailable: "fail"` and a layer
328
+ // is unavailable this throws, and we return a clean failure WITHOUT
329
+ // touching disk or spawning a child.
330
+ const caps = detectSandboxCapabilities();
331
+ // Resolve the GLOBAL sandbox policy ourselves (policy is global-only; the
332
+ // runner no longer accepts a per-run override). With a provider wired at
333
+ // startup this is the durable cluster-wide default; with NO provider (or a
334
+ // provider that throws) it FAILS CLOSED to the most restrictive safe policy
335
+ // (deny egress, scratch + read-only managed packages, privilege drop) —
336
+ // never the permissive default. The fail-closed fallback is surfaced as a
337
+ // synthetic downgrade. On hosts lacking a primitive each layer
338
+ // degrades-and-surfaces (never hard-breaks) per the resolved
339
+ // `onUnavailable`.
340
+ const { policy, failedClosed } = await resolveActiveSandboxPolicy();
341
+
335
342
  const sessionId = randomUUID();
336
343
  const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
337
344
  const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
@@ -340,8 +347,93 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
340
347
  // so module resolution walks up to `<resolutionRoot>/node_modules`.
341
348
  // Otherwise fall back to `os.tmpdir()` (today's behavior - no
342
349
  // node_modules visible, fully backward compatible).
350
+ //
351
+ // The scratch dir is created BEFORE building the hardening so the
352
+ // filesystem layer (Phase 2) can bind it into the namespace. When a
353
+ // fail-closed policy refuses an unenforceable layer we clean the dir up
354
+ // again and never spawn — equivalent to "didn't touch disk" from the
355
+ // caller's view (the dir is gone).
343
356
  const tmpBase = resolutionRoot ?? tmpdir();
344
357
  const tmpDir = await mkdtemp(path.join(tmpBase, "checkstack-script-"));
358
+ // The reconciled managed-package tree, exposed read-only under
359
+ // `scratch-plus-ro`. Only meaningful when a resolutionRoot is set.
360
+ const nodeModulesDir =
361
+ resolutionRoot === undefined
362
+ ? undefined
363
+ : path.join(resolutionRoot, "node_modules");
364
+
365
+ // Path at which we stage the network egress nftables ruleset (consumed by
366
+ // `nsjail --nftables_file`, or the rootless launcher's fail-closed `nft
367
+ // -f`). Resolved on the host before the namespace is entered, so a path
368
+ // inside the per-run dir is fine.
369
+ const nftRulesetPath = path.join(tmpDir, "egress.nft");
370
+ // Path at which we stage the rootless egress launcher script (slirp4netns +
371
+ // the fail-closed nft filter) when the network decision picks the rootless
372
+ // path. Same per-run dir.
373
+ const rootlessLauncherPath = path.join(tmpDir, "rootless-egress.sh");
374
+
375
+ let hardening;
376
+ try {
377
+ hardening = buildSpawnHardening({
378
+ policy,
379
+ caps,
380
+ baseEnv: pickSafeEnv(),
381
+ // Fold the controlled ESM memory fallback (NODE_OPTIONS) in AFTER the
382
+ // denylist runs on caller overrides, so a caller can't smuggle
383
+ // NODE_OPTIONS but the sandbox can still set the heap cap.
384
+ envOverrides: injectedEnv,
385
+ filesystem: {
386
+ scratchDir: tmpDir,
387
+ nodeModulesDir,
388
+ // Bind the Bun runtime read-only into the namespace; under FS
389
+ // confinement the host FS is hidden and the interpreter commonly
390
+ // lives outside /usr,/bin (e.g. ~/.bun/bin/bun), so without this the
391
+ // child cannot exec the runtime.
392
+ interpreterPath: realpathSync(process.execPath),
393
+ },
394
+ nftRulesetPath,
395
+ rootlessLauncherPath,
396
+ // The ESM runner execs a Bun/Node interpreter that honours
397
+ // NODE_OPTIONS=--max-old-space-size, so the per-run JS-heap memory cap
398
+ // IS applied here (unlike the shell runner).
399
+ appliesNodeMemoryCap: true,
400
+ });
401
+ // Surface the fail-closed fallback as a notice in the report so a
402
+ // missing/failed policy provider is never silent (the run still proceeds
403
+ // under the most restrictive policy).
404
+ if (failedClosed) {
405
+ hardening.effective.downgrades.push({
406
+ layer: "network",
407
+ reason: FAIL_CLOSED_DOWNGRADE_REASON,
408
+ });
409
+ }
410
+ } catch (error) {
411
+ if (error instanceof SandboxUnavailableError) {
412
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {
413
+ // Best-effort: nothing was written yet; the OS reaps any straggler.
414
+ });
415
+ return {
416
+ stdout: "",
417
+ stderr: "",
418
+ timedOut: false,
419
+ error: error.message,
420
+ sandbox: {
421
+ requested: policy,
422
+ enforced: {
423
+ resources: false,
424
+ filesystem: false,
425
+ network: false,
426
+ privilege: false,
427
+ },
428
+ downgrades: error.downgrades,
429
+ notes: [],
430
+ platform: caps.platform,
431
+ },
432
+ };
433
+ }
434
+ throw error;
435
+ }
436
+
345
437
  const userScriptPath = path.join(tmpDir, "user.mjs");
346
438
  const runnerPath = path.join(tmpDir, "runner.mjs");
347
439
  const bunfigPath = path.join(tmpDir, "bunfig.toml");
@@ -392,6 +484,20 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
392
484
  // degradation the package feature requires.
393
485
  await writeFile(bunfigPath, '[install]\nauto = "disable"\n', "utf8");
394
486
 
487
+ // Stage the network egress ruleset (if the sandbox produced one) so the
488
+ // wrapper can install it inside the child's net namespace.
489
+ if (hardening.nftRuleset !== undefined) {
490
+ await writeFile(nftRulesetPath, hardening.nftRuleset, "utf8");
491
+ }
492
+ // Stage the rootless egress launcher (slirp4netns orchestration) when the
493
+ // sandbox picked the rootless path. The prelude execs it as `sh <path>`.
494
+ if (hardening.rootlessLauncher !== undefined) {
495
+ await writeFile(rootlessLauncherPath, hardening.rootlessLauncher, {
496
+ encoding: "utf8",
497
+ mode: 0o700,
498
+ });
499
+ }
500
+
395
501
  await writeFile(userScriptPath, userSource, "utf8");
396
502
  await writeFile(
397
503
  runnerPath,
@@ -406,31 +512,61 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
406
512
  );
407
513
 
408
514
  proc = spawn({
409
- cmd: [process.execPath, runnerPath],
515
+ // The sandbox may prepend an rlimit prelude (e.g. `prlimit --as=... --`)
516
+ // to the argv. CWD stays the per-run dir.
517
+ cmd: hardening.wrapCmd([process.execPath, runnerPath]),
410
518
  // CWD = the per-run dir so Bun reads its `bunfig.toml`
411
519
  // (auto-install disabled) and resolves modules from
412
520
  // `<resolutionRoot>/node_modules` when set.
413
521
  cwd: tmpDir,
414
- // Per-run injected env wins over the safe-vars whitelist. The
415
- // injected secret values live only here + the child process; they
416
- // never widen the ambient SAFE_ENV_VARS.
417
- env: { ...pickSafeEnv(), ...injectedEnv },
522
+ // `hardening.env` is the safe-vars base overlaid with the caller's
523
+ // injected env (denylist applied when the sandbox is enabled). The
524
+ // controlled ESM memory fallback (NODE_OPTIONS=--max-old-space-size)
525
+ // is merged on top — it is a sandbox-set cap, not a caller override,
526
+ // so it is intentionally not subject to the denylist.
527
+ env: { ...hardening.env, ...hardening.nodeMemoryFlagEnv },
528
+ // NOTE: we deliberately do NOT pass `uid`/`gid` to Bun.spawn. On the
529
+ // shipped Bun versions it is a silent no-op (the privilege drop is
530
+ // carried by the namespace wrapper's `--uid`, or by inheritance from a
531
+ // non-root supervisor), AND passing it is a forward-compat hazard: a
532
+ // future Bun that honours it would spawn the WRAPPER itself as the
533
+ // dropped id, breaking unprivileged-userns creation. `hardening.uid` is
534
+ // observability-only. See wrapper.ts.
418
535
  stdout: "pipe",
419
536
  stderr: "pipe",
420
537
  });
421
538
 
422
539
  let stdout: string;
423
540
  let stderr: string;
541
+ let streamTruncated = false;
424
542
 
425
543
  try {
426
- [stdout, stderr] = (await Promise.race([
544
+ // Bounded-buffering capture: count bytes against the shared
545
+ // `maxOutputBytes` budget and kill + flag the child once exceeded,
546
+ // rather than buffering the whole output first (OOM guard on a degraded
547
+ // host without RLIMIT_AS — plan §5.1). NOTE: a script that floods past
548
+ // the cap loses its result marker (it is emitted last); that is an
549
+ // acceptable degradation for abusive output, and the run is reported as
550
+ // truncated + "no result".
551
+ const captureProc = proc;
552
+ const [captured] = (await Promise.race([
427
553
  Promise.all([
428
- new Response(proc.stdout as ReadableStream).text(),
429
- new Response(proc.stderr as ReadableStream).text(),
430
- proc.exited,
554
+ readCappedOutput({
555
+ stdout: captureProc.stdout as ReadableStream<Uint8Array>,
556
+ stderr: captureProc.stderr as ReadableStream<Uint8Array>,
557
+ maxOutputBytes: hardening.maxOutputBytes,
558
+ onExceeded: () => captureProc.kill(),
559
+ }),
560
+ captureProc.exited,
431
561
  ]),
432
562
  timeoutPromise,
433
- ])) as [string, string, number];
563
+ ])) as [
564
+ { stdout: string; stderr: string; truncated: boolean },
565
+ number,
566
+ ];
567
+ stdout = captured.stdout;
568
+ stderr = captured.stderr;
569
+ streamTruncated = captured.truncated;
434
570
  } catch (error) {
435
571
  if (timedOut) {
436
572
  return {
@@ -438,6 +574,7 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
438
574
  stderr: "",
439
575
  timedOut: true,
440
576
  error: "Script execution timed out",
577
+ sandbox: hardening.effective,
441
578
  };
442
579
  }
443
580
  throw error;
@@ -467,17 +604,33 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
467
604
  .trim();
468
605
  }
469
606
 
607
+ // Apply output truncation AFTER the result marker has been plucked, so
608
+ // the cap never corrupts the marker the parent relies on. Truncation is
609
+ // the portable resource-cap fallback (pure JS, every platform).
610
+ const {
611
+ stdout: cappedStdout,
612
+ stderr: cappedStderr,
613
+ truncated: trimTruncated,
614
+ } = truncateCapturedOutput({
615
+ stdout: stdout.trim(),
616
+ stderr: cleanStderr,
617
+ maxOutputBytes: hardening.maxOutputBytes,
618
+ });
619
+ const outputTruncated = streamTruncated || trimTruncated;
620
+
470
621
  if (!payload) {
471
622
  // The runner never got far enough to emit — typically a syntax
472
623
  // error in the user module or a hard crash. Surface whatever the
473
624
  // subprocess wrote to stderr as the error.
474
625
  return {
475
- stdout: stdout.trim(),
476
- stderr: cleanStderr,
626
+ stdout: cappedStdout,
627
+ stderr: cappedStderr,
477
628
  timedOut: false,
629
+ outputTruncated,
630
+ sandbox: hardening.effective,
478
631
  error:
479
- cleanStderr.length > 0
480
- ? cleanStderr
632
+ cappedStderr.length > 0
633
+ ? cappedStderr
481
634
  : "Script exited without producing a result",
482
635
  };
483
636
  }
@@ -485,18 +638,22 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
485
638
  if (payload.ok) {
486
639
  return {
487
640
  result: payload.result,
488
- stdout: stdout.trim(),
489
- stderr: cleanStderr,
641
+ stdout: cappedStdout,
642
+ stderr: cappedStderr,
490
643
  timedOut: false,
644
+ outputTruncated,
645
+ sandbox: hardening.effective,
491
646
  };
492
647
  }
493
648
 
494
649
  return {
495
650
  error: payload.error,
496
651
  stack: payload.stack,
497
- stdout: stdout.trim(),
498
- stderr: cleanStderr,
652
+ stdout: cappedStdout,
653
+ stderr: cappedStderr,
499
654
  timedOut: false,
655
+ outputTruncated,
656
+ sandbox: hardening.effective,
500
657
  };
501
658
  } finally {
502
659
  // Order matters:
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./esm-script-runner";
2
2
  export * from "./shell-script-runner";
3
+ export * from "./script-sandbox";
3
4
  export * from "./service-ref";
4
5
  export * from "./extension-point";
5
6
  export * from "./core-services";
@@ -10,6 +11,7 @@ export * from "./auth-strategy";
10
11
  export * from "./zod-config";
11
12
  export * from "./encryption";
12
13
  export * from "./schema-utils";
14
+ export * from "./render-templatable-config";
13
15
  export * from "./config-service";
14
16
  export * from "zod";
15
17
  export * from "./config-versioning";
@@ -34,3 +36,4 @@ export * from "./aggregated-result";
34
36
  export * from "./ws-registry";
35
37
  export * from "./readiness-registry";
36
38
  export * from "./advisory-lock";
39
+ export * from "./bearer-token";
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+
4
+ import { configString, withConfigMeta } from "./zod-config";
5
+ import {
6
+ assertNoSecretTemplatableConflict,
7
+ renderTemplatableConfig,
8
+ renderTemplatePreview,
9
+ } from "./render-templatable-config";
10
+
11
+ const httpLikeSchema = z.object({
12
+ url: configString({ "x-templatable": true }),
13
+ method: z.string(),
14
+ headers: z
15
+ .array(
16
+ z.object({
17
+ name: z.string(),
18
+ value: configString({ "x-templatable": true }),
19
+ }),
20
+ )
21
+ .optional(),
22
+ body: configString({ "x-templatable": true }).optional(),
23
+ });
24
+
25
+ const context = {
26
+ environment: { baseUrl: "https://staging.example.com", tenant: "acme" },
27
+ check: { name: "API health" },
28
+ system: { id: "sys-1" },
29
+ };
30
+
31
+ describe("renderTemplatableConfig", () => {
32
+ test("renders environment.* into url, header values, and body", () => {
33
+ const out = renderTemplatableConfig({
34
+ schema: httpLikeSchema,
35
+ context,
36
+ config: {
37
+ url: "{{ environment.baseUrl }}/healthz",
38
+ method: "GET",
39
+ headers: [
40
+ { name: "Host", value: "{{ environment.baseUrl }}" },
41
+ { name: "X-Tenant", value: "{{ environment.tenant }}" },
42
+ ],
43
+ body: '{"tenant":"{{ environment.tenant }}"}',
44
+ },
45
+ }) as Record<string, unknown>;
46
+
47
+ expect(out.url).toBe("https://staging.example.com/healthz");
48
+ expect(out.headers).toEqual([
49
+ { name: "Host", value: "https://staging.example.com" },
50
+ { name: "X-Tenant", value: "acme" },
51
+ ]);
52
+ expect(out.body).toBe('{"tenant":"acme"}');
53
+ // Non-templatable field passed through verbatim.
54
+ expect(out.method).toBe("GET");
55
+ });
56
+
57
+ test("non-templatable field with a literal {{ is untouched", () => {
58
+ const out = renderTemplatableConfig({
59
+ schema: httpLikeSchema,
60
+ context,
61
+ config: {
62
+ url: "{{ environment.baseUrl }}",
63
+ // method is NOT templatable; its literal braces must survive verbatim.
64
+ method: "{{ environment.baseUrl }}",
65
+ },
66
+ }) as Record<string, unknown>;
67
+
68
+ expect(out.url).toBe("https://staging.example.com");
69
+ expect(out.method).toBe("{{ environment.baseUrl }}");
70
+ });
71
+
72
+ test("missing path renders to empty string (strict: false)", () => {
73
+ const out = renderTemplatableConfig({
74
+ schema: httpLikeSchema,
75
+ context: { environment: {}, check: {}, system: {} },
76
+ config: { url: "{{ environment.baseUrl }}/healthz", method: "GET" },
77
+ }) as Record<string, unknown>;
78
+
79
+ expect(out.url).toBe("/healthz");
80
+ });
81
+
82
+ test("a resolved value containing {{ is not re-interpreted", () => {
83
+ // Simulates secrets-first ordering: a secret already resolved into a value
84
+ // that happens to contain `{{`. The templating pass only renders the
85
+ // x-templatable `url`; the rendered output itself is never re-parsed.
86
+ const out = renderTemplatableConfig({
87
+ schema: httpLikeSchema,
88
+ context: {
89
+ environment: { baseUrl: "https://h/{{ not_a_ref }}" },
90
+ check: {},
91
+ system: {},
92
+ },
93
+ config: { url: "{{ environment.baseUrl }}", method: "GET" },
94
+ }) as Record<string, unknown>;
95
+
96
+ // The `{{ not_a_ref }}` came FROM the resolved value, not the template, so
97
+ // it is emitted literally (single-pass render).
98
+ expect(out.url).toBe("https://h/{{ not_a_ref }}");
99
+ });
100
+
101
+ test("does not mutate the input config", () => {
102
+ const config = {
103
+ url: "{{ environment.baseUrl }}",
104
+ method: "GET",
105
+ headers: [{ name: "Host", value: "{{ environment.baseUrl }}" }],
106
+ };
107
+ renderTemplatableConfig({ schema: httpLikeSchema, context, config });
108
+ expect(config.url).toBe("{{ environment.baseUrl }}");
109
+ expect(config.headers[0].value).toBe("{{ environment.baseUrl }}");
110
+ });
111
+ });
112
+
113
+ describe("renderTemplatePreview", () => {
114
+ test("renders a single value against a sample context", () => {
115
+ expect(
116
+ renderTemplatePreview({
117
+ value: "{{ environment.baseUrl }}/v1",
118
+ context,
119
+ }),
120
+ ).toBe("https://staging.example.com/v1");
121
+ });
122
+
123
+ test("leaves a non-template value untouched", () => {
124
+ expect(
125
+ renderTemplatePreview({ value: "https://static.example", context }),
126
+ ).toBe("https://static.example");
127
+ });
128
+ });
129
+
130
+ describe("assertNoSecretTemplatableConflict", () => {
131
+ test("throws when a field is both x-secret and x-templatable", () => {
132
+ const schema = z.object({
133
+ token: configString({ "x-secret": true, "x-templatable": true }),
134
+ });
135
+ expect(() =>
136
+ assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
137
+ ).toThrow(/both/);
138
+ });
139
+
140
+ test("throws for nested both-flagged field naming the path", () => {
141
+ const schema = z.object({
142
+ headers: z.array(
143
+ z.object({
144
+ value: configString({
145
+ "x-secret-env": true,
146
+ "x-templatable": true,
147
+ }),
148
+ }),
149
+ ),
150
+ });
151
+ expect(() =>
152
+ assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
153
+ ).toThrow(/headers\[\]\.value/);
154
+ });
155
+
156
+ test("passes when secret and templatable are on different fields", () => {
157
+ const schema = z.object({
158
+ token: configString({ "x-secret": true }),
159
+ url: configString({ "x-templatable": true }),
160
+ env: withConfigMeta(z.record(z.string(), z.string()), {
161
+ "x-secret-env": true,
162
+ }),
163
+ });
164
+ expect(() =>
165
+ assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
166
+ ).not.toThrow();
167
+ });
168
+ });