envpkt 0.11.2 → 0.11.3

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/dist/cli.js CHANGED
@@ -27,20 +27,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
27
27
  const issues = [];
28
28
  const created = Option(meta.created).flatMap(parseDate);
29
29
  const expires = Option(meta.expires).flatMap(parseDate);
30
+ const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
30
31
  const rotationUrl = Option(meta.rotation_url);
31
32
  const purpose = Option(meta.purpose);
32
33
  const service = Option(meta.service);
33
34
  const daysRemaining = expires.map((exp) => daysBetween(today, exp));
34
- const daysSinceCreated = created.map((c) => daysBetween(c, today));
35
+ const staleFromRotation = lastRotated.isSome();
36
+ const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
35
37
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
36
38
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
37
- const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
39
+ const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
38
40
  const hasSealed = !!meta.encrypted_value;
39
41
  const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
40
42
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
41
43
  if (isExpired) issues.push("Secret has expired");
42
44
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
43
- if (isStale) issues.push("Secret is stale (no rotation detected)");
45
+ if (isStale) {
46
+ const since = daysSinceRotation.fold(() => "?", (d) => String(d));
47
+ const label = staleFromRotation ? "last rotated" : "created";
48
+ issues.push(`Secret is stale (${label} ${since} days ago)`);
49
+ }
44
50
  if (isMissing) issues.push("Key not found in fnox");
45
51
  if (isMissingMetadata) {
46
52
  if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
@@ -55,6 +61,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
55
61
  purpose,
56
62
  created: Option(meta.created),
57
63
  expires: Option(meta.expires),
64
+ last_rotated_at: Option(meta.last_rotated_at),
58
65
  issues: List(issues),
59
66
  alias_of: Option(void 0)
60
67
  };
@@ -73,6 +80,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
73
80
  purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
74
81
  created: targetHealth.created,
75
82
  expires: targetHealth.expires,
83
+ last_rotated_at: targetHealth.last_rotated_at,
76
84
  issues: List([]),
77
85
  alias_of: Option(targetRef)
78
86
  });
@@ -106,6 +114,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
106
114
  purpose: Option(meta.purpose),
107
115
  created: Option(meta.created),
108
116
  expires: Option(meta.expires),
117
+ last_rotated_at: Option(meta.last_rotated_at),
109
118
  issues: List(["Alias target not resolvable"]),
110
119
  alias_of: Option(targetRef)
111
120
  }), (health) => classifyAlias(key, meta, health, targetRef));
@@ -205,6 +214,10 @@ const SecretMetaSchema = Type.Object({
205
214
  format: "date",
206
215
  description: "Date the secret was provisioned (YYYY-MM-DD)"
207
216
  })),
217
+ last_rotated_at: Type.Optional(Type.String({
218
+ format: "date",
219
+ description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
220
+ })),
208
221
  rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
209
222
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
210
223
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
@@ -3544,9 +3557,13 @@ const META_SECTION_RE = /^\[secret\.(.+)\]\s*$/;
3544
3557
  const ENCRYPTED_VALUE_RE = /^encrypted_value\s*=/;
3545
3558
  const NEW_SECTION_RE = /^\[/;
3546
3559
  const MULTILINE_DELIM = "\"\"\"";
3547
- /** Write sealed values back into the TOML file, preserving structure. */
3548
- const writeSealedToml = (configPath, sealedMeta) => {
3549
- const lines = readFileSync(configPath, "utf-8").split("\n");
3560
+ /**
3561
+ * In-memory transform: inject sealed encrypted_value blocks into raw TOML,
3562
+ * replacing existing encrypted_value lines (single or multiline) or appending
3563
+ * a new block at the end of the section. Pure — no I/O.
3564
+ */
3565
+ const applySealedToml = (raw, sealedMeta) => {
3566
+ const lines = raw.split("\n");
3550
3567
  const getSeal = (key) => Option(sealedMeta[key]?.encrypted_value);
3551
3568
  const isPending = (state, key) => !getSeal(key).isEmpty && !state.consumedKeys.has(key);
3552
3569
  const sealLinesFor = (key) => getSeal(key).fold(() => [], (v) => [
@@ -3628,7 +3645,11 @@ const writeSealedToml = (configPath, sealedMeta) => {
3628
3645
  consumedKeys: Set$1.empty(),
3629
3646
  skipUntil: -1
3630
3647
  };
3631
- const finalContent = flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3648
+ return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
3649
+ };
3650
+ /** Write sealed values back into the TOML file, preserving structure. */
3651
+ const writeSealedToml = (configPath, sealedMeta) => {
3652
+ const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
3632
3653
  validateOrExit(finalContent);
3633
3654
  writeFileSync(configPath, finalContent);
3634
3655
  };
@@ -4005,6 +4026,98 @@ const runSecretAlias = (name, options) => {
4005
4026
  });
4006
4027
  });
4007
4028
  };
