@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.
- package/README.md +43 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/database.d.ts.map +1 -1
- package/dist/lib/database.js +19 -0
- package/dist/lib/database.js.map +1 -1
- package/dist/lib/launchpad.d.ts +153 -0
- package/dist/lib/launchpad.d.ts.map +1 -0
- package/dist/lib/launchpad.js +237 -0
- package/dist/lib/launchpad.js.map +1 -0
- package/dist/lib/launchpad.test.d.ts +2 -0
- package/dist/lib/launchpad.test.d.ts.map +1 -0
- package/dist/lib/launchpad.test.js +200 -0
- package/dist/lib/launchpad.test.js.map +1 -0
- package/dist/lib/link-safety.d.ts +96 -7
- package/dist/lib/link-safety.d.ts.map +1 -1
- package/dist/lib/link-safety.js +108 -8
- package/dist/lib/link-safety.js.map +1 -1
- package/dist/lib/link-safety.test.js +95 -1
- package/dist/lib/link-safety.test.js.map +1 -1
- package/dist/routes/links.d.ts.map +1 -1
- package/dist/routes/links.js +10 -4
- package/dist/routes/links.js.map +1 -1
- package/dist/routes/redirect.d.ts +19 -0
- package/dist/routes/redirect.d.ts.map +1 -1
- package/dist/routes/redirect.js +92 -19
- package/dist/routes/redirect.js.map +1 -1
- package/dist/routes/redirect.launchpad.test.d.ts +2 -0
- package/dist/routes/redirect.launchpad.test.d.ts.map +1 -0
- package/dist/routes/redirect.launchpad.test.js +255 -0
- package/dist/routes/redirect.launchpad.test.js.map +1 -0
- package/dist/routes/redirect.no-web-destination.test.js +32 -11
- package/dist/routes/redirect.no-web-destination.test.js.map +1 -1
- package/dist/routes/redirect.safety.test.js +116 -13
- package/dist/routes/redirect.safety.test.js.map +1 -1
- package/dist/routes/sdk-cache-bypass.test.js +10 -2
- package/dist/routes/sdk-cache-bypass.test.js.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/llms.txt +16 -0
- 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&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('<script>alert("xss")</script> & 'q'');
|
|
157
|
+
expect(html).toContain('"><img src=x onerror=alert(1)>');
|
|
158
|
+
expect(html).toContain('content="https://cdn.example/a.png?x="y""');
|
|
159
|
+
expect(html).toContain('href="https://apps.apple.com/app?a=1&b=2"');
|
|
160
|
+
expect(html).toContain('<b>App</b>');
|
|
161
|
+
expect(html).toContain('og:url" content="https://go.example/abc?""');
|
|
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` —
|
|
12
|
+
* - `block` — do not resolve.
|
|
13
13
|
*
|
|
14
|
-
* `block`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
|
35
|
-
*
|
|
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
|
|
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"}
|
package/dist/lib/link-safety.js
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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":"
|
|
1
|
+
{"version":3,"file":"link-safety.js","sourceRoot":"","sources":["../../src/lib/link-safety.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|