@intentius/chant 0.45.0 → 0.49.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 (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Generated-once secret materialization — the one implementation behind the
3
+ * `ensure-secret` capability verb (components/verbs/ensure-secret.ts) and the
4
+ * `ensureSecret(...)` op step builder (op/builders.ts). chant #1829, epic
5
+ * #1365 decisions 3 and 6.
6
+ *
7
+ * The contract is read-then-write:
8
+ *
9
+ * - Absent: mint once, through the store adapter, and report `created`.
10
+ * - Present: verify presence, the declared key-set, and any declared
11
+ * metadata, then STOP — present means done. Never mint over an existing
12
+ * value. Never rotate implicitly. A re-run of a whole deploy leaves the
13
+ * stored bytes untouched.
14
+ * - Mismatch: fail loudly, naming what mismatched — key names and metadata
15
+ * keys, never a value or a value-derived hash (#1365 decision 6).
16
+ *
17
+ * The constitutional line (../secret-provenance.ts): no code path here may
18
+ * hold, log, hash, or compare a secret value. That is structural, not
19
+ * discipline:
20
+ *
21
+ * - {@link ensureSecretMaterialization} never calls the generator. It hands
22
+ * the generator to the store adapter's `create`, which consumes the
23
+ * material as it writes. The engine's result type has no field that could
24
+ * carry material.
25
+ * - The generator produces {@link SecretMaterial}, an opaque single-use
26
+ * handle. The plaintext lives in a module-private WeakMap, not on the
27
+ * object: enumeration, `JSON.stringify`, `String(...)`, and `util.inspect`
28
+ * all see only a redaction marker. Only {@link consumeSecretMaterial} —
29
+ * meant for store adapters, at the write — yields the plaintext, exactly
30
+ * once; a second consume throws.
31
+ * - `describe` returns key names and metadata only, so the mismatch check
32
+ * has nothing value-shaped to compare even by accident.
33
+ *
34
+ * Store adapters are per-provider (#1830 is the k8s row); core defines only
35
+ * the seam.
36
+ */
37
+
38
+ import { randomBytes } from "node:crypto";
39
+
40
+ // ── Opaque secret material ────────────────────────────────────────────────────
41
+
42
+ /** Module-private plaintext vault. Keyed by handle identity; consuming deletes. */
43
+ const vault = new WeakMap<SecretMaterial, string>();
44
+
45
+ const REDACTED = "[secret material]";
46
+
47
+ /**
48
+ * A single-use, opaque handle to generated secret material. The plaintext is
49
+ * not a property of this object — it lives in a module-private WeakMap — so
50
+ * spreading, enumerating, stringifying, or logging the handle yields only a
51
+ * redaction marker. A store adapter redeems it with
52
+ * {@link consumeSecretMaterial} at the moment it writes; nothing else can.
53
+ */
54
+ export class SecretMaterial {
55
+ private constructor() {}
56
+
57
+ /** Wrap plaintext in an opaque handle. Call inside a generator only. */
58
+ static mint(plaintext: string): SecretMaterial {
59
+ const handle = new SecretMaterial();
60
+ vault.set(handle, plaintext);
61
+ return handle;
62
+ }
63
+
64
+ toString(): string {
65
+ return REDACTED;
66
+ }
67
+
68
+ toJSON(): string {
69
+ return REDACTED;
70
+ }
71
+
72
+ /** node:util.inspect (console.log) sees the redaction marker, not the vault. */
73
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
74
+ return REDACTED;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Redeem a {@link SecretMaterial} handle for its plaintext — exactly once.
80
+ * For store adapters only, at the write to the backing store. Throws if the
81
+ * handle was already consumed (or was never minted here): material flows to
82
+ * the store exactly once and is retained nowhere.
83
+ */
84
+ export function consumeSecretMaterial(material: SecretMaterial): string {
85
+ const plaintext = vault.get(material);
86
+ if (plaintext === undefined) {
87
+ throw new Error(
88
+ "secret material already consumed — material flows to the store adapter exactly once and is never retained",
89
+ );
90
+ }
91
+ vault.delete(material);
92
+ return plaintext;
93
+ }
94
+
95
+ /**
96
+ * Produces the material for one key of a secret being minted. Called by the
97
+ * store adapter (never by the engine) once per declared key, at create time —
98
+ * generation is apply-time I/O, never synthesis.
99
+ */
100
+ export type SecretMaterialGenerator = (key: string) => SecretMaterial | Promise<SecretMaterial>;
101
+
102
+ /** 32 bytes from the CSPRNG, base64url — the default mint. */
103
+ export const defaultSecretMaterialGenerator: SecretMaterialGenerator = () =>
104
+ SecretMaterial.mint(randomBytes(32).toString("base64url"));
105
+
106
+ // ── The store adapter seam ────────────────────────────────────────────────────
107
+
108
+ /** What `describe` reports about an existing secret: key NAMES and metadata. Never values. */
109
+ export interface SecretStoreDescription {
110
+ /** The key names present in the stored secret. */
111
+ readonly keys: readonly string[];
112
+ /** Store metadata (e.g. k8s labels/annotations the provider surfaces). */
113
+ readonly metadata?: Readonly<Record<string, string>>;
114
+ }
115
+
116
+ /**
117
+ * A provider's view of one secret store — the seam #1830 (k8s) and future
118
+ * provider rows implement. `create` receives the generator and consumes each
119
+ * key's material as it writes; no method returns material.
120
+ */
121
+ export interface SecretStoreAdapter {
122
+ /** Whether a secret of this name exists in the store. */
123
+ exists(name: string): Promise<boolean>;
124
+ /** Key names and metadata of an existing secret. Never values. */
125
+ describe(name: string): Promise<SecretStoreDescription>;
126
+ /**
127
+ * Create the secret, minting material for each declared key via
128
+ * `generate` and writing it straight to the store. Returns nothing:
129
+ * material must not travel back through this seam.
130
+ */
131
+ create(name: string, keys: readonly string[], generate: SecretMaterialGenerator): Promise<void>;
132
+ }
133
+
134
+ // ── The ensure contract ───────────────────────────────────────────────────────
135
+
136
+ /** The declared contract to ensure — names and keys only, never material. */
137
+ export interface EnsureSecretSpec {
138
+ /** The secret's name as the store knows it. */
139
+ readonly name: string;
140
+ /** The declared key-set. Creation mints one value per key; verification compares names. */
141
+ readonly keys: readonly string[];
142
+ /** Declared metadata an existing secret must carry (compared per key; mismatches are reported by KEY). */
143
+ readonly metadata?: Readonly<Record<string, string>>;
144
+ }
145
+
146
+ /** What `ensureSecretMaterialization` did. No field can carry material. */
147
+ export interface EnsureSecretOutcome {
148
+ /** `created` = minted now (first materialization); `present` = existed and matched the contract (no write). */
149
+ readonly outcome: "created" | "present";
150
+ /** The secret's name. */
151
+ readonly name: string;
152
+ /** The declared key names the contract was checked (or minted) against. */
153
+ readonly keys: readonly string[];
154
+ }
155
+
156
+ /**
157
+ * The loud failure: an existing secret does not match its declared contract.
158
+ * `mismatches` names what differed — key names and metadata keys only.
159
+ * Constructing one with anything value-shaped is the reviewer's tripwire;
160
+ * nothing in this module can, because nothing in this module holds a value.
161
+ */
162
+ export class SecretContractMismatchError extends Error {
163
+ constructor(
164
+ /** The secret's name. */
165
+ public readonly secretName: string,
166
+ /** Human-readable mismatch descriptions, naming keys — never values. */
167
+ public readonly mismatches: readonly string[],
168
+ ) {
169
+ super(
170
+ `secret "${secretName}" exists but does not match its declared contract: ${mismatches.join("; ")}. ` +
171
+ `chant never mints over or rotates an existing secret — reconcile the declaration or the stored secret explicitly.`,
172
+ );
173
+ this.name = "SecretContractMismatchError";
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Ensure a `generated-once` secret exists and matches its declared contract.
179
+ * Read-then-write:
180
+ *
181
+ * - Absent: `store.create(name, keys, generate)` — one mint, then done.
182
+ * - Present and matching: no write of any kind; returns `present`.
183
+ * - Present and mismatching: throws {@link SecretContractMismatchError}
184
+ * naming the missing/unexpected key names and mismatched metadata keys.
185
+ *
186
+ * The engine never calls `generate` itself and never sees what the adapter
187
+ * writes; its return value carries names only.
188
+ */
189
+ export async function ensureSecretMaterialization(
190
+ store: SecretStoreAdapter,
191
+ spec: EnsureSecretSpec,
192
+ generate: SecretMaterialGenerator = defaultSecretMaterialGenerator,
193
+ ): Promise<EnsureSecretOutcome> {
194
+ if (typeof spec.name !== "string" || spec.name.length === 0) {
195
+ throw new Error("ensureSecret: `name` must be a non-empty string");
196
+ }
197
+ if (!Array.isArray(spec.keys) || spec.keys.length === 0) {
198
+ throw new Error(`ensureSecret("${spec.name}"): \`keys\` must name at least one key to materialize`);
199
+ }
200
+
201
+ if (await store.exists(spec.name)) {
202
+ const actual = await store.describe(spec.name);
203
+ const mismatches: string[] = [];
204
+
205
+ const actualKeys = new Set(actual.keys);
206
+ const declaredKeys = new Set(spec.keys);
207
+ const missing = spec.keys.filter((k) => !actualKeys.has(k));
208
+ const unexpected = actual.keys.filter((k) => !declaredKeys.has(k));
209
+ if (missing.length > 0) {
210
+ mismatches.push(`missing declared key(s): ${missing.join(", ")}`);
211
+ }
212
+ if (unexpected.length > 0) {
213
+ mismatches.push(`undeclared key(s) present: ${unexpected.join(", ")}`);
214
+ }
215
+
216
+ for (const [key, value] of Object.entries(spec.metadata ?? {})) {
217
+ const actualValue = actual.metadata?.[key];
218
+ if (actualValue === undefined) {
219
+ mismatches.push(`missing declared metadata key: ${key}`);
220
+ } else if (actualValue !== value) {
221
+ // Name the KEY only — metadata values stay out of the error by rule,
222
+ // the same line the whole module holds for secret values.
223
+ mismatches.push(`metadata key differs: ${key}`);
224
+ }
225
+ }
226
+
227
+ if (mismatches.length > 0) {
228
+ throw new SecretContractMismatchError(spec.name, mismatches);
229
+ }
230
+ return { outcome: "present", name: spec.name, keys: spec.keys };
231
+ }
232
+
233
+ await store.create(spec.name, spec.keys, generate);
234
+ return { outcome: "created", name: spec.name, keys: spec.keys };
235
+ }
@@ -0,0 +1,388 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { join, dirname, resolve as resolvePath } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ declareSecret,
8
+ isSecretDeclaration,
9
+ isCommittedEncryptedSecret,
10
+ collectSecretDeclarations,
11
+ SECRET_DECLARATION_MARKER,
12
+ SECRET_DECLARATION_ENTITY_TYPE,
13
+ SECRET_PROVENANCE_KINDS,
14
+ type SecretDeclaration,
15
+ } from "./secret-provenance";
16
+ import { isDeclarable, type Declarable } from "./declarable";
17
+ import { build, partitionByLexicon } from "./build";
18
+ import type { Serializer } from "./serializer";
19
+
20
+ describe("declareSecret — referenced", () => {
21
+ test("carries name, kind, and optional scope — nothing else", () => {
22
+ const decl = declareSecret({ name: "db-password", provenance: "referenced", scope: "ns:fountain" });
23
+ expect(decl.name).toBe("db-password");
24
+ expect(decl.provenance).toBe("referenced");
25
+ expect(decl.scope).toBe("ns:fountain");
26
+ expect(decl.lexicon).toBe("chant");
27
+ expect(decl.entityType).toBe(SECRET_DECLARATION_ENTITY_TYPE);
28
+ expect(Object.keys(decl).sort()).toEqual(["entityType", "lexicon", "name", "provenance", "scope"]);
29
+ });
30
+
31
+ test("scope is optional", () => {
32
+ const decl = declareSecret({ name: "api-token", provenance: "referenced" });
33
+ expect("scope" in decl).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("declareSecret — from-provider", () => {
38
+ test("points at the provider binding, never re-models it", () => {
39
+ const decl = declareSecret({
40
+ name: "fountain-secrets",
41
+ provenance: "from-provider",
42
+ provider: { binding: "fountainInfisical", entityType: "K8s::Infisical::InfisicalSecret" },
43
+ });
44
+ expect(decl.provenance).toBe("from-provider");
45
+ expect(decl.provider.binding).toBe("fountainInfisical");
46
+ expect(decl.provider.entityType).toBe("K8s::Infisical::InfisicalSecret");
47
+ expect(Object.isFrozen(decl.provider)).toBe(true);
48
+ });
49
+
50
+ test("requires a non-empty provider.binding", () => {
51
+ expect(() =>
52
+ declareSecret({ name: "x", provenance: "from-provider", provider: { binding: "" } }),
53
+ ).toThrow(/provider\.binding/);
54
+ });
55
+ });
56
+
57
+ describe("declareSecret — generated-once", () => {
58
+ test("carries contract flags only — the declared key-set", () => {
59
+ const decl = declareSecret({
60
+ name: "master-secrets-key",
61
+ provenance: "generated-once",
62
+ keys: ["MASTER_SECRETS_KEY"],
63
+ });
64
+ expect(decl.provenance).toBe("generated-once");
65
+ expect(decl.keys).toEqual(["MASTER_SECRETS_KEY"]);
66
+ expect(Object.isFrozen(decl.keys)).toBe(true);
67
+ });
68
+
69
+ test("keys is optional", () => {
70
+ const decl = declareSecret({ name: "seed", provenance: "generated-once" });
71
+ expect("keys" in decl).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("declareSecret — committed-encrypted", () => {
76
+ test("records a path and nothing else — the bytes stay on disk", () => {
77
+ const decl = declareSecret({
78
+ name: "db-credentials",
79
+ provenance: "committed-encrypted",
80
+ file: "secrets/db-credentials.sops.yaml",
81
+ encryption: "sops",
82
+ recipients: ["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"],
83
+ keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
84
+ });
85
+ expect(decl.provenance).toBe("committed-encrypted");
86
+ expect(decl.file).toBe("secrets/db-credentials.sops.yaml");
87
+ expect(decl.encryption).toBe("sops");
88
+ expect(decl.keys).toEqual(["POSTGRES_USER", "POSTGRES_PASSWORD"]);
89
+ expect(Object.isFrozen(decl.keys)).toBe(true);
90
+ expect(Object.isFrozen(decl.recipients)).toBe(true);
91
+ expect(Object.keys(decl).sort()).toEqual([
92
+ "encryption",
93
+ "entityType",
94
+ "file",
95
+ "keys",
96
+ "lexicon",
97
+ "name",
98
+ "provenance",
99
+ "recipients",
100
+ ]);
101
+ expect(isCommittedEncryptedSecret(decl)).toBe(true);
102
+ });
103
+
104
+ test("encryption defaults to sops; recipients and keys are optional", () => {
105
+ const decl = declareSecret({
106
+ name: "db-credentials",
107
+ provenance: "committed-encrypted",
108
+ file: "secrets/db.sops.yaml",
109
+ });
110
+ expect(decl.encryption).toBe("sops");
111
+ expect("recipients" in decl).toBe(false);
112
+ expect("keys" in decl).toBe(false);
113
+ });
114
+
115
+ test("the factory is pure — it never touches the filesystem", () => {
116
+ // A path that does not exist anywhere declares fine: existence is checked
117
+ // at buildRoots(), not here, which is what keeps the factory foldable.
118
+ const decl = declareSecret({
119
+ name: "nope",
120
+ provenance: "committed-encrypted",
121
+ file: "no/such/dir/nothing-here.sops.yaml",
122
+ });
123
+ expect(decl.file).toBe("no/such/dir/nothing-here.sops.yaml");
124
+ expect(isSecretDeclaration(decl)).toBe(true);
125
+ });
126
+
127
+ test("the same input twice yields equal declarations (foldable)", () => {
128
+ const input = {
129
+ name: "db-credentials",
130
+ provenance: "committed-encrypted",
131
+ file: "secrets/db.sops.yaml",
132
+ keys: ["A"],
133
+ } as const;
134
+ const a = declareSecret({ ...input });
135
+ const b = declareSecret({ ...input });
136
+ expect({ ...a }).toEqual({ ...b });
137
+ });
138
+
139
+ test("rejects a missing, absolute, or escaping path", () => {
140
+ expect(() =>
141
+ // @ts-expect-error — `file` is required
142
+ declareSecret({ name: "x", provenance: "committed-encrypted" }),
143
+ ).toThrow(/`file`/);
144
+ expect(() =>
145
+ declareSecret({ name: "x", provenance: "committed-encrypted", file: "" }),
146
+ ).toThrow(/`file`/);
147
+ expect(() =>
148
+ declareSecret({ name: "x", provenance: "committed-encrypted", file: "/etc/shadow.yaml" }),
149
+ ).toThrow(/repo-relative/);
150
+ expect(() =>
151
+ declareSecret({ name: "x", provenance: "committed-encrypted", file: "../../secrets/x.yaml" }),
152
+ ).toThrow(/\.\./);
153
+ });
154
+
155
+ test("v1 restricts the path to .yaml/.yml", () => {
156
+ expect(() =>
157
+ declareSecret({ name: "x", provenance: "committed-encrypted", file: "secrets/x.sops.json" }),
158
+ ).toThrow(/YAML/);
159
+ expect(
160
+ declareSecret({ name: "x", provenance: "committed-encrypted", file: "secrets/x.sops.YML" })
161
+ .file,
162
+ ).toBe("secrets/x.sops.YML");
163
+ });
164
+
165
+ test("rejects an unknown encryption tool", () => {
166
+ expect(() =>
167
+ declareSecret({
168
+ name: "x",
169
+ provenance: "committed-encrypted",
170
+ file: "secrets/x.yaml",
171
+ // @ts-expect-error — the encryption union is closed
172
+ encryption: "gpg",
173
+ }),
174
+ ).toThrow(/unknown encryption/);
175
+ });
176
+
177
+ test("refuses a private key pasted into recipients, without echoing it", () => {
178
+ const identity =
179
+ "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
180
+ let thrown: Error | undefined;
181
+ try {
182
+ declareSecret({
183
+ name: "x",
184
+ provenance: "committed-encrypted",
185
+ file: "secrets/x.yaml",
186
+ recipients: [identity],
187
+ });
188
+ } catch (e) {
189
+ thrown = e as Error;
190
+ }
191
+ expect(thrown).toBeDefined();
192
+ expect(thrown!.message).toContain("recipients[0]");
193
+ expect(thrown!.message).not.toContain(identity);
194
+
195
+ expect(() =>
196
+ declareSecret({
197
+ name: "x",
198
+ provenance: "committed-encrypted",
199
+ file: "secrets/x.yaml",
200
+ recipients: ["-----BEGIN OPENSSH PRIVATE KEY-----"],
201
+ }),
202
+ ).toThrow(/PRIVATE key/);
203
+ });
204
+
205
+ test("`ciphertext` is still forbidden — the declaration points at bytes, never carries them", () => {
206
+ const input = {
207
+ name: "x",
208
+ provenance: "committed-encrypted",
209
+ file: "secrets/x.yaml",
210
+ ciphertext: "ENC[AES256_GCM,data:xxxx]",
211
+ };
212
+ let thrown: Error | undefined;
213
+ try {
214
+ // @ts-expect-error — `ciphertext` must not compile
215
+ declareSecret(input);
216
+ } catch (e) {
217
+ thrown = e as Error;
218
+ }
219
+ expect(thrown).toBeDefined();
220
+ expect(thrown!.message).toContain('"ciphertext"');
221
+ expect(thrown!.message).not.toContain("AES256_GCM");
222
+ });
223
+ });
224
+
225
+ describe("declareSecret — value unrepresentable", () => {
226
+ test("no declaration kind has a field that could hold material", () => {
227
+ const decls: SecretDeclaration[] = [
228
+ declareSecret({ name: "a", provenance: "referenced" }),
229
+ declareSecret({ name: "b", provenance: "from-provider", provider: { binding: "p" } }),
230
+ declareSecret({ name: "c", provenance: "generated-once", keys: ["k"] }),
231
+ declareSecret({ name: "d", provenance: "committed-encrypted", file: "s/d.sops.yaml" }),
232
+ ];
233
+ for (const decl of decls) {
234
+ for (const field of ["value", "data", "stringData", "material", "plaintext", "ciphertext"]) {
235
+ expect(field in decl).toBe(false);
236
+ }
237
+ // Declared fields are immutable (the object stays extensible so
238
+ // discovery can stamp its symbol-keyed metadata).
239
+ expect(() => {
240
+ (decl as { name: string }).name = "mutated";
241
+ }).toThrow();
242
+ }
243
+ });
244
+
245
+ test("a material-shaped field is rejected at runtime, naming only the key", () => {
246
+ const input = { name: "leaky", provenance: "referenced", value: "hunter2" };
247
+ let thrown: Error | undefined;
248
+ try {
249
+ // @ts-expect-error — a value field must not compile
250
+ declareSecret(input);
251
+ } catch (e) {
252
+ thrown = e as Error;
253
+ }
254
+ expect(thrown).toBeDefined();
255
+ expect(thrown!.message).toContain('"value"');
256
+ // The constitutional line: the error must not echo the material.
257
+ expect(thrown!.message).not.toContain("hunter2");
258
+ });
259
+
260
+ test("type-level: material fields do not compile on any kind", () => {
261
+ // @ts-expect-error — `value` is unrepresentable
262
+ const a = () => declareSecret({ name: "a", provenance: "referenced", value: "s" });
263
+ // @ts-expect-error — `data` is unrepresentable
264
+ const b = () => declareSecret({ name: "b", provenance: "generated-once", data: { k: "v" } });
265
+ const c = () =>
266
+ // @ts-expect-error — `stringData` is unrepresentable
267
+ declareSecret({ name: "c", provenance: "from-provider", provider: { binding: "p" }, stringData: {} });
268
+ // Widened objects (past excess-property checks) are still rejected by the `never` fields.
269
+ const widened = { name: "d", provenance: "referenced", value: "s" } as const;
270
+ // @ts-expect-error — `value` stays unrepresentable through a widened object
271
+ const d = () => declareSecret(widened);
272
+ expect([a, b, c, d].every((f) => typeof f === "function")).toBe(true);
273
+ });
274
+
275
+ test("rejects an empty name and an unknown kind", () => {
276
+ expect(() => declareSecret({ name: "", provenance: "referenced" })).toThrow(/name/);
277
+ // @ts-expect-error — the union is closed
278
+ expect(() => declareSecret({ name: "x", provenance: "sealed-secret" })).toThrow(/unknown provenance/);
279
+ });
280
+ });
281
+
282
+ describe("secret declarations and the entity map", () => {
283
+ test("a declaration is a Declarable and a SecretDeclaration; the kind set is closed", () => {
284
+ const decl = declareSecret({ name: "a", provenance: "referenced" });
285
+ expect(isDeclarable(decl)).toBe(true);
286
+ expect(isSecretDeclaration(decl)).toBe(true);
287
+ expect(isSecretDeclaration({ name: "a", provenance: "referenced" })).toBe(false);
288
+ expect((decl as unknown as Record<symbol, unknown>)[SECRET_DECLARATION_MARKER]).toBe(true);
289
+ expect(SECRET_PROVENANCE_KINDS).toEqual([
290
+ "referenced",
291
+ "from-provider",
292
+ "generated-once",
293
+ "committed-encrypted",
294
+ ]);
295
+ });
296
+
297
+ test("collectSecretDeclarations extracts declarations by entity name", () => {
298
+ const other = {
299
+ lexicon: "test",
300
+ entityType: "Test::Thing",
301
+ [Symbol.for("chant.declarable")]: true,
302
+ } as unknown as Declarable;
303
+ const entities = new Map<string, Declarable>([
304
+ ["thing", other],
305
+ ["dbPassword", declareSecret({ name: "db-password", provenance: "generated-once" })],
306
+ ]);
307
+ const secrets = collectSecretDeclarations(entities);
308
+ expect([...secrets.keys()]).toEqual(["dbPassword"]);
309
+ expect(secrets.get("dbPassword")!.name).toBe("db-password");
310
+ });
311
+
312
+ test("partitionByLexicon excludes declarations from every partition", () => {
313
+ const entities = new Map<string, Declarable>([
314
+ [
315
+ "thing",
316
+ {
317
+ lexicon: "test",
318
+ entityType: "Test::Thing",
319
+ [Symbol.for("chant.declarable")]: true,
320
+ } as unknown as Declarable,
321
+ ],
322
+ ["secret", declareSecret({ name: "s", provenance: "referenced" })],
323
+ ]);
324
+ const partitions = partitionByLexicon(entities);
325
+ expect([...partitions.keys()]).toEqual(["test"]);
326
+ expect(partitions.get("test")!.has("secret")).toBe(false);
327
+ });
328
+ });
329
+
330
+ describe("serializer neutrality (#1828 acceptance)", () => {
331
+ test("a built project's outputs never contain a declaration, and no serializer receives one", async () => {
332
+ const testDir = join(tmpdir(), `chant-secret-prov-${Date.now()}-${Math.random()}`);
333
+ await mkdir(testDir, { recursive: true });
334
+ try {
335
+ const thisDir = dirname(fileURLToPath(import.meta.url));
336
+ const modulePath = resolvePath(thisDir, "secret-provenance");
337
+ await writeFile(
338
+ join(testDir, "secrets.infra.ts"),
339
+ `
340
+ import { declareSecret } from ${JSON.stringify(modulePath)};
341
+ export const dbPassword = declareSecret({ name: "db-password", provenance: "generated-once", keys: ["password"] });
342
+ export const apiToken = declareSecret({ name: "api-token", provenance: "referenced", scope: "vault:prod" });
343
+ export const providerFed = declareSecret({ name: "fountain-secrets", provenance: "from-provider", provider: { binding: "fountainInfisical" } });
344
+ export const realResource = {
345
+ lexicon: "test",
346
+ entityType: "Test::Thing",
347
+ [Symbol.for("chant.declarable")]: true,
348
+ };
349
+ `,
350
+ );
351
+
352
+ const seen: string[] = [];
353
+ const testSerializer: Serializer = {
354
+ name: "test",
355
+ rulePrefix: "TEST",
356
+ serialize: (entities) => {
357
+ seen.push(...entities.keys());
358
+ return JSON.stringify([...entities.keys()]);
359
+ },
360
+ };
361
+
362
+ const result = await build(testDir, [testSerializer]);
363
+
364
+ expect(result.errors).toEqual([]);
365
+ // Discovery found all four exports.
366
+ expect(result.entities.size).toBe(4);
367
+ expect(isSecretDeclaration(result.entities.get("dbPassword")!)).toBe(true);
368
+ const collected = collectSecretDeclarations(result.entities);
369
+ expect([...collected.keys()].sort()).toEqual(["apiToken", "dbPassword", "providerFed"]);
370
+
371
+ // No serializer received a declaration, no output mentions one, and the
372
+ // declarations' pseudo-lexicon produced neither an output nor a warning.
373
+ expect(seen).toEqual(["realResource"]);
374
+ expect([...result.outputs.keys()]).toEqual(["test"]);
375
+ for (const output of result.outputs.values()) {
376
+ const text = typeof output === "string" ? output : JSON.stringify(output);
377
+ expect(text).not.toContain("db-password");
378
+ expect(text).not.toContain("api-token");
379
+ expect(text).not.toContain("fountain-secrets");
380
+ }
381
+ expect(result.warnings.filter((w) => w.includes("No serializer"))).toEqual([]);
382
+ // The manifest's stack list has no pseudo-stack for the declarations.
383
+ expect(result.manifest.lexicons).toEqual(["test"]);
384
+ } finally {
385
+ await rm(testDir, { recursive: true, force: true });
386
+ }
387
+ });
388
+ });