@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
package/build/browser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* @sasjs/api 0.
|
|
1
|
+
/* @sasjs/api 0.10.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
|
|
2
2
|
"use strict";
|
|
3
3
|
var SASjsApi = (() => {
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -23,7 +23,10 @@ var SASjsApi = (() => {
|
|
|
23
23
|
var index_exports = {};
|
|
24
24
|
__export(index_exports, {
|
|
25
25
|
ADMIN_SIGNED_IN: () => ADMIN_SIGNED_IN,
|
|
26
|
+
ALWAYS_VISIBLE_ROUTES: () => ALWAYS_VISIBLE_ROUTES,
|
|
26
27
|
ApiError: () => ApiError,
|
|
28
|
+
CODE_CORPUS: () => CODE_CORPUS,
|
|
29
|
+
DEFAULT_GRANTABLE_ROUTES: () => DEFAULT_GRANTABLE_ROUTES,
|
|
27
30
|
DEFAULT_IDENTITY: () => DEFAULT_IDENTITY,
|
|
28
31
|
DEPLOY_LIMITS: () => DEPLOY_LIMITS,
|
|
29
32
|
DEPLOY_PAYLOAD_BYTES: () => DEPLOY_PAYLOAD_BYTES,
|
|
@@ -38,7 +41,9 @@ var SASjsApi = (() => {
|
|
|
38
41
|
PROVIDER_MANAGED: () => PROVIDER_MANAGED,
|
|
39
42
|
RECORD_CORPUS: () => RECORD_CORPUS,
|
|
40
43
|
ROUTES: () => ROUTES,
|
|
44
|
+
STATIC_AUTHORIZED_ROUTES: () => STATIC_AUTHORIZED_ROUTES,
|
|
41
45
|
TAG_CAPABILITIES: () => TAG_CAPABILITIES,
|
|
46
|
+
TOP_LEVEL_ROUTES: () => TOP_LEVEL_ROUTES,
|
|
42
47
|
basename: () => basename,
|
|
43
48
|
checkDeployTree: () => checkDeployTree,
|
|
44
49
|
createDriveService: () => createDriveService,
|
|
@@ -53,13 +58,15 @@ var SASjsApi = (() => {
|
|
|
53
58
|
isApiError: () => isApiError,
|
|
54
59
|
isDeployTree: () => isDeployTree,
|
|
55
60
|
isRoot: () => isRoot,
|
|
61
|
+
isRouteGranted: () => isRouteGranted,
|
|
56
62
|
measureDeployTree: () => measureDeployTree,
|
|
57
63
|
normaliseDrivePath: () => normaliseDrivePath,
|
|
58
64
|
parseIdentity: () => parseIdentity,
|
|
59
65
|
routeById: () => routeById,
|
|
60
66
|
runCase: () => runCase,
|
|
61
67
|
sanitiseDisplayName: () => sanitiseDisplayName,
|
|
62
|
-
servicesFor: () => servicesFor
|
|
68
|
+
servicesFor: () => servicesFor,
|
|
69
|
+
topLevelRouteFor: () => topLevelRouteFor
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
// src/types.ts
|
|
@@ -2453,11 +2460,9 @@ var SASjsApi = (() => {
|
|
|
2453
2460
|
Web: ["identity"]
|
|
2454
2461
|
};
|
|
2455
2462
|
var 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.";
|
|
2456
|
-
var 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.";
|
|
2457
2463
|
var 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.";
|
|
2458
2464
|
var 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.";
|
|
2459
2465
|
var 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.";
|
|
2460
|
-
var PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
|
|
2461
2466
|
var HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
|
|
2462
2467
|
var WEB_BUNDLE = "The host serves its own web bundle; there is no API decision here for this package to answer.";
|
|
2463
2468
|
var 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.";
|
|
@@ -2524,8 +2529,14 @@ var SASjsApi = (() => {
|
|
|
2524
2529
|
// Code - the editor's three calls, and the runtime one
|
|
2525
2530
|
"POST /SASjsApi/code/execute": { implemented: false, reason: RUNTIME },
|
|
2526
2531
|
"POST /SASjsApi/code/trigger": { implemented: false, reason: RUNTIME },
|
|
2527
|
-
"POST /SASjsApi/code/lint": {
|
|
2528
|
-
|
|
2532
|
+
"POST /SASjsApi/code/lint": {
|
|
2533
|
+
implemented: true,
|
|
2534
|
+
capabilities: ["identity"]
|
|
2535
|
+
},
|
|
2536
|
+
"POST /SASjsApi/code/format": {
|
|
2537
|
+
implemented: true,
|
|
2538
|
+
capabilities: ["identity"]
|
|
2539
|
+
},
|
|
2529
2540
|
// Drive - the file and folder rules, and the three deploy routes
|
|
2530
2541
|
// The body is the file itself, so there is nothing to annotate
|
|
2531
2542
|
"GET /SASjsApi/drive/file": { implemented: true },
|
|
@@ -2604,16 +2615,12 @@ var SASjsApi = (() => {
|
|
|
2604
2615
|
// Info - what the host is, and what this caller may use
|
|
2605
2616
|
"GET /SASjsApi/info": { implemented: false, reason: HOST_CONFIG },
|
|
2606
2617
|
"GET /SASjsApi/info/authorizedRoutes": {
|
|
2607
|
-
implemented:
|
|
2608
|
-
|
|
2609
|
-
// identity even though the rest of the Info tag needs nothing
|
|
2610
|
-
capabilities: ["identity"],
|
|
2611
|
-
reason: PERMISSIONS
|
|
2618
|
+
implemented: true,
|
|
2619
|
+
capabilities: ["identity"]
|
|
2612
2620
|
},
|
|
2613
2621
|
"GET /SASjsApi/info/myAuthorizedRoutes": {
|
|
2614
|
-
implemented:
|
|
2615
|
-
capabilities: ["identity"]
|
|
2616
|
-
reason: PERMISSIONS
|
|
2622
|
+
implemented: true,
|
|
2623
|
+
capabilities: ["identity"]
|
|
2617
2624
|
},
|
|
2618
2625
|
// Permission - the rules the inventory above is derived from
|
|
2619
2626
|
"GET /SASjsApi/permission": {
|
|
@@ -2771,8 +2778,10 @@ var SASjsApi = (() => {
|
|
|
2771
2778
|
continue;
|
|
2772
2779
|
if (param.in === "path") continue;
|
|
2773
2780
|
const given = param.in === "query" ? request.query?.[param.name] : request.body?.[param.name];
|
|
2774
|
-
if (given === void 0
|
|
2781
|
+
if (given === void 0)
|
|
2775
2782
|
throw new ApiError(400, `"${param.name}" is required`);
|
|
2783
|
+
if (given === "")
|
|
2784
|
+
throw new ApiError(400, `"${param.name}" is not allowed to be empty`);
|
|
2776
2785
|
}
|
|
2777
2786
|
};
|
|
2778
2787
|
var targetPath = (request) => {
|
|
@@ -2785,7 +2794,7 @@ var SASjsApi = (() => {
|
|
|
2785
2794
|
var dispatch = async (services, routeId, request = {}) => {
|
|
2786
2795
|
const route = routeById(routeId);
|
|
2787
2796
|
if (!route) throw new ApiError(404, `No such operation: ${routeId}`);
|
|
2788
|
-
const { drive, identity, records } = services;
|
|
2797
|
+
const { drive, identity, records, code } = services;
|
|
2789
2798
|
const identityScoped = ["User", "Group", "Permission", "Client"].includes(route.tag) || routeId === "PATCH /SASjsApi/auth/updatePassword";
|
|
2790
2799
|
if (identityScoped && records) {
|
|
2791
2800
|
const subject = request.subject ?? (identity ? await identity.authenticate(request.token) : void 0);
|
|
@@ -2826,7 +2835,7 @@ var SASjsApi = (() => {
|
|
|
2826
2835
|
status: 200,
|
|
2827
2836
|
body: await identity.token({
|
|
2828
2837
|
clientId: String(body.clientId ?? ""),
|
|
2829
|
-
code:
|
|
2838
|
+
code: body.code
|
|
2830
2839
|
})
|
|
2831
2840
|
};
|
|
2832
2841
|
case "POST /SASjsApi/auth/refresh":
|
|
@@ -2970,6 +2979,42 @@ var SASjsApi = (() => {
|
|
|
2970
2979
|
{ password: body.password }
|
|
2971
2980
|
)
|
|
2972
2981
|
};
|
|
2982
|
+
case "POST /SASjsApi/code/lint":
|
|
2983
|
+
if (!code)
|
|
2984
|
+
throw new ApiError(
|
|
2985
|
+
501,
|
|
2986
|
+
`This host has no code service, so it cannot answer ${routeId}.`
|
|
2987
|
+
);
|
|
2988
|
+
return {
|
|
2989
|
+
status: 200,
|
|
2990
|
+
body: await code.lint(await caller(), {
|
|
2991
|
+
code: body.code,
|
|
2992
|
+
filePath: body.filePath
|
|
2993
|
+
})
|
|
2994
|
+
};
|
|
2995
|
+
case "POST /SASjsApi/code/format":
|
|
2996
|
+
if (!code)
|
|
2997
|
+
throw new ApiError(
|
|
2998
|
+
501,
|
|
2999
|
+
`This host has no code service, so it cannot answer ${routeId}.`
|
|
3000
|
+
);
|
|
3001
|
+
return {
|
|
3002
|
+
status: 200,
|
|
3003
|
+
body: await code.format(await caller(), {
|
|
3004
|
+
code: body.code,
|
|
3005
|
+
filePath: body.filePath
|
|
3006
|
+
})
|
|
3007
|
+
};
|
|
3008
|
+
case "GET /SASjsApi/info/authorizedRoutes":
|
|
3009
|
+
return {
|
|
3010
|
+
status: 200,
|
|
3011
|
+
body: await required().listAuthorizedRoutes(await caller())
|
|
3012
|
+
};
|
|
3013
|
+
case "GET /SASjsApi/info/myAuthorizedRoutes":
|
|
3014
|
+
return {
|
|
3015
|
+
status: 200,
|
|
3016
|
+
body: await required().myAuthorizedRoutes(await caller())
|
|
3017
|
+
};
|
|
2973
3018
|
case "GET /SASjsApi/group":
|
|
2974
3019
|
return { status: 200, body: await required().listGroups(await caller()) };
|
|
2975
3020
|
case "GET /SASjsApi/group/{uid}":
|
|
@@ -3080,6 +3125,10 @@ var SASjsApi = (() => {
|
|
|
3080
3125
|
}
|
|
3081
3126
|
};
|
|
3082
3127
|
var HANDLED_ROUTES = [
|
|
3128
|
+
"POST /SASjsApi/code/lint",
|
|
3129
|
+
"POST /SASjsApi/code/format",
|
|
3130
|
+
"GET /SASjsApi/info/authorizedRoutes",
|
|
3131
|
+
"GET /SASjsApi/info/myAuthorizedRoutes",
|
|
3083
3132
|
"GET /SASjsApi/drive/file",
|
|
3084
3133
|
"POST /SASjsApi/drive/file",
|
|
3085
3134
|
"PATCH /SASjsApi/drive/file",
|
|
@@ -3121,6 +3170,40 @@ var SASjsApi = (() => {
|
|
|
3121
3170
|
"PATCH /SASjsApi/auth/updatePassword"
|
|
3122
3171
|
];
|
|
3123
3172
|
|
|
3173
|
+
// src/identity/routeAccess.ts
|
|
3174
|
+
var TOP_LEVEL_ROUTES = ["/AppStream", "/SASjsApi"];
|
|
3175
|
+
var ALWAYS_VISIBLE_ROUTES = ["/AppStream"];
|
|
3176
|
+
var STATIC_AUTHORIZED_ROUTES = [
|
|
3177
|
+
"/SASjsApi/code/execute",
|
|
3178
|
+
"/SASjsApi/stp/execute",
|
|
3179
|
+
"/SASjsApi/drive/deploy",
|
|
3180
|
+
"/SASjsApi/drive/deploy/upload",
|
|
3181
|
+
"/SASjsApi/drive/file",
|
|
3182
|
+
"/SASjsApi/drive/folder",
|
|
3183
|
+
"/SASjsApi/drive/fileTree",
|
|
3184
|
+
"/SASjsApi/drive/rename"
|
|
3185
|
+
];
|
|
3186
|
+
var DEFAULT_GRANTABLE_ROUTES = [
|
|
3187
|
+
...TOP_LEVEL_ROUTES,
|
|
3188
|
+
...STATIC_AUTHORIZED_ROUTES
|
|
3189
|
+
];
|
|
3190
|
+
var topLevelRouteFor = (routePath) => TOP_LEVEL_ROUTES.find(
|
|
3191
|
+
(route) => routePath.toLowerCase().startsWith(route.toLowerCase())
|
|
3192
|
+
) ?? routePath;
|
|
3193
|
+
var settingOn = (rules, path) => rules.find((rule) => rule.path === path)?.setting;
|
|
3194
|
+
var GRANT = "Grant";
|
|
3195
|
+
var DENY = "Deny";
|
|
3196
|
+
var isRouteGranted = (rules, path, topLevelRoute = topLevelRouteFor(path)) => {
|
|
3197
|
+
const userSetting = settingOn(rules.user, path) ?? settingOn(rules.user, topLevelRoute);
|
|
3198
|
+
if (userSetting) return userSetting === GRANT;
|
|
3199
|
+
const onPath = rules.group.filter((rule) => rule.path === path);
|
|
3200
|
+
if (onPath.some((rule) => rule.setting === GRANT)) return true;
|
|
3201
|
+
if (onPath.some((rule) => rule.setting === DENY)) return false;
|
|
3202
|
+
return rules.group.some(
|
|
3203
|
+
(rule) => rule.path === topLevelRoute && rule.setting === GRANT
|
|
3204
|
+
);
|
|
3205
|
+
};
|
|
3206
|
+
|
|
3124
3207
|
// src/identity/service.ts
|
|
3125
3208
|
var unauthorized = () => new ApiError(401, "Unauthorized");
|
|
3126
3209
|
var liveSubject = async (store, subject) => {
|
|
@@ -3241,7 +3324,8 @@ var SASjsApi = (() => {
|
|
|
3241
3324
|
name: group.name,
|
|
3242
3325
|
description: group.description
|
|
3243
3326
|
});
|
|
3244
|
-
var createRecordService = (store) => {
|
|
3327
|
+
var createRecordService = (store, options = {}) => {
|
|
3328
|
+
const grantableRoutes = options.grantableRoutes || DEFAULT_GRANTABLE_ROUTES;
|
|
3245
3329
|
const groupDetails = async (group) => {
|
|
3246
3330
|
const members = [];
|
|
3247
3331
|
for (const uid of group.users) {
|
|
@@ -3428,6 +3512,32 @@ var SASjsApi = (() => {
|
|
|
3428
3512
|
if (!updated) throw new ApiError(400, "Unable to update group.");
|
|
3429
3513
|
return groupDetails(updated);
|
|
3430
3514
|
},
|
|
3515
|
+
async listAuthorizedRoutes(caller) {
|
|
3516
|
+
await requireCaller(store, caller);
|
|
3517
|
+
return { paths: [...grantableRoutes] };
|
|
3518
|
+
},
|
|
3519
|
+
async myAuthorizedRoutes(caller) {
|
|
3520
|
+
const reader = await requireCaller(store, caller);
|
|
3521
|
+
if (reader.isAdmin) return { paths: [...grantableRoutes] };
|
|
3522
|
+
const rules = await store.permissions();
|
|
3523
|
+
const mine = {
|
|
3524
|
+
user: rules.filter((rule) => rule.user === reader.uid).map((rule) => ({ path: rule.path, setting: rule.setting })),
|
|
3525
|
+
group: rules.filter(
|
|
3526
|
+
(rule) => rule.group !== void 0 && reader.groups.includes(rule.group)
|
|
3527
|
+
).map((rule) => ({ path: rule.path, setting: rule.setting }))
|
|
3528
|
+
};
|
|
3529
|
+
const publicGroup = (await store.groups()).find(
|
|
3530
|
+
(group) => group.name === "public"
|
|
3531
|
+
);
|
|
3532
|
+
const publicGrants = publicGroup ? rules.filter(
|
|
3533
|
+
(rule) => rule.group === publicGroup.uid && rule.setting === "Grant"
|
|
3534
|
+
).map((rule) => rule.path) : [];
|
|
3535
|
+
return {
|
|
3536
|
+
paths: grantableRoutes.filter(
|
|
3537
|
+
(path) => ALWAYS_VISIBLE_ROUTES.includes(path) || publicGrants.includes(path) || isRouteGranted(mine, path, topLevelRouteFor(path))
|
|
3538
|
+
)
|
|
3539
|
+
};
|
|
3540
|
+
},
|
|
3431
3541
|
async listPermissions(caller) {
|
|
3432
3542
|
const reader = await requireCaller(store, caller);
|
|
3433
3543
|
const rules = await store.permissions();
|
|
@@ -3528,6 +3638,53 @@ var SASjsApi = (() => {
|
|
|
3528
3638
|
};
|
|
3529
3639
|
};
|
|
3530
3640
|
|
|
3641
|
+
// src/corpus/code.ts
|
|
3642
|
+
var CODE_CORPUS = [
|
|
3643
|
+
{
|
|
3644
|
+
id: "code-lint-missing-header",
|
|
3645
|
+
route: "POST /SASjsApi/code/lint",
|
|
3646
|
+
description: "answers the diagnostics for a buffer, without touching a file",
|
|
3647
|
+
request: {
|
|
3648
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
3649
|
+
subject: { userId: "user-admin", clientId: "web_app" },
|
|
3650
|
+
body: { code: "data _null_;\n x = 1;\nrun;\n" }
|
|
3651
|
+
},
|
|
3652
|
+
expect: {
|
|
3653
|
+
status: 200,
|
|
3654
|
+
body: [
|
|
3655
|
+
{
|
|
3656
|
+
message: "File missing Doxygen header",
|
|
3657
|
+
lineNumber: 1,
|
|
3658
|
+
startColumnNumber: 1,
|
|
3659
|
+
endColumnNumber: 1,
|
|
3660
|
+
severity: 1
|
|
3661
|
+
}
|
|
3662
|
+
]
|
|
3663
|
+
}
|
|
3664
|
+
},
|
|
3665
|
+
// Two refusals are deliberately not pinned here yet: a `code` that is not a
|
|
3666
|
+
// string, and one that is empty. The package answers 400 `"code" must be a
|
|
3667
|
+
// string` / `"code" is not allowed to be empty` - the messages the server's
|
|
3668
|
+
// schema produces - but sasjs/server's code controller carries no
|
|
3669
|
+
// `@Response(400)` for these routes, so the document states 200 alone and the
|
|
3670
|
+
// parity gate refuses a case answering anything else. The cases land when the
|
|
3671
|
+
// decorators do.
|
|
3672
|
+
{
|
|
3673
|
+
id: "code-format-adds-the-header",
|
|
3674
|
+
route: "POST /SASjsApi/code/format",
|
|
3675
|
+
description: "answers the formatted buffer, header block included",
|
|
3676
|
+
request: {
|
|
3677
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
3678
|
+
subject: { userId: "user-admin", clientId: "web_app" },
|
|
3679
|
+
body: { code: "data _null_;\n x = 1;\nrun;\n" }
|
|
3680
|
+
},
|
|
3681
|
+
expect: {
|
|
3682
|
+
status: 200,
|
|
3683
|
+
body: "/**\n @file\n @brief <Your brief here>\n\n <h4> SAS Macros </h4>\n\n**/\ndata _null_;\n x = 1;\nrun;\n"
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
];
|
|
3687
|
+
|
|
3531
3688
|
// src/identity/fixture.ts
|
|
3532
3689
|
var IDENTITY_FORMAT = 1;
|
|
3533
3690
|
var SECONDS_IN_A_DAY2 = 86400;
|
|
@@ -5343,6 +5500,70 @@ var SASjsApi = (() => {
|
|
|
5343
5500
|
expect: { status: 404, error: "Group not found." }
|
|
5344
5501
|
},
|
|
5345
5502
|
// ------------------------------------------------------------ permissions
|
|
5503
|
+
{
|
|
5504
|
+
id: "authorized-routes",
|
|
5505
|
+
route: "GET /SASjsApi/info/authorizedRoutes",
|
|
5506
|
+
description: "answers the routes that accept a rule, to any caller",
|
|
5507
|
+
identity: ADMIN_SIGNED_IN,
|
|
5508
|
+
request: { token: RESTRICTED },
|
|
5509
|
+
expect: {
|
|
5510
|
+
status: 200,
|
|
5511
|
+
body: {
|
|
5512
|
+
paths: [
|
|
5513
|
+
"/AppStream",
|
|
5514
|
+
"/SASjsApi",
|
|
5515
|
+
"/SASjsApi/code/execute",
|
|
5516
|
+
"/SASjsApi/stp/execute",
|
|
5517
|
+
"/SASjsApi/drive/deploy",
|
|
5518
|
+
"/SASjsApi/drive/deploy/upload",
|
|
5519
|
+
"/SASjsApi/drive/file",
|
|
5520
|
+
"/SASjsApi/drive/folder",
|
|
5521
|
+
"/SASjsApi/drive/fileTree",
|
|
5522
|
+
"/SASjsApi/drive/rename"
|
|
5523
|
+
]
|
|
5524
|
+
}
|
|
5525
|
+
}
|
|
5526
|
+
},
|
|
5527
|
+
{
|
|
5528
|
+
id: "my-authorized-routes-as-admin",
|
|
5529
|
+
route: "GET /SASjsApi/info/myAuthorizedRoutes",
|
|
5530
|
+
description: "answers every route to an administrator",
|
|
5531
|
+
identity: ADMIN_SIGNED_IN,
|
|
5532
|
+
request: { token: ADMIN },
|
|
5533
|
+
expect: {
|
|
5534
|
+
status: 200,
|
|
5535
|
+
body: {
|
|
5536
|
+
paths: [
|
|
5537
|
+
"/AppStream",
|
|
5538
|
+
"/SASjsApi",
|
|
5539
|
+
"/SASjsApi/code/execute",
|
|
5540
|
+
"/SASjsApi/stp/execute",
|
|
5541
|
+
"/SASjsApi/drive/deploy",
|
|
5542
|
+
"/SASjsApi/drive/deploy/upload",
|
|
5543
|
+
"/SASjsApi/drive/file",
|
|
5544
|
+
"/SASjsApi/drive/folder",
|
|
5545
|
+
"/SASjsApi/drive/fileTree",
|
|
5546
|
+
"/SASjsApi/drive/rename"
|
|
5547
|
+
]
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
5550
|
+
},
|
|
5551
|
+
{
|
|
5552
|
+
id: "my-authorized-routes-as-user",
|
|
5553
|
+
route: "GET /SASjsApi/info/myAuthorizedRoutes",
|
|
5554
|
+
description: "answers a caller only the routes its rules grant",
|
|
5555
|
+
identity: ADMIN_SIGNED_IN,
|
|
5556
|
+
request: { token: RESTRICTED },
|
|
5557
|
+
expect: {
|
|
5558
|
+
status: 200,
|
|
5559
|
+
// /AppStream is visible whatever the rules say; the two grants come from
|
|
5560
|
+
// DCGroup; /SASjsApi/code/execute is denied to this user by a user rule,
|
|
5561
|
+
// and nothing grants the rest
|
|
5562
|
+
body: {
|
|
5563
|
+
paths: ["/AppStream", "/SASjsApi/stp/execute", "/SASjsApi/drive/file"]
|
|
5564
|
+
}
|
|
5565
|
+
}
|
|
5566
|
+
},
|
|
5346
5567
|
{
|
|
5347
5568
|
id: "list-permissions-as-admin",
|
|
5348
5569
|
route: "GET /SASjsApi/permission",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCodeService = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The code service, on its own entry point.
|
|
6
|
+
*
|
|
7
|
+
* `@sasjs/lint` reads the filesystem (its config resolution walks up for a
|
|
8
|
+
* `.sasjslint`), so it cannot be bundled for a service worker: the package root
|
|
9
|
+
* stays free of it and a host that wants these routes imports `@sasjs/api/code`.
|
|
10
|
+
*/
|
|
11
|
+
var service_js_1 = require("./service.js");
|
|
12
|
+
Object.defineProperty(exports, "createCodeService", { enumerable: true, get: function () { return service_js_1.createCodeService; } });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCodeService = void 0;
|
|
4
|
+
const lint_1 = require("@sasjs/lint");
|
|
5
|
+
const types_js_1 = require("../types.js");
|
|
6
|
+
/**
|
|
7
|
+
* `code`, refused the way the server's schema refuses it.
|
|
8
|
+
*
|
|
9
|
+
* Coercing with `String(...)` defeated the refusal: `{"code": 123}` was linted as
|
|
10
|
+
* the text "123" and `{"code": ""}` answered "required" where the server says it
|
|
11
|
+
* is empty. The three messages are Joi's, because they are the ones a client
|
|
12
|
+
* written against the document already handles.
|
|
13
|
+
*/
|
|
14
|
+
const codeOf = (body) => {
|
|
15
|
+
const value = body.code;
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
throw new types_js_1.ApiError(400, '"code" is required');
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
throw new types_js_1.ApiError(400, '"code" must be a string');
|
|
20
|
+
if (value === '')
|
|
21
|
+
throw new types_js_1.ApiError(400, '"code" is not allowed to be empty');
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The two editor routes, over `@sasjs/lint`.
|
|
26
|
+
*
|
|
27
|
+
* The linter is a dependency because it is the same everywhere, and it is
|
|
28
|
+
* deliberately NOT reachable from the package root: `@sasjs/lint` resolves its
|
|
29
|
+
* configuration by walking the filesystem, so it cannot be bundled into the
|
|
30
|
+
* service worker the browser entry produces. A host that wants these routes
|
|
31
|
+
* imports `@sasjs/api/code`, which is Node-only, exactly like the linter.
|
|
32
|
+
*
|
|
33
|
+
* The *rules* are the host's: which `.sasjslint` applies to a path is a fact
|
|
34
|
+
* about a drive, so `lintConfig` is asked for it.
|
|
35
|
+
* Neither route touches the drive or runs anything; they answer about the text
|
|
36
|
+
* in the request, which is what lets an editor check unsaved changes.
|
|
37
|
+
*/
|
|
38
|
+
const createCodeService = (options = {}) => {
|
|
39
|
+
const requireCaller = (caller) => {
|
|
40
|
+
// both routes are permissioned at the gate, so a caller that reached the
|
|
41
|
+
// service without one is a host that skipped it rather than a public route
|
|
42
|
+
if (!caller)
|
|
43
|
+
throw new types_js_1.ApiError(401, 'Unauthorized');
|
|
44
|
+
};
|
|
45
|
+
const configFor = (filePath) => options.lintConfig ? options.lintConfig(filePath) : undefined;
|
|
46
|
+
return {
|
|
47
|
+
async lint(caller, body) {
|
|
48
|
+
requireCaller(caller);
|
|
49
|
+
return (0, lint_1.lintText)(codeOf(body), configFor(body.filePath));
|
|
50
|
+
},
|
|
51
|
+
async format(caller, body) {
|
|
52
|
+
requireCaller(caller);
|
|
53
|
+
return (0, lint_1.formatText)(codeOf(body), configFor(body.filePath));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
exports.createCodeService = createCodeService;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CODE_CORPUS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The editor's corpus: the two routes that answer about a buffer rather than a
|
|
6
|
+
* file.
|
|
7
|
+
*
|
|
8
|
+
* Both expectations are the linter's own output for the same input, measured
|
|
9
|
+
* against `@sasjs/lint` with no configuration - the defaults an untitled buffer
|
|
10
|
+
* lints against. A host that supplies a `.sasjslint` changes the rules for its
|
|
11
|
+
* own drive, which is why the case names no `filePath`.
|
|
12
|
+
*
|
|
13
|
+
* lintText('data _null_;\n x = 1;\nrun;\n')
|
|
14
|
+
* -> [{ message: 'File missing Doxygen header', lineNumber: 1, ... }]
|
|
15
|
+
* formatText(...)
|
|
16
|
+
* -> the header block, then the program with its spacing normalised
|
|
17
|
+
*/
|
|
18
|
+
exports.CODE_CORPUS = [
|
|
19
|
+
{
|
|
20
|
+
id: 'code-lint-missing-header',
|
|
21
|
+
route: 'POST /SASjsApi/code/lint',
|
|
22
|
+
description: 'answers the diagnostics for a buffer, without touching a file',
|
|
23
|
+
request: {
|
|
24
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
25
|
+
subject: { userId: 'user-admin', clientId: 'web_app' },
|
|
26
|
+
body: { code: 'data _null_;\n x = 1;\nrun;\n' }
|
|
27
|
+
},
|
|
28
|
+
expect: {
|
|
29
|
+
status: 200,
|
|
30
|
+
body: [
|
|
31
|
+
{
|
|
32
|
+
message: 'File missing Doxygen header',
|
|
33
|
+
lineNumber: 1,
|
|
34
|
+
startColumnNumber: 1,
|
|
35
|
+
endColumnNumber: 1,
|
|
36
|
+
severity: 1
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
// Two refusals are deliberately not pinned here yet: a `code` that is not a
|
|
42
|
+
// string, and one that is empty. The package answers 400 `"code" must be a
|
|
43
|
+
// string` / `"code" is not allowed to be empty` - the messages the server's
|
|
44
|
+
// schema produces - but sasjs/server's code controller carries no
|
|
45
|
+
// `@Response(400)` for these routes, so the document states 200 alone and the
|
|
46
|
+
// parity gate refuses a case answering anything else. The cases land when the
|
|
47
|
+
// decorators do.
|
|
48
|
+
{
|
|
49
|
+
id: 'code-format-adds-the-header',
|
|
50
|
+
route: 'POST /SASjsApi/code/format',
|
|
51
|
+
description: 'answers the formatted buffer, header block included',
|
|
52
|
+
request: {
|
|
53
|
+
// the routes are permissioned; the runner resolves the fixture's admin
|
|
54
|
+
subject: { userId: 'user-admin', clientId: 'web_app' },
|
|
55
|
+
body: { code: 'data _null_;\n x = 1;\nrun;\n' }
|
|
56
|
+
},
|
|
57
|
+
expect: {
|
|
58
|
+
status: 200,
|
|
59
|
+
body: '/**\n @file\n @brief <Your brief here>\n\n <h4> SAS Macros </h4>\n\n**/\ndata _null_;\n x = 1;\nrun;\n'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
];
|
|
@@ -702,6 +702,70 @@ exports.RECORD_CORPUS = [
|
|
|
702
702
|
expect: { status: 404, error: 'Group not found.' }
|
|
703
703
|
},
|
|
704
704
|
// ------------------------------------------------------------ permissions
|
|
705
|
+
{
|
|
706
|
+
id: 'authorized-routes',
|
|
707
|
+
route: 'GET /SASjsApi/info/authorizedRoutes',
|
|
708
|
+
description: 'answers the routes that accept a rule, to any caller',
|
|
709
|
+
identity: exports.ADMIN_SIGNED_IN,
|
|
710
|
+
request: { token: RESTRICTED },
|
|
711
|
+
expect: {
|
|
712
|
+
status: 200,
|
|
713
|
+
body: {
|
|
714
|
+
paths: [
|
|
715
|
+
'/AppStream',
|
|
716
|
+
'/SASjsApi',
|
|
717
|
+
'/SASjsApi/code/execute',
|
|
718
|
+
'/SASjsApi/stp/execute',
|
|
719
|
+
'/SASjsApi/drive/deploy',
|
|
720
|
+
'/SASjsApi/drive/deploy/upload',
|
|
721
|
+
'/SASjsApi/drive/file',
|
|
722
|
+
'/SASjsApi/drive/folder',
|
|
723
|
+
'/SASjsApi/drive/fileTree',
|
|
724
|
+
'/SASjsApi/drive/rename'
|
|
725
|
+
]
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
id: 'my-authorized-routes-as-admin',
|
|
731
|
+
route: 'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
732
|
+
description: 'answers every route to an administrator',
|
|
733
|
+
identity: exports.ADMIN_SIGNED_IN,
|
|
734
|
+
request: { token: ADMIN },
|
|
735
|
+
expect: {
|
|
736
|
+
status: 200,
|
|
737
|
+
body: {
|
|
738
|
+
paths: [
|
|
739
|
+
'/AppStream',
|
|
740
|
+
'/SASjsApi',
|
|
741
|
+
'/SASjsApi/code/execute',
|
|
742
|
+
'/SASjsApi/stp/execute',
|
|
743
|
+
'/SASjsApi/drive/deploy',
|
|
744
|
+
'/SASjsApi/drive/deploy/upload',
|
|
745
|
+
'/SASjsApi/drive/file',
|
|
746
|
+
'/SASjsApi/drive/folder',
|
|
747
|
+
'/SASjsApi/drive/fileTree',
|
|
748
|
+
'/SASjsApi/drive/rename'
|
|
749
|
+
]
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
id: 'my-authorized-routes-as-user',
|
|
755
|
+
route: 'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
756
|
+
description: 'answers a caller only the routes its rules grant',
|
|
757
|
+
identity: exports.ADMIN_SIGNED_IN,
|
|
758
|
+
request: { token: RESTRICTED },
|
|
759
|
+
expect: {
|
|
760
|
+
status: 200,
|
|
761
|
+
// /AppStream is visible whatever the rules say; the two grants come from
|
|
762
|
+
// DCGroup; /SASjsApi/code/execute is denied to this user by a user rule,
|
|
763
|
+
// and nothing grants the rest
|
|
764
|
+
body: {
|
|
765
|
+
paths: ['/AppStream', '/SASjsApi/stp/execute', '/SASjsApi/drive/file']
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
},
|
|
705
769
|
{
|
|
706
770
|
id: 'list-permissions-as-admin',
|
|
707
771
|
route: 'GET /SASjsApi/permission',
|
|
@@ -24,8 +24,13 @@ const validate = (route, request) => {
|
|
|
24
24
|
const given = param.in === 'query'
|
|
25
25
|
? request.query?.[param.name]
|
|
26
26
|
: request.body?.[param.name];
|
|
27
|
-
|
|
27
|
+
// missing and empty are two refusals, the way the schema has them: a
|
|
28
|
+
// client that sent the field an empty value has a different bug from one
|
|
29
|
+
// that omitted it
|
|
30
|
+
if (given === undefined)
|
|
28
31
|
throw new types_js_1.ApiError(400, `"${param.name}" is required`);
|
|
32
|
+
if (given === '')
|
|
33
|
+
throw new types_js_1.ApiError(400, `"${param.name}" is not allowed to be empty`);
|
|
29
34
|
}
|
|
30
35
|
};
|
|
31
36
|
/** The drive path a write names: the query parameter wins over the form field. */
|
|
@@ -44,7 +49,7 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
44
49
|
const route = (0, index_js_1.routeById)(routeId);
|
|
45
50
|
if (!route)
|
|
46
51
|
throw new types_js_1.ApiError(404, `No such operation: ${routeId}`);
|
|
47
|
-
const { drive, identity, records } = services;
|
|
52
|
+
const { drive, identity, records, code } = services;
|
|
48
53
|
// The server authenticates before its payload schema runs, so an anonymous
|
|
49
54
|
// request with an incomplete body is a 401 there rather than a 400. The order
|
|
50
55
|
// is only observable on the route groups that gate on a caller: the sign-in
|
|
@@ -92,7 +97,7 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
92
97
|
status: 200,
|
|
93
98
|
body: await identity.token({
|
|
94
99
|
clientId: String(body.clientId ?? ''),
|
|
95
|
-
code:
|
|
100
|
+
code: body.code
|
|
96
101
|
})
|
|
97
102
|
};
|
|
98
103
|
case 'POST /SASjsApi/auth/refresh':
|
|
@@ -219,6 +224,36 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
219
224
|
status: 200,
|
|
220
225
|
body: await required().deleteUser(await caller(), { username: String(params.username ?? '') }, { password: body.password })
|
|
221
226
|
};
|
|
227
|
+
case 'POST /SASjsApi/code/lint':
|
|
228
|
+
if (!code)
|
|
229
|
+
throw new types_js_1.ApiError(501, `This host has no code service, so it cannot answer ${routeId}.`);
|
|
230
|
+
return {
|
|
231
|
+
status: 200,
|
|
232
|
+
body: await code.lint(await caller(), {
|
|
233
|
+
code: body.code,
|
|
234
|
+
filePath: body.filePath
|
|
235
|
+
})
|
|
236
|
+
};
|
|
237
|
+
case 'POST /SASjsApi/code/format':
|
|
238
|
+
if (!code)
|
|
239
|
+
throw new types_js_1.ApiError(501, `This host has no code service, so it cannot answer ${routeId}.`);
|
|
240
|
+
return {
|
|
241
|
+
status: 200,
|
|
242
|
+
body: await code.format(await caller(), {
|
|
243
|
+
code: body.code,
|
|
244
|
+
filePath: body.filePath
|
|
245
|
+
})
|
|
246
|
+
};
|
|
247
|
+
case 'GET /SASjsApi/info/authorizedRoutes':
|
|
248
|
+
return {
|
|
249
|
+
status: 200,
|
|
250
|
+
body: await required().listAuthorizedRoutes(await caller())
|
|
251
|
+
};
|
|
252
|
+
case 'GET /SASjsApi/info/myAuthorizedRoutes':
|
|
253
|
+
return {
|
|
254
|
+
status: 200,
|
|
255
|
+
body: await required().myAuthorizedRoutes(await caller())
|
|
256
|
+
};
|
|
222
257
|
case 'GET /SASjsApi/group':
|
|
223
258
|
return { status: 200, body: await required().listGroups(await caller()) };
|
|
224
259
|
case 'GET /SASjsApi/group/{uid}':
|
|
@@ -322,6 +357,10 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
322
357
|
exports.dispatch = dispatch;
|
|
323
358
|
/** Whether a route is one `dispatch` can answer today. */
|
|
324
359
|
exports.HANDLED_ROUTES = [
|
|
360
|
+
'POST /SASjsApi/code/lint',
|
|
361
|
+
'POST /SASjsApi/code/format',
|
|
362
|
+
'GET /SASjsApi/info/authorizedRoutes',
|
|
363
|
+
'GET /SASjsApi/info/myAuthorizedRoutes',
|
|
325
364
|
'GET /SASjsApi/drive/file',
|
|
326
365
|
'POST /SASjsApi/drive/file',
|
|
327
366
|
'PATCH /SASjsApi/drive/file',
|