fix: a key killed by an unreadable expiresAt now reads as broken (T1.2.21) - #46
Merged
Conversation
T1.2.20 made a key with an expiresAt nothing can read answer 401. Nothing told the operator. publicKey coerces a non-string expiresAt to null, so a record carrying {}, true or [] drew as a key with no expiry at all, and "garbage" drew as an expiry date; both clients offered Disable, which changes nothing, and the boot warning named neither.
keyExpiryUnreadable is the test, and publicKey folds it into the broken flag the dashboard and `artifacts keys list` already render, so the row loses Disable, keeps Revoke and says the key always answers 401. A second boot warning names the records, separately from the no-hash-or-scopes line because the fix is different: a value in the file rather than a missing field.
An expiry that has simply passed is untouched. It reads back, both clients print the date, and the operator can see what happened.
Four lenses on the first cut. What changed here: - keyExpired and keyExpiryUnreadable both refuse a non-string before asking Date.parse. Date.parse stringifies first, so 2020, 0 and ["2020-01-01"] all read as past dates: the bearer path rejected the key while publicKey dropped the value to null, and the screen drew a working key with no expiry. That is the loop this item exists to close, and it survived the first cut. - Both boot warnings quote the ids they name and fall back to the record position when the id is not a string, which is the same rule GET /api/keys uses to decide what it can list. Naming a record by an id no client shows sent the operator looking for a row that is not there. - The second warning stops repeating the first one's closing sentence, and both point at Revoke, which is the button that exists. - The Revoke dialog no longer warns that clients will stop working when the row just said the key answers 401 for everything. - artifacts keys list drops the rest of the line for a broken record, the way the dashboard already does, so "expires garbage" cannot sit next to "always answers 401". - ci.yml plants a whole record with one unreadable field alongside the three shapes it already plants, checks that row's broken flag by id rather than by a grep that any broken record satisfies, and revokes it over the API. - docs/auth.md carries the second warning and says what separates it from an expiry that has simply passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The item
T1.2.21, backlog 1 epic 1.2, filed 2026-08-11 from the T1.2.20 UX lens.
T1.2.20 made a key with an unreadable
expiresAtanswer 401. That is the right answer, and it saidnothing to anyone.
usableKeyreadshashandscopesonly, sopublicKey'sbrokenflag stayedfalse and the boot warning never named the record.
publicKeyalso coerces a non-stringexpiresAtto
null, so{},trueand[]drew as a key with no expiry at all, and"garbage"drew as anexpiry date. Both clients offered Disable, which changes nothing.
The operator's loop was: key 401s, open the key screen, see a healthy key, press Disable, nothing
changes, hand-edit
auth.jsonor re-mint without ever learning why.What changed
keyExpiryUnreadableinlib/auth.jsis the test: anexpiresAtthat is present and cannot beread as a date.
publicKeyfolds it intobroken, which both clients already render, so the rowloses Disable, keeps Revoke, and says the key always answers 401.
fix is different: a value in the file rather than a missing field.
keyExpiredandkeyExpiryUnreadableboth refuse a non-string before callingDate.parse.string, matching what
GET /api/keysis able to list.artifacts keys listdrops therest of the line the way the dashboard does.
docs/auth.mdcarries the second warning.An expiry that has simply passed is untouched. It reads back, both clients print the date, and the
key stops working for the reason it says.
One change beyond the item text
keyExpiredis T1.2.20's function and this PR edits it, one line. Three lenses reproduced the samehole independently:
Date.parsestringifies its argument, so2020,0and["2020-01-01"]allread as valid past dates. The key was rejected and drawn as healthy, which is the exact loop above,
so flagging it in the UI without fixing the parse would have put a live-looking key in the broken row
or left a dead one out of it. Both functions now treat any non-string as unreadable, so
brokenimplies 401 by construction.parseKeyInputonly ever writes an ISO string, so nothing anAPI caller can send changes behaviour.
Review
Four lenses, because the diff touches
lib/auth.js. Fourteen findings, seven fixed here, one filed,the rest verified clean.
Fixed here, each reproduced first:
Date.parsereads reproduced the original bug. Adversarial, security andQA, independently, all rating it the top finding. Measured on a live instance below.
k.id || 'entry i'prints a truthy non-string id, while
GET /api/keyslists only records whose id is a string, sothe operator went looking for a row that was not on the screen. Both warnings now use the same
rule the route does. The pre-existing warning had the same shape and is fixed with it.
k.idwas interpolated raw into a console line. Security, low, and mitigated by needingwrite access to
auth.jsonin the first place. An id holding a newline split the warning intothree lines, the middle one indistinguishable from the real
admin account created from envline. Both warnings quote now.
"always answers 401. Revoke it and mint a new key.", then the dialog said "Any CLI or MCP client
using this key stops working." Nothing is using it.
artifacts keys listprinted the junk as if it were a date. QA, UX and adversarial. Theflags array was joined rather than replaced, so a record read
[broken, always answers 401, expires garbage, never used]. The dashboard already suppressed therest of the line; the CLI now matches.
UX. Neither client has Delete for a key; both have Revoke.
docs/auth.mddocumented one warning class and quoted it verbatim. QA, security and UX. Anoperator whose record has both a hash and scopes read that and concluded the problem was
elsewhere.
Filed as T1.2.27, because it changes the shape of what the key API returns rather than patching
behaviour: a broken record says it is broken and never says why.
{},trueand a hashless recordall render byte for byte the same, and the row drops the scopes, the prefix and the last-used date
to make room for the words, so the prefix that would match the row to the key in a CI config is
gone. For a record broken only by its expiry all three are valid. Raised by the UX lens as three
separate findings that share one fix.
Verified clean by the security lens, with proofs: the
GET /api/keysgate is still admin-only, sothe flag leaks nothing new;
publicKeystill never carries the hash out;brokenis a pure OR, soit can only add records, never remove one; no key is hidden; no live key can be marked broken, since
unreadableimpliesexpired; and the bearer path's three functions are untouched apart from theone line above.
Tests
npm test: 49 to 52. Three new cases covering eight junk shapes (each checked for bothbrokenand a 401), a readable expiry past and future, and the boot warning naming exactly the junk
record and not the healthy one.
bash .github/workflows/smoke.sh http://localhost:3421 test: 154 ok-lines, all pass, same countas
origin/main.ci.ymlgains a fourth planted record in the step T1.2.19 added, checked by id rather than by agrep any broken record satisfies, then revoked over the API.
Six records planted in
auth.jsonon two local servers.artifacts keys list:expiresAtorigin/mainci-deploy"garbage"expires garbage, never usedbroken, always answers 401backup-runner{}never usedbroken, always answers 401old-laptop2020never usedbroken, always answers 401last-quarterexpires 2020-01-01, never usedhalf-writtenbroken, always answers 401healthy-keynever usedBoot warnings on the branch, both classes and disjoint counts:
On main only the first line printed.
Browser at 1280x900 on the same instance: the three junk rows show
broken recordwith Revokeonly,
last-quarterkeeps its date and both buttons, the Revoke dialog on a broken row reads"This key already answers 401 for every request. Revoking removes the record.", and the page logs
0 console errors and 0 warnings.
Screenshot: https://artifacts.zonily.cloud/a/1decmlu0x1
Merging
Checked with
git merge-tree. This branch merges clean with #43, #44 and #45, in any order.