Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,24 @@ docker run --rm -v linkforty_postgres_data:/data -v $(pwd):/backup alpine \

### Health Checks

The LinkForty container includes a built-in health check:
The server exposes two endpoints:

| Endpoint | Checks | Use for |
|-----------------|-------------------------------------|--------------------------------------------|
| `/health` | Process is up. Never touches the DB | Liveness probes — a DB blip won't restart you |
| `/health/ready` | Database reachable (+ Redis status) | Readiness probes, load balancer draining |

`/health/ready` returns `503` when the database is unreachable:

```bash
curl -s localhost:3000/health/ready
# {"status":"ok","checks":{"database":"ok","redis":"ok"}}
```

Redis is an optional cache with database fallback, so a Redis failure is reported
in `checks` but does not make the instance unready.

The container's built-in `HEALTHCHECK` targets `/health/ready`:

```bash
# Check container health
Expand Down
18 changes: 12 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ WORKDIR /app
# Copy package files
COPY package*.json ./

# Install dependencies (including devDependencies for build)
RUN npm ci
# Install dependencies (including devDependencies for build).
# --ignore-scripts skips the `prepare` script, which would otherwise run tsc here,
# before the source is even copied. The real build is the explicit step below.
RUN npm ci --ignore-scripts

# Copy source files
COPY . .
Expand All @@ -30,13 +32,17 @@ WORKDIR /app
# Copy package files
COPY package*.json ./

# Install production dependencies only
RUN npm ci --only=production && \
# Install production dependencies only.
# --ignore-scripts is required: package.json has a `prepare` script (npm run build)
# that npm runs automatically on install, and it needs tsc from devDependencies —
# which this stage deliberately omits. Without it the build dies with exit 127.
RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force

# Copy built files from builder
# NOTE: there is no migrations/ directory — the schema is created by
# initializeDatabase() in dist/lib/database.js, which dist/scripts/migrate.js runs.
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/migrations ./migrations

# Copy example server file
COPY examples/basic-server.ts ./
Expand All @@ -55,7 +61,7 @@ EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
CMD node -e "require('http').get('http://localhost:3000/health/ready', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))"

# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redi
GET /api/sdk/v1/health # Health check
```

### Health

```bash
GET /health # Liveness — process is up (no DB access)
GET /health/ready # Readiness — 503 if the database is unreachable
```

### Debug & Testing

```bash
Expand Down
14 changes: 7 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ services:
- "${LINKFORTY_PORT:-3000}:3000"
restart: unless-stopped

# Health check (optional, uncomment if needed)
# healthcheck:
# test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 40s
# /health is liveness (process up); /health/ready also checks the database.
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

volumes:
postgres_data:
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { webhookRoutes } from './routes/webhooks.js';
import { templateRoutes } from './routes/templates.js';
import { qrRoutes } from './routes/qr.js';
import { wellKnownRoutes } from './routes/well-known.js';
import { healthRoutes } from './routes/health.js';

/**
* Configuration options for creating a LinkForty server instance.
Expand Down Expand Up @@ -58,6 +59,7 @@ export async function createServer(options: ServerOptions = {}) {
await initializeDatabase(options.database);

// Routes
await fastify.register(healthRoutes);
await fastify.register(wellKnownRoutes);
await fastify.register(redirectRoutes);
await fastify.register(linkRoutes);
Expand All @@ -78,4 +80,4 @@ export * from './lib/fingerprint.js';
export * from './lib/webhook.js';
export * from './lib/event-emitter.js';
export * from './types/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
42 changes: 42 additions & 0 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,29 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
const client = await connectWithRetry();

try {
// Organizations table (must be created before links, which references it).
//
// The redirect path LEFT JOINs this table to read `settings.appConfig`, which
// is the last link in the iOS/Android/web URL fallback chain (link → template
// → organization). Core therefore *depends* on the table existing even though
// richer deployments own the real one: without it every redirect fails with
// `relation "organizations" does not exist` (issue #35).
//
// Deliberately minimal — id and settings are all the redirect reads, plus
// suspended_at for the owner-restriction gate. CREATE TABLE IF NOT EXISTS is a
// no-op against a deployment that already ships a fuller organizations table,
// so this cannot clobber one.
await client.query(`
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255),
settings JSONB DEFAULT '{}',
suspended_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);

// Link templates table (must be created before links, which references it)
await client.query(`
CREATE TABLE IF NOT EXISTS link_templates (
Expand Down Expand Up @@ -190,6 +213,24 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
)
`);

// Add organization_id column to links table.
//
// The redirect join is `ON l.organization_id = o.id`, so the column is as
// load-bearing as the table itself. Nullable and unset by default: a core
// deployment that never populates it simply gets NULL org_settings and the
// fallback chain stops at the template level, exactly as before.
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='organization_id'
) THEN
ALTER TABLE links ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL;
END IF;
END $$;
`);

// Add template_id column to links table
await client.query(`
DO $$
Expand Down Expand Up @@ -496,6 +537,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_link_templates_slug ON link_templates(slug)');
await client.query('CREATE INDEX IF NOT EXISTS idx_link_templates_user_id ON link_templates(user_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_links_template_id ON links(template_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_links_organization_id ON links(organization_id)');

