@sasjs/api 0.4.0 → 0.6.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 (39) hide show
  1. package/README.md +12 -13
  2. package/build/browser.js +515 -54
  3. package/build/cjs/corpus/drive.js +119 -0
  4. package/build/cjs/corpus/runner.js +18 -2
  5. package/build/cjs/drive/deploy.js +270 -0
  6. package/build/cjs/drive/service.js +5 -0
  7. package/build/cjs/host/dispatch.js +15 -2
  8. package/build/cjs/index.js +10 -1
  9. package/build/cjs/routes/generated.js +8 -0
  10. package/build/cjs/routes/index.js +8 -2
  11. package/build/cjs/routes/overlay.js +181 -40
  12. package/build/cjs/types.js +11 -1
  13. package/build/esm/corpus/drive.d.ts +6 -0
  14. package/build/esm/corpus/drive.d.ts.map +1 -1
  15. package/build/esm/corpus/drive.js +119 -0
  16. package/build/esm/corpus/runner.d.ts.map +1 -1
  17. package/build/esm/corpus/runner.js +18 -2
  18. package/build/esm/drive/deploy.d.ts +143 -0
  19. package/build/esm/drive/deploy.d.ts.map +1 -0
  20. package/build/esm/drive/deploy.js +262 -0
  21. package/build/esm/drive/service.d.ts +8 -0
  22. package/build/esm/drive/service.d.ts.map +1 -1
  23. package/build/esm/drive/service.js +5 -0
  24. package/build/esm/host/dispatch.d.ts.map +1 -1
  25. package/build/esm/host/dispatch.js +15 -2
  26. package/build/esm/index.d.ts +2 -0
  27. package/build/esm/index.d.ts.map +1 -1
  28. package/build/esm/index.js +1 -0
  29. package/build/esm/routes/generated.d.ts.map +1 -1
  30. package/build/esm/routes/generated.js +8 -0
  31. package/build/esm/routes/index.d.ts.map +1 -1
  32. package/build/esm/routes/index.js +8 -2
  33. package/build/esm/routes/overlay.d.ts +26 -3
  34. package/build/esm/routes/overlay.d.ts.map +1 -1
  35. package/build/esm/routes/overlay.js +181 -40
  36. package/build/esm/types.d.ts +26 -1
  37. package/build/esm/types.d.ts.map +1 -1
  38. package/build/esm/types.js +11 -1
  39. package/package.json +1 -1
package/build/browser.js CHANGED
@@ -1,4 +1,4 @@
1
- /* @sasjs/api 0.4.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
1
+ /* @sasjs/api 0.6.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;
@@ -24,6 +24,9 @@ var SASjsApi = (() => {
24
24
  __export(index_exports, {
25
25
  ApiError: () => ApiError,
26
26
  DEFAULT_IDENTITY: () => DEFAULT_IDENTITY,
27
+ DEPLOY_LIMITS: () => DEPLOY_LIMITS,
28
+ DEPLOY_PAYLOAD_BYTES: () => DEPLOY_PAYLOAD_BYTES,
29
+ DEPLOY_SUCCESS: () => DEPLOY_SUCCESS,
27
30
  DRIVE_CORPUS: () => DRIVE_CORPUS,
28
31
  HANDLED_ROUTES: () => HANDLED_ROUTES,
29
32
  IDENTITY_CORPUS: () => IDENTITY_CORPUS,
@@ -34,14 +37,19 @@ var SASjsApi = (() => {
34
37
  ROUTES: () => ROUTES,
35
38
  TAG_CAPABILITIES: () => TAG_CAPABILITIES,
36
39
  basename: () => basename,
40
+ checkDeployTree: () => checkDeployTree,
37
41
  createDriveService: () => createDriveService,
38
42
  createIdentityService: () => createIdentityService,
43
+ decodeBase64: () => decodeBase64,
44
+ deployTree: () => deployTree,
39
45
  dirname: () => dirname,
40
46
  dispatch: () => dispatch,
41
47
  findRoute: () => findRoute,
42
48
  hasBlockedExtension: () => hasBlockedExtension,
43
49
  isApiError: () => isApiError,
50
+ isDeployTree: () => isDeployTree,
44
51
  isRoot: () => isRoot,
52
+ measureDeployTree: () => measureDeployTree,
45
53
  normaliseDrivePath: () => normaliseDrivePath,
46
54
  parseIdentity: () => parseIdentity,
47
55
  routeById: () => routeById,
@@ -51,9 +59,10 @@ var SASjsApi = (() => {
51
59
 
52
60
  // src/types.ts
53
61
  var ApiError = class extends Error {
54
- constructor(status, message) {
62
+ constructor(status, message, body) {
55
63
  super(message);
56
64
  this.status = status;
65
+ this.body = body;
57
66
  /** Told apart by marker rather than `instanceof` - see `isApiError`. */
