@linkforty/core 1.25.3 → 1.26.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 (43) hide show
  1. package/README.md +43 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -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 +19 -0
  8. package/dist/lib/database.js.map +1 -1
  9. package/dist/lib/launchpad.d.ts +153 -0
  10. package/dist/lib/launchpad.d.ts.map +1 -0
  11. package/dist/lib/launchpad.js +237 -0
  12. package/dist/lib/launchpad.js.map +1 -0
  13. package/dist/lib/launchpad.test.d.ts +2 -0
  14. package/dist/lib/launchpad.test.d.ts.map +1 -0
  15. package/dist/lib/launchpad.test.js +200 -0
  16. package/dist/lib/launchpad.test.js.map +1 -0
  17. package/dist/lib/link-safety.d.ts +96 -7
  18. package/dist/lib/link-safety.d.ts.map +1 -1
  19. package/dist/lib/link-safety.js +108 -8
  20. package/dist/lib/link-safety.js.map +1 -1
  21. package/dist/lib/link-safety.test.js +95 -1
  22. package/dist/lib/link-safety.test.js.map +1 -1
  23. package/dist/routes/links.d.ts.map +1 -1
  24. package/dist/routes/links.js +10 -4
  25. package/dist/routes/links.js.map +1 -1
  26. package/dist/routes/redirect.d.ts +19 -0
  27. package/dist/routes/redirect.d.ts.map +1 -1
  28. package/dist/routes/redirect.js +92 -19
  29. package/dist/routes/redirect.js.map +1 -1
  30. package/dist/routes/redirect.launchpad.test.d.ts +2 -0
  31. package/dist/routes/redirect.launchpad.test.d.ts.map +1 -0
  32. package/dist/routes/redirect.launchpad.test.js +255 -0
  33. package/dist/routes/redirect.launchpad.test.js.map +1 -0
  34. package/dist/routes/redirect.no-web-destination.test.js +32 -11
  35. package/dist/routes/redirect.no-web-destination.test.js.map +1 -1
  36. package/dist/routes/redirect.safety.test.js +116 -13
  37. package/dist/routes/redirect.safety.test.js.map +1 -1
  38. package/dist/routes/sdk-cache-bypass.test.js +10 -2
  39. package/dist/routes/sdk-cache-bypass.test.js.map +1 -1
  40. package/dist/types/index.d.ts +2 -0
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/llms.txt +16 -0
  43. package/package.json +1 -1
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defaultLaunchpadContent, launchpadContentSecurityPolicy, readLaunchpadLinkMode, readLaunchpadSettings, renderLaunchpadPage, shouldServeLaunchpadOnDesktop, } from './launchpad.js';
3
+ describe('shouldServeLaunchpadOnDesktop', () => {
4
+ /** The full decision table: link mode × workspace mode × whether a destination exists. */
5
+ const table = [
6
+ ['defaults, no destination → page', { hasWebDestination: false }, true],
7
+ ['defaults, destination → redirect', { hasWebDestination: true }, false],
8
+ ['org always, destination → page', { hasWebDestination: true, orgMode: 'always' }, true],
9
+ ['org off, no destination → plain page', { hasWebDestination: false, orgMode: 'off' }, false],
10
+ ['org no-destination, destination → redirect', { hasWebDestination: true, orgMode: 'no-destination' }, false],
11
+ ['link on beats org default', { hasWebDestination: true, linkMode: 'on' }, true],
12
+ ['link on beats org off', { hasWebDestination: false, orgMode: 'off', linkMode: 'on' }, true],
13
+ ['link off beats org always', { hasWebDestination: true, orgMode: 'always', linkMode: 'off' }, false],
14
+ ['link off beats no destination', { hasWebDestination: false, linkMode: 'off' }, false],
15
+ ['link inherit follows org', { hasWebDestination: true, orgMode: 'always', linkMode: 'inherit' }, true],
16
+ ];
17
+ for (const [name, input, expected] of table) {
18
+ it(name, () => expect(shouldServeLaunchpadOnDesktop(input)).toBe(expected));
19
+ }
20
+ });
21
+ describe('readLaunchpadSettings', () => {
22
+ it('returns {} for anything that is not a settings object', () => {
23
+ expect(readLaunchpadSettings(null)).toEqual({});
24
+ expect(readLaunchpadSettings({})).toEqual({});
25
+ expect(readLaunchpadSettings({ launchpad: 'always' })).toEqual({});
26
+ });
27
+ it('keeps known values and drops the rest', () => {
28
+ expect(readLaunchpadSettings({
29
+ launchpad: {
30
+ desktop: 'always',
31
+ mobile: 'page',
32
+ appName: ' Demo App ',
33
+ appIconUrl: 'https://cdn.example/icon.png',
34
+ accentColor: '#ff8800',
35
+ templateId: 'hero',
36
+ extra: 'ignored',
37
+ },
38
+ })).toEqual({
39
+ desktop: 'always',
40
+ mobile: 'page',
41
+ appName: 'Demo App',
42
+ appIconUrl: 'https://cdn.example/icon.png',
43
+ accentColor: '#ff8800',
44
+ templateId: 'hero',
45
+ });
46
+ });
47
+ it('rejects an unknown mode, a non-hex colour and a non-http icon', () => {
48
+ expect(readLaunchpadSettings({
49
+ launchpad: { desktop: 'sometimes', mobile: 42, accentColor: 'red', appIconUrl: 'javascript:alert(1)' },
50
+ })).toEqual({});
51
+ });
52
+ });
53
+ describe('readLaunchpadLinkMode', () => {
54
+ it('normalises anything unexpected to inherit', () => {
55
+ expect(readLaunchpadLinkMode('on')).toBe('on');
56
+ expect(readLaunchpadLinkMode('off')).toBe('off');
57
+ expect(readLaunchpadLinkMode(undefined)).toBe('inherit');
58
+ expect(readLaunchpadLinkMode('always')).toBe('inherit');
59
+ });
60
+ });
61
+ describe('defaultLaunchpadContent', () => {
62
+ it('prefers og fields, then plain fields, then the app name, then a constant', () => {
63
+ expect(defaultLaunchpadContent({ og_title: 'OG', title: 'T' }, {}).title).toBe('OG');
64
+ expect(defaultLaunchpadContent({ title: 'T' }, {}).title).toBe('T');
65
+ expect(defaultLaunchpadContent({}, { appName: 'Demo' }).title).toBe('Demo');
66
+ expect(defaultLaunchpadContent({}, {}).title).toBe('Open in the app');
67
+ expect(defaultLaunchpadContent({ og_description: 'D1', description: 'D2' }, {}).description).toBe('D1');
68
+ });
69
+ it('drops a non-http image', () => {
70
+ expect(defaultLaunchpadContent({ og_image_url: 'data:image/png;base64,AAAA' }, {}).imageUrl).toBeNull();
71
+ expect(defaultLaunchpadContent({ og_image_url: 'https://cdn.example/a.png' }, {}).imageUrl).toBe('https://cdn.example/a.png');
72
+ });
73
+ });
74
+ describe('launchpadContentSecurityPolicy', () => {
75
+ it('nonces scripts, allows inline styles, and forbids everything else by default', () => {
76
+ const csp = launchpadContentSecurityPolicy('abc');
77
+ expect(csp).toContain("default-src 'none'");
78
+ expect(csp).toContain("script-src 'nonce-abc'");
79
+ expect(csp).toContain("style-src 'unsafe-inline'");
80
+ expect(csp).toContain("connect-src 'self'");
81
+ expect(csp).toContain("base-uri 'none'");
82
+ expect(csp).toContain("form-action 'none'");
83
+ });
84
+ it('admits an absolute beacon origin in connect-src', () => {
85
+ expect(launchpadContentSecurityPolicy('abc', 'https://events.example/v1/lp')).toContain("connect-src 'self' https://events.example");
86
+ expect(launchpadContentSecurityPolicy('abc', '/api/lp/event')).toContain("connect-src 'self';");
87
+ });
88
+ });
89
+ describe('renderLaunchpadPage', () => {
90
+ const base = {
91
+ content: {
92
+ title: 'A title',
93
+ description: 'A description',
94
+ imageUrl: 'https://cdn.example/hero.png',
95
+ theme: { appName: 'Demo App', appIconUrl: 'https://cdn.example/icon.png', accentColor: '#ff8800' },
96
+ },
97
+ linkId: 'link-1',
98
+ pageUrl: 'https://go.example/abc',
99
+ iosUrl: 'https://apps.apple.com/app/id1',
100
+ androidUrl: 'https://play.google.com/store/apps/details?id=demo',
101
+ schemeUrl: null,
102
+ showQr: true,
103
+ nonce: 'n0nce',
104
+ };
105
+ it('renders the head, the hero, both store buttons and the QR block', () => {
106
+ const html = renderLaunchpadPage(base);
107
+ expect(html).toContain('<title>A title</title>');
108
+ expect(html).toContain('<meta name="robots" content="noindex, nofollow">');
109
+ expect(html).toContain('<meta property="og:image" content="https://cdn.example/hero.png">');
110
+ expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
111
+ expect(html).toContain('<h1>A title</h1>');
112
+ expect(html).toContain('Download on the App Store');
113
+ expect(html).toContain('Get it on Google Play');
114
+ expect(html).toContain('/api/links/link-1/qr?format=svg&amp;size=264');
115
+ expect(html).toContain('<span>Demo App</span>');
116
+ expect(html).toContain('--lp-accent: #ff8800; --lp-accent-ink: #ffffff;');
117
+ });
118
+ it('ships no script when there is nothing for one to do', () => {
119
+ expect(renderLaunchpadPage(base)).not.toContain('<script');
120
+ });
121
+ it('renders the Open in app button with the scheme URL and the fragment-preserving script', () => {
122
+ const html = renderLaunchpadPage({ ...base, schemeUrl: 'demo://product/1?x=1' });
123
+ expect(html).toContain('id="lp-open"');
124
+ expect(html).toContain('data-scheme="demo://product/1?x=1"');
125
+ expect(html).toContain('href="demo://product/1?x=1"');
126
+ expect(html).toContain('<script nonce="n0nce">');
127
+ expect(html).toContain('window.location.hash');
128
+ expect(html).not.toContain('setTimeout');
129
+ expect(html).not.toContain('location.replace');
130
+ });
131
+ it('adds the beacon attributes and markers only when a beacon URL is set', () => {
132
+ const without = renderLaunchpadPage(base);
133
+ expect(without).not.toContain('data-beacon');
134
+ expect(without).not.toContain('sendBeacon');
135
+ const html = renderLaunchpadPage({ ...base, beaconUrl: '/api/lp/event' });
136
+ expect(html).toContain('data-beacon="/api/lp/event"');
137
+ expect(html.match(/navigator\.sendBeacon\(/g)).toHaveLength(1);
138
+ expect(html).toContain('data-lp-cta="cta_ios"');
139
+ expect(html).toContain('data-lp-cta="cta_android"');
140
+ expect(html).not.toContain('data-lp-cta="cta_open"');
141
+ });
142
+ it('escapes every value that reaches the document', () => {
143
+ const html = renderLaunchpadPage({
144
+ ...base,
145
+ content: {
146
+ title: `<script>alert("xss")</script> & 'q'`,
147
+ description: '"><img src=x onerror=alert(1)>',
148
+ imageUrl: 'https://cdn.example/a.png?x="y"',
149
+ theme: { appName: '<b>App</b>' },
150
+ },
151
+ iosUrl: 'https://apps.apple.com/app?a=1&b=2',
152
+ pageUrl: 'https://go.example/abc?"',
153
+ });
154
+ expect(html).not.toContain('<script>alert');
155
+ expect(html).not.toContain('<img src=x');
156
+ expect(html).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; &amp; &#39;q&#39;');
157
+ expect(html).toContain('&quot;&gt;&lt;img src=x onerror=alert(1)&gt;');
158
+ expect(html).toContain('content="https://cdn.example/a.png?x=&quot;y&quot;"');
159
+ expect(html).toContain('href="https://apps.apple.com/app?a=1&amp;b=2"');
160
+ expect(html).toContain('&lt;b&gt;App&lt;/b&gt;');
161
+ expect(html).toContain('og:url" content="https://go.example/abc?&quot;"');
162
+ });
163
+ it('drops non-http URLs rather than rendering them', () => {
164
+ const html = renderLaunchpadPage({
165
+ ...base,
166
+ content: { ...base.content, imageUrl: 'javascript:alert(1)', theme: { appIconUrl: 'data:text/html,x' } },
167
+ iosUrl: 'javascript:alert(2)',
168
+ androidUrl: null,
169
+ });
170
+ expect(html).not.toContain('javascript:');
171
+ expect(html).not.toContain('data:text');
172
+ expect(html).not.toContain('Download on the App Store');
173
+ });
174
+ it('inserts heroHtml verbatim in place of the default hero', () => {
175
+ const html = renderLaunchpadPage({
176
+ ...base,
177
+ content: { ...base.content, heroHtml: '<div class="custom-hero"><em>rendered by host</em></div>' },
178
+ });
179
+ expect(html).toContain('<div class="custom-hero"><em>rendered by host</em></div>');
180
+ expect(html).not.toContain('<h1>A title</h1>');
181
+ // The head still carries the escaped title for the tab and the unfurl.
182
+ expect(html).toContain('<title>A title</title>');
183
+ });
184
+ it('omits the QR block, the icon row and the description when there is nothing to show', () => {
185
+ const html = renderLaunchpadPage({
186
+ ...base,
187
+ showQr: false,
188
+ content: { title: 'T', description: '', imageUrl: null, theme: {} },
189
+ });
190
+ expect(html).not.toContain('class="lp-qr"');
191
+ expect(html).not.toContain('class="lp-app"');
192
+ expect(html).not.toContain('meta name="description"');
193
+ expect(html).toContain('<meta name="twitter:card" content="summary">');
194
+ });
195
+ it('uses dark button text on a light accent', () => {
196
+ const html = renderLaunchpadPage({ ...base, content: { ...base.content, theme: { accentColor: '#ffe066' } } });
197
+ expect(html).toContain('--lp-accent: #ffe066; --lp-accent-ink: #16181d;');
198
+ });
199
+ });
200
+ //# sourceMappingURL=launchpad.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launchpad.test.js","sourceRoot":"","sources":["../../src/lib/launchpad.test.ts"],"names":[],"mappings":"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"}
@@ -9,11 +9,34 @@
9
9
  * confirmed, this is strictly better than a hard block: a false
10
10
  * positive still lets the visitor through, while a true positive
11
11
  * still breaks the one-click flow a malicious link depends on.
12
- * - `block` — behave as if the link does not exist.
12
+ * - `block` — do not resolve.
13
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.
14
+ * A `block` used to be indistinguishable from an unknown short code, on the
15
+ * reasoning that a distinct response would confirm the code was real and leak that
16
+ * its owner is under a restriction.
17
+ *
18
+ * Two things were wrong with that. The secrecy was close to nil — whoever created
19
+ * the links already knows their own short codes, and a live link answers 302 while
20
+ * a dead one does not, so "real but withdrawn" was already distinguishable by the
21
+ * only party it was meant to be hidden from. And the response it produced was a raw
22
+ * JSON error, rendered on whatever branded domain the short link was served from.
23
+ *
24
+ * So a block caused by an explicit decision now answers 410 with a plain page —
25
+ * `blockCauseIsAbuse()` and `generateBlockedLinkHTML()` below. 410 rather than 404
26
+ * because the code existed and was withdrawn, which is a different fact from never
27
+ * having existed and is worth stating to anything reading the status.
28
+ *
29
+ * **The page says nothing about why.** It makes no claim about the link, its
30
+ * destination, or whoever created it. That is deliberate: a page on a short-link
31
+ * domain is not the place to characterise a user's content, and any claim made
32
+ * there would be wrong — and hard to withdraw — the first time a link is disabled
33
+ * in error.
34
+ *
35
+ * **The carve-out is deliberately narrow, and must stay narrow.** It applies only
36
+ * to an explicit disable or an owner restriction. A link that is merely inactive —
37
+ * expired, or switched off by the person who made it — keeps the old opaque
38
+ * response. Its cause is ambiguous, nobody decided anything about it, and there is
39
+ * nothing to say.
17
40
  */
18
41
  export type LinkSafetyOutcome = 'allow' | 'warn' | 'block';
19
42
  export interface LinkSafetyInput {
@@ -27,12 +50,55 @@ export interface LinkSafetyInput {
27
50
  * restriction simply never pass it.
28
51
  */
29
52
  ownerSuspendedAt?: Date | string | null;
53
+ /**
54
+ * Set when the link was explicitly taken out of resolution.
55
+ *
56
+ * This is the field that separates "someone decided to stop this link" from
57
+ * "this link is off", and the distinction carries the whole notice below. An
58
+ * expiry sweep sets `isActive` and leaves this null; so does a person switching
59
+ * their own link off. Only a deliberate removal sets it.
60
+ *
61
+ * Never infer the cause from `isActive` instead. The two disagree far more often
62
+ * than they agree — most inactive links in a mature deployment expired or were
63
+ * switched off, and none of those visitors should be told they were phished.
64
+ */
65
+ disabledAt?: Date | string | null;
66
+ }
67
+ /** Why a link was blocked. Only the first two are an abuse decision. */
68
+ export type LinkBlockCause = 'owner_suspended' | 'disabled' | 'inactive';
69
+ export interface LinkSafetyDecision {
70
+ outcome: LinkSafetyOutcome;
71
+ /** Present only when `outcome` is `block`. */
72
+ cause?: LinkBlockCause;
30
73
  }
31
74
  /**
32
- * Decide what to do with a link that was found.
75
+ * Whether a block cause represents a decision someone made about abuse, as opposed
76
+ * to a link that is simply not live.
77
+ *
78
+ * The gate for the notice. Kept as a named predicate rather than an inline
79
+ * comparison so there is exactly one place to look when asking "who sees this
80
+ * page", and so widening it is a visible edit rather than an incidental one.
81
+ */
82
+ export declare function blockCauseIsAbuse(cause: LinkBlockCause | undefined): boolean;
83
+ /**
84
+ * Decide what to do with a link that was found, and say why.
33
85
  *
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.
86
+ * Order matters, and it is not the same as the order of severity. Owner
87
+ * restriction and an explicit disable both beat `warn`: a link whose owner is
88
+ * restricted must be unreachable even if it was only flagged to warn.
89
+ *
90
+ * `disabled` is tested before the bare `isActive` check so that an abuse disable
91
+ * keeps its cause. Both set `isActive` to false in practice, and whichever is
92
+ * tested first wins — putting `isActive` first would silently collapse every
93
+ * abuse disable into `inactive` and the notice would never be served. There is a
94
+ * test for exactly that ordering.
95
+ */
96
+ export declare function evaluateLinkSafetyDecision(input: LinkSafetyInput): LinkSafetyDecision;
97
+ /**
98
+ * The outcome alone.
99
+ *
100
+ * Retained with its original signature because it is part of this package's public
101
+ * surface; callers that only branch on allow/warn/block need no change.
36
102
  */
37
103
  export declare function evaluateLinkSafety(input: LinkSafetyInput): LinkSafetyOutcome;
38
104
  /**
@@ -69,6 +135,29 @@ export declare function escapeHtml(value: string): string;
69
135
  export declare function generateWarningLinkHTML(destination: string, options?: {
70
136
  reportUrl?: string;
71
137
  }): string;
138
+ /**
139
+ * Page served when a link was blocked by an explicit decision.
140
+ *
141
+ * Four things are deliberately absent, and each was a decision rather than an
142
+ * omission:
143
+ *
144
+ * - **Any statement of why.** See BLOCKED_COPY above.
145
+ * - **No way to continue.** The warning interstitial offers one because a `warn`
146
+ * link is only suspected. Here there is nowhere to send anyone, and an escape
147
+ * hatch would defeat the block.
148
+ * - **No destination, owner, or other link detail.** The visitor is not entitled
149
+ * to another party's details, and a withheld destination cannot be re-followed.
150
+ * - **Next to no branding.** Whoever reads this has no relationship with whatever
151
+ * is hosting the link.
152
+ *
153
+ * Takes no arguments, so nothing from the link can reach it. That is the property
154
+ * worth preserving: adding a parameter here is how a destination or an owner ends
155
+ * up rendered to a stranger. There is a test asserting the arity stays zero.
156
+ *
157
+ * No JavaScript and no external assets, so it renders on a bare redirect host and
158
+ * under a strict content-security policy.
159
+ */
160
+ export declare function generateBlockedLinkHTML(): string;
72
161
  /**
73
162
  * Build a memoised probe for owner-restriction support.
74
163
  *
@@ -1 +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"}
1
+ {"version":3,"file":"link-safety.d.ts","sourceRoot":"","sources":["../../src/lib/link-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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;IACxC;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,wEAAwE;AACxE,MAAM,MAAM,cAAc,GAAG,iBAAiB,GAAG,UAAU,GAAG,UAAU,CAAC;AAEzE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,GAAG,OAAO,CAE5E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,eAAe,GAAG,kBAAkB,CAMrF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,GAAG,iBAAiB,CAE5E;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;AAoBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CA+BhD;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"}
@@ -1,17 +1,46 @@
1
1
  /**
2
- * Decide what to do with a link that was found.
2
+ * Whether a block cause represents a decision someone made about abuse, as opposed
3
+ * to a link that is simply not live.
3
4
  *
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.
5
+ * The gate for the notice. Kept as a named predicate rather than an inline
6
+ * comparison so there is exactly one place to look when asking "who sees this
7
+ * page", and so widening it is a visible edit rather than an incidental one.
6
8
  */
7
- export function evaluateLinkSafety(input) {
9
+ export function blockCauseIsAbuse(cause) {
10
+ return cause === 'owner_suspended' || cause === 'disabled';
11
+ }
12
+ /**
13
+ * Decide what to do with a link that was found, and say why.
14
+ *
15
+ * Order matters, and it is not the same as the order of severity. Owner
16
+ * restriction and an explicit disable both beat `warn`: a link whose owner is
17
+ * restricted must be unreachable even if it was only flagged to warn.
18
+ *
19
+ * `disabled` is tested before the bare `isActive` check so that an abuse disable
20
+ * keeps its cause. Both set `isActive` to false in practice, and whichever is
21
+ * tested first wins — putting `isActive` first would silently collapse every
22
+ * abuse disable into `inactive` and the notice would never be served. There is a
23
+ * test for exactly that ordering.
24
+ */
25
+ export function evaluateLinkSafetyDecision(input) {
8
26
  if (input.ownerSuspendedAt != null)
9
- return 'block';
27
+ return { outcome: 'block', cause: 'owner_suspended' };
28
+ if (input.disabledAt != null)
29
+ return { outcome: 'block', cause: 'disabled' };
10
30
  if (input.isActive === false)
11
- return 'block';
31
+ return { outcome: 'block', cause: 'inactive' };
12
32
  if (input.warnAt != null)
13
- return 'warn';
14
- return 'allow';
33
+ return { outcome: 'warn' };
34
+ return { outcome: 'allow' };
35
+ }
36
+ /**
37
+ * The outcome alone.
38
+ *
39
+ * Retained with its original signature because it is part of this package's public
40
+ * surface; callers that only branch on allow/warn/block need no change.
41
+ */
42
+ export function evaluateLinkSafety(input) {
43
+ return evaluateLinkSafetyDecision(input).outcome;
15
44
  }
16
45
  /**
17
46
  * A destination is only safe to put in an `href` if it is http(s).
@@ -120,6 +149,77 @@ export function generateWarningLinkHTML(destination, options = {}) {
120
149
  </body>
121
150
  </html>`;
122
151
  }
152
+ /**
153
+ * Copy for the page, separated from the markup.
154
+ *
155
+ * A structure rather than inline strings so a translation is a data addition and
156
+ * not a rewrite.
157
+ *
158
+ * Deliberately states only that the link is gone. Earlier drafts explained why and
159
+ * told the visitor what to do if they had entered a password — useful to someone
160
+ * who followed a hostile link, but it puts an accusation about a user's content on
161
+ * a page we serve, and makes a claim that would be false the first time a link is
162
+ * disabled in error. Nothing here is a claim about anything.
163
+ */
164
+ const BLOCKED_COPY = {
165
+ title: 'This link is no longer available',
166
+ heading: 'This link is no longer available',
167
+ body: 'It was removed and no longer goes anywhere.',
168
+ };
169
+ /**
170
+ * Page served when a link was blocked by an explicit decision.
171
+ *
172
+ * Four things are deliberately absent, and each was a decision rather than an
173
+ * omission:
174
+ *
175
+ * - **Any statement of why.** See BLOCKED_COPY above.
176
+ * - **No way to continue.** The warning interstitial offers one because a `warn`
177
+ * link is only suspected. Here there is nowhere to send anyone, and an escape
178
+ * hatch would defeat the block.
179
+ * - **No destination, owner, or other link detail.** The visitor is not entitled
180
+ * to another party's details, and a withheld destination cannot be re-followed.
181
+ * - **Next to no branding.** Whoever reads this has no relationship with whatever
182
+ * is hosting the link.
183
+ *
184
+ * Takes no arguments, so nothing from the link can reach it. That is the property
185
+ * worth preserving: adding a parameter here is how a destination or an owner ends
186
+ * up rendered to a stranger. There is a test asserting the arity stays zero.
187
+ *
188
+ * No JavaScript and no external assets, so it renders on a bare redirect host and
189
+ * under a strict content-security policy.
190
+ */
191
+ export function generateBlockedLinkHTML() {
192
+ return `<!doctype html>
193
+ <html lang="en">
194
+ <head>
195
+ <meta charset="utf-8">
196
+ <meta name="viewport" content="width=device-width,initial-scale=1">
197
+ <meta name="robots" content="noindex,nofollow">
198
+ <title>${BLOCKED_COPY.title}</title>
199
+ <style>
200
+ :root { color-scheme: light dark; }
201
+ body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
202
+ background:#f6f7f8; color:#16191d; padding:24px;
203
+ font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
204
+ .card { max-width:30rem; width:100%; background:#fff; border:1px solid #e3e6ea;
205
+ border-radius:10px; padding:28px; text-align:center; }
206
+ h1 { margin:0 0 10px; font-size:1.25rem; line-height:1.3; }
207
+ p { margin:0; color:#5b636d; }
208
+ @media (prefers-color-scheme: dark) {
209
+ body { background:#14171a; color:#e8eaed; }
210
+ .card { background:#1d2126; border-color:#2c3238; }
211
+ p { color:#98a1ab; }
212
+ }
213
+ </style>
214
+ </head>
215
+ <body>
216
+ <main class="card">
217
+ <h1>${BLOCKED_COPY.heading}</h1>
218
+ <p>${BLOCKED_COPY.body}</p>
219
+ </main>
220
+ </body>
221
+ </html>`;
222
+ }
123
223
  /**
124
224
  * Build a memoised probe for owner-restriction support.
125
225
  *
@@ -1 +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"}
1
+ {"version":3,"file":"link-safety.js","sourceRoot":"","sources":["../../src/lib/link-safety.ts"],"names":[],"mappings":"AA6EA;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAiC;IACjE,OAAO,KAAK,KAAK,iBAAiB,IAAI,KAAK,KAAK,UAAU,CAAC;AAC7D,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,0BAA0B,CAAC,KAAsB;IAC/D,IAAI,KAAK,CAAC,gBAAgB,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC1F,IAAI,KAAK,CAAC,UAAU,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;IAC7E,IAAI,KAAK,CAAC,QAAQ,KAAK,KAAK;QAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;IAC7E,IAAI,KAAK,CAAC,MAAM,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IACrD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAC9B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAsB;IACvD,OAAO,0BAA0B,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC;AACnD,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;;;;;;;;;;;GAWG;AACH,MAAM,YAAY,GAAG;IACnB,KAAK,EAAE,kCAAkC;IACzC,OAAO,EAAE,kCAAkC;IAC3C,IAAI,EAAE,6CAA6C;CAC3C,CAAC;AAEX;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO;;;;;;SAMA,YAAY,CAAC,KAAK;;;;;;;;;;;;;;;;;;;UAmBjB,YAAY,CAAC,OAAO;SACrB,YAAY,CAAC,IAAI;;;QAGlB,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"}
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { evaluateLinkSafety, generateWarningLinkHTML, escapeHtml, safeHref } from './link-safety.js';
2
+ import { evaluateLinkSafety, generateWarningLinkHTML, escapeHtml, safeHref, evaluateLinkSafetyDecision, blockCauseIsAbuse, generateBlockedLinkHTML, } from './link-safety.js';
3
3
  describe('evaluateLinkSafety', () => {
4
4
  it('allows a plain healthy link', () => {
5
5
  expect(evaluateLinkSafety({ isActive: true })).toBe('allow');
@@ -114,4 +114,98 @@ describe('safeHref', () => {
114
114
  expect(safeHref('')).toBeNull();
115
115
  });
116
116
  });
117
+ describe('evaluateLinkSafetyDecision — why a link was blocked', () => {
118
+ const T = '2026-08-10T00:00:00Z';
119
+ it('reports owner restriction as an abuse cause', () => {
120
+ const d = evaluateLinkSafetyDecision({ ownerSuspendedAt: T });
121
+ expect(d).toEqual({ outcome: 'block', cause: 'owner_suspended' });
122
+ expect(blockCauseIsAbuse(d.cause)).toBe(true);
123
+ });
124
+ it('reports an explicit disable as an abuse cause', () => {
125
+ const d = evaluateLinkSafetyDecision({ isActive: false, disabledAt: T });
126
+ expect(d).toEqual({ outcome: 'block', cause: 'disabled' });
127
+ expect(blockCauseIsAbuse(d.cause)).toBe(true);
128
+ });
129
+ /**
130
+ * The distinction the whole notice rests on. An expiry sweep and a person
131
+ * switching their own link off both land here, and neither of their visitors was
132
+ * targeted by anything.
133
+ */
134
+ it('reports a merely inactive link as NOT an abuse cause', () => {
135
+ const d = evaluateLinkSafetyDecision({ isActive: false });
136
+ expect(d).toEqual({ outcome: 'block', cause: 'inactive' });
137
+ expect(blockCauseIsAbuse(d.cause)).toBe(false);
138
+ });
139
+ /**
140
+ * Ordering guard. An abuse disable sets `isActive: false` as well, so testing
141
+ * `isActive` first would collapse every abuse disable into `inactive` and the
142
+ * notice would never be served to anyone. Every route test would still pass on
143
+ * the owner-restriction path, so this is the only place that catches it.
144
+ */
145
+ it('keeps the disable cause even though the link is also inactive', () => {
146
+ expect(evaluateLinkSafetyDecision({ isActive: false, disabledAt: T }).cause).toBe('disabled');
147
+ });
148
+ it('lets owner restriction outrank an explicit disable', () => {
149
+ expect(evaluateLinkSafetyDecision({ ownerSuspendedAt: T, isActive: false, disabledAt: T }).cause).toBe('owner_suspended');
150
+ });
151
+ it('carries no cause when the link is fine or only warned', () => {
152
+ expect(evaluateLinkSafetyDecision({})).toEqual({ outcome: 'allow' });
153
+ expect(evaluateLinkSafetyDecision({ warnAt: T })).toEqual({ outcome: 'warn' });
154
+ });
155
+ it('agrees with evaluateLinkSafety on every outcome', () => {
156
+ for (const input of [
157
+ {},
158
+ { warnAt: T },
159
+ { isActive: false },
160
+ { disabledAt: T },
161
+ { ownerSuspendedAt: T },
162
+ { warnAt: T, ownerSuspendedAt: T },
163
+ ]) {
164
+ expect(evaluateLinkSafety(input)).toBe(evaluateLinkSafetyDecision(input).outcome);
165
+ }
166
+ });
167
+ });
168
+ describe('generateBlockedLinkHTML', () => {
169
+ const html = generateBlockedLinkHTML();
170
+ it('says only that the link is gone', () => {
171
+ expect(html).toContain('This link is no longer available');
172
+ expect(html).toContain('It was removed and no longer goes anywhere.');
173
+ });
174
+ /**
175
+ * The guard on the decision, not on the wording.
176
+ *
177
+ * The page must not characterise the link, its destination, or whoever made it.
178
+ * An earlier draft explained that the link had been reported for trying to obtain
179
+ * passwords and payment details — useful to a victim, but it is an accusation
180
+ * about a user's content published on a domain we serve, and it would be false
181
+ * the first time a link is disabled in error. If any of these words come back,
182
+ * that decision is being reversed and it should be a deliberate edit here first.
183
+ */
184
+ it('makes no claim about the link or whoever created it', () => {
185
+ for (const word of [
186
+ 'phishing', 'fraud', 'fraudulent', 'scam', 'malicious', 'malware', 'abuse',
187
+ 'reported', 'trick', 'steal', 'suspended', 'restricted', 'violat', 'blocked',
188
+ 'password', 'bank', 'card details',
189
+ ]) {
190
+ expect(html.toLowerCase(), `page must not mention "${word}"`).not.toContain(word);
191
+ }
192
+ });
193
+ it('contains no link out at all', () => {
194
+ expect(html).not.toMatch(/<a\s/i);
195
+ });
196
+ it('contains no script and no external asset', () => {
197
+ expect(html).not.toMatch(/<script/i);
198
+ expect(html).not.toMatch(/https?:\/\//);
199
+ });
200
+ it('asks not to be indexed', () => {
201
+ expect(html).toContain('noindex');
202
+ });
203
+ /**
204
+ * Arity is the real containment. Adding a parameter is how a destination or an
205
+ * owner ends up rendered to a stranger, so it fails here before it fails in review.
206
+ */
207
+ it('takes no input, so it cannot carry anything from the link', () => {
208
+ expect(generateBlockedLinkHTML.length).toBe(0);
209
+ });
210
+ });
117
211
  //# sourceMappingURL=link-safety.test.js.map