monitor.cert-expiry
How long a TLS certificate has left before its notAfter, and whether that is ok, a warning, critical or expired.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript, Python and Rust.
What it does
How long a TLS certificate has left, and whether to worry: `ok`, `warning`, `critical` or `expired`. The caller reads `notAfter` from the certificate (as Unix seconds); this decides what it means today.
## Decisions
For example
certExpiry(1,767,225,600, 1,762,041,600, 30, 7)→ state ok, seconds left 5,184,000, days left 60 sixty days left is okcertExpiry(1,767,225,600, 1,764,633,600, 30, 7)→ state ok, seconds left 2,592,000, days left 30 exactly thirty days left is not fewer than thirty: still okcertExpiry(1,767,225,600, 1,764,633,601, 30, 7)→ state warning, seconds left 2,591,999, days left 29 one second under thirty days is a warning
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function certExpiry(notAfter: number, now: number, warnDays: number, criticalDays: number): CertExpiry
| notAfter | int | the certificate's notAfter in Unix seconds; it is still valid during that second |
| now | int | Unix seconds |
| warnDays | int | warn when fewer than this many days are left, at least criticalDays |
| criticalDays | int | critical when fewer than this many days are left, 0 or more |
| returns | CertExpiry |
The types it declares, generated into your project
export type CertExpiryState = "ok" | "warning" | "critical" | "expired";
/** How close a certificate is to expiring. */
export interface CertExpiry {
readonly state: CertExpiryState;
/** notAfter - now while valid, 0 once expired */
readonly secondsLeft: number;
/** whole days left: floor(secondsLeft / 86400) */
readonly daysLeft: number;
}
Your code names it in one line, in the file that uses it
import { certExpiry } from "#fune/monitor.cert-expiry@^1";
import { type CertExpiry } from "./monitor_cert_expiry_types.ts";
/**
* RFC 5280 makes validity inclusive of notAfter, so a certificate is expired
* only after that second, not at it. Thresholds compare whole days left with
* the day counts: fewer than N days (secondsLeft < N * 86400) is exactly
* floor(secondsLeft / 86400) < N, with no multiplication to overflow.
*/
export function certExpiry(notAfter: number, now: number, warnDays: number, criticalDays: number): CertExpiry {
if (!Number.isSafeInteger(notAfter) || !Number.isSafeInteger(now)) {
throw new RangeError(`notAfter and now must be whole seconds, received ${notAfter} and ${now}`);
}
if (!Number.isSafeInteger(criticalDays) || criticalDays < 0) {
throw new RangeError(`criticalDays must be a whole number 0 or more, received ${criticalDays}`);
}
if (!Number.isSafeInteger(warnDays) || warnDays < criticalDays) {
throw new RangeError(`warnDays must be a whole number at least criticalDays (${criticalDays}), received ${warnDays}`);
}
if (now > notAfter) return { state: "expired", secondsLeft: 0, daysLeft: 0 };
const secondsLeft = notAfter - now;
const daysLeft = Math.floor(secondsLeft / 86400);
const state = daysLeft < criticalDays ? "critical" : daysLeft < warnDays ? "warning" : "ok";
return { state, secondsLeft, daysLeft };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add monitor.cert-expiry
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./monitor.cert-expiry-1.0.0-typescript.fune, or fetch it from a terminal with fune pull monitor.cert-expiry@1.0.0:typescript.
The whole function, every language, is one file too: monitor.cert-expiry-1.0.0.fune, 12,501 bytes, sha256 292a46615a892497895595a25acb9d91861d2afa190688786e5cd8eca8dd9720. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before monitor.cert-expiry
after — your function gets the result and the arguments, and returns the final result.
// fune: after monitor.cert-expiry
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.cert-expiry --steps.
// fune: step monitor.cert-expiry after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| sixty days left is ok | 1,767,225,600, 1,762,041,600, 30, 7 | → | state ok, seconds left 5,184,000, days left 60 |
| exactly thirty days left is not fewer than thirty: still ok | 1,767,225,600, 1,764,633,600, 30, 7 | → | state ok, seconds left 2,592,000, days left 30 |
| one second under thirty days is a warning | 1,767,225,600, 1,764,633,601, 30, 7 | → | state warning, seconds left 2,591,999, days left 29 |
| exactly seven days left is a warning, not critical | 1,767,225,600, 1,766,620,800, 30, 7 | → | state warning, seconds left 604,800, days left 7 |
| one second under seven days is critical | 1,767,225,600, 1,766,620,801, 30, 7 | → | state critical, seconds left 604,799, days left 6 |
| the notAfter second itself is still valid (RFC 5280: inclusive) | 1,767,225,600, 1,767,225,600, 30, 7 | → | state critical, seconds left 0, days left 0 |
| one second after notAfter is expired | 1,767,225,600, 1,767,225,601, 30, 7 | → | state expired, seconds left 0, days left 0 |
| long expired shows zero, never negative | 1,767,225,600, 1,853,625,600, 30, 7 | → | state expired, seconds left 0, days left 0 |
| with no thresholds the last second is ok | 1,767,225,600, 1,767,225,599, 0, 0 | → | state ok, seconds left 1, days left 0 |
| equal warn and critical days: never a warning, straight to critical | 1,767,225,600, 1,766,361,600, 14, 14 | → | state critical, seconds left 864,000, days left 10 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| half a day is zero whole days, critical | 1,767,225,600, 1,767,182,400, 30, 1 | → | state critical, seconds left 43,200, days left 0 |
| the RFC 5280 no-expiry notAfter 99991231235959Z is ok for millions of days | 253,402,300,799, 1,767,225,600, 30, 7 | → | state ok, seconds left 251,635,075,199, days left 2,912,442 |
| a negative criticalDays is an error | 1,767,225,600, 1,767,225,600, 30, -1 | → | error: criticalDays must be a whole number 0 or more, received -1 |
| warnDays below criticalDays is an error | 1,767,225,600, 1,767,225,600, 7, 14 | → | error: warnDays must be a whole number at least criticalDays (14), received 7 |
| a fractional now is an error | 1,767,225,600, 1.5, 30, 7 | → | error: notAfter and now must be whole seconds, received 1767225600 and 1.5 |
More from the author
- **notAfter is inclusive.** RFC 5280 section 4.1.2.5: "The validity period for a certificate is the period of time from notBefore through notAfter, inclusive." So the certificate is still valid during the notAfter second and is `expired` only when `now > notAfter`. At `now == notAfter`, `secondsLeft` is 0 but the state is not yet expired. - **Thresholds are "fewer than N days"**: critical when `secondsLeft < criticalDays * 86400`, else warning when `secondsLeft < warnDays * 86400`, else ok. Exactly 30 days left with a 30-day warning is still ok. This is computed as `floor(secondsLeft / 86400) < N`, which is the same test with nothing to overflow. - `daysLeft` is whole days, rounded down: 6 days 23 hours is 6. - `secondsLeft` and `daysLeft` are 0 once expired, never negative. - `warnDays` equal to `criticalDays` means no warning stage. With `criticalDays` 0 there is no critical stage, and the last valid second is `ok`; pick at least 1 if you want to hear about it. - The usual settings are 30 and 7 days (Let's Encrypt certificates last 90 days and are renewed at 30 left), or 14 and 3. - `99991231235959Z` (253402300799), which RFC 5280 says marks a certificate with no well-defined expiry, is simply a very distant date and reads `ok`.
## Errors
- `criticalDays must be a whole number 0 or more, received X` - `warnDays must be a whole number at least criticalDays (C), received X` - `notAfter and now must be whole seconds, received A and B`
## Sources
- RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile, section 4.1.2.5 Validity: https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.5
Files
| Path | Bytes |
|---|---|
| README.md | 1,908 |
| impl/python.py | 1,457 |
| impl/rust.rs | 2,344 |
| impl/typescript.ts | 1,339 |
| vectors.json | 2,598 |