fix: an artifact whose expiresAt cannot be read now serves 410 (T1.2.22) - #45
Merged
Conversation
isExpired read NaN as "not expired", so junk in a hand-edited or restored meta.json kept serving instead of answering 410. Five call sites gate on it, including the capability-link exchange, so the auto-expire lifecycle README.md:52 promises was off for any record carrying an unreadable value. The rule moves to lib/expiry.js as artifactExpired, the artifact twin of keyExpired: absent, null and empty still mean no expiry, anything present that Date.parse cannot read counts as lapsed. It lives outside server.js because parseExpiresAt refuses these values on the way in, so no request can set one up and no end-to-end test can reach the case. The dashboard row carried the same read and now follows the same rule.
Four lenses on the first cut. What changed here: - artifactExpired refuses a non-string before asking Date.parse. Date.parse stringifies first, so 12345 read as the year 12345 and ["2030-01-01"] read as the string inside the array, and both kept serving forever. The dashboard rule follows. - The row menu no longer dies on a non-string expiresAt. slice() threw inside the lazy fill, which stopped the menu at QR code: no Expiry, no Disable, no Delete, and no retry on reopen, so the row the new badge points at was the one the operator could not fix. - POST /a/:slug/unlock checks expiry after the password rather than before it. The 404/401 pair over that route is deliberately uniform, and a 410 handed out first told an anonymous caller that a private slug exists. - artifacts list prints expired instead of the stored value, so a dead artifact stops listing like a live one and "expires [object Object]" is gone. - ci.yml plants an unreadable expiresAt in meta.json between two boots and drives the real serve paths, which is the only way to reach the case end to end. - docs/api.md says 410 covers a value that cannot be read at all, and the test carries the false and 0 cases that separate this from the shorter falsy check.
DEFAULT_VISIBILITY is private, so the fixture published private and /a/expiry-junk answered 404 before the step ever reached the interesting part. A private artifact 404s for both an expired and a live record, so the case has to run on a public one.
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.22, backlog 1 epic 1.2, filed 2026-08-11 from the T1.2.20 adversarial and security lenses.
isExpiredreadBoolean(meta.expiresAt && Date.parse(meta.expiresAt) <= Date.now()).Date.parseanswers
NaNfor junk andNaN <= nowis false, so an artifact whose storedexpiresAtcannot beread kept serving instead of answering 410. Five gates use it, including the capability-link
exchange, so the auto-expire lifecycle README.md:52 promises was off for any record carrying such a
value. It is the artifact twin of the key bug T1.2.20 fixed.
parseExpiresAtrefuses anything but an ISO string on the way in, so the reachable surface is ahand-edited or restored
meta.json, the same surface T1.2.11 and T1.2.20 covered.What changed
lib/expiry.jsis new and holdsartifactExpired. Absent,nulland''still mean no expiry.A non-string is lapsed without asking
Date.parse, becauseDate.parsestringifies first andthen reads more than an operator would expect:
12345becomes the year 12345 and["2030-01-01"]becomes the string inside the array. Anything else that does not parse is lapsed.It lives outside
server.jsso a unit test can hand it the shapes an API call cannot make.server.jskeeps its short local name (const isExpired = artifactExpired) so the five callsites read as before.
public/index.htmlcarried a hand-copied second version of the rule for the row badge. It nowfollows the same one, with a comment on both sides saying a change belongs in both places.
cli.jsprintsexpiredfor a lapsed artifact. It printed the stored value and never saidwhether it had passed, so a dead artifact listed like a live one and a stored object printed as
expires [object Object].docs/api.mdsays the 410 covers a value that cannot be read at all, not only a date in the past.Review
Four lenses, because the diff touches the serve paths. Twelve findings, six fixed here, three filed,
three recorded.
Fixed here, and each reproduced first:
and QA, independently.
artifactExpired({expiresAt: 2030})wasfalse, and a server booted fromthe first commit served
/a/junkpub200 withmeta.expiresAt = 2030and again with["2030-01-01T00:00:00Z"]. Closed by the non-string rule above.expiresAt, on exactly the row the new badge points at.Adversarial, security, QA and UX, all four.
a.expiresAt.slice(0, 10)threw inside the lazyfill, so the menu stopped after QR code: no Expiry, no Disable, no Delete. It never recoveredeither, because
fillonly runs while the menu has no children, so reopening showed the sametruncated list for the rest of the session. Coerced once before the label and the dialog value.
POST /a/:slug/unlockanswered 410 before checking visibility. Security. That route's 404and 401 are deliberately uniform so it never distinguishes an artifact's mode, and the two GET
paths already order expiry after that rule. The unlock route did not, so an anonymous caller
learned that a private slug exists. The check now runs after the password is proven. Measured on
both trees: on main, unlock with the right password on an expired artifact answered 200 and
served it; here it answers 410, a wrong password still answers 401, and a missing slug still
answers 404.
junk list was
NaNunderDate.parse, so swapping the rule forif (!value) return falsekeptthe suite green while
falseand0went back to meaning no expiry. Both are in the list now,with
12345and a one-element array.sites.
ci.ymlnow plants"garbage"intometa.jsonbetween two boots, the way theauth.jsonstep from T1.2.19 does, and checks 410 on/a/:slugand/a/:slug/source, thenchecks that a
PATCH {"expiresAt": null}brings the artifact back.docs/api.mdunderstated when a 410 fires. QA and UX and adversarial.Filed, because each is bigger than this diff or needs a call from Z:
The key side already has
brokenplus copy that names the reason; the artifact side has no suchfield. About 20 to 30 lines over four files.
not-foundshell. Pre-existing, and this change puts more artifacts into that state. Needs Z onwhether the 410 gets its own copy.
Copied, and the link404s.
GET /api/artifacts/:slug/linkis not one of the five gates.Recorded, no action:
patchItemapplies the typed string locally before the fetch, and under the new rule a value liketomorrowscores as lapsed. It reverts on the 400 with a toast. Adversarial, low.POST /a/:slug/unlockstill separates a missing slug (404) from an existing private one (401),and the 404 branch returns before the rate limiter records a failure. Pre-existing, unrelated to
expiry, and larger than this diff. Security lens flagged it as worth its own item.
rule across 16 shapes, including
"0","2026-13-45","2026/08/12"and[1], and found nocase where the badge and the response part ways.
Tests
npm test: 49 to 52. Three new cases intest/expiry.test.jscovering eleven junk shapes, thethree absent shapes, and a past and a future date.
bash .github/workflows/smoke.sh http://localhost:3411 test: 154 ok-lines, all pass, same countas
origin/main.End to end by hand on two local servers, with the record planted in
meta.jsonbetween two boots:origin/mainGET /a/junkpubGET /a/junkpub/sourceGET /a/junkpw(password artifact)PATCH {"expiresAt": null}thenGETBrowser at 1280x900, four artifacts, three with a planted expiry. All three carry the
expiredpill, the healthy one does not, the
{}row's menu reaches Expiry, Disable and Delete, and thepage logs 0 console errors and 0 warnings.
artifacts listagainst the same instance printsexpiredon all three and nothing on thehealthy one.
Screenshot: https://artifacts.zonily.cloud/a/w3n4hmvyxi
Merging
Checked against both open PRs with
git merge-tree. This branch merges clean with #43 and with #44,in either order.