@sasjs/api 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,13 +38,50 @@ exports.CODE_CORPUS = [
38
38
  ]
39
39
  }
40
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.
41
+ {
42
+ id: 'code-lint-unauthenticated',
43
+ route: 'POST /SASjsApi/code/lint',
44
+ description: 'refuses a caller with no token',
45
+ request: { body: { code: 'data _null_; run;' } },
46
+ expect: { status: 401, error: 'Unauthorized' }
47
+ },
48
+ {
49
+ id: 'code-lint-non-string',
50
+ route: 'POST /SASjsApi/code/lint',
51
+ description: 'refuses a code value that is not a string, rather than coercing it',
52
+ request: {
53
+ subject: { userId: 'user-admin', clientId: 'web_app' },
54
+ body: { code: 123 }
55
+ },
56
+ expect: { status: 400, error: '"code" must be a string' }
57
+ },
58
+ {
59
+ id: 'code-lint-empty',
60
+ route: 'POST /SASjsApi/code/lint',
61
+ description: 'refuses an empty buffer, which is not the same as a missing one',
62
+ request: {
63
+ subject: { userId: 'user-admin', clientId: 'web_app' },
64
+ body: { code: '' }
65
+ },
66
+ expect: { status: 400, error: '"code" is not allowed to be empty' }
67
+ },
68
+ {
69
+ id: 'code-format-unauthenticated',
70
+ route: 'POST /SASjsApi/code/format',
71
+ description: 'refuses a caller with no token',
72
+ request: { body: { code: 'data _null_; run;' } },
73
+ expect: { status: 401, error: 'Unauthorized' }
74
+ },
75
+ {
76
+ id: 'code-format-non-string',
77
+ route: 'POST /SASjsApi/code/format',
78
+ description: 'refuses a code value that is not a string',
79
+ request: {
80
+ subject: { userId: 'user-admin', clientId: 'web_app' },
81
+ body: { code: 123 }
82
+ },
83
+ expect: { status: 400, error: '"code" must be a string' }
84
+ },
48
85
  {
49
86
  id: 'code-format-adds-the-header',
50
87
  route: 'POST /SASjsApi/code/format',
@@ -6,6 +6,8 @@ const service_js_1 = require("../drive/service.js");
6
6
  const memory_store_js_1 = require("../drive/memory-store.js");
7
7
  const service_js_2 = require("../identity/service.js");
8
8
  const records_js_1 = require("../identity/records.js");
9
+ const service_js_3 = require("../session/service.js");
10
+ const memory_host_js_1 = require("../session/memory-host.js");
9
11
  const memory_store_js_2 = require("../identity/memory-store.js");
10
12
  const fixture_js_1 = require("../identity/fixture.js");
11
13
  const types_js_1 = require("../types.js");
@@ -26,7 +28,8 @@ const servicesFor = (testCase) => {
26
28
  ? new RefusingDriveStore(testCase.seed || {})
27
29
  : new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
28
30
  identity: (0, service_js_2.createIdentityService)(identity),
29
- records: (0, records_js_1.createRecordService)(identity)
31
+ records: (0, records_js_1.createRecordService)(identity),
32
+ session: (0, service_js_3.createSessionService)(identity, new memory_host_js_1.MemorySessionHost())
30
33
  };
31
34
  };
32
35
  exports.servicesFor = servicesFor;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SESSION_CORPUS = void 0;
