@linkforty/core 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/database.d.ts.map +1 -1
- package/dist/lib/database.js +79 -1
- package/dist/lib/database.js.map +1 -1
- package/dist/lib/link-safety.d.ts +95 -0
- package/dist/lib/link-safety.d.ts.map +1 -0
- package/dist/lib/link-safety.js +158 -0
- package/dist/lib/link-safety.js.map +1 -0
- package/dist/lib/link-safety.test.d.ts +2 -0
- package/dist/lib/link-safety.test.d.ts.map +1 -0
- package/dist/lib/link-safety.test.js +117 -0
- package/dist/lib/link-safety.test.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +56 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/health.test.d.ts +2 -0
- package/dist/routes/health.test.d.ts.map +1 -0
- package/dist/routes/health.test.js +82 -0
- package/dist/routes/health.test.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/redirect.d.ts +8 -1
- package/dist/routes/redirect.d.ts.map +1 -1
- package/dist/routes/redirect.js +66 -1
- package/dist/routes/redirect.js.map +1 -1
- package/dist/routes/redirect.safety.test.d.ts +2 -0
- package/dist/routes/redirect.safety.test.d.ts.map +1 -0
- package/dist/routes/redirect.safety.test.js +181 -0
- package/dist/routes/redirect.safety.test.js.map +1 -0
- package/dist/routes/sdk-cache-bypass.test.d.ts +2 -0
- package/dist/routes/sdk-cache-bypass.test.d.ts.map +1 -0
- package/dist/routes/sdk-cache-bypass.test.js +167 -0
- package/dist/routes/sdk-cache-bypass.test.js.map +1 -0
- package/dist/routes/sdk.d.ts.map +1 -1
- package/dist/routes/sdk.js +60 -4
- package/dist/routes/sdk.js.map +1 -1
- package/package.json +1 -3
- package/dist/scripts/backfill-attribution.d.ts +0 -2
- package/dist/scripts/backfill-attribution.d.ts.map +0 -1
- package/dist/scripts/backfill-attribution.js +0 -40
- package/dist/scripts/backfill-attribution.js.map +0 -1
- package/dist/scripts/backfill-bot-flags.d.ts +0 -2
- package/dist/scripts/backfill-bot-flags.d.ts.map +0 -1
- package/dist/scripts/backfill-bot-flags.js +0 -45
- package/dist/scripts/backfill-bot-flags.js.map +0 -1
package/README.md
CHANGED
|
@@ -271,6 +271,13 @@ GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redi
|
|
|
271
271
|
GET /api/sdk/v1/health # Health check
|
|
272
272
|
```
|
|
273
273
|
|
|
274
|
+
### Health
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
GET /health # Liveness — process is up (no DB access)
|
|
278
|
+
GET /health/ready # Readiness — 503 if the database is unreachable
|
|
279
|
+
```
|
|
280
|
+
|
|
274
281
|
### Debug & Testing
|
|
275
282
|
|
|
276
283
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export * from './lib/client-ip.js';
|
|
|
30
30
|
export * from './lib/database.js';
|
|
31
31
|
export * from './lib/fingerprint.js';
|
|
32
32
|
export * from './lib/webhook.js';
|
|
33
|
+
export * from './lib/link-safety.js';
|
|
33
34
|
export * from './lib/event-emitter.js';
|
|
34
35
|
export * from './types/index.js';
|
|
35
|
-
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
|
|
36
|
+
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
|
|
36
37
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAWxE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oJAAoJ;IACpJ,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTAiC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { webhookRoutes } from './routes/webhooks.js';
|
|
|
10
10
|
import { templateRoutes } from './routes/templates.js';
|
|
11
11
|
import { qrRoutes } from './routes/qr.js';
|
|
12
12
|
import { wellKnownRoutes } from './routes/well-known.js';
|
|
13
|
+
import { healthRoutes } from './routes/health.js';
|
|
13
14
|
/**
|
|
14
15
|
* Create and configure a LinkForty Fastify server instance.
|
|
15
16
|
*
|
|
@@ -37,6 +38,7 @@ export async function createServer(options = {}) {
|
|
|
37
38
|
// Database
|
|
38
39
|
await initializeDatabase(options.database);
|
|
39
40
|
// Routes
|
|
41
|
+
await fastify.register(healthRoutes);
|
|
40
42
|
await fastify.register(wellKnownRoutes);
|
|
41
43
|
await fastify.register(redirectRoutes);
|
|
42
44
|
await fastify.register(linkRoutes);
|
|
@@ -53,7 +55,8 @@ export * from './lib/client-ip.js';
|
|
|
53
55
|
export * from './lib/database.js';
|
|
54
56
|
export * from './lib/fingerprint.js';
|
|
55
57
|
export * from './lib/webhook.js';
|
|
58
|
+
export * from './lib/link-safety.js';
|
|
56
59
|
export * from './lib/event-emitter.js';
|
|
57
60
|
export * from './types/index.js';
|
|
58
|
-
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
|
|
61
|
+
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, healthRoutes } from './routes/index.js';
|
|
59
62
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAkBlD;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,UAAyB,EAAE;IAC5D,MAAM,OAAO,GAAG,OAAO,CAAC;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;QAC5D,UAAU,EAAE,OAAO,CAAC,UAAU;KAC/B,CAAC,CAAC;IAEH,OAAO;IACP,MAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE;QAC3B,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG;KACpC,CAAC,CAAC;IAEH,mBAAmB;IACnB,IAAI,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE;YAC5B,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;SACvB,CAAC,CAAC;IACL,CAAC;IAED,WAAW;IACX,MAAM,kBAAkB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,SAAS;IACT,MAAM,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACrC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;IACtC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAEjC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,gCAAgC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAIpB,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE;QACL,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,eAAO,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;AA6BvB,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAIpB,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE;QACL,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,eAAO,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;AA6BvB,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,eAAoB,iBA6jBrE"}
|
package/dist/lib/database.js
CHANGED
|
@@ -38,6 +38,28 @@ export async function initializeDatabase(options = {}) {
|
|
|
38
38
|
});
|
|
39
39
|
const client = await connectWithRetry();
|
|
40
40
|
try {
|
|
41
|
+
// Organizations table (must be created before links, which references it).
|
|
42
|
+
//
|
|
43
|
+
// The redirect path LEFT JOINs this table to read `settings.appConfig`, which
|
|
44
|
+
// is the last link in the iOS/Android/web URL fallback chain (link → template
|
|
45
|
+
// → organization). Core therefore *depends* on the table existing even though
|
|
46
|
+
// richer deployments own the real one: without it every redirect fails with
|
|
47
|
+
// `relation "organizations" does not exist` (issue #35).
|
|
48
|
+
//
|
|
49
|
+
// Deliberately minimal — id and settings are all the redirect reads, plus
|
|
50
|
+
// suspended_at for the owner-restriction gate. CREATE TABLE IF NOT EXISTS is a
|
|
51
|
+
// no-op against a deployment that already ships a fuller organizations table,
|
|
52
|
+
// so this cannot clobber one.
|
|
53
|
+
await client.query(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS organizations (
|
|
55
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
56
|
+
name VARCHAR(255),
|
|
57
|
+
settings JSONB DEFAULT '{}',
|
|
58
|
+
suspended_at TIMESTAMP,
|
|
59
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
60
|
+
updated_at TIMESTAMP DEFAULT NOW()
|
|
61
|
+
)
|
|
62
|
+
`);
|
|
41
63
|
// Link templates table (must be created before links, which references it)
|
|
42
64
|
await client.query(`
|
|
43
65
|
CREATE TABLE IF NOT EXISTS link_templates (
|
|
@@ -169,6 +191,23 @@ export async function initializeDatabase(options = {}) {
|
|
|
169
191
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
170
192
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
171
193
|
)
|
|
194
|
+
`);
|
|
195
|
+
// Add organization_id column to links table.
|
|
196
|
+
//
|
|
197
|
+
// The redirect join is `ON l.organization_id = o.id`, so the column is as
|
|
198
|
+
// load-bearing as the table itself. Nullable and unset by default: a core
|
|
199
|
+
// deployment that never populates it simply gets NULL org_settings and the
|
|
200
|
+
// fallback chain stops at the template level, exactly as before.
|
|
201
|
+
await client.query(`
|
|
202
|
+
DO $$
|
|
203
|
+
BEGIN
|
|
204
|
+
IF NOT EXISTS (
|
|
205
|
+
SELECT 1 FROM information_schema.columns
|
|
206
|
+
WHERE table_name='links' AND column_name='organization_id'
|
|
207
|
+
) THEN
|
|
208
|
+
ALTER TABLE links ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL;
|
|
209
|
+
END IF;
|
|
210
|
+
END $$;
|
|
172
211
|
`);
|
|
173
212
|
// Add template_id column to links table
|
|
174
213
|
await client.query(`
|
|
@@ -181,6 +220,44 @@ export async function initializeDatabase(options = {}) {
|
|
|
181
220
|
ALTER TABLE links ADD COLUMN template_id UUID REFERENCES link_templates(id) ON DELETE SET NULL;
|
|
182
221
|
END IF;
|
|
183
222
|
END $$;
|
|
223
|
+
`);
|
|
224
|
+
// Link safety states.
|
|
225
|
+
//
|
|
226
|
+
// `is_active = false` already removes a link from resolution entirely. These
|
|
227
|
+
// add a softer state and an explanation:
|
|
228
|
+
//
|
|
229
|
+
// warn_at — resolve to an interstitial warning instead of a 302, so a
|
|
230
|
+
// visitor sees the destination and chooses. Useful when a
|
|
231
|
+
// link is suspected rather than confirmed unsafe, since a
|
|
232
|
+
// hard block on a false positive takes a legitimate link
|
|
233
|
+
// offline with no recourse for the visitor.
|
|
234
|
+
// disabled_at — when the link was taken out of resolution.
|
|
235
|
+
// disabled_reason — why, so the decision can be explained or reversed later.
|
|
236
|
+
//
|
|
237
|
+
// All nullable and additive: an existing deployment that sets none of them
|
|
238
|
+
// behaves exactly as before.
|
|
239
|
+
await client.query(`
|
|
240
|
+
DO $$
|
|
241
|
+
BEGIN
|
|
242
|
+
IF NOT EXISTS (
|
|
243
|
+
SELECT 1 FROM information_schema.columns
|
|
244
|
+
WHERE table_name='links' AND column_name='warn_at'
|
|
245
|
+
) THEN
|
|
246
|
+
ALTER TABLE links ADD COLUMN warn_at TIMESTAMPTZ;
|
|
247
|
+
END IF;
|
|
248
|
+
IF NOT EXISTS (
|
|
249
|
+
SELECT 1 FROM information_schema.columns
|
|
250
|
+
WHERE table_name='links' AND column_name='disabled_at'
|
|
251
|
+
) THEN
|
|
252
|
+
ALTER TABLE links ADD COLUMN disabled_at TIMESTAMPTZ;
|
|
253
|
+
END IF;
|
|
254
|
+
IF NOT EXISTS (
|
|
255
|
+
SELECT 1 FROM information_schema.columns
|
|
256
|
+
WHERE table_name='links' AND column_name='disabled_reason'
|
|
257
|
+
) THEN
|
|
258
|
+
ALTER TABLE links ADD COLUMN disabled_reason TEXT;
|
|
259
|
+
END IF;
|
|
260
|
+
END $$;
|
|
184
261
|
`);
|
|
185
262
|
// Add description column to existing links table if it doesn't exist
|
|
186
263
|
await client.query(`
|
|
@@ -361,7 +438,7 @@ export async function initializeDatabase(options = {}) {
|
|
|
361
438
|
// Bot classification columns on click_events (SIT-298). Classified at
|
|
362
439
|
// ingestion (see lib/bot-detection.ts) and persisted so every consumer reads
|
|
363
440
|
// one consistent flag; analytics excludes is_bot rows. Backward compatible:
|
|
364
|
-
// legacy rows default to is_bot=false
|
|
441
|
+
// legacy rows default to is_bot=false and age out of the retention window.
|
|
365
442
|
await client.query(`
|
|
366
443
|
DO $$
|
|
367
444
|
BEGIN
|
|
@@ -456,6 +533,7 @@ export async function initializeDatabase(options = {}) {
|
|
|
456
533
|
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_link_templates_slug ON link_templates(slug)');
|
|
457
534
|
await client.query('CREATE INDEX IF NOT EXISTS idx_link_templates_user_id ON link_templates(user_id)');
|
|
458
535
|
await client.query('CREATE INDEX IF NOT EXISTS idx_links_template_id ON links(template_id)');
|
|
536
|
+
await client.query('CREATE INDEX IF NOT EXISTS idx_links_organization_id ON links(organization_id)');
|
|
459
537
|
// Indexes for webhooks
|
|
460
538
|
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id)');
|
|
461
539
|
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(is_active) WHERE is_active = true');
|
package/dist/lib/database.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link safety states for the redirect path.
|
|
3
|
+
*
|
|
4
|
+
* Three outcomes are possible for a link that exists:
|
|
5
|
+
*
|
|
6
|
+
* - `allow` — resolve normally.
|
|
7
|
+
* - `warn` — serve an interstitial that shows the destination and requires an
|
|
8
|
+
* explicit click. For a link that is *suspected* unsafe rather than
|
|
9
|
+
* confirmed, this is strictly better than a hard block: a false
|
|
10
|
+
* positive still lets the visitor through, while a true positive
|
|
11
|
+
* still breaks the one-click flow a malicious link depends on.
|
|
12
|
+
* - `block` — behave as if the link does not exist.
|
|
13
|
+
*
|
|
14
|
+
* `block` deliberately produces the same response as an unknown short code. A
|
|
15
|
+
* distinct "this link was disabled" response would confirm to whoever is probing
|
|
16
|
+
* that the code was real, and would leak that its owner is under a restriction.
|
|
17
|
+
*/
|
|
18
|
+
export type LinkSafetyOutcome = 'allow' | 'warn' | 'block';
|
|
19
|
+
export interface LinkSafetyInput {
|
|
20
|
+
/** Whether the link is active. Absent/undefined is treated as active. */
|
|
21
|
+
isActive?: boolean | null;
|
|
22
|
+
/** Set when the link should serve a warning instead of resolving. */
|
|
23
|
+
warnAt?: Date | string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Set when the link's owner is restricted. Optional because the owning table is
|
|
26
|
+
* not part of this package's schema — deployments that do not model owner
|
|
27
|
+
* restriction simply never pass it.
|
|
28
|
+
*/
|
|
29
|
+
ownerSuspendedAt?: Date | string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decide what to do with a link that was found.
|
|
33
|
+
*
|
|
34
|
+
* Order matters: owner restriction and inactivity both beat `warn`. A link whose
|
|
35
|
+
* owner is restricted must be unreachable even if it was only flagged to warn.
|
|
36
|
+
*/
|
|
37
|
+
export declare function evaluateLinkSafety(input: LinkSafetyInput): LinkSafetyOutcome;
|
|
38
|
+
/**
|
|
39
|
+
* A destination is only safe to put in an `href` if it is http(s).
|
|
40
|
+
*
|
|
41
|
+
* `escapeHtml` is not sufficient on its own: a URL scheme contains none of the
|
|
42
|
+
* characters it neutralises, so `javascript:alert(1)` passes through untouched and
|
|
43
|
+
* becomes executable on click. That matters here more than almost anywhere else —
|
|
44
|
+
* this page is shown *only* for links already flagged as suspicious, so the
|
|
45
|
+
* destinations reaching it are the most likely in the system to be hostile.
|
|
46
|
+
*
|
|
47
|
+
* Reachable in practice, not just in theory: zod's `.url()` accepts `javascript:`
|
|
48
|
+
* and `data:` URLs, so a stored destination can already hold one. Tightening
|
|
49
|
+
* validation at write time is worth doing separately, but this page must not
|
|
50
|
+
* depend on that having happened.
|
|
51
|
+
*
|
|
52
|
+
* Returns null when there is nothing safe to link to — including a bare deep-link
|
|
53
|
+
* path, which would otherwise render as a relative link to the redirect host
|
|
54
|
+
* rather than the real destination. Callers render that as inert text.
|
|
55
|
+
*/
|
|
56
|
+
export declare function safeHref(destination: string): string | null;
|
|
57
|
+
/** Minimal HTML escaping for interpolating a URL into markup and an href. */
|
|
58
|
+
export declare function escapeHtml(value: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Interstitial shown for a `warn` outcome.
|
|
61
|
+
*
|
|
62
|
+
* Shows the destination in full — the point is to hand back the information the
|
|
63
|
+
* short link hid, so the visitor can judge for themselves. Continuing takes a
|
|
64
|
+
* deliberate click, which is what breaks the one-click flow.
|
|
65
|
+
*
|
|
66
|
+
* Contains no JavaScript and no external assets: it must render on a bare
|
|
67
|
+
* redirect host and under a strict content-security policy.
|
|
68
|
+
*/
|
|
69
|
+
export declare function generateWarningLinkHTML(destination: string, options?: {
|
|
70
|
+
reportUrl?: string;
|
|
71
|
+
}): string;
|
|
72
|
+
/**
|
|
73
|
+
* Build a memoised probe for owner-restriction support.
|
|
74
|
+
*
|
|
75
|
+
* A factory rather than module state: each registration gets its own, so two servers
|
|
76
|
+
* in one process pointed at different databases cannot share an answer, and no
|
|
77
|
+
* test-only reset has to be exported.
|
|
78
|
+
*
|
|
79
|
+
* Shared by every path that resolves a link and caches the result, and that sharing
|
|
80
|
+
* is the whole point. The redirect and the SDK resolve endpoint write the SAME Redis
|
|
81
|
+
* key, so if one selects `owner_suspended_at` and the other does not, the second
|
|
82
|
+
* silently caches a row that makes the first one's gate pass. Keeping the SELECT
|
|
83
|
+
* fragment in one place is what stops them drifting apart again.
|
|
84
|
+
*
|
|
85
|
+
* The in-flight promise is memoised, not just the result, so concurrent cold requests
|
|
86
|
+
* issue one probe. A failure resolves to "unsupported", so a probe error can never
|
|
87
|
+
* take a resolution path down.
|
|
88
|
+
*/
|
|
89
|
+
export declare function createOwnerSuspensionSelect(deps: {
|
|
90
|
+
query: (sql: string) => Promise<{
|
|
91
|
+
rows: unknown[];
|
|
92
|
+
}>;
|
|
93
|
+
onSupported?: () => void;
|
|
94
|
+
}): () => Promise<string>;
|
|
95
|
+
//# sourceMappingURL=link-safety.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-safety.d.ts","sourceRoot":"","sources":["../../src/lib/link-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1B,qEAAqE;IACrE,MAAM,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,GAAG,iBAAiB,CAK5E;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ3D;AAED,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,MAAM,CA4DR;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE;IAChD,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC,CAAC;IACrD,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;CAC1B,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAkBxB"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide what to do with a link that was found.
|
|
3
|
+
*
|
|
4
|
+
* Order matters: owner restriction and inactivity both beat `warn`. A link whose
|
|
5
|
+
* owner is restricted must be unreachable even if it was only flagged to warn.
|
|
6
|
+
*/
|
|
7
|
+
export function evaluateLinkSafety(input) {
|
|
8
|
+
if (input.ownerSuspendedAt != null)
|
|
9
|
+
return 'block';
|
|
10
|
+
if (input.isActive === false)
|
|
11
|
+
return 'block';
|
|
12
|
+
if (input.warnAt != null)
|
|
13
|
+
return 'warn';
|
|
14
|
+
return 'allow';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A destination is only safe to put in an `href` if it is http(s).
|
|
18
|
+
*
|
|
19
|
+
* `escapeHtml` is not sufficient on its own: a URL scheme contains none of the
|
|
20
|
+
* characters it neutralises, so `javascript:alert(1)` passes through untouched and
|
|
21
|
+
* becomes executable on click. That matters here more than almost anywhere else —
|
|
22
|
+
* this page is shown *only* for links already flagged as suspicious, so the
|
|
23
|
+
* destinations reaching it are the most likely in the system to be hostile.
|
|
24
|
+
*
|
|
25
|
+
* Reachable in practice, not just in theory: zod's `.url()` accepts `javascript:`
|
|
26
|
+
* and `data:` URLs, so a stored destination can already hold one. Tightening
|
|
27
|
+
* validation at write time is worth doing separately, but this page must not
|
|
28
|
+
* depend on that having happened.
|
|
29
|
+
*
|
|
30
|
+
* Returns null when there is nothing safe to link to — including a bare deep-link
|
|
31
|
+
* path, which would otherwise render as a relative link to the redirect host
|
|
32
|
+
* rather than the real destination. Callers render that as inert text.
|
|
33
|
+
*/
|
|
34
|
+
export function safeHref(destination) {
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(destination);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? destination : null;
|
|
43
|
+
}
|
|
44
|
+
/** Minimal HTML escaping for interpolating a URL into markup and an href. */
|
|
45
|
+
export function escapeHtml(value) {
|
|
46
|
+
return String(value)
|
|
47
|
+
.replace(/&/g, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"')
|
|
51
|
+
.replace(/'/g, ''');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Interstitial shown for a `warn` outcome.
|
|
55
|
+
*
|
|
56
|
+
* Shows the destination in full — the point is to hand back the information the
|
|
57
|
+
* short link hid, so the visitor can judge for themselves. Continuing takes a
|
|
58
|
+
* deliberate click, which is what breaks the one-click flow.
|
|
59
|
+
*
|
|
60
|
+
* Contains no JavaScript and no external assets: it must render on a bare
|
|
61
|
+
* redirect host and under a strict content-security policy.
|
|
62
|
+
*/
|
|
63
|
+
export function generateWarningLinkHTML(destination, options = {}) {
|
|
64
|
+
const shown = destination && destination.trim() ? escapeHtml(destination) : '(no destination recorded)';
|
|
65
|
+
const href = destination ? safeHref(destination) : null;
|
|
66
|
+
// No anchor at all when there is nothing safe to link to. An href="" would
|
|
67
|
+
// re-request the warning page, so "Continue anyway" would just reload it.
|
|
68
|
+
const continueButton = href
|
|
69
|
+
? `<a class="go" href="${escapeHtml(href)}" rel="nofollow noopener noreferrer">Continue anyway</a>`
|
|
70
|
+
: `<p class="inert">This link has no usable web destination, so there is nothing to continue to.</p>`;
|
|
71
|
+
const reportLink = options.reportUrl
|
|
72
|
+
? `<p class="report"><a href="${escapeHtml(options.reportUrl)}" rel="nofollow noopener">Report this link</a></p>`
|
|
73
|
+
: '';
|
|
74
|
+
return `<!doctype html>
|
|
75
|
+
<html lang="en">
|
|
76
|
+
<head>
|
|
77
|
+
<meta charset="utf-8">
|
|
78
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
79
|
+
<meta name="robots" content="noindex,nofollow">
|
|
80
|
+
<title>Check this link before continuing</title>
|
|
81
|
+
<style>
|
|
82
|
+
:root { color-scheme: light dark; }
|
|
83
|
+
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
|
84
|
+
background:#f6f7f8; color:#16191d; padding:24px;
|
|
85
|
+
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
|
|
86
|
+
.card { max-width:34rem; width:100%; background:#fff; border:1px solid #e3e6ea;
|
|
87
|
+
border-radius:10px; padding:28px; }
|
|
88
|
+
h1 { margin:0 0 12px; font-size:1.35rem; line-height:1.25; }
|
|
89
|
+
p { margin:0 0 14px; }
|
|
90
|
+
.dest { display:block; word-break:break-all; background:#f0f2f4; border-radius:6px;
|
|
91
|
+
padding:10px 12px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
92
|
+
font-size:.9rem; margin-bottom:18px; }
|
|
93
|
+
a.go { display:inline-block; text-decoration:none; border:1px solid #c9ced4; color:#16191d;
|
|
94
|
+
border-radius:6px; padding:9px 15px; font-size:.95rem; font-weight:600; }
|
|
95
|
+
.inert { margin:0; font-size:.9rem; color:#5b636d; }
|
|
96
|
+
.report { margin:16px 0 0; font-size:.85rem; }
|
|
97
|
+
.report a { color:#5b636d; }
|
|
98
|
+
@media (prefers-color-scheme: dark) {
|
|
99
|
+
body { background:#14171a; color:#e8eaed; }
|
|
100
|
+
.card { background:#1d2126; border-color:#2c3238; }
|
|
101
|
+
.dest { background:#14171a; }
|
|
102
|
+
a.go { border-color:#3a4149; color:#e8eaed; }
|
|
103
|
+
.report a { color:#98a1ab; }
|
|
104
|
+
.inert { color:#98a1ab; }
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
<main class="card">
|
|
110
|
+
<h1>Check this link before continuing</h1>
|
|
111
|
+
<p>This short link has been flagged as possibly unsafe, so we have not sent you
|
|
112
|
+
straight there. It leads to:</p>
|
|
113
|
+
<span class="dest">${shown}</span>
|
|
114
|
+
<p>If you were not expecting this link, or it claims to be from a bank, a government
|
|
115
|
+
service, or a company you do business with, close this page. Do not enter any
|
|
116
|
+
password or personal details.</p>
|
|
117
|
+
${continueButton}
|
|
118
|
+
${reportLink}
|
|
119
|
+
</main>
|
|
120
|
+
</body>
|
|
121
|
+
</html>`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a memoised probe for owner-restriction support.
|
|
125
|
+
*
|
|
126
|
+
* A factory rather than module state: each registration gets its own, so two servers
|
|
127
|
+
* in one process pointed at different databases cannot share an answer, and no
|
|
128
|
+
* test-only reset has to be exported.
|
|
129
|
+
*
|
|
130
|
+
* Shared by every path that resolves a link and caches the result, and that sharing
|
|
131
|
+
* is the whole point. The redirect and the SDK resolve endpoint write the SAME Redis
|
|
132
|
+
* key, so if one selects `owner_suspended_at` and the other does not, the second
|
|
133
|
+
* silently caches a row that makes the first one's gate pass. Keeping the SELECT
|
|
134
|
+
* fragment in one place is what stops them drifting apart again.
|
|
135
|
+
*
|
|
136
|
+
* The in-flight promise is memoised, not just the result, so concurrent cold requests
|
|
137
|
+
* issue one probe. A failure resolves to "unsupported", so a probe error can never
|
|
138
|
+
* take a resolution path down.
|
|
139
|
+
*/
|
|
140
|
+
export function createOwnerSuspensionSelect(deps) {
|
|
141
|
+
let probe = null;
|
|
142
|
+
return () => {
|
|
143
|
+
if (!probe) {
|
|
144
|
+
probe = deps
|
|
145
|
+
.query(`SELECT 1 FROM information_schema.columns
|
|
146
|
+
WHERE table_name = 'organizations' AND column_name = 'suspended_at'`)
|
|
147
|
+
.then((r) => {
|
|
148
|
+
const supported = r.rows.length > 0;
|
|
149
|
+
if (supported)
|
|
150
|
+
deps.onSupported?.();
|
|
151
|
+
return supported ? ', o.suspended_at AS owner_suspended_at' : '';
|
|
152
|
+
})
|
|
153
|
+
.catch(() => '');
|
|
154
|
+
}
|
|
155
|
+
return probe;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=link-safety.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-safety.js","sourceRoot":"","sources":["../../src/lib/link-safety.ts"],"names":[],"mappings":"AAgCA;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAsB;IACvD,IAAI,KAAK,CAAC,gBAAgB,IAAI,IAAI;QAAE,OAAO,OAAO,CAAC;IACnD,IAAI,KAAK,CAAC,QAAQ,KAAK,KAAK;QAAE,OAAO,OAAO,CAAC;IAC7C,IAAI,KAAK,CAAC,MAAM,IAAI,IAAI;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,QAAQ,CAAC,WAAmB;IAC1C,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC;AACpF,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,MAAM,CAAC,KAAK,CAAC;SACjB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AAC5B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB,CACrC,WAAmB,EACnB,UAAkC,EAAE;IAEpC,MAAM,KAAK,GAAG,WAAW,IAAI,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,2BAA2B,CAAC;IACxG,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACxD,2EAA2E;IAC3E,0EAA0E;IAC1E,MAAM,cAAc,GAAG,IAAI;QACzB,CAAC,CAAC,uBAAuB,UAAU,CAAC,IAAI,CAAC,0DAA0D;QACnG,CAAC,CAAC,mGAAmG,CAAC;IACxG,MAAM,UAAU,GAAG,OAAO,CAAC,SAAS;QAClC,CAAC,CAAC,8BAA8B,UAAU,CAAC,OAAO,CAAC,SAAS,CAAC,oDAAoD;QACjH,CAAC,CAAC,EAAE,CAAC;IAEP,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yBAuCgB,KAAK;;;;MAIxB,cAAc;MACd,UAAU;;;QAGR,CAAC;AACT,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,2BAA2B,CAAC,IAG3C;IACC,IAAI,KAAK,GAA2B,IAAI,CAAC;IACzC,OAAO,GAAG,EAAE;QACV,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,KAAK,GAAG,IAAI;iBACT,KAAK,CACJ;+EACqE,CACtE;iBACA,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;gBACV,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;gBACpC,IAAI,SAAS;oBAAE,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;gBACpC,OAAO,SAAS,CAAC,CAAC,CAAC,wCAAwC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnE,CAAC,CAAC;iBACD,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-safety.test.d.ts","sourceRoot":"","sources":["../../src/lib/link-safety.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluateLinkSafety, generateWarningLinkHTML, escapeHtml, safeHref } from './link-safety.js';
|
|
3
|
+
describe('evaluateLinkSafety', () => {
|
|
4
|
+
it('allows a plain healthy link', () => {
|
|
5
|
+
expect(evaluateLinkSafety({ isActive: true })).toBe('allow');
|
|
6
|
+
expect(evaluateLinkSafety({})).toBe('allow');
|
|
7
|
+
});
|
|
8
|
+
it('treats an absent isActive as active, so existing rows are unaffected', () => {
|
|
9
|
+
expect(evaluateLinkSafety({ isActive: undefined })).toBe('allow');
|
|
10
|
+
expect(evaluateLinkSafety({ isActive: null })).toBe('allow');
|
|
11
|
+
});
|
|
12
|
+
it('blocks an inactive link', () => {
|
|
13
|
+
expect(evaluateLinkSafety({ isActive: false })).toBe('block');
|
|
14
|
+
});
|
|
15
|
+
it('warns when warn_at is set', () => {
|
|
16
|
+
expect(evaluateLinkSafety({ isActive: true, warnAt: new Date() })).toBe('warn');
|
|
17
|
+
expect(evaluateLinkSafety({ isActive: true, warnAt: '2026-08-10T00:00:00Z' })).toBe('warn');
|
|
18
|
+
});
|
|
19
|
+
it('blocks when the owner is restricted, even if the link itself is fine', () => {
|
|
20
|
+
expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: new Date() })).toBe('block');
|
|
21
|
+
});
|
|
22
|
+
it('prefers block over warn — a restricted owner outranks a mere warning', () => {
|
|
23
|
+
expect(evaluateLinkSafety({ isActive: true, warnAt: new Date(), ownerSuspendedAt: new Date() })).toBe('block');
|
|
24
|
+
});
|
|
25
|
+
it('ignores owner restriction when it is not modelled at all', () => {
|
|
26
|
+
// Deployments without an owner table never pass the field.
|
|
27
|
+
expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: null })).toBe('allow');
|
|
28
|
+
expect(evaluateLinkSafety({ isActive: true, ownerSuspendedAt: undefined })).toBe('allow');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('escapeHtml', () => {
|
|
32
|
+
it('neutralises markup and quote characters', () => {
|
|
33
|
+
expect(escapeHtml('<script>alert("x")</script>')).toBe('<script>alert("x")</script>');
|
|
34
|
+
expect(escapeHtml("it's & more")).toBe('it's & more');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('generateWarningLinkHTML', () => {
|
|
38
|
+
it('shows the destination so the visitor can judge it', () => {
|
|
39
|
+
const html = generateWarningLinkHTML('https://example.com/login');
|
|
40
|
+
expect(html).toContain('https://example.com/login');
|
|
41
|
+
expect(html).toContain('Check this link before continuing');
|
|
42
|
+
});
|
|
43
|
+
it('escapes a hostile destination rather than injecting it', () => {
|
|
44
|
+
const html = generateWarningLinkHTML('https://evil.test/"><script>alert(1)</script>');
|
|
45
|
+
expect(html).not.toContain('<script>alert(1)</script>');
|
|
46
|
+
expect(html).toContain('<script>');
|
|
47
|
+
});
|
|
48
|
+
it('asks search engines not to index it', () => {
|
|
49
|
+
expect(generateWarningLinkHTML('https://example.com')).toContain('noindex');
|
|
50
|
+
});
|
|
51
|
+
it('carries no JavaScript of its own, so it survives a strict CSP', () => {
|
|
52
|
+
const html = generateWarningLinkHTML('https://example.com');
|
|
53
|
+
expect(html).not.toContain('<script');
|
|
54
|
+
expect(html).not.toMatch(/\son[a-z]+=/i);
|
|
55
|
+
});
|
|
56
|
+
// The CSP case above used a benign destination, so it could never have failed for
|
|
57
|
+
// the reason it appeared to cover. These pass the hostile input instead.
|
|
58
|
+
describe('hostile destinations', () => {
|
|
59
|
+
it('never emits a javascript: href', () => {
|
|
60
|
+
const html = generateWarningLinkHTML('javascript:alert(document.domain)');
|
|
61
|
+
expect(html).not.toMatch(/href="javascript:/i);
|
|
62
|
+
});
|
|
63
|
+
it('never emits a data: href', () => {
|
|
64
|
+
const html = generateWarningLinkHTML('data:text/html,<script>alert(1)</script>');
|
|
65
|
+
expect(html).not.toMatch(/href="data:/i);
|
|
66
|
+
});
|
|
67
|
+
it('offers no continue button when the scheme is not http(s)', () => {
|
|
68
|
+
const html = generateWarningLinkHTML('javascript:alert(1)');
|
|
69
|
+
expect(html).not.toContain('class="go"');
|
|
70
|
+
expect(html).toContain('nothing to continue to');
|
|
71
|
+
});
|
|
72
|
+
it('still shows the destination as inert text so the visitor can see it', () => {
|
|
73
|
+
const html = generateWarningLinkHTML('javascript:alert(1)');
|
|
74
|
+
expect(html).toContain('javascript:alert(1)');
|
|
75
|
+
});
|
|
76
|
+
it('emits no empty href for a bare path or an absent destination', () => {
|
|
77
|
+
for (const d of ['/deep/link/path', '']) {
|
|
78
|
+
const html = generateWarningLinkHTML(d);
|
|
79
|
+
expect(html).not.toContain('href=""');
|
|
80
|
+
expect(html).not.toContain('class="go"');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it('still links a legitimate http(s) destination', () => {
|
|
84
|
+
const html = generateWarningLinkHTML('https://example.com/x');
|
|
85
|
+
expect(html).toContain('class="go"');
|
|
86
|
+
expect(html).toContain('href="https://example.com/x"');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it('marks the outbound link nofollow/noopener so we pass no reputation to it', () => {
|
|
90
|
+
const html = generateWarningLinkHTML('https://example.com');
|
|
91
|
+
expect(html).toContain('rel="nofollow noopener noreferrer"');
|
|
92
|
+
});
|
|
93
|
+
it('includes a report link only when one is configured', () => {
|
|
94
|
+
expect(generateWarningLinkHTML('https://example.com')).not.toContain('Report this link');
|
|
95
|
+
const withReport = generateWarningLinkHTML('https://example.com', {
|
|
96
|
+
reportUrl: 'https://example.org/abuse',
|
|
97
|
+
});
|
|
98
|
+
expect(withReport).toContain('Report this link');
|
|
99
|
+
expect(withReport).toContain('https://example.org/abuse');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('safeHref', () => {
|
|
103
|
+
it('allows http and https', () => {
|
|
104
|
+
expect(safeHref('http://example.com')).toBe('http://example.com');
|
|
105
|
+
expect(safeHref('https://example.com/a?b=c')).toBe('https://example.com/a?b=c');
|
|
106
|
+
});
|
|
107
|
+
it('rejects every other scheme', () => {
|
|
108
|
+
for (const d of ['javascript:alert(1)', 'data:text/html,x', 'file:///etc/passwd', 'vbscript:x']) {
|
|
109
|
+
expect(safeHref(d), d).toBeNull();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it('rejects a relative path, which would point at the redirect host', () => {
|
|
113
|
+
expect(safeHref('/deep/link/path')).toBeNull();
|
|
114
|
+
expect(safeHref('')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
//# sourceMappingURL=link-safety.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-safety.test.js","sourceRoot":"","sources":["../../src/lib/link-safety.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAErG,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC7D,MAAM,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClE,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2BAA2B,EAAE,GAAG,EAAE;QACnC,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAChF,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9F,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7F,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CACJ,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,gBAAgB,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CACzF,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0DAA0D,EAAE,GAAG,EAAE;QAClE,2DAA2D;QAC3D,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACrF,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,gBAAgB,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC5F,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,YAAY,EAAE,GAAG,EAAE;IAC1B,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,UAAU,CAAC,6BAA6B,CAAC,CAAC,CAAC,IAAI,CACpD,mDAAmD,CACpD,CAAC;QACF,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,MAAM,IAAI,GAAG,uBAAuB,CAAC,2BAA2B,CAAC,CAAC;QAClE,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,2BAA2B,CAAC,CAAC;QACpD,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,mCAAmC,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,IAAI,GAAG,uBAAuB,CAAC,+CAA+C,CAAC,CAAC;QACtF,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,2BAA2B,CAAC,CAAC;QACxD,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C,MAAM,CAAC,uBAAuB,CAAC,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACvE,MAAM,IAAI,GAAG,uBAAuB,CAAC,qBAAqB,CAAC,CAAC;QAC5D,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;QACtC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,kFAAkF;IAClF,yEAAyE;IACzE,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;QACpC,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;YACxC,MAAM,IAAI,GAAG,uBAAuB,CAAC,mCAAmC,CAAC,CAAC;YAC1E,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC;QACjD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;YAClC,MAAM,IAAI,GAAG,uBAAuB,CAAC,0CAA0C,CAAC,CAAC;YACjF,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;QAC3C,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,0DAA0D,EAAE,GAAG,EAAE;YAClE,MAAM,IAAI,GAAG,uBAAuB,CAAC,qBAAqB,CAAC,CAAC;YAC5D,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;YACzC,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,wBAAwB,CAAC,CAAC;QACnD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,qEAAqE,EAAE,GAAG,EAAE;YAC7E,MAAM,IAAI,GAAG,uBAAuB,CAAC,qBAAqB,CAAC,CAAC;YAC5D,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,qBAAqB,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,8DAA8D,EAAE,GAAG,EAAE;YACtE,KAAK,MAAM,CAAC,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC,EAAE,CAAC;gBACxC,MAAM,IAAI,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAAC;gBACxC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;gBACtC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;YAC3C,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,8CAA8C,EAAE,GAAG,EAAE;YACtD,MAAM,IAAI,GAAG,uBAAuB,CAAC,uBAAuB,CAAC,CAAC;YAC9D,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;YACrC,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACzD,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0EAA0E,EAAE,GAAG,EAAE;QAClF,MAAM,IAAI,GAAG,uBAAuB,CAAC,qBAAqB,CAAC,CAAC;QAC5D,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,oCAAoC,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,CAAC,uBAAuB,CAAC,qBAAqB,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,kBAAkB,CAAC,CAAC;QACzF,MAAM,UAAU,GAAG,uBAAuB,CAAC,qBAAqB,EAAE;YAChE,SAAS,EAAE,2BAA2B;SACvC,CAAC,CAAC;QACH,MAAM,CAAC,UAAU,CAAC,CAAC,SAAS,CAAC,kBAAkB,CAAC,CAAC;QACjD,MAAM,CAAC,UAAU,CAAC,CAAC,SAAS,CAAC,2BAA2B,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAGH,QAAQ,CAAC,UAAU,EAAE,GAAG,EAAE;IACxB,EAAE,CAAC,uBAAuB,EAAE,GAAG,EAAE;QAC/B,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;QAClE,MAAM,CAAC,QAAQ,CAAC,2BAA2B,CAAC,CAAC,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAC;IAClF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;QACpC,KAAK,MAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,YAAY,CAAC,EAAE,CAAC;YAChG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;QACpC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;QAC/C,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAClC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|