diff --git a/src/index.ts b/src/index.ts
index 5cee4a5..07d7b50 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -78,6 +78,7 @@ export * from './lib/client-ip.js';
export * from './lib/database.js';
export * from './lib/fingerprint.js';
export * from './lib/webhook.js';
+export * from './lib/link-safety.js';
export * from './lib/event-emitter.js';
export * from './types/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
diff --git a/src/lib/database.ts b/src/lib/database.ts
index c35c27d..d5b44db 100644
--- a/src/lib/database.ts
+++ b/src/lib/database.ts
@@ -244,6 +244,45 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);
+ // Link safety states.
+ //
+ // `is_active = false` already removes a link from resolution entirely. These
+ // add a softer state and an explanation:
+ //
+ // warn_at — resolve to an interstitial warning instead of a 302, so a
+ // visitor sees the destination and chooses. Useful when a
+ // link is suspected rather than confirmed unsafe, since a
+ // hard block on a false positive takes a legitimate link
+ // offline with no recourse for the visitor.
+ // disabled_at — when the link was taken out of resolution.
+ // disabled_reason — why, so the decision can be explained or reversed later.
+ //
+ // All nullable and additive: an existing deployment that sets none of them
+ // behaves exactly as before.
+ await client.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name='links' AND column_name='warn_at'
+ ) THEN
+ ALTER TABLE links ADD COLUMN warn_at TIMESTAMPTZ;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name='links' AND column_name='disabled_at'
+ ) THEN
+ ALTER TABLE links ADD COLUMN disabled_at TIMESTAMPTZ;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name='links' AND column_name='disabled_reason'
+ ) THEN
+ ALTER TABLE links ADD COLUMN disabled_reason TEXT;
+ END IF;
+ END $$;
+ `);
+
// Add description column to existing links table if it doesn't exist
await client.query(`
DO $$
diff --git a/src/lib/link-safety.test.ts b/src/lib/link-safety.test.ts
new file mode 100644
index 0000000..5cc52e0
--- /dev/null
+++ b/src/lib/link-safety.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest';
+import { evaluateLinkSafety, generateWarningLinkHTML, escapeHtml, safeHref } from './link-safety.js';
+
+describe('evaluateLinkSafety', () => {
+ it('allows a plain healthy link', () => {
+ expect(evaluateLinkSafety({ isActive: true })).toBe('allow');
+ expect(evaluateLinkSafety({})).toBe('allow');
+ });
+
+ it('treats an absent isActive as active, so existing rows are unaffected', () => {
+ expect(evaluateLinkSafety({ isActive: undefined })).toBe('allow');
+ expect(evaluateLinkSafety({ isActive: null })).toBe('allow');
+ });
+
+ it('blocks an inactive link', () => {
+ expect(evaluateLinkSafety({ isActive: false })).toBe('block');
+ });
+
+ it('warns when warn_at is set', () => {
+ expect(evaluateLinkSafety({ isActive: true, warnAt: new Date() })).toBe('warn');
+ expect(evaluateLinkSafety({ isActive: true, warnAt: '2026-08-10T00:00:00Z' })).toBe('warn');
+ });
+
+ it('blocks when the owner is restricted, even if the link itself is fine', () => {
+ expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: new Date() })).toBe('block');
+ });
+
+ it('prefers block over warn — a restricted owner outranks a mere warning', () => {
+ expect(
+ evaluateLinkSafety({ isActive: true, warnAt: new Date(), ownerSuspendedAt: new Date() })
+ ).toBe('block');
+ });
+
+ it('ignores owner restriction when it is not modelled at all', () => {
+ // Deployments without an owner table never pass the field.
+ expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: null })).toBe('allow');
+ expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: undefined })).toBe('allow');
+ });
+});
+
+describe('escapeHtml', () => {
+ it('neutralises markup and quote characters', () => {
+ expect(escapeHtml('')).toBe(
+ '<script>alert("x")</script>'
+ );
+ expect(escapeHtml("it's & more")).toBe('it's & more');
+ });
+});
+
+describe('generateWarningLinkHTML', () => {
+ it('shows the destination so the visitor can judge it', () => {
+ const html = generateWarningLinkHTML('https://example.com/login');
+ expect(html).toContain('https://example.com/login');
+ expect(html).toContain('Check this link before continuing');
+ });
+
+ it('escapes a hostile destination rather than injecting it', () => {
+ const html = generateWarningLinkHTML('https://evil.test/">');
+ expect(html).not.toContain('');
+ expect(html).toContain('<script>');
+ });
+
+ it('asks search engines not to index it', () => {
+ expect(generateWarningLinkHTML('https://example.com')).toContain('noindex');
+ });
+
+ it('carries no JavaScript of its own, so it survives a strict CSP', () => {
+ const html = generateWarningLinkHTML('https://example.com');
+ expect(html).not.toContain('');
+ expect(html).not.toMatch(/href="data:/i);
+ });
+
+ it('offers no continue button when the scheme is not http(s)', () => {
+ const html = generateWarningLinkHTML('javascript:alert(1)');
+ expect(html).not.toContain('class="go"');
+ expect(html).toContain('nothing to continue to');
+ });
+
+ it('still shows the destination as inert text so the visitor can see it', () => {
+ const html = generateWarningLinkHTML('javascript:alert(1)');
+ expect(html).toContain('javascript:alert(1)');
+ });
+
+ it('emits no empty href for a bare path or an absent destination', () => {
+ for (const d of ['/deep/link/path', '']) {
+ const html = generateWarningLinkHTML(d);
+ expect(html).not.toContain('href=""');
+ expect(html).not.toContain('class="go"');
+ }
+ });
+
+ it('still links a legitimate http(s) destination', () => {
+ const html = generateWarningLinkHTML('https://example.com/x');
+ expect(html).toContain('class="go"');
+ expect(html).toContain('href="https://example.com/x"');
+ });
+ });
+
+ it('marks the outbound link nofollow/noopener so we pass no reputation to it', () => {
+ const html = generateWarningLinkHTML('https://example.com');
+ expect(html).toContain('rel="nofollow noopener noreferrer"');
+ });
+
+ it('includes a report link only when one is configured', () => {
+ expect(generateWarningLinkHTML('https://example.com')).not.toContain('Report this link');
+ const withReport = generateWarningLinkHTML('https://example.com', {
+ reportUrl: 'https://example.org/abuse',
+ });
+ expect(withReport).toContain('Report this link');
+ expect(withReport).toContain('https://example.org/abuse');
+ });
+});
+
+
+describe('safeHref', () => {
+ it('allows http and https', () => {
+ expect(safeHref('http://example.com')).toBe('http://example.com');
+ expect(safeHref('https://example.com/a?b=c')).toBe('https://example.com/a?b=c');
+ });
+
+ it('rejects every other scheme', () => {
+ for (const d of ['javascript:alert(1)', 'data:text/html,x', 'file:///etc/passwd', 'vbscript:x']) {
+ expect(safeHref(d), d).toBeNull();
+ }
+ });
+
+ it('rejects a relative path, which would point at the redirect host', () => {
+ expect(safeHref('/deep/link/path')).toBeNull();
+ expect(safeHref('')).toBeNull();
+ });
+});
diff --git a/src/lib/link-safety.ts b/src/lib/link-safety.ts
new file mode 100644
index 0000000..c670643
--- /dev/null
+++ b/src/lib/link-safety.ts
@@ -0,0 +1,197 @@
+/**
+ * Link safety states for the redirect path.
+ *
+ * Three outcomes are possible for a link that exists:
+ *
+ * - `allow` — resolve normally.
+ * - `warn` — serve an interstitial that shows the destination and requires an
+ * explicit click. For a link that is *suspected* unsafe rather than
+ * confirmed, this is strictly better than a hard block: a false
+ * positive still lets the visitor through, while a true positive
+ * still breaks the one-click flow a malicious link depends on.
+ * - `block` — behave as if the link does not exist.
+ *
+ * `block` deliberately produces the same response as an unknown short code. A
+ * distinct "this link was disabled" response would confirm to whoever is probing
+ * that the code was real, and would leak that its owner is under a restriction.
+ */
+export type LinkSafetyOutcome = 'allow' | 'warn' | 'block';
+
+export interface LinkSafetyInput {
+ /** Whether the link is active. Absent/undefined is treated as active. */
+ isActive?: boolean | null;
+ /** Set when the link should serve a warning instead of resolving. */
+ warnAt?: Date | string | null;
+ /**
+ * Set when the link's owner is restricted. Optional because the owning table is
+ * not part of this package's schema — deployments that do not model owner
+ * restriction simply never pass it.
+ */
+ ownerSuspendedAt?: Date | string | null;
+}
+
+/**
+ * Decide what to do with a link that was found.
+ *
+ * Order matters: owner restriction and inactivity both beat `warn`. A link whose
+ * owner is restricted must be unreachable even if it was only flagged to warn.
+ */
+export function evaluateLinkSafety(input: LinkSafetyInput): LinkSafetyOutcome {
+ if (input.ownerSuspendedAt != null) return 'block';
+ if (input.isActive === false) return 'block';
+ if (input.warnAt != null) return 'warn';
+ return 'allow';
+}
+
+/**
+ * A destination is only safe to put in an `href` if it is http(s).
+ *
+ * `escapeHtml` is not sufficient on its own: a URL scheme contains none of the
+ * characters it neutralises, so `javascript:alert(1)` passes through untouched and
+ * becomes executable on click. That matters here more than almost anywhere else —
+ * this page is shown *only* for links already flagged as suspicious, so the
+ * destinations reaching it are the most likely in the system to be hostile.
+ *
+ * Reachable in practice, not just in theory: zod's `.url()` accepts `javascript:`
+ * and `data:` URLs, so a stored destination can already hold one. Tightening
+ * validation at write time is worth doing separately, but this page must not
+ * depend on that having happened.
+ *
+ * Returns null when there is nothing safe to link to — including a bare deep-link
+ * path, which would otherwise render as a relative link to the redirect host
+ * rather than the real destination. Callers render that as inert text.
+ */
+export function safeHref(destination: string): string | null {
+ let url: URL;
+ try {
+ url = new URL(destination);
+ } catch {
+ return null;
+ }
+ return url.protocol === 'http:' || url.protocol === 'https:' ? destination : null;
+}
+
+/** Minimal HTML escaping for interpolating a URL into markup and an href. */
+export function escapeHtml(value: string): string {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Interstitial shown for a `warn` outcome.
+ *
+ * Shows the destination in full — the point is to hand back the information the
+ * short link hid, so the visitor can judge for themselves. Continuing takes a
+ * deliberate click, which is what breaks the one-click flow.
+ *
+ * Contains no JavaScript and no external assets: it must render on a bare
+ * redirect host and under a strict content-security policy.
+ */
+export function generateWarningLinkHTML(
+ destination: string,
+ options: { reportUrl?: string } = {}
+): string {
+ const shown = destination && destination.trim() ? escapeHtml(destination) : '(no destination recorded)';
+ const href = destination ? safeHref(destination) : null;
+ // No anchor at all when there is nothing safe to link to. An href="" would
+ // re-request the warning page, so "Continue anyway" would just reload it.
+ const continueButton = href
+ ? `Continue anyway`
+ : `
This link has no usable web destination, so there is nothing to continue to.
`;
+ const reportLink = options.reportUrl
+ ? `Report this link
`
+ : '';
+
+ return `
+
+
+
+
+
+Check this link before continuing
+
+
+
+
+ Check this link before continuing
+ This short link has been flagged as possibly unsafe, so we have not sent you
+ straight there. It leads to:
+ ${shown}
+ If you were not expecting this link, or it claims to be from a bank, a government
+ service, or a company you do business with, close this page. Do not enter any
+ password or personal details.
+ ${continueButton}
+ ${reportLink}
+
+
+`;
+}
+
+/**
+ * Build a memoised probe for owner-restriction support.
+ *
+ * A factory rather than module state: each registration gets its own, so two servers
+ * in one process pointed at different databases cannot share an answer, and no
+ * test-only reset has to be exported.
+ *
+ * Shared by every path that resolves a link and caches the result, and that sharing
+ * is the whole point. The redirect and the SDK resolve endpoint write the SAME Redis
+ * key, so if one selects `owner_suspended_at` and the other does not, the second
+ * silently caches a row that makes the first one's gate pass. Keeping the SELECT
+ * fragment in one place is what stops them drifting apart again.
+ *
+ * The in-flight promise is memoised, not just the result, so concurrent cold requests
+ * issue one probe. A failure resolves to "unsupported", so a probe error can never
+ * take a resolution path down.
+ */
+export function createOwnerSuspensionSelect(deps: {
+ query: (sql: string) => Promise<{ rows: unknown[] }>;
+ onSupported?: () => void;
+}): () => Promise {
+ let probe: Promise | null = null;
+ return () => {
+ if (!probe) {
+ probe = deps
+ .query(
+ `SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'organizations' AND column_name = 'suspended_at'`
+ )
+ .then((r) => {
+ const supported = r.rows.length > 0;
+ if (supported) deps.onSupported?.();
+ return supported ? ', o.suspended_at AS owner_suspended_at' : '';
+ })
+ .catch(() => '');
+ }
+ return probe;
+ };
+}
diff --git a/src/routes/redirect.safety.test.ts b/src/routes/redirect.safety.test.ts
new file mode 100644
index 0000000..b7f96d5
--- /dev/null
+++ b/src/routes/redirect.safety.test.ts
@@ -0,0 +1,205 @@
+/**
+ * Route-level tests for the link safety gate.
+ *
+ * These drive the real `redirectRoutes` plugin through fastify.inject(), with only
+ * the data layer mocked. The unit tests in lib/link-safety.test.ts already cover
+ * the decision table; what matters here is the behaviour a visitor actually gets:
+ * status codes, headers, whether a 302 happens, and whether a click is recorded.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+// vi.mock is hoisted above these imports, so redirect.js still receives the mock.
+import { redirectRoutes } from './redirect.js';
+
+const query = vi.fn();
+vi.mock('../lib/database.js', () => ({
+ db: {
+ query: (...args: unknown[]) => query(...args),
+ },
+}));
+
+/** A resolved link row as the redirect query would return it. */
+function linkRow(overrides: Record = {}) {
+ return {
+ id: '00000000-0000-0000-0000-0000000000aa',
+ short_code: 'abc123',
+ original_url: 'https://example.com/landing',
+ web_fallback_url: null,
+ deep_link_path: null,
+ is_active: true,
+ warn_at: null,
+ owner_suspended_at: null,
+ expires_at: null,
+ targeting_rules: null,
+ template_settings: null,
+ org_settings: null,
+ utm_parameters: null,
+ append_click_id: false,
+ ...overrides,
+ };
+}
+
+/**
+ * @param row the link the lookup resolves to, or null for "not found"
+ * @param suspensionColumn whether organizations.suspended_at exists in this database
+ */
+function mockDb(row: Record | null, suspensionColumn = true) {
+ query.mockReset();
+ query.mockImplementation(async (sql: string) => {
+ if (/information_schema\.columns/i.test(sql)) {
+ return { rows: suspensionColumn ? [{ '?column?': 1 }] : [], rowCount: suspensionColumn ? 1 : 0 };
+ }
+ if (/^\s*SELECT\s+l\.\*/i.test(sql) || /FROM links l/i.test(sql)) {
+ return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
+ }
+ // click inserts, anything else
+ return { rows: [], rowCount: 0 };
+ });
+}
+
+/** Did any click get written? Flushes the setImmediate the redirect uses. */
+async function clickWasRecorded(): Promise {
+ await new Promise((r) => setImmediate(r));
+ return query.mock.calls.some(([sql]) => /INSERT INTO click_events/i.test(String(sql)));
+}
+
+let app: FastifyInstance;
+
+beforeEach(async () => {
+ // No probe reset needed: the probe is scoped to each registration, so a fresh
+ // Fastify instance per test gets a fresh probe. That the reset export is gone is
+ // the point — it existed only to work around module-global state.
+ app = Fastify();
+ await app.register(redirectRoutes, { abuseReportUrl: 'https://example.org/abuse' });
+ await app.ready();
+});
+
+afterEach(async () => {
+ // Click recording is fire-and-forget (setImmediate). Let any pending insert from
+ // this test finish BEFORE the next test resets the mock, otherwise a stray click
+ // from a previous redirect lands in the next test's call log and looks like a bug.
+ await new Promise((r) => setImmediate(r));
+ await app.close();
+});
+
+describe('redirect safety gate', () => {
+ it('redirects a healthy link', async () => {
+ mockDb(linkRow());
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(302);
+ expect(res.headers.location).toBe('https://example.com/landing');
+ });
+
+ it('404s an inactive link', async () => {
+ mockDb(linkRow({ is_active: false }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('404s when the owner is restricted, even though the link itself is fine', async () => {
+ mockDb(linkRow({ owner_suspended_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('gives a restricted owner the SAME response as an unknown code, leaking nothing', async () => {
+ mockDb(linkRow({ owner_suspended_at: '2026-08-10T00:00:00Z' }));
+ const restricted = await app.inject({ method: 'GET', url: '/abc123' });
+ mockDb(null);
+ const unknown = await app.inject({ method: 'GET', url: '/nosuchcode' });
+ expect(restricted.statusCode).toBe(unknown.statusCode);
+ expect(restricted.body).toBe(unknown.body);
+ });
+
+ describe('a link flagged to warn', () => {
+ it('serves an interstitial instead of redirecting', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(200);
+ expect(res.statusCode).not.toBe(302);
+ expect(res.headers['content-type']).toMatch(/text\/html/);
+ expect(res.body).toContain('Check this link before continuing');
+ });
+
+ it('shows the destination the short link was hiding', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.body).toContain('https://example.com/landing');
+ });
+
+ it('asks not to be indexed or cached', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.headers['x-robots-tag']).toMatch(/noindex/);
+ expect(res.headers['cache-control']).toMatch(/no-store/);
+ });
+
+ it('links to the configured reporting page', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.body).toContain('https://example.org/abuse');
+ });
+
+ it('records NO click — a warning view is not a click on the link', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z' }));
+ await app.inject({ method: 'GET', url: '/abc123' });
+ expect(await clickWasRecorded()).toBe(false);
+ });
+
+ it('falls back to the web fallback url when there is no original url', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z', original_url: '', web_fallback_url: 'https://example.net/x' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.body).toContain('https://example.net/x');
+ });
+
+ it('is outranked by owner restriction', async () => {
+ mockDb(linkRow({ warn_at: '2026-08-10T00:00:00Z', owner_suspended_at: '2026-08-10T00:00:00Z' }));
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe('a database without organizations.suspended_at', () => {
+ it('still resolves links normally rather than erroring', async () => {
+ mockDb(linkRow(), /* suspensionColumn */ false);
+ const res = await app.inject({ method: 'GET', url: '/abc123' });
+ expect(res.statusCode).toBe(302);
+ });
+
+ it('never asks for the column it just proved absent', async () => {
+ mockDb(linkRow(), false);
+ await app.inject({ method: 'GET', url: '/abc123' });
+ const lookups = query.mock.calls
+ .map(([sql]) => String(sql))
+ .filter((sql) => /FROM links l/i.test(sql));
+ expect(lookups.length).toBeGreaterThan(0);
+ for (const sql of lookups) expect(sql).not.toMatch(/owner_suspended_at/);
+ });
+
+ it('probes once even under CONCURRENT cold requests', async () => {
+ // The sequential test below passes trivially because inject() awaits. This one
+ // fires them together, which is what the memoised promise actually buys.
+ mockDb(linkRow(), false);
+ await Promise.all([
+ app.inject({ method: 'GET', url: '/abc123' }),
+ app.inject({ method: 'GET', url: '/abc123' }),
+ app.inject({ method: 'GET', url: '/abc123' }),
+ app.inject({ method: 'GET', url: '/abc123' }),
+ ]);
+ const probes = query.mock.calls.filter(([sql]) =>
+ /information_schema\.columns/i.test(String(sql))
+ );
+ expect(probes).toHaveLength(1);
+ });
+
+ it('probes only once across sequential requests', async () => {
+ mockDb(linkRow(), false);
+ await app.inject({ method: 'GET', url: '/abc123' });
+ await app.inject({ method: 'GET', url: '/abc123' });
+ const probes = query.mock.calls.filter(([sql]) =>
+ /information_schema\.columns/i.test(String(sql))
+ );
+ expect(probes).toHaveLength(1);
+ });
+ });
+});
diff --git a/src/routes/redirect.ts b/src/routes/redirect.ts
index 5b05632..4cfb3bf 100644
--- a/src/routes/redirect.ts
+++ b/src/routes/redirect.ts
@@ -6,6 +6,11 @@ import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } fro
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
+import {
+ evaluateLinkSafety,
+ generateWarningLinkHTML,
+ createOwnerSuspensionSelect,
+} from '../lib/link-safety.js';
/**
* Detect iOS in-app browsers where Universal Links don't fire.
@@ -136,7 +141,48 @@ function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title?