4
+ const ADMIN = { userId: 'user-admin', clientId: 'web_app' };
5
+ const RESTRICTED = { userId: 'user-restricted', clientId: 'web_app' };
6
+ exports.SESSION_CORPUS = [
7
+ {
8
+ id: 'session-summary-own',
9
+ route: 'GET /SASjsApi/session/{sessionId}',
10
+ description: 'answers the owner the summary of their own session',
11
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
12
+ expect: {
13
+ status: 200,
14
+ body: {
15
+ sessionId: 'sess-admin',
16
+ state: 'completed',
17
+ pid: 4242,
18
+ creationTimeStamp: '2026-11-07T09:00:00.000Z',
19
+ deathTimeStamp: '2026-11-07T09:00:04.000Z',
20
+ expiresAfterMins: 60
21
+ }
22
+ }
23
+ },
24
+ {
25
+ id: 'session-summary-as-admin',
26
+ route: 'GET /SASjsApi/session/{sessionId}',
27
+ description: "answers an administrator another account's session",
28
+ request: { subject: ADMIN, params: { sessionId: 'sess-restricted' } },
29
+ expect: {
30
+ status: 200,
31
+ body: {
32
+ sessionId: 'sess-restricted',
33
+ state: 'running',
34
+ pid: 4243,
35
+ creationTimeStamp: '2026-11-07T09:05:00.000Z'
36
+ }
37
+ }
38
+ },
39
+ {
40
+ id: 'session-log-own',
41
+ route: 'GET /SASjsApi/session/{sessionId}/log',
42
+ description: 'answers the log this session wrote',
43
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
44
+ expect: {
45
+ status: 200,
46
+ contents: 'NOTE: session started\nNOTE: session completed\n'
47
+ }
48
+ },
49
+ {
50
+ id: 'session-log-nothing-written',
51
+ route: 'GET /SASjsApi/session/{sessionId}/log',
52
+ description: 'answers an empty body for a session that has written nothing',
53
+ request: { subject: ADMIN, params: { sessionId: 'sess-silent' } },
54
+ expect: { status: 200, contents: '' }
55
+ },
56
+ {
57
+ id: 'session-output-own',
58
+ route: 'GET /SASjsApi/session/{sessionId}/output',
59
+ description: 'answers the webout this session produced',
60
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
61
+ expect: { status: 200, contents: '<html><body>42</body></html>' }
62
+ },
63
+ {
64
+ id: 'session-state-own',
65
+ route: 'GET /SASjsApi/session/{sessionId}/state',
66
+ description: "answers the run time's state for this session",
67
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-restricted' } },
68
+ expect: {
69
+ status: 200,
70
+ body: { state: 'running', pid: 4243, lineCount: 3 }
71
+ }
72
+ }
73
+ ];
@@ -74,6 +74,12 @@ const dispatch = async (services, routeId, request = {}) => {
74
74
  const body = request.body || {};
75
75
  const params = request.params || {};
76
76
  /** The record service, or the 501 a host without one answers. */
77
+ /** The host's sessions, or the 501 that names the missing capability. */
78
+ const sessionRequired = () => {
79
+ if (!services.session)
80
+ throw new types_js_1.ApiError(501, `This host has no session service, so it cannot answer ${routeId}.`);
81
+ return services.session;
82
+ };
77
83
  const required = () => {
78
84
  if (!records)
79
85
  throw new types_js_1.ApiError(501, `This host has no record service, so it cannot answer ${routeId}.`);
@@ -171,6 +177,31 @@ const dispatch = async (services, routeId, request = {}) => {
171
177
  }
172
178
  case 'GET /SASjsApi/drive/filetree':
173
179
  return { status: 200, body: await drive.tree(query._folderPath) };
180
+ // ------------------------------------------------------------- sessions
181
+ case 'GET /SASjsApi/session/{sessionId}': {
182
+ return {
183
+ status: 200,
184
+ body: await sessionRequired().summary(await caller(), String(params.sessionId ?? ''))
185
+ };
186
+ }
187
+ case 'GET /SASjsApi/session/{sessionId}/log': {
188
+ return {
189
+ status: 200,
190
+ contents: await sessionRequired().log(await caller(), String(params.sessionId ?? ''))
191
+ };
192
+ }
193
+ case 'GET /SASjsApi/session/{sessionId}/output': {
194
+ return {
195
+ status: 200,
196
+ contents: await sessionRequired().output(await caller(), String(params.sessionId ?? ''))
197
+ };
198
+ }
199
+ case 'GET /SASjsApi/session/{sessionId}/state': {
200
+ return {
201
+ status: 200,
202
+ body: await sessionRequired().state(await caller(), String(params.sessionId ?? ''))
203
+ };
204
+ }
174
205
  // ------------------------------------------------------------- records
175
206
  //
176
207
  // The administration routes: users, groups, permission rules, clients and the
@@ -357,6 +388,10 @@ const dispatch = async (services, routeId, request = {}) => {
357
388
  exports.dispatch = dispatch;
358
389
  /** Whether a route is one `dispatch` can answer today. */
359
390
  exports.HANDLED_ROUTES = [
391
+ 'GET /SASjsApi/session/{sessionId}',
392
+ 'GET /SASjsApi/session/{sessionId}/log',
393
+ 'GET /SASjsApi/session/{sessionId}/output',
394
+ 'GET /SASjsApi/session/{sessionId}/state',
360
395
  'POST /SASjsApi/code/lint',
361
396
  'POST /SASjsApi/code/format',
362
397
  'GET /SASjsApi/info/authorizedRoutes',
@@ -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.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;
17
+ exports.servicesFor = exports.runCase = exports.PROVIDER_MANAGED = exports.ADMIN_SIGNED_IN = exports.RECORD_CORPUS = exports.SESSION_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
  *
@@ -79,6 +79,8 @@ var drive_js_1 = require("./corpus/drive.js");
79
79
  Object.defineProperty(exports, "DRIVE_CORPUS", { enumerable: true, get: function () { return drive_js_1.DRIVE_CORPUS; } });
80
80
  var identity_js_1 = require("./corpus/identity.js");
81
81
  Object.defineProperty(exports, "IDENTITY_CORPUS", { enumerable: true, get: function () { return identity_js_1.IDENTITY_CORPUS; } });
82
+ var session_js_1 = require("./corpus/session.js");
83
+ Object.defineProperty(exports, "SESSION_CORPUS", { enumerable: true, get: function () { return session_js_1.SESSION_CORPUS; } });
82
84
  var records_js_2 = require("./corpus/records.js");
83
85
  Object.defineProperty(exports, "RECORD_CORPUS", { enumerable: true, get: function () { return records_js_2.RECORD_CORPUS; } });
84
86
  Object.defineProperty(exports, "ADMIN_SIGNED_IN", { enumerable: true, get: function () { return records_js_2.ADMIN_SIGNED_IN; } });
@@ -347,6 +347,14 @@ exports.GENERATED_ROUTES = [
347
347
  {
348
348
  status: 200,
349
349
  description: 'Ok'
350
+ },
351
+ {
352
+ status: 400,
353
+ description: '"code" is required / "code" must be a string / "code" is not allowed to be empty'
354
+ },
355
+ {
356
+ status: 401,
357
+ description: 'Unauthorized'
350
358
  }
351
359
  ]
352
360
  },
@@ -375,6 +383,14 @@ exports.GENERATED_ROUTES = [
375
383
  {
376
384
  status: 200,
377
385
  description: 'Ok'
386
+ },
387
+ {
388
+ status: 400,
389
+ description: '"code" is required / "code" must be a string / "code" is not allowed to be empty'
390
+ },
391
+ {
392
+ status: 401,
393
+ description: 'Unauthorized'
378
394
  }
379
395
  ]
380
396
  },
@@ -27,7 +27,6 @@ exports.TAG_CAPABILITIES = {
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
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.';
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.";
31
30
  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.";
32
31
  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.";
33
32
  const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
@@ -205,22 +204,13 @@ exports.OVERLAY = {
205
204
  implemented: true,
206
205
  responses: { 200: user }
207
206
  },
208
- 'GET /SASjsApi/session/{sessionId}': {
209
- implemented: false,
210
- reason: SESSION_RECORD
211
- },
212
- 'GET /SASjsApi/session/{sessionId}/log': {
213
- implemented: false,
214
- reason: SESSION_RECORD
215
- },
216
- 'GET /SASjsApi/session/{sessionId}/output': {
217
- implemented: false,
218
- reason: SESSION_RECORD
219
- },
220
- 'GET /SASjsApi/session/{sessionId}/state': {
221
- implemented: false,
222
- reason: SESSION_RECORD
223
- },
207
+ // The port takes the owner as a uid and falls back to a username, which is
208
+ // what sasjs/server records today - a host hands its sessions through as they
209
+ // are, and the comparison tries the identifier first.
210
+ 'GET /SASjsApi/session/{sessionId}': { implemented: true },
211
+ 'GET /SASjsApi/session/{sessionId}/log': { implemented: true },
212
+ 'GET /SASjsApi/session/{sessionId}/output': { implemented: true },
213
+ 'GET /SASjsApi/session/{sessionId}/state': { implemented: true },
224
214
  // STP - a stored program, on the runtime port
225
215
  'GET /SASjsApi/stp/execute': { implemented: false, reason: RUNTIME },
226
216
  'POST /SASjsApi/stp/execute': { implemented: false, reason: RUNTIME },
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemorySessionHost = exports.DEFAULT_SESSIONS = void 0;
4
+ /**
5
+ * The sessions the corpus and the package's own tests read.
6
+ *
7
+ * One session per account, so the interesting case is expressible: an
8
+ * administrator may read a session that is not theirs, and the other way round
9
+ * answers the 404 that hides it. `sess-silent` exists with nothing written yet,
10
+ * because an empty log is a 200 and not a 404.
11
+ */
12
+ exports.DEFAULT_SESSIONS = {
13
+ 'sess-admin': {
14
+ owner: 'user-admin',
15
+ summary: {
16
+ sessionId: 'sess-admin',
17
+ state: 'completed',
18
+ pid: 4242,
19
+ creationTimeStamp: '2026-11-07T09:00:00.000Z',
20
+ deathTimeStamp: '2026-11-07T09:00:04.000Z',
21
+ expiresAfterMins: 60
22
+ }
23
+ },
24
+ 'sess-restricted': {
25
+ owner: 'user-restricted',
26
+ summary: {
27
+ sessionId: 'sess-restricted',
28
+ state: 'running',
29
+ pid: 4243,
30
+ creationTimeStamp: '2026-11-07T09:05:00.000Z'
31
+ }
32
+ },
33
+ 'sess-silent': {
34
+ owner: 'user-admin',
35
+ summary: {
36
+ sessionId: 'sess-silent',
37
+ state: 'running',
38
+ pid: 4244,
39
+ creationTimeStamp: '2026-11-07T09:10:00.000Z'
40
+ }
41
+ }
42
+ };
43
+ const STATES = {
44
+ 'sess-admin': { state: 'completed', pid: 4242, lineCount: 12 },
45
+ 'sess-restricted': { state: 'running', pid: 4243, lineCount: 3 }
46
+ };
47
+ const LOGS = {
48
+ 'sess-admin': 'NOTE: session started\nNOTE: session completed\n',
49
+ 'sess-restricted': 'NOTE: session started\n',
50
+ 'sess-silent': ''
51
+ };
52
+ const OUTPUTS = {
53
+ 'sess-admin': '<html><body>42</body></html>',
54
+ 'sess-restricted': '',
55
+ 'sess-silent': ''
56
+ };
57
+ class MemorySessionHost {
58
+ constructor(sessions = exports.DEFAULT_SESSIONS) {
59
+ this.sessions = sessions;
60
+ }
61
+ async session(id) {
62
+ return this.sessions[id];
63
+ }
64
+ async state(id) {
65
+ return STATES[id];
66
+ }
67
+ async log(id) {
68
+ return LOGS[id];
69
+ }
70
+ async output(id) {
71
+ return OUTPUTS[id];
72
+ }
73
+ }
74
+ exports.MemorySessionHost = MemorySessionHost;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSessionService = void 0;
4
+ const types_js_1 = require("../types.js");
5
+ /**
6
+ * The four session routes, over the host's own session store.
7
+ *
8
+ * The caller passes a `Subject` and nothing else: which account that is, whether
9
+ * it is an administrator, and whether the session is theirs are all resolved
10
+ * here, against the same store the records service reads. A host never states a
11
+ * role.
12
+ *
13
+ * `404` covers both "no such session" and "not yours", on purpose: a session id
14
+ * is handed to the caller that triggered the program and is built from a
15
+ * timestamp plus a small random number, so it is guessable within a window, and
16
+ * confirming the id exists would say it was right.
17
+ */
18
+ const createSessionService = (store, host) => {
19
+ /** The server's wording, so adopting it here does not become a later change. */
20
+ const notFound = (id) => `Session with ID '${id}' was not found.`;
21
+ /** The caller, and the session if they may see it - or the 404 that hides it. */
22
+ const visible = async (caller, id) => {
23
+ if (!caller)
24
+ throw new types_js_1.ApiError(401, 'Unauthorized');
25
+ const reader = await store.userById(caller.userId);
26
+ if (!reader || !reader.isActive)
27
+ throw new types_js_1.ApiError(401, 'Unauthorized');
28
+ const session = await host.session(id);
29
+ if (!session)
30
+ throw new types_js_1.ApiError(404, notFound(id));
31
+ // the uid is the key to compare; a name is the fallback a host may need
32
+ const owner = session.owner;
33
+ if (owner !== reader.uid && owner !== reader.username && !reader.isAdmin)
34
+ throw new types_js_1.ApiError(404, notFound(id));
35
+ return session;
36
+ };
37
+ return {
38
+ async summary(caller, id) {
39
+ const session = await visible(caller, id);
40
+ return session.summary;
41
+ },
42
+ async state(caller, id) {
43
+ await visible(caller, id);
44
+ const state = await host.state(id);
45
+ if (!state)
46
+ throw new types_js_1.ApiError(404, notFound(id));
47
+ return state;
48
+ },
49
+ async log(caller, id) {
50
+ await visible(caller, id);
51
+ const log = await host.log(id);
52
+ if (log === undefined)
53
+ throw new types_js_1.ApiError(404, notFound(id));
54
+ return log;
55
+ },
56
+ async output(caller, id) {
57
+ await visible(caller, id);
58
+ const output = await host.output(id);
59
+ if (output === undefined)
60
+ throw new types_js_1.ApiError(404, notFound(id));
61
+ return output;
62
+ }
63
+ };
64
+ };
65
+ exports.createSessionService = createSessionService;
@@ -1 +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"}
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,EAoFnC,CAAA"}
@@ -35,13 +35,50 @@ export const CODE_CORPUS = [
35
35
  ]
36
36
  }
37
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.
38
+ {
39
+ id: 'code-lint-unauthenticated',
40
+ route: 'POST /SASjsApi/code/lint',
41
+ description: 'refuses a caller with no token',
42
+ request: { body: { code: 'data _null_; run;' } },
43
+ expect: { status: 401, error: 'Unauthorized' }
44
+ },
45
+ {
46
+ id: 'code-lint-non-string',
47
+ route: 'POST /SASjsApi/code/lint',
48
+ description: 'refuses a code value that is not a string, rather than coercing it',
49
+ request: {
50
+ subject: { userId: 'user-admin', clientId: 'web_app' },
51
+ body: { code: 123 }
52
+ },
53
+ expect: { status: 400, error: '"code" must be a string' }
54
+ },
55
+ {
56
+ id: 'code-lint-empty',
57
+ route: 'POST /SASjsApi/code/lint',
58
+ description: 'refuses an empty buffer, which is not the same as a missing one',
59
+ request: {
60
+ subject: { userId: 'user-admin', clientId: 'web_app' },
61
+ body: { code: '' }
62
+ },
63
+ expect: { status: 400, error: '"code" is not allowed to be empty' }
64
+ },
65
+ {
66
+ id: 'code-format-unauthenticated',
67
+ route: 'POST /SASjsApi/code/format',
68
+ description: 'refuses a caller with no token',
69
+ request: { body: { code: 'data _null_; run;' } },
70
+ expect: { status: 401, error: 'Unauthorized' }
71
+ },
72
+ {
73
+ id: 'code-format-non-string',
74
+ route: 'POST /SASjsApi/code/format',
75
+ description: 'refuses a code value that is not a string',
76
+ request: {
77
+ subject: { userId: 'user-admin', clientId: 'web_app' },
78
+ body: { code: 123 }
79
+ },
80
+ expect: { status: 400, error: '"code" must be a string' }
81
+ },
45
82
  {
46
83
  id: 'code-format-adds-the-header',
47
84
  route: 'POST /SASjsApi/code/format',
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AASnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QAkBlD,CAAA;AAkBD;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAWnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QAmBlD,CAAA;AAkBD;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAepB,CAAA"}
@@ -3,6 +3,8 @@ import { createDriveService } from '../drive/service.js';
3
3
  import { MemoryDriveStore } from '../drive/memory-store.js';
4
4
  import { createIdentityService } from '../identity/service.js';
5
5
  import { createRecordService } from '../identity/records.js';
6
+ import { createSessionService } from '../session/service.js';
7
+ import { MemorySessionHost } from '../session/memory-host.js';
6
8
  import { MemoryIdentityStore } from '../identity/memory-store.js';
7
9
  import { DEFAULT_IDENTITY } from '../identity/fixture.js';
8
10
  import { isApiError } from '../types.js';
@@ -23,7 +25,8 @@ export const servicesFor = (testCase) => {
23
25
  ? new RefusingDriveStore(testCase.seed || {})
24
26
  : new MemoryDriveStore(testCase.seed || {})),
25
27
  identity: createIdentityService(identity),
26
- records: createRecordService(identity)
28
+ records: createRecordService(identity),
29
+ session: createSessionService(identity, new MemorySessionHost())
27
30
  };
28
31
  };
29
32
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The session routes' conformance corpus.
3
+ *
4
+ * Only the `200` answers are here, and that is a statement about the document
5
+ * rather than about the routes: sasjs/server documents these four with a `200`
6
+ * and nothing else, so `spec/corpus-parity.spec.ts` refuses a case that pins a
7
+ * refusal. Their `401` and the `404` that covers both "no such session" and
8
+ * "not yours" are pinned in `spec/session.spec.ts` instead, which the parity
9
+ * gate does not govern.
10
+ *
11
+ * The routes are in `documentRefusals.baseline.json` upstream; when they carry
12
+ * their `@Response(401)` and `@Response(404)`, those cases move here.
13
+ */
14
+ import type { CorpusCase } from './drive.js';
15
+ export declare const SESSION_CORPUS: CorpusCase[];
16
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../../src/corpus/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAK5C,eAAO,MAAM,cAAc,EAAE,UAAU,EAmEtC,CAAA"}
@@ -0,0 +1,70 @@
1
+ const ADMIN = { userId: 'user-admin', clientId: 'web_app' };
2
+ const RESTRICTED = { userId: 'user-restricted', clientId: 'web_app' };
3
+ export const SESSION_CORPUS = [
4
+ {
5
+ id: 'session-summary-own',
6
+ route: 'GET /SASjsApi/session/{sessionId}',
7
+ description: 'answers the owner the summary of their own session',
8
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
9
+ expect: {
10
+ status: 200,
11
+ body: {
12
+ sessionId: 'sess-admin',
13
+ state: 'completed',
14
+ pid: 4242,
15
+ creationTimeStamp: '2026-11-07T09:00:00.000Z',
16
+ deathTimeStamp: '2026-11-07T09:00:04.000Z',
17
+ expiresAfterMins: 60
18
+ }
19
+ }
20
+ },
21
+ {
22
+ id: 'session-summary-as-admin',
23
+ route: 'GET /SASjsApi/session/{sessionId}',
24
+ description: "answers an administrator another account's session",
25
+ request: { subject: ADMIN, params: { sessionId: 'sess-restricted' } },
26
+ expect: {
27
+ status: 200,
28
+ body: {
29
+ sessionId: 'sess-restricted',
30
+ state: 'running',
31
+ pid: 4243,
32
+ creationTimeStamp: '2026-11-07T09:05:00.000Z'
33
+ }
34
+ }
35
+ },
36
+ {
37
+ id: 'session-log-own',
38
+ route: 'GET /SASjsApi/session/{sessionId}/log',
39
+ description: 'answers the log this session wrote',
40
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
41
+ expect: {
42
+ status: 200,
43
+ contents: 'NOTE: session started\nNOTE: session completed\n'
44
+ }
45
+ },
46
+ {
47
+ id: 'session-log-nothing-written',
48
+ route: 'GET /SASjsApi/session/{sessionId}/log',
49
+ description: 'answers an empty body for a session that has written nothing',
50
+ request: { subject: ADMIN, params: { sessionId: 'sess-silent' } },
51
+ expect: { status: 200, contents: '' }
52
+ },
53
+ {
54
+ id: 'session-output-own',
55
+ route: 'GET /SASjsApi/session/{sessionId}/output',
56
+ description: 'answers the webout this session produced',
57
+ request: { subject: ADMIN, params: { sessionId: 'sess-admin' } },
58
+ expect: { status: 200, contents: '<html><body>42</body></html>' }
59
+ },
60
+ {
61
+ id: 'session-state-own',
62
+ route: 'GET /SASjsApi/session/{sessionId}/state',
63
+ description: "answers the run time's state for this session",
64
+ request: { subject: RESTRICTED, params: { sessionId: 'sess-restricted' } },
65
+ expect: {
66
+ status: 200,
67
+ body: { state: 'running', pid: 4243, lineCount: 3 }
68
+ }
69
+ }
70
+ ];
@@ -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 { SessionService } from '../session/service.js';
4
5
  import type { CodeService } from '../code/service.js';
5
6
  import type { Subject } from '../identity/types.js';
6
7
  export interface ApiRequest {
@@ -45,6 +46,11 @@ export interface Services {
45
46
  identity?: IdentityService;
46
47
  /** Users, groups, permission rules and clients. Absent, the record routes answer 501. */
47
48
  records?: RecordService;
49
+ /**
50
+ * A triggered session's record, log, output and state. A host without one
51
+ * answers 501 for these four, naming the capability.
52
+ */
53
+ session?: SessionService;
48
54
  }
49
55
  export declare const dispatch: (services: Services, routeId: string, request?: ApiRequest) => Promise<ApiResponse>;
50
56
  /** Whether a route is one `dispatch` can answer today. */
@@ -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,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"}
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,cAAc,EAAE,MAAM,uBAAuB,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;IACvB;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAA;CACzB;AA2CD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CA8brB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EAgDlC,CAAA"}