Skip to content

Commit f6f1513

Browse files
committed
v1.5.0 - breaking changes
1 parent 5b70e09 commit f6f1513

10 files changed

Lines changed: 220 additions & 214 deletions

File tree

.env.example

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,6 @@ LINKFORTY_PORT=3000
3333
# -----------------------------------------------------------------------------
3434
CORS_ORIGIN=*
3535

36-
# -----------------------------------------------------------------------------
37-
# Authentication (Optional)
38-
# Generate a secure JWT secret with: openssl rand -hex 32
39-
# -----------------------------------------------------------------------------
40-
# JWT_SECRET=your-super-secret-jwt-key-here
41-
42-
# -----------------------------------------------------------------------------
43-
# Email Configuration (Optional - for notifications)
44-
# -----------------------------------------------------------------------------
45-
# SMTP_HOST=smtp.gmail.com
46-
# SMTP_PORT=587
47-
# SMTP_USER=your-email@gmail.com
48-
# SMTP_PASS=your-app-password
49-
# EMAIL_FROM=Your App <noreply@yourdomain.com>
50-
5136
# -----------------------------------------------------------------------------
5237
# Custom Domain Configuration (Optional)
5338
# -----------------------------------------------------------------------------

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. This projec
44

55
Automated releases are managed by [semantic-release](https://github.com/semantic-release/semantic-release).
66

7+
## 1.5.0 (2026-02-20)
8+
9+
### BREAKING CHANGES - Making Core less opinionated
10+
11+
* **Remove `users` table from Core** — Core no longer creates or manages a `users` table. Authentication and user management are now the consumer's responsibility, aligning Core with a framework-first philosophy (bring your own auth). Consumers that relied on Core's `users` table must create it themselves before any tables that reference `users(id)`.
12+
* **Make `user_id` nullable on `links` and `webhooks` tables** — The `user_id` column on both `links` and `webhooks` is now nullable with no foreign key constraint. This enables single-tenant usage without a user model.
13+
* **Remove `User`, `Organization`, `AppConfig`, and `OrganizationSettings` types** — These Cloud-only types have been removed from `@linkforty/core/types`. Consumers that imported them must define their own.
14+
15+
### Features
16+
17+
* **Optional `userId` across all API endpoints** — All link, analytics, webhook, and debug endpoints now accept `userId` as an optional parameter. When provided, queries are scoped to that user (multi-tenant mode). When omitted, all records are accessible (single-tenant mode).
18+
* **Single-tenant mode** — Core can now be used without any user/auth model. Create and manage links, view analytics, and configure webhooks without providing a `userId`.
19+
* **WebSocket live debug stream no longer requires `userId`** — When `userId` is omitted, the `/api/debug/live` WebSocket streams all click events.
20+
21+
### Removed
22+
23+
* `users` table DDL and `idx_users_email` index from `initializeDatabase()`
24+
* `JWT_SECRET` and email configuration sections from `.env.example`
25+
* `User`, `Organization`, `AppConfig`, `OrganizationSettings` interfaces from types
26+
727
## 1.4.4 (2026-02-11)
828

929
### Features

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@linkforty/core",
3-
"version": "1.4.4",
3+
"version": "1.5.0",
44
"type": "module",
55
"description": "Open-source deeplink management engine with device detection and analytics",
66
"main": "dist/index.js",

src/lib/database.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,11 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
5151
const client = await connectWithRetry();
5252

5353
try {
54-
// Users table
55-
await client.query(`
56-
CREATE TABLE IF NOT EXISTS users (
57-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
58-
email VARCHAR(255) UNIQUE NOT NULL,
59-
name VARCHAR(255) NOT NULL,
60-
password_hash VARCHAR(255) NOT NULL,
61-
created_at TIMESTAMP DEFAULT NOW(),
62-
updated_at TIMESTAMP DEFAULT NOW()
63-
)
64-
`);
65-
6654
// Links table
6755
await client.query(`
6856
CREATE TABLE IF NOT EXISTS links (
6957
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
70-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
58+
user_id UUID,
7159
short_code VARCHAR(20) UNIQUE NOT NULL,
7260
original_url TEXT NOT NULL,
7361
title VARCHAR(255),
@@ -168,7 +156,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
168156
await client.query(`
169157
CREATE TABLE IF NOT EXISTS webhooks (
170158
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
171-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
159+
user_id UUID,
172160
name VARCHAR(255) NOT NULL,
173161
url TEXT NOT NULL,
174162
secret VARCHAR(255) NOT NULL,
@@ -361,8 +349,6 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
361349
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_id ON click_events(link_id)');
362350
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_timestamp ON click_events(clicked_at DESC)');
363351
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_date ON click_events(link_id, clicked_at DESC)');
364-
await client.query('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)');
365-
366352
// Indexes for deferred deep linking
367353
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_hash ON device_fingerprints(fingerprint_hash)');
368354
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_click_id ON device_fingerprints(click_id)');

src/lib/event-emitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export interface ClickEventData {
1414
timestamp: string;
1515
linkId: string;
1616
shortCode: string;
17-
userId: string;
17+
userId?: string;
1818
organizationId?: string;
1919

2020
// Request details

src/routes/analytics.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
22
import { db } from '../lib/database.js';
33

44
export async function analyticsRoutes(fastify: FastifyInstance) {
5-
// Get overall analytics for a user
5+
// Get overall analytics (optionally filtered by userId)
66
fastify.get('/api/analytics/overview', async (request: FastifyRequest<{
7-
Querystring: { userId: string; days?: number }
7+
Querystring: { userId?: string; days?: number }
88
}>) => {
99
const { userId, days = 30 } = request.query;
1010

11-
if (!userId) {
12-
throw new Error('userId query parameter is required');
13-
}
11+
const userFilter = userId ? 'AND l.user_id = $1' : '';
12+
const userFilterWhere = userId ? 'WHERE l.user_id = $1' : '';
13+
const params = userId ? [userId] : [];
1414

1515
// Get total and unique clicks
1616
const clicksResult = await db.query(
@@ -19,8 +19,8 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
1919
COUNT(DISTINCT ip_address) as unique_clicks
2020
FROM click_events ce
2121
JOIN links l ON ce.link_id = l.id
22-
WHERE l.user_id = $1 AND ce.clicked_at >= NOW() - INTERVAL '${days} days'`,
23-
[userId]
22+
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter}`,
23+
params
2424
);
2525

2626
// Get clicks by date
@@ -30,10 +30,10 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
3030
COUNT(*) as clicks
3131
FROM click_events ce
3232
JOIN links l ON ce.link_id = l.id
33-
WHERE l.user_id = $1 AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
33+
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter}
3434
GROUP BY DATE(ce.clicked_at)
3535
ORDER BY date`,
36-
[userId]
36+
params
3737
);
3838

3939
// Get clicks by country
@@ -44,10 +44,10 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
4444
COUNT(*) as clicks
4545
FROM click_events ce
4646
JOIN links l ON ce.link_id = l.id
47-
WHERE l.user_id = $1 AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
47+
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter}
4848
GROUP BY ce.country_code, ce.country_name
4949
ORDER BY clicks DESC`,
50-
[userId]
50+
params
5151
);
5252

5353
// Get clicks by device
@@ -57,10 +57,10 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
5757
COUNT(*) as clicks
5858
FROM click_events ce
5959
JOIN links l ON ce.link_id = l.id
60-
WHERE l.user_id = $1 AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
60+
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter}
6161
GROUP BY ce.device_type
6262
ORDER BY clicks DESC`,
63-
[userId]
63+
params
6464
);
6565

6666
// Get clicks by platform
@@ -70,10 +70,10 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
7070
COUNT(*) as clicks
7171
FROM click_events ce
7272
JOIN links l ON ce.link_id = l.id
73-
WHERE l.user_id = $1 AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
73+
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter}
7474
GROUP BY ce.platform
7575
ORDER BY clicks DESC`,
76-
[userId]
76+
params
7777
);
7878

7979
// Get top performing links
@@ -88,11 +88,11 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
8888
FROM links l
8989
LEFT JOIN click_events ce ON l.id = ce.link_id
9090
AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
91-
WHERE l.user_id = $1
91+
${userFilterWhere}
9292
GROUP BY l.id
9393
ORDER BY total_clicks DESC
9494
LIMIT 10`,
95-
[userId]
95+
params
9696
);
9797

9898
return {
@@ -129,21 +129,25 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
129129
// Get link-specific analytics
130130
fastify.get('/api/analytics/links/:linkId', async (request: FastifyRequest<{
131131
Params: { linkId: string };
132-
Querystring: { userId: string; days?: number };
132+
Querystring: { userId?: string; days?: number };
133133
}>) => {
134134
const { linkId } = request.params;
135135
const { userId, days = 30 } = request.query;
136136

137-
if (!userId) {
138-
throw new Error('userId query parameter is required');
137+
// Verify link exists (and ownership if userId provided)
138+
let linkResult;
139+
if (userId) {
140+
linkResult = await db.query(
141+
'SELECT id FROM links WHERE id = $1 AND user_id = $2',
142+
[linkId, userId]
143+
);
144+
} else {
145+
linkResult = await db.query(
146+
'SELECT id FROM links WHERE id = $1',
147+
[linkId]
148+
);
139149
}
140150

141-
// Verify link ownership
142-
const linkResult = await db.query(
143-
'SELECT id FROM links WHERE id = $1 AND user_id = $2',
144-
[linkId, userId]
145-
);
146-
147151
if (linkResult.rows.length === 0) {
148152
throw new Error('Link not found');
149153
}

src/routes/debug.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { subscribeToClickEvents, ClickEventData } from '../lib/event-emitter.js'
99
*/
1010
const simulateRequestSchema = z.object({
1111
linkId: z.string().uuid(),
12-
userId: z.string().uuid(),
12+
userId: z.string().uuid().optional(),
1313
deviceType: z.enum(['ios', 'android', 'web']).optional(),
1414
userAgent: z.string().optional(),
1515
country: z.string().length(2).optional(), // ISO country code
@@ -33,10 +33,18 @@ export async function debugRoutes(fastify: FastifyInstance) {
3333
const data = simulateRequestSchema.parse(request.body);
3434

3535
// Fetch the link
36-
const linkResult = await db.query(
37-
`SELECT * FROM links WHERE id = $1 AND user_id = $2`,
38-
[data.linkId, data.userId]
39-
);
36+
let linkResult;
37+
if (data.userId) {
38+
linkResult = await db.query(
39+
`SELECT * FROM links WHERE id = $1 AND user_id = $2`,
40+
[data.linkId, data.userId]
41+
);
42+
} else {
43+
linkResult = await db.query(
44+
`SELECT * FROM links WHERE id = $1`,
45+
[data.linkId]
46+
);
47+
}
4048

4149
if (linkResult.rows.length === 0) {
4250
throw new Error('Link not found');
@@ -299,37 +307,26 @@ export async function debugRoutes(fastify: FastifyInstance) {
299307
'/api/debug/live',
300308
{ websocket: true },
301309
(connection: any, request: FastifyRequest<{
302-
Querystring: { userId: string; linkId?: string };
310+
Querystring: { userId?: string; linkId?: string };
303311
}>) => {
304312
const { userId, linkId } = request.query;
305313

306-
if (!userId) {
307-
connection.socket.send(
308-
JSON.stringify({
309-
type: 'error',
310-
message: 'userId query parameter is required',
311-
})
312-
);
313-
connection.socket.close();
314-
return;
315-
}
316-
317314
// Send welcome message
318315
connection.socket.send(
319316
JSON.stringify({
320317
type: 'connected',
321318
message: 'Connected to live request inspector',
322319
filters: {
323-
userId,
320+
userId: userId || 'all',
324321
linkId: linkId || 'all',
325322
},
326323
})
327324
);
328325

329326
// Subscribe to click events
330327
const unsubscribe = subscribeToClickEvents((eventData: ClickEventData) => {
331-
// Filter by userId
332-
if (eventData.userId !== userId) {
328+
// Filter by userId if provided
329+
if (userId && eventData.userId !== userId) {
333330
return;
334331
}
335332

0 commit comments

Comments
 (0)