@reclaimprotocol/agent 0.1.38 → 0.1.39
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/lib/mcp/tools/agent/example-app.js +66 -22
- package/lib/provider/draft.js +4 -3
- package/lib/provider/matcher.js +1 -1
- package/package.json +2 -2
- package/samples/demo-server/package.json +2 -1
- package/samples/demo-server/server.js +6 -1
- package/src/mcp/tools/agent/example-app.ts +80 -28
- package/src/provider/draft.ts +4 -3
- package/src/provider/matcher.ts +1 -1
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
import { badGateway, badRequest, conflict } from '@hapi/boom';
|
|
11
11
|
import assert from 'node:assert';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import { mkdir,
|
|
15
|
-
import {
|
|
13
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
16
16
|
import { defineTool } from "../../server.js";
|
|
17
17
|
const REPO = 'reclaimprotocol/builder-example';
|
|
18
|
-
|
|
18
|
+
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
19
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json';
|
|
19
20
|
const CLIENT_PACKAGE = '@reclaimprotocol/client';
|
|
20
21
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
21
22
|
const MAX_FILES = 500;
|
|
@@ -44,8 +45,9 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
44
45
|
},
|
|
45
46
|
},
|
|
46
47
|
annotations: { openWorldHint: true, destructiveHint: false },
|
|
47
|
-
}, async (
|
|
48
|
-
assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
48
|
+
}, async (args) => {
|
|
49
|
+
assert(isAbsolute(args.projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
50
|
+
const projectDir = await realpath(args.projectDir);
|
|
49
51
|
const commit = await latestCommit();
|
|
50
52
|
const files = await listFiles(commit);
|
|
51
53
|
const clientVersion = await latestClientVersion();
|
|
@@ -58,6 +60,9 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
58
60
|
: bytes,
|
|
59
61
|
};
|
|
60
62
|
}));
|
|
63
|
+
for (const { path } of staged) {
|
|
64
|
+
assertNoSymlinks(projectDir, path);
|
|
65
|
+
}
|
|
61
66
|
const conflicts = staged
|
|
62
67
|
.filter(({ path, bytes }) => {
|
|
63
68
|
const dest = join(projectDir, path);
|
|
@@ -67,21 +72,8 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
67
72
|
assert(!conflicts.length, conflict('These files already exist with different content: '
|
|
68
73
|
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
69
74
|
+ 'another directory or move them.'));
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
for (const { path, bytes } of staged) {
|
|
74
|
-
await mkdir(dirname(join(tmp, path)), { recursive: true });
|
|
75
|
-
await writeFile(join(tmp, path), bytes);
|
|
76
|
-
}
|
|
77
|
-
for (const { path } of staged) {
|
|
78
|
-
const dest = join(projectDir, path);
|
|
79
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
80
|
-
await rename(join(tmp, path), dest);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
await rm(tmp, { recursive: true, force: true });
|
|
75
|
+
for (const { path, bytes } of staged) {
|
|
76
|
+
await installFile(projectDir, path, bytes);
|
|
85
77
|
}
|
|
86
78
|
return {
|
|
87
79
|
projectDir,
|
|
@@ -142,7 +134,7 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
142
134
|
return bytes;
|
|
143
135
|
}
|
|
144
136
|
async function latestClientVersion() {
|
|
145
|
-
const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE
|
|
137
|
+
const res = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`);
|
|
146
138
|
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
|
|
147
139
|
const body = await res.json();
|
|
148
140
|
assert(typeof body === 'object' && body !== null && 'version' in body
|
|
@@ -151,6 +143,58 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
151
143
|
return body.version;
|
|
152
144
|
}
|
|
153
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Write one file under `root` without following a symlink out of it or
|
|
148
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
149
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
150
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
151
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
152
|
+
* with the same bytes (a re-run) is left as is.
|
|
153
|
+
*/
|
|
154
|
+
async function installFile(root, path, bytes) {
|
|
155
|
+
const parts = path.split('/');
|
|
156
|
+
let dir = root;
|
|
157
|
+
for (const part of parts.slice(0, -1)) {
|
|
158
|
+
dir = join(dir, part);
|
|
159
|
+
await mkdir(dir).catch((err) => {
|
|
160
|
+
if (!isEexist(err)) {
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
assertNoSymlinks(root, relative(root, dir));
|
|
165
|
+
}
|
|
166
|
+
const dest = join(dir, parts.at(-1));
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(dest, bytes, { flag: 'wx' });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (!isEexist(err)) {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
assertNoSymlinks(root, path);
|
|
175
|
+
assert(readFileSync(dest).equals(bytes), conflict(`${path} appeared with different content; nothing replaced`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const real = await realpath(dest);
|
|
179
|
+
if (!real.startsWith(root + sep)) {
|
|
180
|
+
await rm(dest, { force: true });
|
|
181
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function isEexist(err) {
|
|
185
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST';
|
|
186
|
+
}
|
|
187
|
+
/** Throws when `path`, or any of its existing parents under `root`, is a
|
|
188
|
+
* symlink: writing through one would land outside the workspace. */
|
|
189
|
+
function assertNoSymlinks(root, path) {
|
|
190
|
+
let current = root;
|
|
191
|
+
for (const part of path.split(/[\\/]/)) {
|
|
192
|
+
current += sep + part;
|
|
193
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
194
|
+
assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; the example app is only written to `
|
|
195
|
+
+ 'real directories inside the workspace'));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
154
198
|
/** The backend's package.json with the client range set to `^version`. */
|
|
155
199
|
function withClientVersion(bytes, version) {
|
|
156
200
|
const text = bytes.toString('utf8');
|
package/lib/provider/draft.js
CHANGED
|
@@ -80,10 +80,11 @@ export function assertPortableMatches(matches, redactions, targets) {
|
|
|
80
80
|
// proving an email's domain substring. Reject it regardless of the declared
|
|
81
81
|
// targets (the local part is usually not itself a target, so the value scan
|
|
82
82
|
// below wouldn't catch it). Strip params + named groups + regex escapes
|
|
83
|
-
// first, then look for `localpart@` as a surviving literal.
|
|
83
|
+
// first, then look for `localpart@` as a surviving literal. A placeholder
|
|
84
|
+
// never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
|
|
84
85
|
const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/;
|
|
85
86
|
const deregex = (s) => s
|
|
86
|
-
.replace(/\{\{[^}]*\}\}/g, '')
|
|
87
|
+
.replace(/\{\{[^{}]*\}\}/g, '')
|
|
87
88
|
.replace(/\(\?<[^>]*>[^)]*\)/g, '')
|
|
88
89
|
.replace(/\\(.)/g, '$1');
|
|
89
90
|
const scanEmail = (text, where) => {
|
|
@@ -110,7 +111,7 @@ export function assertPortableMatches(matches, redactions, targets) {
|
|
|
110
111
|
if (!guarded.length) {
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
|
-
const stripParams = (s) => s.replace(/\{\{[^}]*\}\}/g, '');
|
|
114
|
+
const stripParams = (s) => s.replace(/\{\{[^{}]*\}\}/g, '');
|
|
114
115
|
const scan = (text, where, own) => {
|
|
115
116
|
const bare = stripParams(text);
|
|
116
117
|
for (const v of guarded) {
|
package/lib/provider/matcher.js
CHANGED
|
@@ -572,7 +572,7 @@ function stableOpeningTail(openTag) {
|
|
|
572
572
|
* content (the JSON body the attestor exposes when a jsonPath is paired with
|
|
573
573
|
* the script's xPath); otherwise undefined. */
|
|
574
574
|
function scriptJsonContent(markup) {
|
|
575
|
-
const match = /^<script\b[^>]*>([\s\S]*)<\/script
|
|
575
|
+
const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup);
|
|
576
576
|
if (!match) {
|
|
577
577
|
return undefined;
|
|
578
578
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reclaimprotocol/agent",
|
|
3
3
|
"title": "Reclaim Agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.39",
|
|
5
5
|
"description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@noble/hashes": "^2.2.0",
|
|
85
85
|
"@puppeteer/browsers": "3.2.4",
|
|
86
86
|
"@reclaimprotocol/attestor-core": "^5.1.6",
|
|
87
|
-
"@reclaimprotocol/client": "0.1.
|
|
87
|
+
"@reclaimprotocol/client": "0.1.39",
|
|
88
88
|
"@reclaimprotocol/tls": "^0.1.3",
|
|
89
89
|
"chrome-remote-interface": "^0.33.0",
|
|
90
90
|
"domhandler": "^6.0.0",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
+
import { rateLimit } from "express-rate-limit";
|
|
2
3
|
import "dotenv/config";
|
|
3
4
|
import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -9,8 +10,12 @@ const providerId = __PROVIDER_ID__;
|
|
|
9
10
|
// Left unpinned by default — set this if you need to pin a specific version.
|
|
10
11
|
const providerVersion = __PROVIDER_VERSION__;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// One proxy hop (ngrok or a load balancer), so the rate limit below keys on
|
|
14
|
+
// the client's address rather than the proxy's.
|
|
15
|
+
app.set("trust proxy", 1);
|
|
13
16
|
app.use(express.json({ limit: "2mb" }));
|
|
17
|
+
// Every route verifies or creates something; cap each client's request rate.
|
|
18
|
+
app.use(rateLimit({ windowMs: 60_000, limit: 60 }));
|
|
14
19
|
|
|
15
20
|
app.get("/health", (_req, res) => {
|
|
16
21
|
res.json({ ok: true });
|
|
@@ -10,19 +10,14 @@
|
|
|
10
10
|
import { badGateway, badRequest, conflict } from '@hapi/boom'
|
|
11
11
|
import assert from 'node:assert'
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
mkdtemp,
|
|
17
|
-
rename,
|
|
18
|
-
rm,
|
|
19
|
-
writeFile
|
|
20
|
-
} from 'node:fs/promises'
|
|
21
|
-
import { dirname, isAbsolute, join } from 'node:path'
|
|
13
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
22
16
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
23
17
|
|
|
24
18
|
const REPO = 'reclaimprotocol/builder-example'
|
|
25
|
-
|
|
19
|
+
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
20
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json'
|
|
26
21
|
const CLIENT_PACKAGE = '@reclaimprotocol/client'
|
|
27
22
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
28
23
|
const MAX_FILES = 500
|
|
@@ -56,11 +51,12 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
56
51
|
},
|
|
57
52
|
annotations: { openWorldHint: true, destructiveHint: false },
|
|
58
53
|
},
|
|
59
|
-
async(
|
|
54
|
+
async(args) => {
|
|
60
55
|
assert(
|
|
61
|
-
isAbsolute(projectDir),
|
|
56
|
+
isAbsolute(args.projectDir),
|
|
62
57
|
badRequest('`projectDir` must be an absolute path.'),
|
|
63
58
|
)
|
|
59
|
+
const projectDir = await realpath(args.projectDir)
|
|
64
60
|
const commit = await latestCommit()
|
|
65
61
|
const files = await listFiles(commit)
|
|
66
62
|
const clientVersion = await latestClientVersion()
|
|
@@ -73,6 +69,10 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
73
69
|
: bytes,
|
|
74
70
|
}
|
|
75
71
|
}))
|
|
72
|
+
for(const { path } of staged) {
|
|
73
|
+
assertNoSymlinks(projectDir, path)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
76
|
const conflicts = staged
|
|
77
77
|
.filter(({ path, bytes }) => {
|
|
78
78
|
const dest = join(projectDir, path)
|
|
@@ -88,21 +88,8 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
88
88
|
),
|
|
89
89
|
)
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
for(const { path, bytes } of staged) {
|
|
95
|
-
await mkdir(dirname(join(tmp, path)), { recursive: true })
|
|
96
|
-
await writeFile(join(tmp, path), bytes)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
for(const { path } of staged) {
|
|
100
|
-
const dest = join(projectDir, path)
|
|
101
|
-
await mkdir(dirname(dest), { recursive: true })
|
|
102
|
-
await rename(join(tmp, path), dest)
|
|
103
|
-
}
|
|
104
|
-
} finally {
|
|
105
|
-
await rm(tmp, { recursive: true, force: true })
|
|
91
|
+
for(const { path, bytes } of staged) {
|
|
92
|
+
await installFile(projectDir, path, bytes)
|
|
106
93
|
}
|
|
107
94
|
|
|
108
95
|
return {
|
|
@@ -190,7 +177,7 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
190
177
|
|
|
191
178
|
async function latestClientVersion() {
|
|
192
179
|
const res = await fetchFn(
|
|
193
|
-
`https://registry.npmjs.org/${CLIENT_PACKAGE
|
|
180
|
+
`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
|
|
194
181
|
)
|
|
195
182
|
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
|
|
196
183
|
const body: unknown = await res.json()
|
|
@@ -204,6 +191,71 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
204
191
|
}
|
|
205
192
|
}
|
|
206
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Write one file under `root` without following a symlink out of it or
|
|
196
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
197
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
198
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
199
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
200
|
+
* with the same bytes (a re-run) is left as is.
|
|
201
|
+
*/
|
|
202
|
+
async function installFile(root: string, path: string, bytes: Buffer) {
|
|
203
|
+
const parts = path.split('/')
|
|
204
|
+
let dir = root
|
|
205
|
+
for(const part of parts.slice(0, -1)) {
|
|
206
|
+
dir = join(dir, part)
|
|
207
|
+
await mkdir(dir).catch((err: unknown) => {
|
|
208
|
+
if(!isEexist(err)) {
|
|
209
|
+
throw err
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
assertNoSymlinks(root, relative(root, dir))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const dest = join(dir, parts.at(-1)!)
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(dest, bytes, { flag: 'wx' })
|
|
218
|
+
} catch(err) {
|
|
219
|
+
if(!isEexist(err)) {
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
assertNoSymlinks(root, path)
|
|
224
|
+
assert(
|
|
225
|
+
readFileSync(dest).equals(bytes),
|
|
226
|
+
conflict(`${path} appeared with different content; nothing replaced`),
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const real = await realpath(dest)
|
|
232
|
+
if(!real.startsWith(root + sep)) {
|
|
233
|
+
await rm(dest, { force: true })
|
|
234
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isEexist(err: unknown) {
|
|
239
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Throws when `path`, or any of its existing parents under `root`, is a
|
|
243
|
+
* symlink: writing through one would land outside the workspace. */
|
|
244
|
+
function assertNoSymlinks(root: string, path: string) {
|
|
245
|
+
let current = root
|
|
246
|
+
for(const part of path.split(/[\\/]/)) {
|
|
247
|
+
current += sep + part
|
|
248
|
+
const stat = lstatSync(current, { throwIfNoEntry: false })
|
|
249
|
+
assert(
|
|
250
|
+
!stat?.isSymbolicLink(),
|
|
251
|
+
badRequest(
|
|
252
|
+
`${current} is a symlink; the example app is only written to `
|
|
253
|
+
+ 'real directories inside the workspace',
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
207
259
|
/** The backend's package.json with the client range set to `^version`. */
|
|
208
260
|
function withClientVersion(bytes: Buffer, version: string) {
|
|
209
261
|
const text = bytes.toString('utf8')
|
package/src/provider/draft.ts
CHANGED
|
@@ -132,10 +132,11 @@ export function assertPortableMatches(
|
|
|
132
132
|
// proving an email's domain substring. Reject it regardless of the declared
|
|
133
133
|
// targets (the local part is usually not itself a target, so the value scan
|
|
134
134
|
// below wouldn't catch it). Strip params + named groups + regex escapes
|
|
135
|
-
// first, then look for `localpart@` as a surviving literal.
|
|
135
|
+
// first, then look for `localpart@` as a surviving literal. A placeholder
|
|
136
|
+
// never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
|
|
136
137
|
const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
|
|
137
138
|
const deregex = (s: string): string => s
|
|
138
|
-
.replace(/\{\{[^}]*\}\}/g, '')
|
|
139
|
+
.replace(/\{\{[^{}]*\}\}/g, '')
|
|
139
140
|
.replace(/\(\?<[^>]*>[^)]*\)/g, '')
|
|
140
141
|
.replace(/\\(.)/g, '$1')
|
|
141
142
|
const scanEmail = (text: string, where: string) => {
|
|
@@ -168,7 +169,7 @@ export function assertPortableMatches(
|
|
|
168
169
|
return
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
|
|
172
|
+
const stripParams = (s: string): string => s.replace(/\{\{[^{}]*\}\}/g, '')
|
|
172
173
|
const scan = (text: string, where: string, own: string | null) => {
|
|
173
174
|
const bare = stripParams(text)
|
|
174
175
|
for(const v of guarded) {
|
package/src/provider/matcher.ts
CHANGED
|
@@ -789,7 +789,7 @@ function stableOpeningTail(openTag: string): string {
|
|
|
789
789
|
* content (the JSON body the attestor exposes when a jsonPath is paired with
|
|
790
790
|
* the script's xPath); otherwise undefined. */
|
|
791
791
|
function scriptJsonContent(markup: string): string | undefined {
|
|
792
|
-
const match = /^<script\b[^>]*>([\s\S]*)<\/script
|
|
792
|
+
const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup)
|
|
793
793
|
if(!match) {
|
|
794
794
|
return undefined
|
|
795
795
|
}
|