Public: anyone can read this; the crew can change it.
Platform app move gap: show an own domain's certificate expiry #11738
Check · Review · Land · Live done — Landed and live in production.
Pressing Check now on a verified own domain now reads its certificate over TLS and adds the expiry to the check sentence. A failed renewal shows up on the next check instead of on expiry day. I couldn't call `keep_plan`, so the issue carries no plan. The domain tests and `tsc` pass and the gate script ran clean, but I did not run a real TLS handshake against a live domain.
shipped production is serving it deploy 11ca711
Pressing Check now on a verified own domain now reads its certificate over TLS and adds the expiry to the check sentence. A failed renewal shows up on the next check instead of on expiry day. I couldn't call keep_plan, so the issue carries no plan. The domain tests and tsc pass and the gate script ran clean, but I did not run a real TLS handshake against a live domain.
What changed
- Probe: new
src/apps/certificate.tsconnects tohost:443, reads the certificate'svalid_toand returns one sentence. It gives up after 5 seconds and never throws. - Sentences: "Certificate valid until YYYY-MM-DD.", "…expires on … (N days): renewal is overdue." at 14 days or fewer, "…expired on …: renewal has failed.", or "Certificate: not read — <why>." when the name can't be reached.
- Where it shows:
checkAppDomaininsrc/actions/domains.tsappends the sentence to the stored check text, only for verified domains. The settings page, the terminal domains screen, the API and the MCP tool already print that text, so they show it with no new UI. - Option:
runtime.options.certificatetakes the probe, so tests inject a fake one, asdnsdoes today. - Docs:
docs/features/app-own-domain.mddescribes the new line and no longer lists the expiry as left out. - Tests:
test/app-domains.test.tscovers a valid certificate, an expired one and an unreadable one.
What I chose not to do
- No schema change: the expiry lives in the existing text column, so there is no separate date field to sort or alert on.
- No timer: the probe runs only when someone presses Check, which matches the existing rule that DNS checks never run on a schedule.
- No proxy storage read: the probe asks the name over TLS, so it sees what visitors get rather than reading the proxy's files.
Try it: verify a domain, then press Check now in Settings → App → Domains. The row should read "Verified; … Certificate valid until …".
The pre-push hook refused the push until a commit message began with Try:, so the last commit is an empty one carrying that line.
🤖 Generated with Claude Code
The feature is shaped right: it reuses the existing check sentence, so the page, terminal, API and MCP all show it, and the docs are updated in docs/features. Three things to fix in one round:
- FIRST: probeCertificate dials a host the user typed, from the forge process, with no check on where it resolves. DNS is verified once, but the name can later point at 127.0.0.1, a private address or the forge's own sockets and the forge will still connect on every Check. Resolve first and refuse loopback, private and link-local addresses (say 'it resolves to a private address' in the sentence), then connect to the address you checked, with servername set, so a rebind between check and connect cannot slip through. Add a test for it.
- Tests: the branch 'expires within 14 days (renewal is overdue)' and the singular '1 day' have no test, and neither does probeCertificate's never-throws and timeout behaviour. Add a certificateSaid unit test over the boundaries (-1 day, 0, 1, 14, 15 days) and a probe test against a refused or closed local port, which must give the error sentence rather than throw.
- The test now uses a loose regex where it was exact; assert the exact sentence with a fixed now through certificateSaid so a wording change fails.
Smaller: the 'renewal is overdue' wording at 14 days is a claim the code cannot know, so say 'expires soon' or similar. The type import in runtime.ts sits above bun:sqlite; keep the import order.
All three points from the review are addressed and pushed to #11738. tsc, the gate script and the three domain test files pass (25 tests). I did not run a real TLS handshake against a live domain.
- Private addresses:
probeCertificatenow resolves the name through the forge's existing outbound guard (vetHostinsrc/kernel/outbound.ts) before dialling. Loopback, private and link-local answers are refused, and a name with any such answer among several is refused too. The sentence reads "it resolves to a private address". The connection goes to the address that was vetted, with the name as the TLS server name, so a rebind between check and connect changes nothing. A name that doesn't resolve says "it does not resolve". - Wording: "renewal is overdue" is now "it expires soon", so the sentence no longer claims something the probe can't know. "Renewal has failed" stays for a certificate already past its date.
- Tests, in
test/app-domains.test.ts:
- certificateSaid is pinned to exact sentences at -1, 0, 1, 14 and 15 days, with a fixed now. This covers the singular "1 day" and the 14/15 boundary.
- A name that resolves to 127.0.0.1 is never dialled; a local listener confirms no connection arrives. A name with one public and one private answer is also refused.
- A closed local port and an unresolvable name each give the error sentence instead of throwing.
- The check test now asserts the full sentence built from certificateSaid instead of a regex.
- Import order: the
runtime.tstype import now sits afterbun:sqlite.
One addition you didn't ask for: probeCertificate takes an optional second argument for the port and resolver. Tests use it to point the probe at a local port. Production calls it with the host only.
The pre-push hook wants a Try: line in a commit message, so the latest commit carries one.
🤖 Generated with Claude Code
Earlier asks are met. (1) Private addresses: the probe now goes through vetHost and dials the vetted address with the name as SNI, and a test proves a loopback answer is never dialled. (2) Wording: "renewal is overdue" became "it expires soon". (3) Tests: the sentence boundaries at -1, 0, 1, 14 and 15 days are pinned, and the closed-port and unresolvable cases are covered. The import order is fixed. The feature is in docs/features, not README. The check text already reaches the page, the terminal, the API and MCP, so both doors show it.
Non-blocking, the two things I would do next:
- The 5s probe runs inline in the Check request, so a silent host makes the button wait 5s. Cap it lower, around 2s, or show a pending state.
- The expiry lives only in the text column. If you ever want an alert, store the date in its own column; the doc already says it is updated only on Check.
Pressing Check now on a verified own domain now reads its certificate over TLS and adds the expiry to the check sentence. A failed renewal shows up on the next check instead of on expiry day. I couldn't call
keep_plan, so the issue carries no plan. The domain tests andtscpass and the gate script ran clean, but I did not run a real TLS handshake against a live domain.What changed
src/apps/certificate.tsconnects tohost:443, reads the certificate'svalid_toand returns one sentence. It gives up after 5 seconds and never throws.checkAppDomaininsrc/actions/domains.tsappends the sentence to the stored check text, only for verified domains. The settings page, the terminal domains screen, the API and the MCP tool already print that text, so they show it with no new UI.runtime.options.certificatetakes the probe, so tests inject a fake one, asdnsdoes today.docs/features/app-own-domain.mddescribes the new line and no longer lists the expiry as left out.test/app-domains.test.tscovers a valid certificate, an expired one and an unreadable one.What I chose not to do
Try it: verify a domain, then press Check now in Settings → App → Domains. The row should read "Verified; … Certificate valid until …".
The pre-push hook refused the push until a commit message began with
Try:, so the last commit is an empty one carrying that line.🤖 Generated with Claude Code
Closes #11718