Skip to content

fix: Copy on an expired row says so instead of minting a dead link (T1.2.26) - #48

Merged
kuyazee merged 1 commit into
mainfrom
task/expired-copy-link
Aug 12, 2026
Merged

kuyazee merged 1 commit into
mainfrom
task/expired-copy-link

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The item

T1.2.26, filed 2026-08-12 from the T1.2.22 UX lens. GET /api/artifacts/:slug/link is not one of
the 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. The
recipient 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:

  • The route refuses. GET /api/artifacts/:slug/link answers 410 {"error":"artifact expired"}
    for a lapsed artifact, using the same isExpired(meta) the five other gates use. That covers the
    CLI, MCP, and any REST caller, not just the dashboard.
  • The row refuses first. A public artifact never calls the route: its bare /a/<slug> is the
    share 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 and
    says to clear or extend the expiry. The button title says the same thing on hover.

Changed

File What
server.js 3 lines: the expiry gate on the link route, with a comment saying why
public/index.html 11 lines: the guard, the toast, the title copy
.github/workflows/smoke.sh 9 lines: two cases, refuse while lapsed and mint once cleared
docs/api.md 1 line: the 410 named where the route is described

23 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-private and lapsed-demo carry expiresAt: 2020-01-01,
live-demo does not. The Copy button on the lapsed row reads Expired and the toast explains what
to 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 proof
    is 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.
  • Both new cases were run red first. Against the code before the fix, link route refuses an expired artifact failed with expected 410, got 200.
  • Browser pass on a local instance: signed in, planted one public and one private lapsed artifact,
    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 two
    entries in it are a 429 from the login limiter the smoke suite had just tripped, and the 410
    from a deliberate fetch of 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 read scope key, so it is not one of the surfaces the rule says to spend four subagents
on regardless of size.

Adversarial. A junk expiresAt ({}, 2030) still mints here, because isExpired on main
reads 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 calls isExpired, so it inherits that fix the moment #45
lands and duplicates none of it. A row rendered before the expiry passes and clicked after it does
gets Copy failed from the server, which is the correct backstop. The expiry check runs before
ensureSessionSecret(), 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 a 410 leaks nothing new. The toast prints
the slug through toast(), which assigns textContent, so a hand-crafted slug cannot inject
markup. SLUG_RE gates the read anyway.

QA. Two smoke cases, one for each direction. The dashboard has no unit harness (that is T1.2.6,
createApp factory), so the browser pass is the coverage for the row half. No existing case
depended on the route answering 200 for an expired artifact.

UX. The row already carried an expired pill, so the word in the flash is not a surprise. The
Copy 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 ... and Delete 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/artifacts with an expiresAt already in the past returns a url for an artifact that
    serves 410 on the first click. Same class as this item, but refusing the publish or dropping the
    url from the response is a product call and a documented response shape, so it is not something
    to decide inside a 23-line fix.
  • GET /api/artifacts/:slug/qr also answers for a lapsed artifact. The route comment says the QR
    encodes 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-tree against every other open branch: clean with #42, #43, #44, #45, #46 and #47, in any
order.

…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.
@kuyazee
kuyazee merged commit f8a9428 into main Aug 12, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant