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 +123 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +16 -3
- package/package.json +1 -1
- package/schemas/envpkt.schema.json +5 -0
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
|
|
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 =
|
|
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)
|
|
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
|
-
/**
|
|
3548
|
-
|
|
3549
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
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
|
@@ -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"
|