58
67
  this.apiError = true;
59
68
  this.name = "ApiError";
@@ -91,6 +100,169 @@ var SASjsApi = (() => {
91
100
  return { path };
92
101
  };
93
102
 
103
+ // src/drive/deploy.ts
104
+ var DEPLOY_LIMITS = {
105
+ entries: 1e4,
106
+ totalBytes: 100 * 1024 * 1024,
107
+ fileBytes: 20 * 1024 * 1024
108
+ };
109
+ var DEPLOY_PAYLOAD_BYTES = 140 * 1024 * 1024;
110
+ var DEPLOY_SUCCESS = {
111
+ status: "success",
112
+ message: "Files deployed successfully to @sasjs/server."
113
+ };
114
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
115
+ var base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, "");
116
+ var base64Length = (value) => Math.floor(base64Body(value).length * 3 / 4);
117
+ var decodeBase64 = (value) => {
118
+ const body = base64Body(value);
119
+ const bytes2 = new Uint8Array(Math.floor(body.length * 3 / 4));
120
+ let buffer = 0;
121
+ let bits = 0;
122
+ let at = 0;
123
+ for (const char of body) {
124
+ buffer = buffer << 6 | BASE64_ALPHABET.indexOf(char);
125
+ bits += 6;
126
+ if (bits >= 8) {
127
+ bits -= 8;
128
+ bytes2[at++] = buffer >> bits & 255;
129
+ }
130
+ }
131
+ return bytes2.subarray(0, at);
132
+ };
133
+ var utf8Length = (value) => {
134
+ let bytes2 = 0;
135
+ for (let at = 0; at < value.length; at++) {
136
+ const code = value.charCodeAt(at);
137
+ if (code < 128) bytes2 += 1;
138
+ else if (code < 2048) bytes2 += 2;
139
+ else if (code >= 55296 && code <= 56319 && at + 1 < value.length) {
140
+ bytes2 += 4;
141
+ at++;
142
+ } else bytes2 += 3;
143
+ }
144
+ return bytes2;
145
+ };
146
+ var memberName = (member) => member.type === "service" ? `${member.name}.sas` : member.name;
147
+ var memberBytes = (member) => member.type === "file" ? base64Length(member.code) : utf8Length(member.code);
148
+ var describeSize = (bytes2) => bytes2 < 1024 * 1024 ? `${bytes2} bytes` : `${Math.round(bytes2 / (1024 * 1024) * 10) / 10} MB`;
149
+ var emptyWalk = () => ({
150
+ entries: 0,
151
+ totalBytes: 0,
152
+ largest: { path: "", bytes: 0 }
153
+ });
154
+ var isSafeSegment = (name) => name !== "" && name !== "." && name !== ".." && !/[/\\]/.test(name) && // a Windows drive or a NUL byte is never a segment either
155
+ !/[\0]/.test(name);
156
+ var walk = (members, parentPath, acc) => {
157
+ for (const member of members) {
158
+ const name = memberName(member);
159
+ const memberPath = parentPath ? `${parentPath}/${name}` : name;
160
+ if (!isSafeSegment(name) && !acc.nameRefusal)
161
+ acc.nameRefusal = {
162
+ code: 400,
163
+ status: "failure",
164
+ message: `Invalid member name: '${name}'. Member names must be single path segments (no '/', '\\', '..').`
165
+ };
166
+ acc.entries++;
167
+ if (member.type === "folder") {
168
+ walk(member.members, memberPath, acc);
169
+ continue;
170
+ }
171
+ const bytes2 = memberBytes(member);
172
+ acc.totalBytes += bytes2;
173
+ if (bytes2 > acc.largest.bytes) acc.largest = { path: memberPath, bytes: bytes2 };
174
+ }
175
+ };
176
+ var measureDeployTree = (members) => {
177
+ const acc = emptyWalk();
178
+ walk(members, "", acc);
179
+ return {
180
+ entries: acc.entries,
181
+ totalBytes: acc.totalBytes,
182
+ largest: acc.largest
183
+ };
184
+ };
185
+ var checkDeployTree = (members, limits = DEPLOY_LIMITS) => {
186
+ const measured = emptyWalk();
187
+ walk(members, "", measured);
188
+ if (measured.nameRefusal) return measured.nameRefusal;
189
+ if (measured.entries > limits.entries)
190
+ return {
191
+ code: 413,
192
+ status: "failure",
193
+ message: `The file tree exceeds the deploy limit: ${measured.entries} entries (maximum ${limits.entries}).`
194
+ };
195
+ if (measured.totalBytes > limits.totalBytes)
196
+ return {
197
+ code: 413,
198
+ status: "failure",
199
+ message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeSize(limits.totalBytes)}).`
200
+ };
201
+ if (measured.largest.bytes > limits.fileBytes)
202
+ return {
203
+ code: 413,
204
+ status: "failure",
205
+ message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeSize(limits.fileBytes)} per file).`
206
+ };
207
+ return null;
208
+ };
209
+ var isDeployTree = (value) => {
210
+ if (!value || typeof value !== "object") return false;
211
+ const members = value.members;
212
+ return Array.isArray(members) && members.every(isDeployMember);
213
+ };
214
+ var isDeployMember = (value) => {
215
+ if (!value || typeof value !== "object") return false;
216
+ const member = value;
217
+ if (typeof member.name !== "string") return false;
218
+ if (member.type === "folder")
219
+ return Array.isArray(member.members) && member.members.every(isDeployMember);
220
+ if (member.type === "service" || member.type === "file")
221
+ return typeof member.code === "string";
222
+ return false;
223
+ };
224
+ var deployTree = async (store, appLoc, tree, limits = DEPLOY_LIMITS) => {
225
+ if (!isDeployTree(tree))
226
+ throw new ApiError(400, "Provided not supported data format.", {
227
+ status: "failure",
228
+ message: "Provided not supported data format."
229
+ });
230
+ const root = normaliseDrivePath(appLoc);
231
+ if (root === null)
232
+ throw new ApiError(400, "appLoc cannot be outside drive.", {
233
+ status: "failure",
234
+ message: "appLoc cannot be outside drive."
235
+ });
236
+ const refusal = checkDeployTree(tree.members, limits);
237
+ if (refusal)
238
+ throw new ApiError(refusal.code, refusal.message, {
239
+ status: refusal.status,
240
+ message: refusal.message
241
+ });
242
+ try {
243
+ await writeMembers(store, tree.members, root);
244
+ } catch {
245
+ throw new ApiError(500, "Deployment failed!", {
246
+ status: "failure",
247
+ message: "Deployment failed!"
248
+ });
249
+ }
250
+ };
251
+ var writeMembers = async (store, members, parentPath) => {
252
+ for (const member of members) {
253
+ const path = `${parentPath}/${memberName(member)}`;
254
+ if (member.type === "folder") {
255
+ await store.mkdirp(path);
256
+ await writeMembers(store, member.members, path);
257
+ continue;
258
+ }
259
+ await store.write(
260
+ path,
261
+ member.type === "file" ? decodeBase64(member.code) : member.code
262
+ );
263
+ }
264
+ };
265
+
94
266
  // src/drive/service.ts