// Indexes for webhooks
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id)');
Expand Down
94 changes: 94 additions & 0 deletions src/routes/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Route-level tests for the health endpoints.
*
* The regression these lock down (issue #35): /health used to have no route at
* all, so it fell through to the catch-all redirect `/:shortCode` and was
* answered as a short-code lookup. The last test registers the real redirect
* plugin alongside health to prove the static path wins.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { healthRoutes } from './health.js';
import { redirectRoutes } from './redirect.js';

const query = vi.fn();
vi.mock('../lib/database.js', () => ({
db: {
query: (...args: unknown[]) => query(...args),
},
}));

let app: FastifyInstance;

beforeEach(async () => {
query.mockReset();
query.mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 });
app = Fastify();
await app.register(healthRoutes);
await app.ready();
});

afterEach(async () => {
await app.close();
});

describe('GET /health', () => {
it('reports the process is up', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ status: 'ok' });
});

it('never touches the database, so a database outage cannot fail liveness', async () => {
query.mockRejectedValue(new Error('connection refused'));
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(query).not.toHaveBeenCalled();
});
});

describe('GET /health/ready', () => {
it('is ready when the database answers', async () => {
const res = await app.inject({ method: 'GET', url: '/health/ready' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: 'ok', checks: { database: 'ok' } });
});

it('is 503 when the database is unreachable', async () => {
query.mockRejectedValue(new Error('connection refused'));
const res = await app.inject({ method: 'GET', url: '/health/ready' });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({ status: 'error', checks: { database: 'error' } });
});

it('reports a failing Redis as degraded, not unready', async () => {
const withRedis = Fastify();
withRedis.decorate('redis', { ping: async () => { throw new Error('down'); } } as never);
await withRedis.register(healthRoutes);
await withRedis.ready();

const res = await withRedis.inject({ method: 'GET', url: '/health/ready' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: 'ok', checks: { database: 'ok', redis: 'error' } });
await withRedis.close();
});
});

describe('regression: /health is not swallowed by the redirect route', () => {
it('answers health, not a short-code lookup, when both plugins are registered', async () => {
const combined = Fastify();
// Redirect first — the static route must win on specificity, not order.
await combined.register(redirectRoutes);
await combined.register(healthRoutes);
await combined.ready();

const res = await combined.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ status: 'ok' });
// The redirect handler would have run a links lookup; health must not.
const linkLookups = query.mock.calls.filter(([sql]) => /FROM links/i.test(String(sql)));
expect(linkLookups).toHaveLength(0);

await combined.close();
});
});
59 changes: 59 additions & 0 deletions src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';

/**
* Health endpoints.
*
* Without these, `GET /health` fell through to the catch-all redirect route
* `/:shortCode` and was answered as a short-code lookup — a 404 at best, and on
* a self-hosted install a 500, which made the Docker HEALTHCHECK (which targets
* /health) permanently unhealthy and buried the real error (issue #35).
*
* Two levels, following the usual liveness/readiness split:
*
* /health — liveness. The process is up and serving. Never touches the
* database, so a database blip cannot get the container killed
* by an orchestrator that restarts on a failing probe.
* /health/ready — readiness. Confirms the database answers, and reports Redis
* when it is configured. 503 when the database is unreachable,
* so a load balancer can drain the instance.
*
* Both are static paths, which Fastify's router always prefers over the
* parametric `/:shortCode`, so they cannot be shadowed by a short link — and by
* the same token `health` is no longer usable as a short code.
*/
export async function healthRoutes(fastify: FastifyInstance) {
fastify.get('/health', async () => ({
status: 'ok',
uptime: Math.floor(process.uptime()),
}));

fastify.get('/health/ready', async (_request, reply) => {
const checks: Record<string, string> = {};

try {
await db.query('SELECT 1');
checks.database = 'ok';
} catch (error) {
fastify.log.error(`Health: database check failed: ${error}`);
checks.database = 'error';
}

if (fastify.redis) {
try {
await fastify.redis.ping();
checks.redis = 'ok';
} catch {
// Redis is an optional cache with database fallback, so a failure here
// is degraded, not unready — it must not flip the overall status.
checks.redis = 'error';
}
}

const ready = checks.database === 'ok';
return reply.status(ready ? 200 : 503).send({
status: ready ? 'ok' : 'error',
checks,
});
});
}
1 change: 1 addition & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { templateRoutes } from './templates.js';
export { previewRoutes } from './preview.js';
export { debugRoutes } from './debug.js';
export { wellKnownRoutes } from './well-known.js';
export { healthRoutes } from './health.js';
Loading