diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 30d5d2f..dce98bb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -245,6 +245,45 @@ jobs:
[ "$code" = "200" ] || { echo "FAIL: DELETE /api/keys/k_broken -> $code"; exit 1; }
if curl -s -H "$AUTH" http://localhost:3000/api/keys | grep -q k_broken; then echo "FAIL: revoked record still listed"; exit 1; fi
echo "ok: the broken record patches and revokes from the key screen"
+ - name: An artifact whose expiresAt cannot be read serves 410
+ run: |
+ # parseExpiresAt refuses a non-ISO expiresAt on the way in, so the only route to this
+ # record is the one an operator takes: a hand edit, or a restore of an older backup.
+ # Plant it between two boots the way the auth.json step above does, then drive the
+ # real serve path rather than trusting the unit test on lib/expiry.js. Visibility is
+ # explicit because DEFAULT_VISIBILITY is private, and a private artifact answers 404
+ # rather than 410, which would prove nothing either way.
+ AUTH='Authorization: Bearer ci-test-key'
+ curl -s -o /dev/null -X POST http://localhost:3000/api/artifacts -H "$AUTH" \
+ -H 'Content-Type: application/json' \
+ -d '{"slug":"expiry-junk","content":"
still here
","frame":false,"visibility":"public"}'
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk)
+ [ "$code" = "200" ] || { echo "FAIL: the artifact did not publish (got $code)"; exit 1; }
+ pkill -f 'node server.js' || true
+ sleep 1
+ node -e '
+ const fs = require("fs");
+ const p = process.env.DATA_DIR + "/artifacts/expiry-junk/meta.json";
+ const m = JSON.parse(fs.readFileSync(p, "utf8"));
+ m.expiresAt = "garbage";
+ fs.writeFileSync(p, JSON.stringify(m, null, 2));
+ '
+ node server.js &
+ for i in $(seq 1 20); do curl -sf http://localhost:3000/healthz && break; sleep 0.5; done
+ curl -sf http://localhost:3000/healthz > /dev/null \
+ || { echo "FAIL: the server did not come back up after the restart"; exit 1; }
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk)
+ [ "$code" = "410" ] || { echo "FAIL: /a/expiry-junk -> $code, expected 410"; exit 1; }
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk/source)
+ [ "$code" = "410" ] || { echo "FAIL: /a/expiry-junk/source -> $code, expected 410"; exit 1; }
+ echo "ok: an unreadable expiresAt serves 410 on both serve paths"
+ # And the operator can clear it from the dashboard without touching the file again.
+ code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
+ -d '{"expiresAt":null}' http://localhost:3000/api/artifacts/expiry-junk)
+ [ "$code" = "200" ] || { echo "FAIL: PATCH clearing the expiry -> $code"; exit 1; }
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/a/expiry-junk)
+ [ "$code" = "200" ] || { echo "FAIL: the artifact did not come back after clearing the expiry (got $code)"; exit 1; }
+ echo "ok: clearing the expiry over the API brings the artifact back"
smoke-s3:
runs-on: ubuntu-latest
diff --git a/cli.js b/cli.js
index 2d2d78e..80e37ae 100644
--- a/cli.js
+++ b/cli.js
@@ -5,6 +5,8 @@ import { parseArgs } from 'node:util';
import AdmZip from 'adm-zip';
+import { artifactExpired } from './lib/expiry.js';
+
const USAGE = `artifacts — publish to a self-hosted artifacts instance
Usage:
@@ -196,7 +198,12 @@ switch (command) {
for (const a of artifacts) {
const frameFlag = a.frame === true ? 'frame:on' : a.frame === false ? 'frame:off' : null;
const visFlag = a.visibility === 'private' ? 'private' : a.visibility === 'password' ? 'password' : null;
- const flags = [a.disabled && 'disabled', visFlag, frameFlag, a.expiresAt && `expires ${a.expiresAt}`].filter(Boolean);
+ // The row printed an expiry and never said whether it had passed, so an artifact
+ // answering 410 listed the same as a live one, and a stored value that is not a string
+ // printed as "expires [object Object]". Same rule the server serves by.
+ const expired = artifactExpired(a);
+ const expiry = expired ? 'expired' : typeof a.expiresAt === 'string' && `expires ${a.expiresAt}`;
+ const flags = [a.disabled && 'disabled', visFlag, frameFlag, expiry].filter(Boolean);
const project = a.project ? `@${a.project}` : '';
const tags = a.tags?.length ? `#${a.tags.join(' #')}` : '';
const meta = [project, tags].filter(Boolean).join(' ');
diff --git a/docs/api.md b/docs/api.md
index 9078d51..40f4e50 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -32,7 +32,7 @@ Semantics:
- A write that stores a redirect answers with `target`, the normalized URL it stored, which is not always the string that was sent.
- QR codes: `GET /api/artifacts/:slug/qr` returns an image of the artifact's canonical URL (`/a/`, with the trailing slash for a zip site). `format` is `svg` (default) or `png`, `scale` is 1 to 16 pixels per module (default 8) and `margin` is 0 to 8 modules of quiet zone (default 4). Both take digits only: anything else, including a value out of range, is a `400` rather than a silent fallback. Rendering a PNG is the only synchronous CPU on a `read` route, which is what the scale ceiling is for. The code always carries the permanent link, never a capability link: a `?k=` token expires and can be revoked, and a printed code cannot be reissued. A scan of a password artifact lands on the unlock page; a private artifact answers `404` on its bare link, so make it public or password-protected before printing the code. A disabled or expired artifact still has a QR, the same way it still has a share link. Encoding is byte mode at error-correction level M, up to 666 bytes, generated in-process with no external service.
- `POST` with an existing slug → `409` (use `PUT` to update).
-- Disabled artifacts return `404`; expired ones (`expiresAt` in the past) return `410`. Both keep their content — re-enable or clear/extend the expiry to serve again.
+- Disabled artifacts return `404`; expired ones (`expiresAt` in the past, or holding a value that cannot be read as a date at all) return `410`. Both keep their content — re-enable or clear/extend the expiry to serve again.
- Tags: an array of strings, or one comma-separated string (the only form the zip endpoint's `?tags=` accepts). Each tag must match `[a-z0-9][a-z0-9-]{0,31}`; max 10 per artifact. Input is lowercased and deduplicated. `PATCH` replaces the whole list; an empty list clears it. `PUT` without `tags` keeps the existing ones. Artifacts published before tags existed list as `"tags": []`. In the web UI, tags render as chips — click one to filter the list.
- Project: a single grouping label (one per artifact), distinct from tags. Unicode letters/digits, spaces, and `-` `_` `.`, starting with a letter or digit, max 64 chars; internal whitespace is collapsed and case is preserved. Matching (`?project=` and UI grouping) is **exact and case-sensitive** — `Acme` and `acme` are different projects. `PATCH` sets it; an empty string clears it. `PUT` without `project` keeps the existing one. `GET /api/artifacts?project=` returns only that project's artifacts (an empty `?project=` is ignored, not a filter for "no project"). The web UI groups the list into collapsible sections per project (with a search box across project / title / slug / tags / a redirect's target).
diff --git a/lib/expiry.js b/lib/expiry.js
new file mode 100644
index 0000000..999d9f4
--- /dev/null
+++ b/lib/expiry.js
@@ -0,0 +1,23 @@
+// Whether an artifact has lapsed.
+//
+// This lives outside server.js so a unit test can hand it the shapes an API call cannot make.
+// parseExpiresAt refuses anything but an ISO string on the way in, so the values below arrive
+// from a hand-edited or restored meta.json, the same surface T1.2.11 and T1.2.20 covered on the
+// auth side. CI reaches them the other way, by writing meta.json between two boots.
+//
+// The rule is keyExpired's, applied to artifact metadata: absent means no expiry, and anything
+// present that cannot be read as a date counts as lapsed. Reading NaN as "not expired" is the
+// direction that fails open, and it disables the auto-expire lifecycle the README promises for
+// every record it touches.
+//
+// Non-strings are lapsed without asking Date.parse, because Date.parse stringifies first and
+// then accepts more than an operator would expect: `12345` reads as the year 12345 and
+// `["2026-01-01T00:00:00Z"]` reads as the string inside the array, so both would otherwise keep
+// serving forever. Every value this server writes is an ISO string.
+export function artifactExpired(meta) {
+ const value = meta.expiresAt;
+ if (value === undefined || value === null || value === '') return false;
+ if (typeof value !== 'string') return true;
+ const t = Date.parse(value);
+ return Number.isNaN(t) || t <= Date.now();
+}
diff --git a/public/index.html b/public/index.html
index 817174f..975d40d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1544,7 +1544,11 @@ Published
function buildItem(a, opts) {
opts = opts || {};
- const expired = a.expiresAt && Date.parse(a.expiresAt) <= Date.now();
+ // Same rule as artifactExpired in lib/expiry.js, kept in step by hand: a value the
+ // server cannot read is lapsed, so the row never shows a live artifact the server
+ // answers 410 for. Any change here belongs in both places.
+ const expired = a.expiresAt != null && a.expiresAt !== ''
+ && (typeof a.expiresAt !== 'string' || !(Date.parse(a.expiresAt) > Date.now()));
const item = document.createElement('div');
item.className = 'item' + ((a.disabled || expired) ? ' off' : '');
@@ -1778,11 +1782,15 @@ Published
menuItem(menu, 'QR code…', '', () => openQr(a));
- menuItem(menu, a.expiresAt ? 'Expiry: ' + a.expiresAt.slice(0, 10) + '…' : 'Expiry…', '', async () => {
+ // A hand-edited meta.json can hold anything here, and the row menu is the screen the
+ // operator opens to clear it. slice() on a number or an object throws inside the fill
+ // callback, which stops the menu at this line: no Expiry, no Delete, nothing below.
+ const expiry = typeof a.expiresAt === 'string' ? a.expiresAt : '';
+ menuItem(menu, expiry ? 'Expiry: ' + expiry.slice(0, 10) + '…' : 'Expiry…', '', async () => {
const v = await dialog({
title: 'Expiry for “' + a.slug + '”',
body: 'e.g. 2026-08-01 or 2026-08-01T12:00. Leave empty to clear.',
- input: true, value: a.expiresAt ? a.expiresAt.slice(0, 16) : '',
+ input: true, value: expiry.slice(0, 16),
});
if (v === null) return;
patchItem(a, { expiresAt: v.trim() || null }, { expiresAt: v.trim() || undefined });
diff --git a/server.js b/server.js
index 3812824..ca81e95 100644
--- a/server.js
+++ b/server.js
@@ -33,6 +33,7 @@ import {
} from './lib/auth.js';
import { createConfigStore } from './lib/config.js';
import { ApiError } from './lib/errors.js';
+import { artifactExpired } from './lib/expiry.js';
import { qrPng, qrSvg } from './lib/qr.js';
import { parseRedirectTarget, resolveRedirectTarget } from './lib/redirect.js';
@@ -565,9 +566,9 @@ function parseExpiresAt(value) {
return new Date(value).toISOString();
}
-function isExpired(meta) {
- return Boolean(meta.expiresAt && Date.parse(meta.expiresAt) <= Date.now());
-}
+// The rule itself is in lib/expiry.js, where a unit test can reach the records a request
+// cannot make. The five call sites below keep the short local name.
+const isExpired = artifactExpired;
async function saveArtifact({ content, type = 'html', slug, title, expiresAt, frame, tags, project, visibility, password }, { replace = false } = {}) {
if (typeof content !== 'string' || !content.trim()) {
@@ -1305,7 +1306,6 @@ app.post('/a/:slug/unlock', async (req, res, next) => {
}
const meta = SLUG_RE.test(slug) ? await readMeta(slug) : null;
if (!meta || meta.disabled) return res.status(404).json({ error: 'not found' });
- if (isExpired(meta)) return res.status(410).json({ error: 'expired' });
const password = req.body?.password;
if (meta.visibility !== 'password') {
// private uses capability links, not passwords; public needs no unlock. Uniform 401
@@ -1321,6 +1321,11 @@ app.post('/a/:slug/unlock', async (req, res, next) => {
logAuth('unlock', { ip, slug, outcome: 'fail' });
return res.status(401).json({ error: 'incorrect password' });
}
+ // Expiry is checked here rather than above the visibility branch. The 404/401 pair over
+ // this route is deliberately uniform (see the comment on that branch), and a 410 handed
+ // out before the password is proven told an anonymous caller the slug exists. The two GET
+ // paths already order it this way; this one did not.
+ if (isExpired(meta)) return res.status(410).json({ error: 'expired' });
await issueUnlock(res, meta);
res.json({ ok: true });
} catch (err) {
diff --git a/test/expiry.test.js b/test/expiry.test.js
new file mode 100644
index 0000000..e167b6f
--- /dev/null
+++ b/test/expiry.test.js
@@ -0,0 +1,36 @@
+// When an artifact has lapsed.
+//
+// This is here rather than in the smoke suite because the API cannot produce the records that
+// matter: parseExpiresAt refuses anything that is not an ISO string on the way in, so a junk
+// expiresAt only ever arrives from a hand-edited or restored meta.json. These tests pin what
+// each shape means before Date.parse gets a say. The 410 those records serve is proven end to
+// end in ci.yml, which plants one in meta.json between two boots.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { artifactExpired } from '../lib/expiry.js';
+
+// `false` and `0` are the two that separate this from the shorter `if (!meta.expiresAt)` guard:
+// both are falsy, so that version reads them as "no expiry" and the artifact serves forever.
+// `12345` and a one-element array are the two Date.parse accepts after stringifying, which is
+// why the rule refuses a non-string before it asks.
+test('an unreadable expiresAt counts as expired', () => {
+ const junk = ['garbage', '2026-13-45', {}, true, false, [], 0, 12345, ['2026-01-01T00:00:00Z'], 'null', ' '];
+ for (const value of junk) {
+ assert.equal(artifactExpired({ expiresAt: value }), true, `${JSON.stringify(value)} should be expired`);
+ }
+});
+
+test('no expiry means the artifact never lapses', () => {
+ for (const absent of [undefined, null, '']) {
+ assert.equal(artifactExpired({ expiresAt: absent }), false, `${String(absent)} should not be expired`);
+ }
+ assert.equal(artifactExpired({}), false);
+});
+
+test('a readable expiresAt is compared against now', () => {
+ const past = new Date(Date.now() - 60_000).toISOString();
+ const future = new Date(Date.now() + 60_000).toISOString();
+ assert.equal(artifactExpired({ expiresAt: past }), true);
+ assert.equal(artifactExpired({ expiresAt: future }), false);
+});