fix: Copy on an expired row says so instead of minting a dead link (T1.2.26) - #48
Merged
Merged
Conversation
…1.2.26) GET /api/artifacts/:slug/link was not one of the expiry gates, so it minted a fresh capability token for an artifact that had already lapsed. The dashboard's Copy button calls that route for any non-public artifact and flashed Copied, so the operator was told the link was ready and the recipient got a 404. The route now answers 410 for a lapsed artifact. The dashboard checks the row first, because a public artifact never calls the route: the bare URL is its share link, so the row is the only place that can answer for it. An expired row flashes Expired and a toast names the artifact and says to clear or extend its expiry. Two smoke cases cover it: the route refuses while the expiry is in the past, and mints again once it is cleared.
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.26, filed 2026-08-12 from the T1.2.22 UX lens.
GET /api/artifacts/:slug/linkis not one ofthe five gates that check expiry, so it minted a fresh capability token for an artifact that had
lapsed. The dashboard's Copy button calls it for any non-public artifact and flashed
Copied. Therecipient then got a 404, which is the right answer for a locked artifact, so the wrong half was the
operator's: they were told the link was ready.
The item allowed either half of the fix. This does both, because they cover different rows:
GET /api/artifacts/:slug/linkanswers410 {"error":"artifact expired"}for a lapsed artifact, using the same
isExpired(meta)the five other gates use. That covers theCLI, MCP, and any REST caller, not just the dashboard.
/a/<slug>is theshare link and the dashboard builds it locally. So the row is the only place that can answer for a
public expired artifact. Copy on an expired row flashes
Expired, and a toast names the slug andsays to clear or extend the expiry. The button title says the same thing on hover.
Changed
server.jspublic/index.html.github/workflows/smoke.shdocs/api.md410named where the route is described23 lines total. No new file, no new dependency, no storage change.
Screenshot
https://artifacts.zonily.cloud/a/91kw4nbxfd
Three rows on a local instance.
lapsed-privateandlapsed-democarryexpiresAt: 2020-01-01,live-demodoes not. The Copy button on the lapsed row readsExpiredand the toast explains whatto do. The auto-dismiss timers were paused for the still so both are visible in one frame; nothing
else about the interaction was changed.
Tests
npm test: 49 passing, unchanged. The change has no unit-testable module of its own, so the proofis in the smoke suite where the route lives.
bash .github/workflows/smoke.sh http://localhost:3000 test: all passing, 156 assertions,two of them new.
link route refuses an expired artifactfailed withexpected 410, got 200.clicked Copy on both, confirmed the button text, the toast text and the button title, then
confirmed the live row still copies and reads
Copied. Console carries no page errors. The twoentries in it are a
429from the login limiter the smoke suite had just tripped, and the410from a deliberate
fetchof the link route.Review
23-line diff, so this is a self-review against all four lenses rather than four subagents. The route
requires a
readscope key, so it is not one of the surfaces the rule says to spend four subagentson regardless of size.
Adversarial. A junk
expiresAt({},2030) still mints here, becauseisExpiredon mainreads a non-string as never expiring. That is exactly T1.2.22, which is open as #45 and moves the
rule into
lib/expiry.js. This change callsisExpired, so it inherits that fix the moment #45lands and duplicates none of it. A row rendered before the expiry passes and clicked after it does
gets
Copy failedfrom the server, which is the correct backstop. The expiry check runs beforeensureSessionSecret(), so a lapsed artifact no longer triggers secret generation on this path.Security. No new surface without a key. The route already told an authenticated read-scope
caller whether a slug exists, through its own
404, so a410leaks nothing new. The toast printsthe slug through
toast(), which assignstextContent, so a hand-crafted slug cannot injectmarkup.
SLUG_REgates the read anyway.QA. Two smoke cases, one for each direction. The dashboard has no unit harness (that is T1.2.6,
createAppfactory), so the browser pass is the coverage for the row half. No existing casedepended on the route answering 200 for an expired artifact.
UX. The row already carried an
expiredpill, so the word in the flash is not a surprise. TheCopy button stays clickable rather than disappearing, because a disabled button explains nothing;
the click is how the operator learns why. The toast names what to do next, matching the tone of
Repointed to ...andDelete failed.already there. The menu on the same row still offers Expiry,which is the fix the toast points at.
Findings outside the item
Two, both recorded rather than fixed or filed:
POST /api/artifactswith anexpiresAtalready in the past returns aurlfor an artifact thatserves 410 on the first click. Same class as this item, but refusing the publish or dropping the
urlfrom the response is a product call and a documented response shape, so it is not somethingto decide inside a 23-line fix.
GET /api/artifacts/:slug/qralso answers for a lapsed artifact. The route comment says the QRencodes the canonical URL on purpose, and the smoke suite already asserts a disabled artifact
still has a QR, so this looks deliberate rather than missed. Left alone.
Merges
git merge-treeagainst every other open branch: clean with #42, #43, #44, #45, #46 and #47, in anyorder.