95
267
  var filePath = (input, outside) => {
96
268
  const checked = checkFilePath(input);
@@ -226,6 +398,10 @@ var SASjsApi = (() => {
226
398
  const stat = await store.stat(path);
227
399
  if (!stat.directory) throw new ApiError(400, "Not a Folder.");
228
400
  return { status: "success", tree: await treeOf(path) };
401
+ },
402
+ async deploy(appLoc, tree) {
403
+ await deployTree(store, appLoc, tree);
404
+ return { status: "success" };
229
405
  }
230
406
  };
231
407
  };
@@ -816,6 +992,10 @@ var SASjsApi = (() => {
816
992
  status: 400,
817
993
  description: "Invalid Format"
818
994
  },
995
+ {
996
+ status: 413,
997
+ description: "File tree exceeds the deploy limit"
998
+ },
819
999
  {
820
1000
  status: 500,
821
1001
  description: "Execution Error"
@@ -846,6 +1026,10 @@ var SASjsApi = (() => {
846
1026
  status: 400,
847
1027
  description: "Invalid Format"
848
1028
  },
1029
+ {
1030
+ status: 413,
1031
+ description: "File tree exceeds the deploy limit"
1032
+ },
849
1033
  {
850
1034
  status: 500,
851
1035
  description: "Execution Error"
@@ -2070,6 +2254,16 @@ var SASjsApi = (() => {
2070
2254
  Client: ["identity"],
2071
2255
  Web: ["identity"]
2072
2256
  };
2257
+ var IDENTITY_RECORDS = "The identity service answers the sign-in flow. User, group, permission rule, client and provider records need read and write methods on the same store, which this package does not have yet.";
2258
+ 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.";
2259
+ 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.";
2260
+ 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.";
2261
+ 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.";
2262
+ var PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
2263
+ 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.";
2264
+ var WEB_BUNDLE = "The host serves its own web bundle; there is no API decision here for this package to answer.";
2265
+ 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.";
2266
+ var UNPUBLISH = "Unpublishing inverts the deploy route, so the two land together.";
2073
2267
  var success = { status: "success" };
2074
2268
  var user = {
2075
2269
  uid: "",
@@ -2079,11 +2273,61 @@ var SASjsApi = (() => {
2079
2273
  needsToUpdatePassword: false
2080
2274
  };
2081
2275
  var OVERLAY = {
2082
- // the route inventory reports what the CALLER may use, so it needs an identity
2083
- // even though the rest of the Info tag needs nothing
2084
- "GET /SASjsApi/info/authorizedRoutes": { capabilities: ["identity"] },
2085
- "GET /SASjsApi/info/myAuthorizedRoutes": { capabilities: ["identity"] },
2086
- // the body is the file itself, so there is nothing to annotate
2276
+ // Web - the sign-in pages the identity service answers, and the bundle itself
2277
+ "GET /": { implemented: false, reason: WEB_BUNDLE },
2278
+ "POST /SASLogon/login": {
2279
+ implemented: true,
2280
+ // The lockout and the provider failure are answered before the package is
2281
+ // consulted (routes/web/web.ts in sasjs/server).
2282
+ hostResponses: [429, 500],
2283
+ responses: { 200: { loggedIn: true, user } }
2284
+ },
2285
+ "POST /SASLogon/authorize": {
2286
+ implemented: true,
2287
+ responses: { 200: { code: "" } }
2288
+ },
2289
+ "GET /SASLogon/logout": {
2290
+ implemented: true,
2291
+ hostResponses: [500],
2292
+ responses: { 200: "OK!" }
2293
+ },
2294
+ // Admin - the host's own process and files
2295
+ "GET /SASjsApi/admin/diagnostics": { implemented: false, reason: HOST_OWNED },
2296
+ "GET /SASjsApi/admin/logs": { implemented: false, reason: HOST_OWNED },
2297
+ "GET /SASjsApi/admin/logs/{fileName}": {
2298
+ implemented: false,
2299
+ reason: HOST_OWNED
2300
+ },
2301
+ // Auth - the token flow, plus a password change the identity store cannot write
2302
+ "POST /SASjsApi/auth/token": {
2303
+ implemented: true,
2304
+ responses: { 200: { accessToken: "", refreshToken: "" } }
2305
+ },
2306
+ "POST /SASjsApi/auth/refresh": {
2307
+ implemented: true,
2308
+ responses: { 200: { accessToken: "", refreshToken: "" } }
2309
+ },
2310
+ "POST /SASjsApi/auth/logout": { implemented: true },
2311
+ "PATCH /SASjsApi/auth/updatePassword": {
2312
+ implemented: false,
2313
+ reason: IDENTITY_RECORDS
2314
+ },
2315
+ // Auth Config - the providers a host was started with, and an LDAP import
2316
+ "GET /SASjsApi/authConfig": { implemented: false, reason: HOST_CONFIG },
2317
+ "POST /SASjsApi/authConfig/synchroniseWithLDAP": {
2318
+ implemented: false,
2319
+ reason: IDENTITY_RECORDS
2320
+ },
2321
+ // Client - the OAuth clients the identity store already holds for sign-in
2322
+ "GET /SASjsApi/client": { implemented: false, reason: IDENTITY_RECORDS },
2323
+ "POST /SASjsApi/client": { implemented: false, reason: IDENTITY_RECORDS },
2324
+ // Code - the editor's three calls, and the runtime one
2325
+ "POST /SASjsApi/code/execute": { implemented: false, reason: RUNTIME },
2326
+ "POST /SASjsApi/code/trigger": { implemented: false, reason: RUNTIME },
2327
+ "POST /SASjsApi/code/lint": { implemented: false, reason: EDITOR },
2328
+ "POST /SASjsApi/code/format": { implemented: false, reason: EDITOR },
2329
+ // Drive - the file and folder rules, and the three deploy routes
2330
+ // The body is the file itself, so there is nothing to annotate
2087
2331
  "GET /SASjsApi/drive/file": { implemented: true },
2088
2332
  "POST /SASjsApi/drive/file": {
2089
2333
  implemented: true,
@@ -2121,60 +2365,137 @@ var SASjsApi = (() => {
2121
2365
  implemented: true,
2122
2366
  responses: { 200: { status: "success", tree: {} } }
2123
2367
  },
2124
- // The two deploy routes write a whole tree in one request. They are the only
2125
- // ones where a caller can exhaust the host's storage, so they carry a cap -
2126
- // see the plan in sasjs/cli#1550 - and they are not implemented yet.
2368
+ // The deploy routes answer an ENVELOPE, unlike the file routes above: their
2369
+ // `400` is `{status:'failure', message}` rather than the message as text, and
2370
+ // `413` is the cap refusing the tree before anything is written.
2371
+ // `"appLoc" is required` and `"fileTree" is required` are the other `400` this
2372
+ // route sends - a missing body property, worded by the table's own
2373
+ // validation - so the body annotated here is the substantial one.
2127
2374
  "POST /SASjsApi/drive/deploy": {
2128
- implemented: false
2129
- },
2130
- "POST /SASjsApi/drive/deploy/upload": {
2131
- implemented: false
2132
- },
2133
- // Unpublishing is the counterpart of the two above and lands with them: the
2134
- // document in sasjs/server states it (sasjs/server#543), so a host may answer
2135
- // it in the meantime, but this package cannot until the publish side it would
2136
- // invert is implemented here. The statuses are the document's; `404` is a pair
2137
- // - the registry name and the appLoc it was published from - that matches no
2138
- // entry, and the drive's content is deliberately left alone.
2139
- "DELETE /SASjsApi/drive/deploy": {
2140
- implemented: false
2141
- },
2142
- // The lockout and the provider failure are answered before the package is
2143
- // consulted (routes/web/web.ts in sasjs/server).
2144
- "POST /SASLogon/login": {
2145
- implemented: true,
2146
- hostResponses: [429, 500],
2147
- responses: { 200: { loggedIn: true, user } }
2148
- },
2149
- // The sign-in flow. The statuses are the document's; these entries state the
2150
- // body a client keys on, and that this package answers the operation.
2151
- "POST /SASjsApi/auth/token": {
2152
- implemented: true,
2153
- responses: { 200: { accessToken: "", refreshToken: "" } }
2154
- },
2155
- "POST /SASjsApi/auth/refresh": {
2156
- implemented: true,
2157
- responses: { 200: { accessToken: "", refreshToken: "" } }
2158
- },
2159
- "POST /SASjsApi/auth/logout": { implemented: true },
2160
- "POST /SASLogon/authorize": {
2161
2375
  implemented: true,
2162
- responses: { 200: { code: "" } }
2376
+ responses: {
2377
+ 200: DEPLOY_SUCCESS,
2378
+ 400: {
2379
+ status: "failure",
2380
+ message: "Provided not supported data format."
2381
+ },
2382
+ 413: "The file tree exceeds the deploy limit.",
2383
+ 500: { status: "failure", message: "Deployment failed!" }
2384
+ }
2163
2385
  },
2164
- "GET /SASLogon/logout": {
2165
- implemented: true,
2166
- hostResponses: [500],
2167
- responses: { 200: "OK!" }
2386
+ "POST /SASjsApi/drive/deploy/upload": {
2387
+ implemented: false,
2388
+ reason: ARCHIVE
2168
2389
  },
2390
+ // `404` is a pair - the registry name and the appLoc it was published from -
2391
+ // that matches no entry, and the drive's content is deliberately left alone.
2392
+ "DELETE /SASjsApi/drive/deploy": {
2393
+ implemented: false,
2394
+ reason: UNPUBLISH
2395
+ },
2396
+ // Group - members and the groups themselves
2397
+ "GET /SASjsApi/group": { implemented: false, reason: IDENTITY_RECORDS },
2398
+ "GET /SASjsApi/group/{uid}": { implemented: false, reason: IDENTITY_RECORDS },
2399
+ "GET /SASjsApi/group/by/groupname/{name}": {
2400
+ implemented: false,
2401
+ reason: IDENTITY_RECORDS
2402
+ },
2403
+ "POST /SASjsApi/group": { implemented: false, reason: IDENTITY_RECORDS },
2404
+ "POST /SASjsApi/group/{groupUid}/{userUid}": {
2405
+ implemented: false,
2406
+ reason: IDENTITY_RECORDS
2407
+ },
2408
+ "DELETE /SASjsApi/group/{groupUid}/{userUid}": {
2409
+ implemented: false,
2410
+ reason: IDENTITY_RECORDS
2411
+ },
2412
+ "DELETE /SASjsApi/group/{uid}": {
2413
+ implemented: false,
2414
+ reason: IDENTITY_RECORDS
2415
+ },
2416
+ // Info - what the host is, and what this caller may use
2417
+ "GET /SASjsApi/info": { implemented: false, reason: HOST_CONFIG },
2418
+ "GET /SASjsApi/info/authorizedRoutes": {
2419
+ implemented: false,
2420
+ // the route inventory reports what the CALLER may use, so it needs an
2421
+ // identity even though the rest of the Info tag needs nothing
2422
+ capabilities: ["identity"],
2423
+ reason: PERMISSIONS
2424
+ },
2425
+ "GET /SASjsApi/info/myAuthorizedRoutes": {
2426
+ implemented: false,
2427
+ capabilities: ["identity"],
2428
+ reason: PERMISSIONS
2429
+ },
2430
+ // Permission - the rules the inventory above is derived from
2431
+ "GET /SASjsApi/permission": { implemented: false, reason: IDENTITY_RECORDS },
2432
+ "POST /SASjsApi/permission": { implemented: false, reason: IDENTITY_RECORDS },
2433
+ "PATCH /SASjsApi/permission/{uid}": {
2434
+ implemented: false,
2435
+ reason: IDENTITY_RECORDS
2436
+ },
2437
+ "DELETE /SASjsApi/permission/{uid}": {
2438
+ implemented: false,
2439
+ reason: IDENTITY_RECORDS
2440
+ },
2441
+ // Session - the caller's own session, then the record behind a triggered one
2169
2442
  "GET /SASjsApi/session": {
2170
2443
  implemented: true,
2171
2444
  responses: { 200: user }
2445
+ },
2446
+ "GET /SASjsApi/session/{sessionId}": {
2447
+ implemented: false,
2448
+ reason: SESSION_RECORD
2449
+ },
2450
+ "GET /SASjsApi/session/{sessionId}/log": {
2451
+ implemented: false,
2452
+ reason: SESSION_RECORD
2453
+ },
2454
+ "GET /SASjsApi/session/{sessionId}/output": {
2455
+ implemented: false,
2456
+ reason: SESSION_RECORD
2457
+ },
2458
+ "GET /SASjsApi/session/{sessionId}/state": {
2459
+ implemented: false,
2460
+ reason: SESSION_RECORD
2461
+ },
2462
+ // STP - a stored program, on the runtime port
2463
+ "GET /SASjsApi/stp/execute": { implemented: false, reason: RUNTIME },
2464
+ "POST /SASjsApi/stp/execute": { implemented: false, reason: RUNTIME },
2465
+ "POST /SASjsApi/stp/trigger": { implemented: false, reason: RUNTIME },
2466
+ // User - the accounts the identity store already holds for sign-in
2467
+ "GET /SASjsApi/user": { implemented: false, reason: IDENTITY_RECORDS },
2468
+ "GET /SASjsApi/user/{uid}": { implemented: false, reason: IDENTITY_RECORDS },
2469
+ "GET /SASjsApi/user/by/username/{username}": {
2470
+ implemented: false,
2471
+ reason: IDENTITY_RECORDS
2472
+ },
2473
+ "POST /SASjsApi/user": { implemented: false, reason: IDENTITY_RECORDS },
2474
+ "PATCH /SASjsApi/user/{uid}": {
2475
+ implemented: false,
2476
+ reason: IDENTITY_RECORDS
2477
+ },
2478
+ "PATCH /SASjsApi/user/by/username/{username}": {
2479
+ implemented: false,
2480
+ reason: IDENTITY_RECORDS
2481
+ },
2482
+ "DELETE /SASjsApi/user/{uid}": {
2483
+ implemented: false,
2484
+ reason: IDENTITY_RECORDS
2485
+ },
2486
+ "DELETE /SASjsApi/user/by/username/{username}": {
2487
+ implemented: false,
2488
+ reason: IDENTITY_RECORDS
2172
2489
  }
2173
2490
  };
2174
2491
 
2175
2492
  // src/routes/index.ts
2176
2493
  var merge = (generated) => {
2177
- const overlay = OVERLAY[generated.id] || {};
2494
+ const overlay = OVERLAY[generated.id];
2495
+ if (!overlay)
2496
+ throw new Error(
2497
+ `src/routes/overlay.ts has no entry for ${generated.id}. Every operation states whether this package implements it and, when it does not, the reason it does not: add \`{ implemented: false, reason: '...' }\` until a handler exists.`
2498
+ );
2178
2499
  const documented = new Map(
2179
2500
  generated.responses.map((r) => [r.status, r.description])
2180
2501
  );
@@ -2203,7 +2524,8 @@ var SASjsApi = (() => {
2203
2524
  responses,
2204
2525
  hostResponses: overlay.hostResponses || [],
2205
2526
  capabilities: overlay.capabilities || TAG_CAPABILITIES[generated.tag] || [],
2206
- implemented: overlay.implemented === true
2527
+ implemented: overlay.implemented,
2528
+ ...overlay.implemented ? {} : { reason: overlay.reason }
2207
2529
  };
2208
2530
  };
2209
2531
  var ROUTES = GENERATED_ROUTES.map(merge);
@@ -2365,10 +2687,19 @@ var SASjsApi = (() => {
2365
2687
  String(request.body?.newPath || "")
2366
2688
  )
2367
2689
  };
2690
+ case "POST /SASjsApi/drive/deploy": {
2691
+ await drive.deploy(String(body.appLoc ?? ""), body.fileTree);
2692
+ return { status: 200, body: DEPLOY_SUCCESS };
2693
+ }
2368
2694
  case "GET /SASjsApi/drive/filetree":
2369
2695
  return { status: 200, body: await drive.tree(query._folderPath) };
2370
- default:
2371
- throw new ApiError(501, `This package has no handler for ${routeId}.`);
2696
+ default: {
2697
+ const reason = routeById(routeId)?.reason;
2698
+ throw new ApiError(
2699
+ 501,
2700
+ `This package has no handler for ${routeId}.${reason ? ` ${reason}` : ""}`
2701
+ );
2702
+ }
2372
2703
  }
2373
2704
  };
2374
2705
  var HANDLED_ROUTES = [
@@ -2381,6 +2712,7 @@ var SASjsApi = (() => {
2381
2712
  "DELETE /SASjsApi/drive/folder",
2382
2713
  "POST /SASjsApi/drive/rename",
2383
2714
  "GET /SASjsApi/drive/filetree",
2715
+ "POST /SASjsApi/drive/deploy",
2384
2716
  "POST /SASjsApi/auth/token",
2385
2717
  "POST /SASjsApi/auth/refresh",
2386
2718
  "POST /SASjsApi/auth/logout",
@@ -2747,6 +3079,11 @@ var SASjsApi = (() => {
2747
3079
  // src/corpus/drive.ts
2748
3080
  var success2 = { status: "success" };
2749
3081
  var bytes = (...values) => Uint8Array.from(values);
3082
+ var manyMembers = (count) => Array.from({ length: count }, (_, at) => ({
3083
+ name: `f${at}.txt`,
3084
+ type: "file",
3085
+ code: ""
3086
+ }));
2750
3087
  var DRIVE_CORPUS = [
2751
3088
  {
2752
3089
  id: "create-file",
@@ -3036,6 +3373,119 @@ var SASjsApi = (() => {
3036
3373
  }
3037
3374
  }
3038
3375
  },
3376
+ {
3377
+ id: "deploy-tree",
3378
+ route: "POST /SASjsApi/drive/deploy",
3379
+ description: "writes a whole tree under the appLoc it names",
3380
+ request: {
3381
+ body: {
3382
+ appLoc: "/Public/apps/demo",
3383
+ fileTree: {
3384
+ members: [
3385
+ {
3386
+ name: "services",
3387
+ type: "folder",
3388
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3389
+ },
3390
+ { name: "index.html", type: "file", code: "PGgxPkhlbGxvPC9oMT4=" }
3391
+ ]
3392
+ }
3393
+ }
3394
+ },
3395
+ expect: {
3396
+ status: 200,
3397
+ body: {
3398
+ status: "success",
3399
+ message: "Files deployed successfully to @sasjs/server."
3400
+ }
3401
+ }
3402
+ },
3403
+ {
3404
+ id: "deploy-refused-by-entry-count",
3405
+ route: "POST /SASjsApi/drive/deploy",
3406
+ description: "refuses a tree over the entry cap, naming the figure and the cap",
3407
+ request: {
3408
+ body: {
3409
+ appLoc: "/Public/apps/many",
3410
+ fileTree: { members: manyMembers(10001) }
3411
+ }
3412
+ },
3413
+ expect: {
3414
+ status: 413,
3415
+ body: {
3416
+ status: "failure",
3417
+ message: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3418
+ },
3419
+ error: "The file tree exceeds the deploy limit: 10001 entries (maximum 10000)."
3420
+ }
3421
+ },
3422
+ {
3423
+ id: "deploy-refused-by-name",
3424
+ route: "POST /SASjsApi/drive/deploy",
3425
+ description: "refuses a member name that would escape the deployment root",
3426
+ request: {
3427
+ body: {
3428
+ appLoc: "/Public/apps/hostile",
3429
+ fileTree: {
3430
+ members: [{ name: "../escape", type: "service", code: "%put hi;" }]
3431
+ }
3432
+ }
3433
+ },
3434
+ expect: {
3435
+ status: 400,
3436
+ body: {
3437
+ status: "failure",
3438
+ message: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3439
+ },
3440
+ error: "Invalid member name: '../escape.sas'. Member names must be single path segments (no '/', '\\', '..')."
3441
+ }
3442
+ },
3443
+ {
3444
+ id: "deploy-refused-by-shape",
3445
+ route: "POST /SASjsApi/drive/deploy",
3446
+ description: "refuses a fileTree that is not a tree, in the routes own envelope",
3447
+ request: { body: { appLoc: "/Public/apps/bad", fileTree: { members: 1 } } },
3448
+ expect: {
3449
+ status: 400,
3450
+ body: {
3451
+ status: "failure",
3452
+ message: "Provided not supported data format."
3453
+ },
3454
+ error: "Provided not supported data format."
3455
+ }
3456
+ },
3457
+ {
3458
+ id: "deploy-refused-by-store",
3459
+ route: "POST /SASjsApi/drive/deploy",
3460
+ description: "answers the routes 500 when the drive refuses the write",
3461
+ unwritable: true,
3462
+ request: {
3463
+ body: {
3464
+ appLoc: "/Public/apps/demo",
3465
+ fileTree: {
3466
+ members: [{ name: "run", type: "service", code: "%put hi;" }]
3467
+ }
3468
+ }
3469
+ },
3470
+ expect: {
3471
+ status: 500,
3472
+ body: { status: "failure", message: "Deployment failed!" },
3473
+ error: "Deployment failed!"
3474
+ }
3475
+ },
3476
+ {
3477
+ id: "deploy-refused-outside-drive",
3478
+ route: "POST /SASjsApi/drive/deploy",
3479
+ description: "refuses an appLoc that resolves outside the drive",
3480
+ request: {
3481
+ body: { appLoc: "/../outside", fileTree: { members: [] } }
3482
+ },
3483
+ expect: {
3484
+ status: 400,
3485
+ body: { status: "failure", message: "appLoc cannot be outside drive." },
3486
+ error: "appLoc cannot be outside drive."
3487
+ }
3488
+ },
3039
3489
  {
3040
3490
  id: "bytes-round-trip",
3041
3491
  route: "GET /SASjsApi/drive/file",
@@ -3313,11 +3763,21 @@ var SASjsApi = (() => {
3313
3763
 
3314
3764
  // src/corpus/runner.ts
3315
3765
  var servicesFor = (testCase) => ({
3316
- drive: createDriveService(new MemoryDriveStore(testCase.seed || {})),
3766
+ drive: createDriveService(
3767
+ testCase.unwritable ? new RefusingDriveStore(testCase.seed || {}) : new MemoryDriveStore(testCase.seed || {})
3768
+ ),
3317
3769
  identity: createIdentityService(
3318
3770
  new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY)
3319
3771
  )
3320
3772
  });
3773
+ var RefusingDriveStore = class extends MemoryDriveStore {
3774
+ async write() {
3775
+ throw new Error("the store refused the write");
3776
+ }
3777
+ async mkdirp() {
3778
+ throw new Error("the store refused the write");
3779
+ }
3780
+ };
3321
3781
  var runCase = async (testCase, services = servicesFor(testCase)) => {
3322
3782
  try {
3323
3783
  const response = await dispatch(services, testCase.route, testCase.request);
@@ -3327,7 +3787,8 @@ var SASjsApi = (() => {
3327
3787
  contents: response.contents
3328
3788
  };
3329
3789
  } catch (error) {
3330
- if (isApiError(error)) return { status: error.status, error: error.message };
3790
+ if (isApiError(error))
3791
+ return { status: error.status, body: error.body, error: error.message };
3331
3792
  throw error;
3332
3793
  }
3333
3794
  };