@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.
Files changed (52) hide show
  1. package/README.md +7 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/database.d.ts.map +1 -1
  7. package/dist/lib/database.js +79 -1
  8. package/dist/lib/database.js.map +1 -1
  9. package/dist/lib/link-safety.d.ts +95 -0
  10. package/dist/lib/link-safety.d.ts.map +1 -0
  11. package/dist/lib/link-safety.js +158 -0
  12. package/dist/lib/link-safety.js.map +1 -0
  13. package/dist/lib/link-safety.test.d.ts +2 -0
  14. package/dist/lib/link-safety.test.d.ts.map +1 -0
  15. package/dist/lib/link-safety.test.js +117 -0
  16. package/dist/lib/link-safety.test.js.map +1 -0
  17. package/dist/routes/health.d.ts +24 -0
  18. package/dist/routes/health.d.ts.map +1 -0
  19. package/dist/routes/health.js +56 -0
  20. package/dist/routes/health.js.map +1 -0
  21. package/dist/routes/health.test.d.ts +2 -0
  22. package/dist/routes/health.test.d.ts.map +1 -0
  23. package/dist/routes/health.test.js +82 -0
  24. package/dist/routes/health.test.js.map +1 -0
  25. package/dist/routes/index.d.ts +1 -0
  26. package/dist/routes/index.d.ts.map +1 -1
  27. package/dist/routes/index.js +1 -0
  28. package/dist/routes/index.js.map +1 -1
  29. package/dist/routes/redirect.d.ts +8 -1
  30. package/dist/routes/redirect.d.ts.map +1 -1
  31. package/dist/routes/redirect.js +66 -1
  32. package/dist/routes/redirect.js.map +1 -1
  33. package/dist/routes/redirect.safety.test.d.ts +2 -0
  34. package/dist/routes/redirect.safety.test.d.ts.map +1 -0
  35. package/dist/routes/redirect.safety.test.js +181 -0
  36. package/dist/routes/redirect.safety.test.js.map +1 -0
  37. package/dist/routes/sdk-cache-bypass.test.d.ts +2 -0
  38. package/dist/routes/sdk-cache-bypass.test.d.ts.map +1 -0
  39. package/dist/routes/sdk-cache-bypass.test.js +167 -0
  40. package/dist/routes/sdk-cache-bypass.test.js.map +1 -0
  41. package/dist/routes/sdk.d.ts.map +1 -1
  42. package/dist/routes/sdk.js +60 -4
  43. package/dist/routes/sdk.js.map +1 -1
  44. package/package.json +1 -3
  45. package/dist/scripts/backfill-attribution.d.ts +0 -2
  46. package/dist/scripts/backfill-attribution.d.ts.map +0 -1
  47. package/dist/scripts/backfill-attribution.js +0 -40
  48. package/dist/scripts/backfill-attribution.js.map +0 -1
  49. package/dist/scripts/backfill-bot-flags.d.ts +0 -2
  50. package/dist/scripts/backfill-bot-flags.d.ts.map +0 -1
  51. package/dist/scripts/backfill-bot-flags.js +0 -45
  52. 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
@@ -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;AAUxE;;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,kTAgC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,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,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;AAkBzD;;;;;;;;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,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,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,MAAM,mBAAmB,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,iBA4erE"}
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"}
@@ -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 until the one-time backfill runs.
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');
@@ -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, '&amp;')
48
+ .replace(/</g, '&lt;')
49
+ .replace(/>/g, '&gt;')
50
+ .replace(/"/g, '&quot;')
51
+ .replace(/'/g, '&#39;');
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=link-safety.test.d.ts.map
@@ -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('&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
34
+ expect(escapeHtml("it's & more")).toBe('it&#39;s &amp; 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('&lt;script&gt;');
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":"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"}