@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.
Files changed (38) hide show
  1. package/build/browser.js +239 -18
  2. package/build/cjs/code/index.js +12 -0
  3. package/build/cjs/code/service.js +57 -0
  4. package/build/cjs/corpus/code.js +62 -0
  5. package/build/cjs/corpus/records.js +64 -0
  6. package/build/cjs/host/dispatch.js +42 -3
  7. package/build/cjs/identity/records.js +38 -1
  8. package/build/cjs/identity/routeAccess.js +77 -0
  9. package/build/cjs/index.js +5 -1
  10. package/build/cjs/routes/overlay.js +12 -12
  11. package/build/esm/code/index.d.ts +10 -0
  12. package/build/esm/code/index.d.ts.map +1 -0
  13. package/build/esm/code/index.js +8 -0
  14. package/build/esm/code/service.d.ts +40 -0
  15. package/build/esm/code/service.d.ts.map +1 -0
  16. package/build/esm/code/service.js +53 -0
  17. package/build/esm/corpus/code.d.ts +17 -0
  18. package/build/esm/corpus/code.d.ts.map +1 -0
  19. package/build/esm/corpus/code.js +59 -0
  20. package/build/esm/corpus/drive.d.ts +9 -0
  21. package/build/esm/corpus/drive.d.ts.map +1 -1
  22. package/build/esm/corpus/records.d.ts.map +1 -1
  23. package/build/esm/corpus/records.js +64 -0
  24. package/build/esm/host/dispatch.d.ts +3 -0
  25. package/build/esm/host/dispatch.d.ts.map +1 -1
  26. package/build/esm/host/dispatch.js +42 -3
  27. package/build/esm/identity/records.d.ts +20 -1
  28. package/build/esm/identity/records.d.ts.map +1 -1
  29. package/build/esm/identity/records.js +38 -1
  30. package/build/esm/identity/routeAccess.d.ts +57 -0
  31. package/build/esm/identity/routeAccess.d.ts.map +1 -0
  32. package/build/esm/identity/routeAccess.js +72 -0
  33. package/build/esm/index.d.ts +3 -1
  34. package/build/esm/index.d.ts.map +1 -1
  35. package/build/esm/index.js +3 -0
  36. package/build/esm/routes/overlay.d.ts.map +1 -1
  37. package/build/esm/routes/overlay.js +12 -12
  38. package/package.json +9 -1
package/build/browser.js CHANGED
@@ -1,4 +1,4 @@
1
- /* @sasjs/api 0.8.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
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": { implemented: false, reason: EDITOR },
2528
- "POST /SASjsApi/code/format": { implemented: false, reason: EDITOR },
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: false,
2608
- // the route inventory reports what the CALLER may use, so it needs an
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: false,
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 || given === "")
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: String(body.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
- if (given === undefined || given === '')
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: String(body.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',