4029
+ const readNewValue = async (key) => {
4030
+ if (!process.stdin.isTTY) {
4031
+ const chunks = [];
4032
+ return new Promise((resolveP, rejectP) => {
4033
+ process.stdin.on("data", (c) => chunks.push(Buffer.from(c)));
4034
+ process.stdin.on("end", () => resolveP(Buffer.concat(chunks).toString("utf-8").replace(/\r?\n$/, "")));
4035
+ process.stdin.on("error", rejectP);
4036
+ });
4037
+ }
4038
+ const rl = createInterface({
4039
+ input: process.stdin,
4040
+ output: process.stderr
4041
+ });
4042
+ return new Promise((resolveP) => {
4043
+ rl.question(`Enter new value for ${key}: `, (answer) => {
4044
+ rl.close();
4045
+ resolveP(answer);
4046
+ });
4047
+ });
4048
+ };
4049
+ const runSecretRotate = async (name, options) => {
4050
+ const { configPath, source } = resolveConfigPath(options.config).fold((err) => {
4051
+ console.error(formatError(err));
4052
+ process.exit(2);
4053
+ return {
4054
+ configPath: "",
4055
+ source: "flag"
4056
+ };
4057
+ }, ({ path, source: s }) => ({
4058
+ configPath: path,
4059
+ source: s
4060
+ }));
4061
+ const sourceMsg = formatConfigSource(configPath, source);
4062
+ if (sourceMsg) console.error(sourceMsg);
4063
+ const config = loadConfig(configPath).fold((err) => {
4064
+ console.error(formatError(err));
4065
+ process.exit(2);
4066
+ }, (c) => c);
4067
+ const meta = config.secret?.[name];
4068
+ if (!meta) {
4069
+ console.error(`${RED}Error:${RESET} Secret "${name}" not found in ${configPath}`);
4070
+ process.exit(1);
4071
+ }
4072
+ if (meta.from_key) {
4073
+ console.error(`${RED}Error:${RESET} "${name}" is an alias (from_key = "${meta.from_key}"). Rotate the target secret instead.`);
4074
+ process.exit(1);
4075
+ }
4076
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4077
+ const wasSealed = !!meta.encrypted_value;
4078
+ const raw = readFileSync(configPath, "utf-8");
4079
+ if (!wasSealed) {
4080
+ updateSectionFields(raw, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4081
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4082
+ process.exit(2);
4083
+ }, (result) => {
4084
+ if (options.dryRun) {
4085
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4086
+ console.log(result);
4087
+ return;
4088
+ }
4089
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
4090
+ });
4091
+ return;
4092
+ }
4093
+ if (!config.identity?.recipient) {
4094
+ console.error(`${RED}Error:${RESET} identity.recipient is required to rotate a sealed secret`);
4095
+ console.error(`${DIM}Run ${CYAN}envpkt keygen${DIM} to configure one.${RESET}`);
4096
+ process.exit(2);
4097
+ }
4098
+ const { recipient } = config.identity;
4099
+ const value = await readNewValue(name);
4100
+ if (value === "") {
4101
+ console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
4102
+ process.exit(1);
4103
+ }
4104
+ const ciphertext = ageEncrypt(value, recipient).fold((err) => {
4105
+ console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
4106
+ process.exit(2);
4107
+ return "";
4108
+ }, (ct) => ct);
4109
+ updateSectionFields(applySealedToml(raw, { [name]: { encrypted_value: ciphertext } }), `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4110
+ console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4111
+ process.exit(2);
4112
+ }, (result) => {
4113
+ if (options.dryRun) {
4114
+ console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
4115
+ console.log(result);
4116
+ return;
4117
+ }
4118
+ writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
4119
+ });
4120
+ };
4008
4121
  const addSecretFlags = (cmd) => cmd.option("--service <service>", "Service this secret authenticates to").option("--purpose <purpose>", "Why this secret exists").option("--comment <comment>", "Free-form annotation").option("--expires <date>", "Expiration date (YYYY-MM-DD)").option("--capabilities <caps>", "Comma-separated capabilities (e.g. read,write)").option("--rotates <schedule>", "Rotation schedule (e.g. 90d, quarterly)").option("--rate-limit <limit>", "Rate limit info (e.g. 1000/min)").option("--model-hint <hint>", "Suggested model or tier").option("--source <source>", "Where the value originates (e.g. vault, ci)").option("--rotation-url <url>", "URL for secret rotation procedure").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)");
4009
4122
  const registerSecretCommands = (program) => {
4010
4123
  const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
@@ -4020,6 +4133,9 @@ const registerSecretCommands = (program) => {
4020
4133
  secret.command("rename").description("Rename a secret entry, preserving all metadata").argument("<old>", "Current secret name").argument("<new>", "New secret name").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((oldName, newName, options) => {
4021
4134
  runSecretRename(oldName, newName, options);
4022
4135
  });
4136
+ secret.command("rotate").description("Rotate a secret's value (sealed: reseal + stamp; unsealed: stamp only)").argument("<name>", "Secret name to rotate").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action(async (name, options) => {
4137
+ await runSecretRotate(name, options);
4138
+ });
4023
4139
  secret.command("alias").description("Create an alias entry that reuses another secret's resolved value").argument("<name>", "Alias name (becomes the env var key)").requiredOption("--from <ref>", "Target reference — must be \"secret.<KEY>\"").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this alias exists (local metadata)").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--force", "Overwrite the entry if <name> already exists").option("--dry-run", "Preview the TOML block without writing").action((name, options) => {
4024
4140
  runSecretAlias(name, options);
4025
4141
  });
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
40
40
  comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
41
41
  capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
42
42
  created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
43
+ last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
43
44
  rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
44
45
  rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
45
46
  model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -94,6 +95,7 @@ declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
94
95
  comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
95
96
  capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
96
97
  created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
98
+ last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
97
99
  rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
98
100
  rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
99
101
  model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -138,6 +140,7 @@ type SecretHealth = {
138
140
  readonly purpose: Option<string>;
139
141
  readonly created: Option<string>;
140
142
  readonly expires: Option<string>;
143
+ readonly last_rotated_at: Option<string>;
141
144
  readonly issues: List<string>; /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
142
145
  readonly alias_of: Option<string>;
143
146
  };
package/dist/index.js CHANGED
@@ -56,6 +56,10 @@ const SecretMetaSchema = Type.Object({
56
56
  format: "date",
57
57
  description: "Date the secret was provisioned (YYYY-MM-DD)"
58
58
  })),
59
+ last_rotated_at: Type.Optional(Type.String({
60
+ format: "date",
61
+ description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
62
+ })),
59
63
  rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
60
64
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
61
65
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
@@ -565,20 +569,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
565
569
  const issues = [];
566
570
  const created = Option(meta.created).flatMap(parseDate);
567
571
  const expires = Option(meta.expires).flatMap(parseDate);
572
+ const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
568
573
  const rotationUrl = Option(meta.rotation_url);
569
574
  const purpose = Option(meta.purpose);
570
575
  const service = Option(meta.service);
571
576
  const daysRemaining = expires.map((exp) => daysBetween(today, exp));
572
- const daysSinceCreated = created.map((c) => daysBetween(c, today));
577
+ const staleFromRotation = lastRotated.isSome();
578
+ const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
573
579
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
574
580
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
575
- const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
581
+ const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
576
582
  const hasSealed = !!meta.encrypted_value;
577
583
  const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
578
584
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
579
585
  if (isExpired) issues.push("Secret has expired");
580
586
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
581
- if (isStale) issues.push("Secret is stale (no rotation detected)");
587
+ if (isStale) {
588
+ const since = daysSinceRotation.fold(() => "?", (d) => String(d));
589
+ const label = staleFromRotation ? "last rotated" : "created";
590
+ issues.push(`Secret is stale (${label} ${since} days ago)`);
591
+ }
582
592
  if (isMissing) issues.push("Key not found in fnox");
583
593
  if (isMissingMetadata) {
584
594
  if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
@@ -593,6 +603,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
593
603
  purpose,
594
604
  created: Option(meta.created),
595
605
  expires: Option(meta.expires),
606
+ last_rotated_at: Option(meta.last_rotated_at),
596
607
  issues: List(issues),
597
608
  alias_of: Option(void 0)
598
609
  };
@@ -611,6 +622,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
611
622
  purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
612
623
  created: targetHealth.created,
613
624
  expires: targetHealth.expires,
625
+ last_rotated_at: targetHealth.last_rotated_at,
614
626
  issues: List([]),
615
627
  alias_of: Option(targetRef)
616
628
  });
@@ -644,6 +656,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
644
656
  purpose: Option(meta.purpose),
645
657
  created: Option(meta.created),
646
658
  expires: Option(meta.expires),
659
+ last_rotated_at: Option(meta.last_rotated_at),
647
660
  issues: List(["Alias target not resolvable"]),
648
661
  alias_of: Option(targetRef)
649
662
  }), (health) => classifyAlias(key, meta, health, targetRef));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -131,6 +131,11 @@
131
131
  "description": "Date the secret was provisioned (YYYY-MM-DD)",
132
132
  "type": "string"
133
133
  },
134
+ "last_rotated_at": {
135
+ "format": "date",
136
+ "description": "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness.",
137
+ "type": "string"
138
+ },
134
139
  "rotates": {
135
140
  "description": "Rotation schedule (e.g. '90d', 'quarterly')",
136
141
  "type": "string"