@sasjs/api 0.8.0 → 0.10.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/build/browser.js +239 -18
- package/build/cjs/code/index.js +12 -0
- package/build/cjs/code/service.js +57 -0
- package/build/cjs/corpus/code.js +62 -0
- package/build/cjs/corpus/records.js +64 -0
- package/build/cjs/host/dispatch.js +42 -3
- package/build/cjs/identity/records.js +38 -1
- package/build/cjs/identity/routeAccess.js +77 -0
- package/build/cjs/index.js +5 -1
- package/build/cjs/routes/overlay.js +12 -12
- package/build/esm/code/index.d.ts +10 -0
- package/build/esm/code/index.d.ts.map +1 -0
- package/build/esm/code/index.js +8 -0
- package/build/esm/code/service.d.ts +40 -0
- package/build/esm/code/service.d.ts.map +1 -0
- package/build/esm/code/service.js +53 -0
- package/build/esm/corpus/code.d.ts +17 -0
- package/build/esm/corpus/code.d.ts.map +1 -0
- package/build/esm/corpus/code.js +59 -0
- package/build/esm/corpus/drive.d.ts +9 -0
- package/build/esm/corpus/drive.d.ts.map +1 -1
- package/build/esm/corpus/records.d.ts.map +1 -1
- package/build/esm/corpus/records.js +64 -0
- package/build/esm/host/dispatch.d.ts +3 -0
- package/build/esm/host/dispatch.d.ts.map +1 -1
- package/build/esm/host/dispatch.js +42 -3
- package/build/esm/identity/records.d.ts +20 -1
- package/build/esm/identity/records.d.ts.map +1 -1
- package/build/esm/identity/records.js +38 -1
- package/build/esm/identity/routeAccess.d.ts +57 -0
- package/build/esm/identity/routeAccess.d.ts.map +1 -0
- package/build/esm/identity/routeAccess.js +72 -0
- package/build/esm/index.d.ts +3 -1
- package/build/esm/index.d.ts.map +1 -1
- package/build/esm/index.js +3 -0
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +12 -12
- package/package.json +9 -1
|
@@ -17,6 +17,7 @@ exports.createRecordService = exports.sanitiseDisplayName = void 0;
|
|
|
17
17
|
* the server sends.
|
|
18
18
|
*/
|
|
19
19
|
const types_js_1 = require("../types.js");
|
|
20
|
+
const routeAccess_js_1 = require("./routeAccess.js");
|
|
20
21
|
/** The name the server gives the group every account is added to on creation. */
|
|
21
22
|
const ALL_USERS_GROUP = 'all-users';
|
|
22
23
|
/** The group whose membership the API refuses to change. */
|
|
@@ -83,7 +84,8 @@ const asGroup = (group) => ({
|
|
|
83
84
|
name: group.name,
|
|
84
85
|
description: group.description
|
|
85
86
|
});
|
|
86
|
-
const createRecordService = (store) => {
|
|
87
|
+
const createRecordService = (store, options = {}) => {
|
|
88
|
+
const grantableRoutes = options.grantableRoutes || routeAccess_js_1.DEFAULT_GRANTABLE_ROUTES;
|
|
87
89
|
const groupDetails = async (group) => {
|
|
88
90
|
const members = [];
|
|
89
91
|
for (const uid of group.users) {
|
|
@@ -285,6 +287,41 @@ const createRecordService = (store) => {
|
|
|
285
287
|
throw new types_js_1.ApiError(400, 'Unable to update group.');
|
|
286
288
|
return groupDetails(updated);
|
|
287
289
|
},
|
|
290
|
+
async listAuthorizedRoutes(caller) {
|
|
291
|
+
// an identity is required because the route is about a caller's access,
|
|
292
|
+
// even though what it returns is the whole inventory
|
|
293
|
+
await requireCaller(store, caller);
|
|
294
|
+
return { paths: [...grantableRoutes] };
|
|
295
|
+
},
|
|
296
|
+
async myAuthorizedRoutes(caller) {
|
|
297
|
+
const reader = await requireCaller(store, caller);
|
|
298
|
+
// an administrator holds every route, and the always-visible ones are
|
|
299
|
+
// visible whatever the rules say
|
|
300
|
+
if (reader.isAdmin)
|
|
301
|
+
return { paths: [...grantableRoutes] };
|
|
302
|
+
const rules = await store.permissions();
|
|
303
|
+
const mine = {
|
|
304
|
+
user: rules
|
|
305
|
+
.filter((rule) => rule.user === reader.uid)
|
|
306
|
+
.map((rule) => ({ path: rule.path, setting: rule.setting })),
|
|
307
|
+
group: rules
|
|
308
|
+
.filter((rule) => rule.group !== undefined && reader.groups.includes(rule.group))
|
|
309
|
+
.map((rule) => ({ path: rule.path, setting: rule.setting }))
|
|
310
|
+
};
|
|
311
|
+
// a grant on the reserved public group admits any caller, and it is
|
|
312
|
+
// checked before the rules so no user or group rule can take it away
|
|
313
|
+
const publicGroup = (await store.groups()).find((group) => group.name === 'public');
|
|
314
|
+
const publicGrants = publicGroup
|
|
315
|
+
? rules
|
|
316
|
+
.filter((rule) => rule.group === publicGroup.uid && rule.setting === 'Grant')
|
|
317
|
+
.map((rule) => rule.path)
|
|
318
|
+
: [];
|
|
319
|
+
return {
|
|
320
|
+
paths: grantableRoutes.filter((path) => routeAccess_js_1.ALWAYS_VISIBLE_ROUTES.includes(path) ||
|
|
321
|
+
publicGrants.includes(path) ||
|
|
322
|
+
(0, routeAccess_js_1.isRouteGranted)(mine, path, (0, routeAccess_js_1.topLevelRouteFor)(path)))
|
|
323
|
+
};
|
|
324
|
+
},
|
|
288
325
|
async listPermissions(caller) {
|
|
289
326
|
const reader = await requireCaller(store, caller);
|
|
290
327
|
const rules = await store.permissions();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Which routes a caller may use, from the permission rules alone.
|
|
4
|
+
*
|
|
5
|
+
* This is the answer `GET /SASjsApi/info/myAuthorizedRoutes` gives, and it is the
|
|
6
|
+
* same decision the gate makes when the route is actually requested - a
|
|
7
|
+
* web interface that offers a route the gate then refuses is the bug this exists
|
|
8
|
+
* to prevent, so the two must not be two implementations. A host that gates its
|
|
9
|
+
* own middleware should call `isRouteGranted` rather than re-derive it.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.isRouteGranted = exports.topLevelRouteFor = exports.DEFAULT_GRANTABLE_ROUTES = exports.STATIC_AUTHORIZED_ROUTES = exports.ALWAYS_VISIBLE_ROUTES = exports.TOP_LEVEL_ROUTES = void 0;
|
|
13
|
+
/** The two top-level routes, which a rule can address to cover everything below. */
|
|
14
|
+
exports.TOP_LEVEL_ROUTES = ['/AppStream', '/SASjsApi'];
|
|
15
|
+
/**
|
|
16
|
+
* Routes any authenticated caller may use whatever the rules say. `/AppStream` is
|
|
17
|
+
* the navigation page whose job is to list the apps the caller may open, so
|
|
18
|
+
* gating it on a grant that lists them would hide the page that leads to them.
|
|
19
|
+
*/
|
|
20
|
+
exports.ALWAYS_VISIBLE_ROUTES = ['/AppStream'];
|
|
21
|
+
/**
|
|
22
|
+
* The routes that accept a rule, before a host adds its own. A host with
|
|
23
|
+
* streamed applications extends this with `/AppStream/<app>` for each one, which
|
|
24
|
+
* is why the inventory is passed in rather than derived from a fixed list here.
|
|
25
|
+
*/
|
|
26
|
+
exports.STATIC_AUTHORIZED_ROUTES = [
|
|
27
|
+
'/SASjsApi/code/execute',
|
|
28
|
+
'/SASjsApi/stp/execute',
|
|
29
|
+
'/SASjsApi/drive/deploy',
|
|
30
|
+
'/SASjsApi/drive/deploy/upload',
|
|
31
|
+
'/SASjsApi/drive/file',
|
|
32
|
+
'/SASjsApi/drive/folder',
|
|
33
|
+
'/SASjsApi/drive/fileTree',
|
|
34
|
+
'/SASjsApi/drive/rename'
|
|
35
|
+
];
|
|
36
|
+
/** The route inventory when the host states no additions of its own. */
|
|
37
|
+
exports.DEFAULT_GRANTABLE_ROUTES = [
|
|
38
|
+
...exports.TOP_LEVEL_ROUTES,
|
|
39
|
+
...exports.STATIC_AUTHORIZED_ROUTES
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* The top-level route that governs a request: `/SASjsApi/code/execute` is
|
|
43
|
+
* governed by `/SASjsApi`, and a rule on the top-level route applies to
|
|
44
|
+
* everything beneath it.
|
|
45
|
+
*/
|
|
46
|
+
const topLevelRouteFor = (routePath) => exports.TOP_LEVEL_ROUTES.find((route) => routePath.toLowerCase().startsWith(route.toLowerCase())) ?? routePath;
|
|
47
|
+
exports.topLevelRouteFor = topLevelRouteFor;
|
|
48
|
+
const settingOn = (rules, path) => rules.find((rule) => rule.path === path)?.setting;
|
|
49
|
+
const GRANT = 'Grant';
|
|
50
|
+
const DENY = 'Deny';
|
|
51
|
+
/**
|
|
52
|
+
* Whether these rules grant access to `path`.
|
|
53
|
+
*
|
|
54
|
+
* The order is the documented one and the only implementation of it:
|
|
55
|
+
*
|
|
56
|
+
* 1. a rule addressed to the user on the path itself decides either way;
|
|
57
|
+
* 2. otherwise a rule addressed to the user on the top-level route decides;
|
|
58
|
+
* 3. otherwise a group grant on the path allows, and a group deny on the path
|
|
59
|
+
* blocks the top-level check - but a grant from another group still allows;
|
|
60
|
+
* 4. otherwise a group grant on the top-level route allows;
|
|
61
|
+
* 5. otherwise denied.
|
|
62
|
+
*
|
|
63
|
+
* A grant beats a deny at the same level, which is why step 3 looks at every
|
|
64
|
+
* group rule rather than stopping at the first deny.
|
|
65
|
+
*/
|
|
66
|
+
const isRouteGranted = (rules, path, topLevelRoute = (0, exports.topLevelRouteFor)(path)) => {
|
|
67
|
+
const userSetting = settingOn(rules.user, path) ?? settingOn(rules.user, topLevelRoute);
|
|
68
|
+
if (userSetting)
|
|
69
|
+
return userSetting === GRANT;
|
|
70
|
+
const onPath = rules.group.filter((rule) => rule.path === path);
|
|
71
|
+
if (onPath.some((rule) => rule.setting === GRANT))
|
|
72
|
+
return true;
|
|
73
|
+
if (onPath.some((rule) => rule.setting === DENY))
|
|
74
|
+
return false;
|
|
75
|
+
return rules.group.some((rule) => rule.path === topLevelRoute && rule.setting === GRANT);
|
|
76
|
+
};
|
|
77
|
+
exports.isRouteGranted = isRouteGranted;
|
package/build/cjs/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.servicesFor = exports.runCase = exports.PROVIDER_MANAGED = exports.ADMIN_SIGNED_IN = exports.RECORD_CORPUS = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.sanitiseDisplayName = exports.createRecordService = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.measureDeployTree = exports.isDeployTree = exports.deployTree = exports.decodeBase64 = exports.checkDeployTree = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
|
|
17
|
+
exports.servicesFor = exports.runCase = exports.PROVIDER_MANAGED = exports.ADMIN_SIGNED_IN = exports.RECORD_CORPUS = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.CODE_CORPUS = exports.sanitiseDisplayName = exports.createRecordService = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.measureDeployTree = exports.isDeployTree = exports.deployTree = exports.decodeBase64 = exports.checkDeployTree = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
|
|
18
18
|
/**
|
|
19
19
|
* `@sasjs/api` - the SASjs Server API described once.
|
|
20
20
|
*
|
|
@@ -60,11 +60,15 @@ Object.defineProperty(exports, "TAG_CAPABILITIES", { enumerable: true, get: func
|
|
|
60
60
|
var dispatch_js_1 = require("./host/dispatch.js");
|
|
61
61
|
Object.defineProperty(exports, "dispatch", { enumerable: true, get: function () { return dispatch_js_1.dispatch; } });
|
|
62
62
|
Object.defineProperty(exports, "HANDLED_ROUTES", { enumerable: true, get: function () { return dispatch_js_1.HANDLED_ROUTES; } });
|
|
63
|
+
// a host gating its own middleware reads the same decision the inventory reports
|
|
64
|
+
__exportStar(require("./identity/routeAccess.js"), exports);
|
|
63
65
|
var service_js_2 = require("./identity/service.js");
|
|
64
66
|
Object.defineProperty(exports, "createIdentityService", { enumerable: true, get: function () { return service_js_2.createIdentityService; } });
|
|
65
67
|
var records_js_1 = require("./identity/records.js");
|
|
66
68
|
Object.defineProperty(exports, "createRecordService", { enumerable: true, get: function () { return records_js_1.createRecordService; } });
|
|
67
69
|
Object.defineProperty(exports, "sanitiseDisplayName", { enumerable: true, get: function () { return records_js_1.sanitiseDisplayName; } });
|
|
70
|
+
var code_js_1 = require("./corpus/code.js");
|
|
71
|
+
Object.defineProperty(exports, "CODE_CORPUS", { enumerable: true, get: function () { return code_js_1.CODE_CORPUS; } });
|
|
68
72
|
var memory_store_js_2 = require("./identity/memory-store.js");
|
|
69
73
|
Object.defineProperty(exports, "MemoryIdentityStore", { enumerable: true, get: function () { return memory_store_js_2.MemoryIdentityStore; } });
|
|
70
74
|
var fixture_js_1 = require("./identity/fixture.js");
|
|
@@ -26,11 +26,9 @@ exports.TAG_CAPABILITIES = {
|
|
|
26
26
|
* thing thirty times.
|
|
27
27
|
*/
|
|
28
28
|
const DIRECTORY = 'Synchronising with an external auth provider needs a directory port, which this package does not have: a host decides which directory it holds accounts in, and answers this itself.';
|
|
29
|
-
const EDITOR = 'Linting and formatting come from @sasjs/lint, which this package does not depend on. They land with the code routes so an editor has one wiring.';
|
|
30
29
|
const RUNTIME = 'Starting a program needs a runtime port, which this package does not have: a host decides which languages it holds, and answers this itself until then.';
|
|
31
30
|
const SESSION_RECORD = "A triggered session's record, log, output and state belong to the runtime that ran the program, and this package has no runtime port.";
|
|
32
31
|
const HOST_CONFIG = "The body is the host's own configuration - ports, run times and providers - which a mock states for itself and this package cannot know.";
|
|
33
|
-
const PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
|
|
34
32
|
const HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
|
|
35
33
|
const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
|
|
36
34
|
const ARCHIVE = "Expanding the uploaded archive is the host's transport work - multer and a zip library on the server, `DecompressionStream` in a worker - and the host hands this package the tree it produces, which is the same tree `POST /SASjsApi/drive/deploy` takes.";
|
|
@@ -97,8 +95,14 @@ exports.OVERLAY = {
|
|
|
97
95
|
// Code - the editor's three calls, and the runtime one
|
|
98
96
|
'POST /SASjsApi/code/execute': { implemented: false, reason: RUNTIME },
|
|
99
97
|
'POST /SASjsApi/code/trigger': { implemented: false, reason: RUNTIME },
|
|
100
|
-
'POST /SASjsApi/code/lint': {
|
|
101
|
-
|
|
98
|
+
'POST /SASjsApi/code/lint': {
|
|
99
|
+
implemented: true,
|
|
100
|
+
capabilities: ['identity']
|
|
101
|
+
},
|
|
102
|
+
'POST /SASjsApi/code/format': {
|
|
103
|
+
implemented: true,
|
|
104
|
+
capabilities: ['identity']
|
|
105
|
+
},
|
|
102
106
|
// Drive - the file and folder rules, and the three deploy routes
|
|
103
107
|
// The body is the file itself, so there is nothing to annotate
|
|
104
108
|
'GET /SASjsApi/drive/file': { implemented: true },
|
|
@@ -177,16 +181,12 @@ exports.OVERLAY = {
|
|
|
177
181
|
// Info - what the host is, and what this caller may use
|
|
178
182
|
'GET /SASjsApi/info': { implemented: false, reason: HOST_CONFIG },
|
|
179
183
|
'GET /SASjsApi/info/authorizedRoutes': {
|
|
180
|
-
implemented:
|
|
181
|
-
|
|
182
|
-
// identity even though the rest of the Info tag needs nothing
|
|
183
|
-
capabilities: ['identity'],
|
|
184
|
-
reason: PERMISSIONS
|
|
184
|
+
implemented: true,
|
|
185
|
+
capabilities: ['identity']
|
|
185
186
|
},
|
|
186
187
|
'GET /SASjsApi/info/myAuthorizedRoutes': {
|
|
187
|
-
implemented:
|
|
188
|
-
capabilities: ['identity']
|
|
189
|
-
reason: PERMISSIONS
|
|
188
|
+
implemented: true,
|
|
189
|
+
capabilities: ['identity']
|
|
190
190
|
},
|
|
191
191
|
// Permission - the rules the inventory above is derived from
|
|
192
192
|
'GET /SASjsApi/permission': {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code service, on its own entry point.
|
|
3
|
+
*
|
|
4
|
+
* `@sasjs/lint` reads the filesystem (its config resolution walks up for a
|
|
5
|
+
* `.sasjslint`), so it cannot be bundled for a service worker: the package root
|
|
6
|
+
* stays free of it and a host that wants these routes imports `@sasjs/api/code`.
|
|
7
|
+
*/
|
|
8
|
+
export { createCodeService } from './service.js';
|
|
9
|
+
export type { CodeRequest, CodeService, CodeServiceOptions } from './service.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/code/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAChD,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code service, on its own entry point.
|
|
3
|
+
*
|
|
4
|
+
* `@sasjs/lint` reads the filesystem (its config resolution walks up for a
|
|
5
|
+
* `.sasjslint`), so it cannot be bundled for a service worker: the package root
|
|
6
|
+
* stays free of it and a host that wants these routes imports `@sasjs/api/code`.
|
|
7
|
+
*/
|
|
8
|
+
export { createCodeService } from './service.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Diagnostic, LintConfig } from '@sasjs/lint';
|
|
2
|
+
import type { Subject } from '../identity/types.js';
|
|
3
|
+
/** What both code routes take: a buffer, and where it came from when it has a file. */
|
|
4
|
+
export interface CodeRequest {
|
|
5
|
+
code: string;
|
|
6
|
+
/** The drive path the code belongs to, so a host can pick that folder's rules. */
|
|
7
|
+
filePath?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CodeServiceOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The lint configuration that applies to a file.
|
|
12
|
+
*
|
|
13
|
+
* A host resolves the nearest `.sasjslint` at or above `filePath`, so each
|
|
14
|
+
* folder can carry its own rules; without one the linter's defaults apply,
|
|
15
|
+
* which is what an untitled buffer lints against.
|
|
16
|
+
*/
|
|
17
|
+
lintConfig?: (filePath?: string) => LintConfig | undefined;
|
|
18
|
+
}
|
|
19
|
+
export interface CodeService {
|
|
20
|
+
/** The diagnostics for a buffer. Nothing is written and nothing is executed. */
|
|
21
|
+
lint(caller: Subject | undefined, body: CodeRequest): Promise<Diagnostic[]>;
|
|
22
|
+
/** The formatted buffer. */
|
|
23
|
+
format(caller: Subject | undefined, body: CodeRequest): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The two editor routes, over `@sasjs/lint`.
|
|
27
|
+
*
|
|
28
|
+
* The linter is a dependency because it is the same everywhere, and it is
|
|
29
|
+
* deliberately NOT reachable from the package root: `@sasjs/lint` resolves its
|
|
30
|
+
* configuration by walking the filesystem, so it cannot be bundled into the
|
|
31
|
+
* service worker the browser entry produces. A host that wants these routes
|
|
32
|
+
* imports `@sasjs/api/code`, which is Node-only, exactly like the linter.
|
|
33
|
+
*
|
|
34
|
+
* The *rules* are the host's: which `.sasjslint` applies to a path is a fact
|
|
35
|
+
* about a drive, so `lintConfig` is asked for it.
|
|
36
|
+
* Neither route touches the drive or runs anything; they answer about the text
|
|
37
|
+
* in the request, which is what lets an editor check unsaved changes.
|
|
38
|
+
*/
|
|
39
|
+
export declare const createCodeService: (options?: CodeServiceOptions) => CodeService;
|
|
40
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/code/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAGzD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAEnD,uFAAuF;AACvF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAA;CAC3D;AAED,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,IAAI,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;IAC3E,4BAA4B;IAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACxE;AAmBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAC5B,UAAS,kBAAuB,KAC/B,WAqBF,CAAA"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { formatText, lintText } from '@sasjs/lint';
|
|
2
|
+
import { ApiError } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* `code`, refused the way the server's schema refuses it.
|
|
5
|
+
*
|
|
6
|
+
* Coercing with `String(...)` defeated the refusal: `{"code": 123}` was linted as
|
|
7
|
+
* the text "123" and `{"code": ""}` answered "required" where the server says it
|
|
8
|
+
* is empty. The three messages are Joi's, because they are the ones a client
|
|
9
|
+
* written against the document already handles.
|
|
10
|
+
*/
|
|
11
|
+
const codeOf = (body) => {
|
|
12
|
+
const value = body.code;
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
throw new ApiError(400, '"code" is required');
|
|
15
|
+
if (typeof value !== 'string')
|
|
16
|
+
throw new ApiError(400, '"code" must be a string');
|
|
17
|
+
if (value === '')
|
|
18
|
+
throw new ApiError(400, '"code" is not allowed to be empty');
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The two editor routes, over `@sasjs/lint`.
|
|
23
|
+
*
|
|
24
|
+
* The linter is a dependency because it is the same everywhere, and it is
|
|
25
|
+
* deliberately NOT reachable from the package root: `@sasjs/lint` resolves its
|
|
26
|
+
* configuration by walking the filesystem, so it cannot be bundled into the
|
|
27
|
+
* service worker the browser entry produces. A host that wants these routes
|
|
28
|
+
* imports `@sasjs/api/code`, which is Node-only, exactly like the linter.
|
|
29
|
+
*
|
|
30
|
+
* The *rules* are the host's: which `.sasjslint` applies to a path is a fact
|
|
31
|
+
* about a drive, so `lintConfig` is asked for it.
|
|
32
|
+
* Neither route touches the drive or runs anything; they answer about the text
|
|
33
|
+
* in the request, which is what lets an editor check unsaved changes.
|
|
34
|
+
*/
|
|
35
|
+
export const createCodeService = (options = {}) => {
|
|
36
|
+
const requireCaller = (caller) => {
|
|
37
|
+
// both routes are permissioned at the gate, so a caller that reached the
|
|
38
|
+
// service without one is a host that skipped it rather than a public route
|
|
39
|
+
if (!caller)
|
|
40
|
+
throw new ApiError(401, 'Unauthorized');
|
|
41
|
+
};
|
|
42
|
+
const configFor = (filePath) => options.lintConfig ? options.lintConfig(filePath) : undefined;
|
|
43
|
+
return {
|
|
44
|
+
async lint(caller, body) {
|
|
45
|
+
requireCaller(caller);
|
|
46
|
+
return lintText(codeOf(body), configFor(body.filePath));
|
|
47
|
+
},
|
|
48
|
+
async format(caller, body) {
|
|
49
|
+
requireCaller(caller);
|
|
50
|
+
return formatText(codeOf(body), configFor(body.filePath));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CorpusCase } from './drive.js';
|
|
2
|
+
/**
|
|
3
|
+
* The editor's corpus: the two routes that answer about a buffer rather than a
|
|
4
|
+
* file.
|
|
5
|
+
*
|
|
6
|
+
* Both expectations are the linter's own output for the same input, measured
|
|
7
|
+
* against `@sasjs/lint` with no configuration - the defaults an untitled buffer
|
|
8
|
+
* lints against. A host that supplies a `.sasjslint` changes the rules for its
|
|
9
|
+
* own drive, which is why the case names no `filePath`.
|
|
10
|
+
*
|
|
11
|
+
* lintText('data _null_;\n x = 1;\nrun;\n')
|
|
12
|
+
* -> [{ message: 'File missing Doxygen header', lineNumber: 1, ... }]
|
|
13
|
+
* formatText(...)
|
|
14
|
+
* -> the header block, then the program with its spacing normalised
|
|
15
|
+
*/
|
|
16
|
+
export declare const CODE_CORPUS: CorpusCase[];
|
|
17
|
+
//# sourceMappingURL=code.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"code.d.ts","sourceRoot":"","sources":["../../../src/corpus/code.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,WAAW,EAAE,UAAU,EA6CnC,CAAA"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's corpus: the two routes that answer about a buffer rather than a
|
|
3
|
+
* file.
|
|
4
|
+
*
|
|
5
|
+
* Both expectations are the linter's own output for the same input, measured
|
|
6
|
+
* against `@sasjs/lint` with no configuration - the defaults an untitled buffer
|
|
7
|
+
* lints against. A host that supplies a `.sasjslint` changes the rules for its
|
|
8
|
+
* own drive, which is why the case names no `filePath`.
|
|
9
|
+
*
|
|
10
|
+
* lintText('data _null_;\n x = 1;\nrun;\n')
|
|
11
|
+
* -> [{ message: 'File missing Doxygen header', lineNumber: 1, ... }]
|
|
12
|
+
* formatText(...)
|
|
13
|
+
* -> the header block, then the program with its spacing normalised
|
|
14
|
+
*/
|
|
15
|
+
export const CODE_CORPUS = [
|
|
16
|
+
{
|
|
17
|
+
id: 'code-lint-missing-header',
|
|
18
|
+
route: 'POST /SASjsApi/code/lint',
|
|
19
|
+
description: 'answers the diagnostics for a buffer, without touching a file',
|
|
20
|
+
request: {
|
|
21
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
22
|
+
subject: { userId: 'user-admin', clientId: 'web_app' },
|
|
23
|
+
body: { code: 'data _null_;\n x = 1;\nrun;\n' }
|
|
24
|
+
},
|
|
25
|
+
expect: {
|
|
26
|
+
status: 200,
|
|
27
|
+
body: [
|
|
28
|
+
{
|
|
29
|
+
message: 'File missing Doxygen header',
|
|
30
|
+
lineNumber: 1,
|
|
31
|
+
startColumnNumber: 1,
|
|
32
|
+
endColumnNumber: 1,
|
|
33
|
+
severity: 1
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
// Two refusals are deliberately not pinned here yet: a `code` that is not a
|
|
39
|
+
// string, and one that is empty. The package answers 400 `"code" must be a
|
|
40
|
+
// string` / `"code" is not allowed to be empty` - the messages the server's
|
|
41
|
+
// schema produces - but sasjs/server's code controller carries no
|
|
42
|
+
// `@Response(400)` for these routes, so the document states 200 alone and the
|
|
43
|
+
// parity gate refuses a case answering anything else. The cases land when the
|
|
44
|
+
// decorators do.
|
|
45
|
+
{
|
|
46
|
+
id: 'code-format-adds-the-header',
|
|
47
|
+
route: 'POST /SASjsApi/code/format',
|
|
48
|
+
description: 'answers the formatted buffer, header block included',
|
|
49
|
+
request: {
|
|
50
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
51
|
+
subject: { userId: 'user-admin', clientId: 'web_app' },
|
|
52
|
+
body: { code: 'data _null_;\n x = 1;\nrun;\n' }
|
|
53
|
+
},
|
|
54
|
+
expect: {
|
|
55
|
+
status: 200,
|
|
56
|
+
body: '/**\n @file\n @brief <Your brief here>\n\n <h4> SAS Macros </h4>\n\n**/\ndata _null_;\n x = 1;\nrun;\n'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
];
|
|
@@ -47,6 +47,15 @@ export interface CorpusCase {
|
|
|
47
47
|
* minted token is not a value a case can state.
|
|
48
48
|
*/
|
|
49
49
|
keys?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* How the body travels, where a string could be either. `json` is a JSON
|
|
52
|
+
* string on the wire - what tsoa emits for `Promise<string>` and what the
|
|
53
|
+
* server's `res.json` sends; `text` is a body the route sends itself, which
|
|
54
|
+
* the document carries in the `description` with no content. A case that
|
|
55
|
+
* names neither is read as `text` when the document declares no content and
|
|
56
|
+
* as `json` when it does.
|
|
57
|
+
*/
|
|
58
|
+
encoding?: 'json' | 'text';
|
|
50
59
|
};
|
|
51
60
|
}
|
|
52
61
|
export declare const DRIVE_CORPUS: CorpusCase[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;QACf;;;;;;;WAOG;QACH,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAC3B,CAAA;CACF;AAmBD,eAAO,MAAM,YAAY,EAAE,UAAU,EAqapC,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../../src/corpus/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAA;AAkB/B,qFAAqF;AACrF,eAAO,MAAM,eAAe,EAAE,eAG7B,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,eA0C9B,CAAA;AAOD,eAAO,MAAM,aAAa,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../../src/corpus/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAA;AAkB/B,qFAAqF;AACrF,eAAO,MAAM,eAAe,EAAE,eAG7B,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,eA0C9B,CAAA;AAOD,eAAO,MAAM,aAAa,EAAE,UAAU,EA4oCrC,CAAA"}
|
|
@@ -699,6 +699,70 @@ export const RECORD_CORPUS = [
|
|
|
699
699
|
expect: { status: 404, error: 'Group not found.' }
|
|
700
700
|
},
|
|
701
701
|
// ------------------------------------------------------------ permissions
|
|
702
|
+
{
|
|
703
|
+
id: 'authorized-routes',
|
|
704
|
+
route: 'GET /SASjsApi/info/authorizedRoutes',
|
|
705
|
+
description: 'answers the routes that accept a rule, to any caller',
|
|
706
|
+
identity: ADMIN_SIGNED_IN,
|
|
707
|
+
request: { token: RESTRICTED },
|
|
708
|
+
expect: {
|
|
709
|
+
status: 200,
|
|
710
|
+
body: {
|
|
711
|
+
paths: [
|
|
712
|
+
'/AppStream',
|
|
713
|
+
'/SASjsApi',
|
|
714
|
+
'/SASjsApi/code/execute',
|
|
715
|
+
'/SASjsApi/stp/execute',
|
|
716
|
+
'/SASjsApi/drive/deploy',
|
|
717
|
+
'/SASjsApi/drive/deploy/upload',
|
|
718
|
+
'/SASjsApi/drive/file',
|
|
719
|
+
'/SASjsApi/drive/folder',
|
|
720
|
+
'/SASjsApi/drive/fileTree',
|
|
721
|
+
'/SASjsApi/drive/rename'
|
|
722
|
+
]
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
id: 'my-authorized-routes-as-admin',
|
|
728
|
+
route: 'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
729
|
+
description: 'answers every route to an administrator',
|
|
730
|
+
identity: ADMIN_SIGNED_IN,
|
|
731
|
+
request: { token: ADMIN },
|
|
732
|
+
expect: {
|
|
733
|
+
status: 200,
|
|
734
|
+
body: {
|
|
735
|
+
paths: [
|
|
736
|
+
'/AppStream',
|
|
737
|
+
'/SASjsApi',
|
|
738
|
+
'/SASjsApi/code/execute',
|
|
739
|
+
'/SASjsApi/stp/execute',
|
|
740
|
+
'/SASjsApi/drive/deploy',
|
|
741
|
+
'/SASjsApi/drive/deploy/upload',
|
|
742
|
+
'/SASjsApi/drive/file',
|
|
743
|
+
'/SASjsApi/drive/folder',
|
|
744
|
+
'/SASjsApi/drive/fileTree',
|
|
745
|
+
'/SASjsApi/drive/rename'
|
|
746
|
+
]
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: 'my-authorized-routes-as-user',
|
|
752
|
+
route: 'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
753
|
+
description: 'answers a caller only the routes its rules grant',
|
|
754
|
+
identity: ADMIN_SIGNED_IN,
|
|
755
|
+
request: { token: RESTRICTED },
|
|
756
|
+
expect: {
|
|
757
|
+
status: 200,
|
|
758
|
+
// /AppStream is visible whatever the rules say; the two grants come from
|
|
759
|
+
// DCGroup; /SASjsApi/code/execute is denied to this user by a user rule,
|
|
760
|
+
// and nothing grants the rest
|
|
761
|
+
body: {
|
|
762
|
+
paths: ['/AppStream', '/SASjsApi/stp/execute', '/SASjsApi/drive/file']
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
},
|
|
702
766
|
{
|
|
703
767
|
id: 'list-permissions-as-admin',
|
|
704
768
|
route: 'GET /SASjsApi/permission',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DriveService } from '../drive/service.js';
|
|
2
2
|
import type { IdentityService } from '../identity/service.js';
|
|
3
3
|
import type { RecordService } from '../identity/records.js';
|
|
4
|
+
import type { CodeService } from '../code/service.js';
|
|
4
5
|
import type { Subject } from '../identity/types.js';
|
|
5
6
|
export interface ApiRequest {
|
|
6
7
|
query?: Record<string, string | undefined>;
|
|
@@ -33,6 +34,8 @@ export interface ApiResponse {
|
|
|
33
34
|
contents?: string | Uint8Array;
|
|
34
35
|
}
|
|
35
36
|
export interface Services {
|
|
37
|
+
/** The editor routes, where the host supplies a linter. */
|
|
38
|
+
code?: CodeService;
|
|
36
39
|
drive: DriveService;
|
|
37
40
|
/**
|
|
38
41
|
* Users, clients and tokens. A host without one answers 501 for the identity
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../../src/host/dispatch.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAOnD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;IACtD;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,yFAAyF;IACzF,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;
|
|
1
|
+
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../../src/host/dispatch.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAOnD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;IACtD;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,2DAA2D;IAC3D,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,KAAK,EAAE,YAAY,CAAA;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,yFAAyF;IACzF,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;AA2CD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CA2YrB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EA4ClC,CAAA"}
|
|
@@ -21,8 +21,13 @@ const validate = (route, request) => {
|
|
|
21
21
|
const given = param.in === 'query'
|
|
22
22
|
? request.query?.[param.name]
|
|
23
23
|
: request.body?.[param.name];
|
|
24
|
-
|
|
24
|
+
// missing and empty are two refusals, the way the schema has them: a
|
|
25
|
+
// client that sent the field an empty value has a different bug from one
|
|
26
|
+
// that omitted it
|
|
27
|
+
if (given === undefined)
|
|
25
28
|
throw new ApiError(400, `"${param.name}" is required`);
|
|
29
|
+
if (given === '')
|
|
30
|
+
throw new ApiError(400, `"${param.name}" is not allowed to be empty`);
|
|
26
31
|
}
|
|
27
32
|
};
|
|
28
33
|
/** The drive path a write names: the query parameter wins over the form field. */
|
|
@@ -41,7 +46,7 @@ export const dispatch = async (services, routeId, request = {}) => {
|
|
|
41
46
|
const route = routeById(routeId);
|
|
42
47
|
if (!route)
|
|
43
48
|
throw new ApiError(404, `No such operation: ${routeId}`);
|
|
44
|
-
const { drive, identity, records } = services;
|
|
49
|
+
const { drive, identity, records, code } = services;
|
|
45
50
|
// The server authenticates before its payload schema runs, so an anonymous
|
|
46
51
|
// request with an incomplete body is a 401 there rather than a 400. The order
|
|
47
52
|
// is only observable on the route groups that gate on a caller: the sign-in
|
|
@@ -89,7 +94,7 @@ export const dispatch = async (services, routeId, request = {}) => {
|
|
|
89
94
|
status: 200,
|
|
90
95
|
body: await identity.token({
|
|
91
96
|
clientId: String(body.clientId ?? ''),
|
|
92
|
-
code:
|
|
97
|
+
code: body.code
|
|
93
98
|
})
|
|
94
99
|
};
|
|
95
100
|
case 'POST /SASjsApi/auth/refresh':
|
|
@@ -216,6 +221,36 @@ export const dispatch = async (services, routeId, request = {}) => {
|
|
|
216
221
|
status: 200,
|
|
217
222
|
body: await required().deleteUser(await caller(), { username: String(params.username ?? '') }, { password: body.password })
|
|
218
223
|
};
|
|
224
|
+
case 'POST /SASjsApi/code/lint':
|
|
225
|
+
if (!code)
|
|
226
|
+
throw new ApiError(501, `This host has no code service, so it cannot answer ${routeId}.`);
|
|
227
|
+
return {
|
|
228
|
+
status: 200,
|
|
229
|
+
body: await code.lint(await caller(), {
|
|
230
|
+
code: body.code,
|
|
231
|
+
filePath: body.filePath
|
|
232
|
+
})
|
|
233
|
+
};
|
|
234
|
+
case 'POST /SASjsApi/code/format':
|
|
235
|
+
if (!code)
|
|
236
|
+
throw new ApiError(501, `This host has no code service, so it cannot answer ${routeId}.`);
|
|
237
|
+
return {
|
|
238
|
+
status: 200,
|
|
239
|
+
body: await code.format(await caller(), {
|
|
240
|
+
code: body.code,
|
|
241
|
+
filePath: body.filePath
|
|
242
|
+
})
|
|
243
|
+
};
|
|
244
|
+
case 'GET /SASjsApi/info/authorizedRoutes':
|
|
245
|
+
return {
|
|
246
|
+
status: 200,
|
|
247
|
+
body: await required().listAuthorizedRoutes(await caller())
|
|
248
|
+
};
|
|
249
|
+
case 'GET /SASjsApi/info/myAuthorizedRoutes':
|
|
250
|
+
return {
|
|
251
|
+
status: 200,
|
|
252
|
+
body: await required().myAuthorizedRoutes(await caller())
|
|
253
|
+
};
|
|
219
254
|
case 'GET /SASjsApi/group':
|
|
220
255
|
return { status: 200, body: await required().listGroups(await caller()) };
|
|
221
256
|
case 'GET /SASjsApi/group/{uid}':
|
|
@@ -318,6 +353,10 @@ export const dispatch = async (services, routeId, request = {}) => {
|
|
|
318
353
|
};
|
|
319
354
|
/** Whether a route is one `dispatch` can answer today. */
|
|
320
355
|
export const HANDLED_ROUTES = [
|
|
356
|
+
'POST /SASjsApi/code/lint',
|
|
357
|
+
'POST /SASjsApi/code/format',
|
|
358
|
+
'GET /SASjsApi/info/authorizedRoutes',
|
|
359
|
+
'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
321
360
|
'GET /SASjsApi/drive/file',
|
|
322
361
|
'POST /SASjsApi/drive/file',
|
|
323
362
|
'PATCH /SASjsApi/drive/file',
|