@sasjs/cli 6.10.0 → 6.11.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.
@@ -954,6 +954,35 @@ const resolveProgram = (program) => {
954
954
  return null
955
955
  }
956
956
 
957
+ /**
958
+ * The header file a session starts with. sasjs/server seeds this exact line
959
+ * into a JS session (controllers/internal/Session.ts), so a service that
960
+ * declares no type answers text/html rather than leaving the choice to the
961
+ * browser - a mock build matches that here.
962
+ *
963
+ * The trailing newline is part of it: the server writes it for a JS session
964
+ * (`'content-type: text/html; charset=utf-8\n'` in Session.ts, as against the
965
+ * SAS 9 session's seed with no newline), and it is what keeps a service that
966
+ * appends a header without a leading newline on its own line - otherwise the two
967
+ * concatenate into one unusable value.
968
+ *
969
+ * It is a default, not a declaration: the worker ignores it for a byte body,
970
+ * so a service that writes bytes and names no type is still an undeclared
971
+ * download rather than being labelled text/html. The worker reads the value
972
+ * from HEADERS_SEED below, so the seed and the rule cannot drift.
973
+ */
974
+ const SEEDED_HEADERS = 'content-type: text/html; charset=utf-8\n'
975
+
976
+ /**
977
+ * The largest body a single upload may carry.
978
+ *
979
+ * A browser worker holds the whole payload in memory, so an oversized upload is
980
+ * an out-of-memory failure rather than a limit a client can act on. This is the
981
+ * same 10 MB a deploy allows for one file, and it is checked from the File's
982
+ * `size` before the body is read.
983
+ */
984
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024
985
+
957
986
  const runProgram = ({ program, code, params = {}, tables = [] }) => {
958
987
  const payloadCode = code
959
988
  const codePath = program ? resolveProgram(program) : null
@@ -977,7 +1006,7 @@ const runProgram = ({ program, code, params = {}, tables = [] }) => {
977
1006
  const headersPath = `${sessionDir}/stpsrv_header.txt`
978
1007
  const tokenFile = `${sessionDir}/reqHeaders.txt`
979
1008
  FILES.set(weboutPath, '')
980
- FILES.set(headersPath, '')
1009
+ FILES.set(headersPath, SEEDED_HEADERS)
981
1010
  FILES.set(tokenFile, '')
982
1011
 
983
1012
  const varStatements = Object.entries(params)
@@ -1144,7 +1173,9 @@ const readDriveFile = (filePath) => {
1144
1173
  String(filePath).replace(/^\/+/, '')
1145
1174
  )
1146
1175
  if (!FILES.has(abs)) {
1147
- const err = new Error('File not found')
1176
+ // the server's wording, because a client prints this: GET /drive/file
1177
+ // answers `res.status(404).send(err.message)`
1178
+ const err = new Error(`File doesn't exist.`)
1148
1179
  err.status = 404
1149
1180
  throw err
1150
1181
  }
@@ -1160,6 +1191,207 @@ const readDriveFile = (filePath) => {
1160
1191
  /** The stored base64 of a byte entry, once it is known to decode. */
1161
1192
  const validateEntry = (contents) => cleanedBase64(contents)
1162
1193
 
1194
+ // ------------------------------------------------------------- drive writes
1195
+ // The /drive endpoints answer the same contract sasjs/server serves, so a client
1196
+ // that uploads, renames or deletes needs no change for a mock build - only the
1197
+ // storage differs (a Map here, a folder on the server). Every mutation marks the
1198
+ // entry dirty, which is how the page learns to persist it: the worker can be
1199
+ // terminated between requests, so a write it did not hand back would live only
1200
+ // until the next reload.
1201
+
1202
+ const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
1203
+
1204
+ const driveError = (status, message) => {
1205
+ const error = new Error(message)
1206
+ error.status = status
1207
+ return error
1208
+ }
1209
+
1210
+ /**
1211
+ * A caller-supplied path as the absolute path under the drive root, or null when
1212
+ * it lands outside - the rule sasjs/server applies in resolveWithinDrive. A
1213
+ * leading slash means "under the drive root", never filesystem absolute, and the
1214
+ * test is on the whole normalised path rather than a substring: `/drive/filesx`
1215
+ * is not inside `/drive/files`.
1216
+ */
1217
+ const drivePath = (unsafePath) => {
1218
+ const root = DRIVE_FILES()
1219
+ const normalised = String(
1220
+ unsafePath === undefined || unsafePath === null ? '' : unsafePath
1221
+ )
1222
+ .replace(/\\/g, '/')
1223
+ .replace(/^\/+/, '')
1224
+ const resolved = P.resolve(root, normalised)
1225
+ return resolved === root || resolved.startsWith(root + '/') ? resolved : null
1226
+ }
1227
+
1228
+ /**
1229
+ * Writes one entry: bytes stay bytes (behind the base64 marker the drive reads
1230
+ * them back from), a string is text, and the entry is marked for persistence.
1231
+ *
1232
+ * An upload arrives as bytes and is stored as bytes, which is also what the
1233
+ * server does: multer writes the request body to disk and `saveFile` moves that
1234
+ * file, so an uploaded CSV is a byte file there too - a program reading it
1235
+ * without an encoding gets a Buffer on both hosts, and with `'utf8'` a string.
1236
+ * The difference a client actually sees is the content type a download is served
1237
+ * with, and that is decided by the extension on the GET route, as `sendFile`
1238
+ * does on the server.
1239
+ */
1240
+ const storeEntry = (abs, data) => {
1241
+ const bytes = asBytes(data)
1242
+ FILES.set(
1243
+ abs,
1244
+ bytes
1245
+ ? BINARY_PREFIX + bytesToBase64(bytes)
1246
+ : encodeText(String(data === undefined || data === null ? '' : data))
1247
+ )
1248
+ SIZES.delete(abs)
1249
+ markDirty(abs)
1250
+ }
1251
+
1252
+ /**
1253
+ * The ancestor of a path that is itself a file, if there is one. `mkdirp` would
1254
+ * happily turn `/data/x.csv` into a folder to hold `/data/x.csv/child`, leaving a
1255
+ * path that is a file and has children at the same time - a state the server
1256
+ * never reaches, because the filesystem refuses it (ENOTDIR).
1257
+ */
1258
+ const fileAncestorOf = (abs) => {
1259
+ const parts = abs.split('/').filter(Boolean)
1260
+ let acc = ''
1261
+ for (const part of parts.slice(0, -1)) {
1262
+ acc += '/' + part
1263
+ if (FILES.has(acc)) return acc
1264
+ }
1265
+ return null
1266
+ }
1267
+
1268
+ const saveDriveFile = (filePath, data) => {
1269
+ const abs = drivePath(filePath)
1270
+ if (!abs || abs === DRIVE_FILES())
1271
+ throw driveError(400, `Can't put file outside drive.`)
1272
+ // a folder at the same path would leave one path present as both, which the
1273
+ // server fails at the filesystem (EISDIR) rather than reporting as a conflict
1274
+ if (FILES.has(abs) || DIRS.has(abs))
1275
+ throw driveError(409, 'File already exists.')
1276
+ // the server fails this at the filesystem with ENOTDIR and answers a 500; a
1277
+ // named refusal is more use to a client than reproducing that, so it is a 400
1278
+ const ancestor = fileAncestorOf(abs)
1279
+ if (ancestor)
1280
+ // the drive-relative path, never the host's absolute one: a client has no
1281
+ // business knowing where the drive lives
1282
+ throw driveError(
1283
+ 400,
1284
+ `An ancestor of the path is a file: ${ancestor.slice(DRIVE_FILES().length) || '/'}`
1285
+ )
1286
+ mkdirp(P.dirname(abs))
1287
+ storeEntry(abs, data)
1288
+ return { status: 'success' }
1289
+ }
1290
+
1291
+ const updateDriveFile = (filePath, data) => {
1292
+ const abs = drivePath(filePath)
1293
+ if (!abs || abs === DRIVE_FILES())
1294
+ throw driveError(400, `Can't modify file outside drive.`)
1295
+ if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
1296
+ storeEntry(abs, data)
1297
+ return { status: 'success' }
1298
+ }
1299
+
1300
+ const removeDriveFile = (filePath) => {
1301
+ const abs = drivePath(filePath)
1302
+ if (!abs || abs === DRIVE_FILES())
1303
+ throw driveError(400, `Can't delete file outside drive.`)
1304
+ if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
1305
+ FILES.delete(abs)
1306
+ SIZES.delete(abs)
1307
+ markDirty(abs)
1308
+ return { status: 'success' }
1309
+ }
1310
+
1311
+ const addDriveFolder = (folderPath) => {
1312
+ const abs = drivePath(folderPath)
1313
+ if (!abs || abs === DRIVE_FILES())
1314
+ throw driveError(400, `Can't put folder outside drive.`)
1315
+ if (DIRS.has(abs) || FILES.has(abs))
1316
+ throw driveError(409, 'Folder already exists.')
1317
+ mkdirp(abs)
1318
+ return { status: 'success' }
1319
+ }
1320
+
1321
+ /**
1322
+ * Deletes a folder and everything under it. The drive is a flat map of paths, so
1323
+ * this is the entries' prefix rather than a directory walk - and each file it
1324
+ * removes is marked dirty (as a deletion) so the page drops it from IndexedDB
1325
+ * too. An empty folder is not represented in the persisted drive, which is a
1326
+ * map of files; see the note on the docs page.
1327
+ */
1328
+ const removeDriveFolder = (folderPath) => {
1329
+ const abs = drivePath(folderPath)
1330
+ if (!abs || abs === DRIVE_FILES())
1331
+ throw driveError(400, `Can't delete folder outside drive.`)
1332
+ if (!DIRS.has(abs)) throw driveError(404, `Folder doesn't exist.`)
1333
+ const prefix = abs + '/'
1334
+ for (const path of [...FILES.keys()]) {
1335
+ if (!path.startsWith(prefix)) continue
1336
+ FILES.delete(path)
1337
+ SIZES.delete(path)
1338
+ markDirty(path)
1339
+ }
1340
+ for (const dir of [...DIRS]) {
1341
+ if (dir === abs || dir.startsWith(prefix)) DIRS.delete(dir)
1342
+ }
1343
+ return { status: 'success' }
1344
+ }
1345
+
1346
+ /** Moves one stored file, leaving both paths for the page to persist. */
1347
+ const moveEntry = (from, to) => {
1348
+ FILES.set(to, FILES.get(from))
1349
+ FILES.delete(from)
1350
+ SIZES.delete(from)
1351
+ SIZES.delete(to)
1352
+ markDirty(to)
1353
+ markDirty(from)
1354
+ }
1355
+
1356
+ const renameDriveEntry = (oldPath, newPath) => {
1357
+ const from = drivePath(oldPath)
1358
+ if (!from || from === DRIVE_FILES())
1359
+ throw driveError(400, `Old path can't be outside of drive.`)
1360
+ const to = drivePath(newPath)
1361
+ if (!to || to === DRIVE_FILES())
1362
+ throw driveError(400, `New path can't be outside of drive.`)
1363
+
1364
+ if (DIRS.has(from)) {
1365
+ // the server fails this at the filesystem; naming it here says why
1366
+ if (to.startsWith(from + '/'))
1367
+ throw driveError(400, `New path can't be inside the folder being moved.`)
1368
+ if (DIRS.has(to) || FILES.has(to))
1369
+ throw driveError(409, 'Folder with new name already exists.')
1370
+ const prefix = from + '/'
1371
+ mkdirp(P.dirname(to))
1372
+ for (const dir of [...DIRS]) {
1373
+ if (dir === from || dir.startsWith(prefix)) {
1374
+ DIRS.delete(dir)
1375
+ mkdirp(to + dir.slice(from.length))
1376
+ }
1377
+ }
1378
+ for (const path of [...FILES.keys()]) {
1379
+ if (path.startsWith(prefix)) moveEntry(path, to + path.slice(from.length))
1380
+ }
1381
+ return { status: 'success' }
1382
+ }
1383
+
1384
+ if (FILES.has(from)) {
1385
+ if (FILES.has(to) || DIRS.has(to))
1386
+ throw driveError(409, 'File with new name already exists.')
1387
+ mkdirp(P.dirname(to))
1388
+ moveEntry(from, to)
1389
+ return { status: 'success' }
1390
+ }
1391
+
1392
+ throw driveError(404, 'No file/folder found for provided path.')
1393
+ }
1394
+
1163
1395
  const buildTree = () => {
1164
1396
  const walk = (dir) => {
1165
1397
  const node = {
@@ -1337,7 +1569,21 @@ const api = {
1337
1569
  run: (payload) => runProgram(payload),
1338
1570
  list: (folder) => listFolder(folder),
1339
1571
  read: (file) => readDriveFile(file),
1572
+ // the write half of the drive API: each one answers the server's success body
1573
+ // or throws an error carrying the server's status and message
1574
+ save: (filePath, data) => saveDriveFile(filePath, data),
1575
+ update: (filePath, data) => updateDriveFile(filePath, data),
1576
+ remove: (filePath) => removeDriveFile(filePath),
1577
+ mkdir: (folderPath) => addDriveFolder(folderPath),
1578
+ rmdir: (folderPath) => removeDriveFolder(folderPath),
1579
+ rename: (oldPath, newPath) => renameDriveEntry(oldPath, newPath),
1340
1580
  tree: () => buildTree(),
1581
+ // the content type a session's header file is seeded with, so the worker can
1582
+ // tell a default from a declaration rather than carrying a second literal
1583
+ HEADERS_SEED: SEEDED_HEADERS,
1584
+ // the largest single upload the host accepts, so the worker can refuse a body
1585
+ // before materialising it and the specs and docs read the same number
1586
+ MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES,
1341
1587
  // the format the snapshot download stamps, so persist.js writes the number
1342
1588
  // this runtime reads rather than a second literal
1343
1589
  DRIVE_FORMAT
@@ -11,12 +11,13 @@
11
11
  * receives the changed files back after every run.
12
12
  *
13
13
  * TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
14
- * path on the site's origin, runs whatever program the request names, and runs
15
- * the code a code/execute request carries - the same contract a SASjs server
16
- * offers, minus the server. Nothing here is a boundary: a page at this origin
17
- * can already do all of it, because a visitor's own browser is the client. So
18
- * a site holding anything private, or one served from a shared origin, belongs
19
- * behind a real server rather than a static host.
14
+ * path on the site's origin, writes to the drive when a drive request names a
15
+ * path, runs whatever program the request names, and runs the code a
16
+ * code/execute request carries - the same contract a SASjs server offers, minus
17
+ * the server. Nothing here is a boundary: a page at this origin can already do
18
+ * all of it, because a visitor's own browser is the client. So a site holding
19
+ * anything private, or one served from a shared origin, belongs behind a real
20
+ * server rather than a static host.
20
21
  */
21
22
  importScripts('/host/runtime-core.js')
22
23
 
@@ -101,6 +102,14 @@ const parseHeaders = (content) => {
101
102
  return headers
102
103
  }
103
104
 
105
+ /**
106
+ * The content type a session's header file is seeded with - the server's
107
+ * default, not something the service asked for. The runtime owns the seed line,
108
+ * so the value is taken from it rather than repeated here: a byte body that
109
+ * carries only this type is an undeclared download, not a text/html one.
110
+ */
111
+ const SEEDED_HEADERS = String(R.HEADERS_SEED)
112
+
104
113
  /**
105
114
  * The reply a run produces: the webout, in the shape and under the name the
106
115
  * service asked for. A debug reply is the webout framed with the log, so it
@@ -114,6 +123,12 @@ const weboutResponse = (result, debug) => {
114
123
 
115
124
  const headers = parseHeaders(result.headers)
116
125
  if (result.binary) {
126
+ // the seed is a default, so a byte body that leaves the seeded file untouched
127
+ // declares nothing: it keeps the type an undeclared download gets. Compared as
128
+ // the raw text rather than as the parsed value, so a service that declares
129
+ // this same content type itself keeps it - only the untouched seed is dropped.
130
+ if (String(result.headers) === SEEDED_HEADERS)
131
+ delete headers['content-type']
117
132
  if (!headers['content-type'])
118
133
  headers['content-type'] = 'application/octet-stream'
119
134
  return new Response(toBytes(result.webout), { status: 200, headers })
@@ -159,6 +174,123 @@ const readPayload = async (request) => {
159
174
  return { params: body.params || {}, tables: body.tables || [], json: body }
160
175
  }
161
176
 
177
+ // ---------------------------------------------------------------- requests
178
+ /** A request body as JSON, and {} when it is not JSON - a client that posts a
179
+ * form where the route wants JSON gets the missing-field answer rather than a
180
+ * parse error. */
181
+ const readJson = async (request) => {
182
+ try {
183
+ return (await request.json()) || {}
184
+ } catch (e) {
185
+ return {}
186
+ }
187
+ }
188
+
189
+ const badRequest = (message) => {
190
+ const error = new Error(message)
191
+ error.status = 400
192
+ return error
193
+ }
194
+
195
+ const payloadTooLarge = (limit) => {
196
+ const error = new Error(
197
+ `The upload is larger than the browser host allows (${Math.round(
198
+ limit / (1024 * 1024)
199
+ )} MB).`
200
+ )
201
+ error.status = 413
202
+ return error
203
+ }
204
+
205
+ /**
206
+ * The value a route needs, or the 400 the server answers with when it is absent.
207
+ * The message is Joi's, because that is what a client sees from the real API.
208
+ */
209
+ const required = (value, name) => {
210
+ if (typeof value === 'string' && value.length) return value
211
+ throw badRequest(`"${name}" is required`)
212
+ }
213
+
214
+ /**
215
+ * sasjs/server refuses a file path with one of these extensions (filePathSchema
216
+ * in api/src/utils/validation.ts). Nothing is executable on a static host, but a
217
+ * client that would be refused on deploy should hear it here too.
218
+ */
219
+ const BLOCKED_FILE = /\.(exe|sh|htaccess)$/i
220
+
221
+ const fileTarget = (value, name) => {
222
+ const given = required(value, name)
223
+ if (BLOCKED_FILE.test(given)) throw badRequest('Invalid file extension')
224
+ return given
225
+ }
226
+
227
+ /** The bytes of an uploaded file, from the multipart field the API names. */
228
+ const uploadBytes = async (request) => {
229
+ const form = await request.formData().catch(() => null)
230
+ const upload = form && form.get('file')
231
+ if (!upload || typeof upload === 'string')
232
+ throw badRequest('"file" is not present.')
233
+ // refused from the File's size, before the body is read: a worker holds the
234
+ // whole payload in memory, so an oversized upload there is an out-of-memory
235
+ // failure rather than an answer a client can act on
236
+ if (typeof upload.size === 'number' && upload.size > R.MAX_UPLOAD_BYTES)
237
+ throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
238
+ const target = form.get('filePath')
239
+ return {
240
+ bytes: new Uint8Array(await upload.arrayBuffer()),
241
+ filePath: target === null ? undefined : target
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The content type `sendFile` would answer with for this path - by extension,
247
+ * because that is what express does, and because a byte entry would otherwise be
248
+ * served as `application/octet-stream` however text-like it is.
249
+ */
250
+ const CONTENT_TYPES = {
251
+ '.csv': 'text/csv',
252
+ '.json': 'application/json',
253
+ '.sas': 'text/plain',
254
+ '.txt': 'text/plain',
255
+ '.log': 'text/plain',
256
+ '.html': 'text/html',
257
+ '.htm': 'text/html',
258
+ '.js': 'text/javascript',
259
+ '.css': 'text/css',
260
+ '.zip': 'application/zip',
261
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
262
+ '.xls': 'application/vnd.ms-excel',
263
+ '.pdf': 'application/pdf',
264
+ '.png': 'image/png',
265
+ '.jpg': 'image/jpeg',
266
+ '.jpeg': 'image/jpeg',
267
+ '.gif': 'image/gif',
268
+ '.svg': 'image/svg+xml'
269
+ }
270
+
271
+ const contentTypeFor = (filePath, binary) => {
272
+ const name = String(filePath || '').toLowerCase()
273
+ const dot = name.lastIndexOf('.')
274
+ const known = dot > 0 ? CONTENT_TYPES[name.slice(dot)] : undefined
275
+ if (known) return known
276
+ return binary ? 'application/octet-stream' : 'text/plain'
277
+ }
278
+
279
+ /**
280
+ * Runs one drive mutation and then hands the page the entries it changed. The
281
+ * worker can be terminated between requests, so a write is only durable once the
282
+ * page has put it in IndexedDB.
283
+ *
284
+ * Await it at the call site: a bare `return driveResponse(...)` hands the
285
+ * promise back through handle's try without running its catch, so a refused
286
+ * write would reach the page as an unhandled rejection instead of its status.
287
+ */
288
+ const driveResponse = async (runner) => {
289
+ const result = runner()
290
+ await persistChanges()
291
+ return json(result)
292
+ }
293
+
162
294
  const handle = async (request, url) => {
163
295
  const path = url.pathname.replace(/\/+$/, '')
164
296
  const query = {}
@@ -178,20 +310,77 @@ const handle = async (request, url) => {
178
310
  }
179
311
 
180
312
  if (path.endsWith('/SASjsApi/drive/folder')) {
181
- return json(await R.list(query._folderPath || ''))
313
+ if (request.method === 'GET')
314
+ return json(await R.list(query._folderPath || ''))
315
+
316
+ if (request.method === 'POST') {
317
+ const body = await readJson(request)
318
+ return await driveResponse(() =>
319
+ R.mkdir(required(body.folderPath, 'folderPath'))
320
+ )
321
+ }
322
+
323
+ if (request.method === 'DELETE') {
324
+ // an empty path names the drive root, which the server refuses at
325
+ // validation rather than deleting everything
326
+ const folderPath = required(query._folderPath, '_folderPath')
327
+ return await driveResponse(() => R.rmdir(folderPath))
328
+ }
182
329
  }
183
330
 
184
331
  if (path.endsWith('/SASjsApi/drive/filetree')) {
185
332
  return json(R.tree())
186
333
  }
187
334
 
188
- if (path.endsWith('/SASjsApi/drive/file') && request.method === 'GET') {
189
- const file = R.read(query._filePath || '')
190
- return file.binary
191
- ? new Response(toBytes(file.contents), {
192
- headers: { 'content-type': 'application/octet-stream' }
193
- })
194
- : text(file.contents)
335
+ if (path.endsWith('/SASjsApi/drive/rename')) {
336
+ if (request.method === 'POST') {
337
+ const body = await readJson(request)
338
+ // both ends go through the extension check: without it a file could be
339
+ // renamed into a blocked name and then be unreachable by DELETE and PATCH,
340
+ // which apply the same check to the path they are given
341
+ const oldPath = fileTarget(body.oldPath, 'oldPath')
342
+ const newPath = fileTarget(body.newPath, 'newPath')
343
+ return await driveResponse(() => R.rename(oldPath, newPath))
344
+ }
345
+ }
346
+
347
+ if (path.endsWith('/SASjsApi/drive/file')) {
348
+ if (request.method === 'GET') {
349
+ const file = R.read(query._filePath || '')
350
+ // the server answers this with `sendFile`, so the type comes from the
351
+ // extension: a .csv is text/csv whether it was uploaded as bytes or
352
+ // written by a service as text
353
+ return new Response(
354
+ file.binary ? toBytes(file.contents) : file.contents,
355
+ {
356
+ status: 200,
357
+ headers: {
358
+ 'content-type': contentTypeFor(query._filePath, file.binary)
359
+ }
360
+ }
361
+ )
362
+ }
363
+
364
+ if (request.method === 'DELETE') {
365
+ const filePath = fileTarget(query._filePath, '_filePath')
366
+ return await driveResponse(() => R.remove(filePath))
367
+ }
368
+
369
+ // POST creates (409 when the path is taken) and PATCH replaces (404 when
370
+ // it is not there) - the split the server's routes make, so a client that
371
+ // keys on the status code behaves the same way
372
+ if (request.method === 'POST' || request.method === 'PATCH') {
373
+ const upload = await uploadBytes(request)
374
+ const filePath = fileTarget(
375
+ query._filePath || upload.filePath,
376
+ '_filePath'
377
+ )
378
+ return await driveResponse(() =>
379
+ request.method === 'PATCH'
380
+ ? R.update(filePath, upload.bytes)
381
+ : R.save(filePath, upload.bytes)
382
+ )
383
+ }
195
384
  }
196
385
 
197
386
  if (
@@ -238,6 +427,12 @@ const handle = async (request, url) => {
238
427
  } catch (e) {
239
428
  const message = (e && e.stack) || String(e)
240
429
  broadcast({ type: 'sasjs-host-error', path, message })
430
+ // A drive refusal is the message itself, in plain text: the server answers
431
+ // these with `res.status(code).send(err.message)`, and its own suite asserts
432
+ // `res.text`. The other routes answer JSON, because a program failure
433
+ // carries more than a message (`status`, `error`), so they keep the envelope.
434
+ if (path.includes('/SASjsApi/drive/') && e && e.status)
435
+ return text((e && e.message) || String(e), e.status)
241
436
  return json(
242
437
  { message: (e && e.message) || String(e) },
243
438
  (e && e.status) || 500