@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
@@ -0,0 +1,262 @@
1
+ import { ApiError } from '../types.js';
2
+ import { normaliseDrivePath } from './paths.js';
3
+ export const DEPLOY_LIMITS = {
4
+ entries: 10000,
5
+ totalBytes: 100 * 1024 * 1024,
6
+ fileBytes: 20 * 1024 * 1024
7
+ };
8
+ /**
9
+ * The largest a deploy tree can be on the wire, which is what a host's transport
10
+ * has to admit.
11
+ *
12
+ * The cap is stated in DECODED bytes; the tree travels as JSON in which a file
13
+ * member's contents are base64 - 4/3 of what they write - and a service's are
14
+ * escaped text (~1.04). A tree at `totalBytes` therefore reaches the parser at
15
+ * up to ~134MB, and a transport that admits less refuses first with its own
16
+ * shape, so the cap it was meant to enforce is never consulted. 140MB leaves
17
+ * room for the tree's JSON scaffolding.
18
+ *
19
+ * Only the deploy routes use it: everywhere else a smaller limit applies,
20
+ * because a body is buffered before authentication and nothing else needs the
21
+ * headroom.
22
+ */
23
+ export const DEPLOY_PAYLOAD_BYTES = 140 * 1024 * 1024;
24
+ /**
25
+ * The body a deploy answers success with.
26
+ *
27
+ * The wording is the published contract - a client may key on the message - so
28
+ * it is one constant here rather than a string in each host. It names
29
+ * `@sasjs/server` because that is the document's example, which predates this
30
+ * package; changing it is a document change, not a host's choice.
31
+ */
32
+ export const DEPLOY_SUCCESS = {
33
+ status: 'success',
34
+ message: 'Files deployed successfully to @sasjs/server.'
35
+ };
36
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
37
+ /** A base64 body without its padding, which is what the arithmetic needs. */
38
+ const base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, '');
39
+ /** The bytes a base64 body decodes to, without decoding it. */
40
+ const base64Length = (value) => Math.floor((base64Body(value).length * 3) / 4);
41
+ /** The bytes a base64 body decodes to, for a store that writes bytes. */
42
+ export const decodeBase64 = (value) => {
43
+ const body = base64Body(value);
44
+ const bytes = new Uint8Array(Math.floor((body.length * 3) / 4));
45
+ let buffer = 0;
46
+ let bits = 0;
47
+ let at = 0;
48
+ for (const char of body) {
49
+ buffer = (buffer << 6) | BASE64_ALPHABET.indexOf(char);
50
+ bits += 6;
51
+ if (bits >= 8) {
52
+ bits -= 8;
53
+ bytes[at++] = (buffer >> bits) & 0xff;
54
+ }
55
+ }
56
+ return bytes.subarray(0, at);
57
+ };
58
+ /** The bytes a string occupies as UTF-8, without an encoding API. */
59
+ const utf8Length = (value) => {
60
+ let bytes = 0;
61
+ for (let at = 0; at < value.length; at++) {
62
+ const code = value.charCodeAt(at);
63
+ if (code < 0x80)
64
+ bytes += 1;
65
+ else if (code < 0x800)
66
+ bytes += 2;
67
+ else if (code >= 0xd800 && code <= 0xdbff && at + 1 < value.length) {
68
+ // a surrogate pair is one code point of four bytes
69
+ bytes += 4;
70
+ at++;
71
+ }
72
+ else
73
+ bytes += 3;
74
+ }
75
+ return bytes;
76
+ };
77
+ /** The name a member is written under - a service gains the extension. */
78
+ const memberName = (member) => member.type === 'service' ? `${member.name}.sas` : member.name;
79
+ /** The bytes a member writes. */
80
+ const memberBytes = (member) => member.type === 'file' ? base64Length(member.code) : utf8Length(member.code);
81
+ /**
82
+ * Sizes as the messages state them: one decimal in binary megabytes, and bytes
83
+ * below a megabyte - a limit small enough to be measured in bytes should not
84
+ * read as "0 MB", which is what a stricter host would otherwise report.
85
+ */
86
+ const describeSize = (bytes) => bytes < 1024 * 1024
87
+ ? `${bytes} bytes`
88
+ : `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
89
+ /** A walk's starting point. */
90
+ const emptyWalk = () => ({
91
+ entries: 0,
92
+ totalBytes: 0,
93
+ largest: { path: '', bytes: 0 }
94
+ });
95
+ /**
96
+ * Every member name becomes a path SEGMENT under the deployment root, so a name
97
+ * carrying '/', '\' or '..' is a traversal attempt rather than a name.
98
+ */
99
+ const isSafeSegment = (name) => name !== '' &&
100
+ name !== '.' &&
101
+ name !== '..' &&
102
+ !/[/\\]/.test(name) &&
103
+ // a Windows drive or a NUL byte is never a segment either
104
+ !/[\0]/.test(name);
105
+ /**
106
+ * Walks a tree, counting it and noting the first unsafe name.
107
+ *
108
+ * A name problem is RECORDED rather than returned, so the walk always finishes:
109
+ * a caller that only wants the numbers gets the size of the whole tree, and the
110
+ * validator decides whether the name matters. Returning early would report a
111
+ * truncated tree as if it were the real one.
112
+ */
113
+ const walk = (members, parentPath, acc) => {
114
+ for (const member of members) {
115
+ const name = memberName(member);
116
+ const memberPath = parentPath ? `${parentPath}/${name}` : name;
117
+ if (!isSafeSegment(name) && !acc.nameRefusal)
118
+ acc.nameRefusal = {
119
+ code: 400,
120
+ status: 'failure',
121
+ message: `Invalid member name: '${name}'. Member names must be single path segments (no '/', '\\', '..').`
122
+ };
123
+ acc.entries++;
124
+ if (member.type === 'folder') {
125
+ walk(member.members, memberPath, acc);
126
+ continue;
127
+ }
128
+ const bytes = memberBytes(member);
129
+ acc.totalBytes += bytes;
130
+ if (bytes > acc.largest.bytes)
131
+ acc.largest = { path: memberPath, bytes };
132
+ }
133
+ };
134
+ /**
135
+ * Measures a tree without touching a drive.
136
+ *
137
+ * Every member is counted, whatever its name: this answers "how big is this
138
+ * tree", and a caller that also needs the names checked is `checkDeployTree`.
139
+ */
140
+ export const measureDeployTree = (members) => {
141
+ const acc = emptyWalk();
142
+ walk(members, '', acc);
143
+ return {
144
+ entries: acc.entries,
145
+ totalBytes: acc.totalBytes,
146
+ largest: acc.largest
147
+ };
148
+ };
149
+ /**
150
+ * The refusal a file tree earns, or `null` when it can be written.
151
+ *
152
+ * Names are checked first, then the dimensions in a fixed order - entries,
153
+ * total, single file - so a tree breaching two of them is refused for the same
154
+ * one every time, which is what lets a caller key on the message and a test pin
155
+ * it.
156
+ *
157
+ * `limits` defaults to `DEPLOY_LIMITS`; a host may pass its own, stricter set.
158
+ */
159
+ export const checkDeployTree = (members, limits = DEPLOY_LIMITS) => {
160
+ const measured = emptyWalk();
161
+ walk(members, '', measured);
162
+ if (measured.nameRefusal)
163
+ return measured.nameRefusal;
164
+ if (measured.entries > limits.entries)
165
+ return {
166
+ code: 413,
167
+ status: 'failure',
168
+ message: `The file tree exceeds the deploy limit: ${measured.entries} entries (maximum ${limits.entries}).`
169
+ };
170
+ if (measured.totalBytes > limits.totalBytes)
171
+ return {
172
+ code: 413,
173
+ status: 'failure',
174
+ message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeSize(limits.totalBytes)}).`
175
+ };
176
+ if (measured.largest.bytes > limits.fileBytes)
177
+ return {
178
+ code: 413,
179
+ status: 'failure',
180
+ message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeSize(limits.fileBytes)} per file).`
181
+ };
182
+ return null;
183
+ };
184
+ /**
185
+ * The tree's shape, as the wire format states it.
186
+ *
187
+ * A port of `isFileTree` from `@sasjs/utils`, which is what sasjs/server
188
+ * validates with: a tree is an object with a `members` array, and every member
189
+ * is a folder with its own members, or a service/file with a string `code` and
190
+ * a string `name`. Anything else is the `400` the routes already answer.
191
+ */
192
+ export const isDeployTree = (value) => {
193
+ if (!value || typeof value !== 'object')
194
+ return false;
195
+ const members = value.members;
196
+ return Array.isArray(members) && members.every(isDeployMember);
197
+ };
198
+ const isDeployMember = (value) => {
199
+ if (!value || typeof value !== 'object')
200
+ return false;
201
+ const member = value;
202
+ if (typeof member.name !== 'string')
203
+ return false;
204
+ if (member.type === 'folder')
205
+ return Array.isArray(member.members) && member.members.every(isDeployMember);
206
+ if (member.type === 'service' || member.type === 'file')
207
+ return typeof member.code === 'string';
208
+ return false;
209
+ };
210
+ /**
211
+ * Deploys a tree into `appLoc`, or refuses without writing.
212
+ *
213
+ * The order is the promise: shape, then containment, then the cap and the names,
214
+ * and only then the first write. `appLoc` is caller-supplied and every segment
215
+ * must stay a segment, so it is resolved against the drive root and refused when
216
+ * it leaves it - joined naively, a `..` would write outside the drive.
217
+ *
218
+ * A write that fails part-way answers the deploy routes' `500`; the tree before
219
+ * the failure is already on the drive, which the message does not hide.
220
+ */
221
+ export const deployTree = async (store, appLoc, tree, limits = DEPLOY_LIMITS) => {
222
+ if (!isDeployTree(tree))
223
+ throw new ApiError(400, 'Provided not supported data format.', {
224
+ status: 'failure',
225
+ message: 'Provided not supported data format.'
226
+ });
227
+ const root = normaliseDrivePath(appLoc);
228
+ if (root === null)
229
+ throw new ApiError(400, 'appLoc cannot be outside drive.', {
230
+ status: 'failure',
231
+ message: 'appLoc cannot be outside drive.'
232
+ });
233
+ const refusal = checkDeployTree(tree.members, limits);
234
+ // the body is the envelope WITHOUT the refusal's own `code`: that field is
235
+ // how a host turns the refusal into a status, and it is not sent
236
+ if (refusal)
237
+ throw new ApiError(refusal.code, refusal.message, {
238
+ status: refusal.status,
239
+ message: refusal.message
240
+ });
241
+ try {
242
+ await writeMembers(store, tree.members, root);
243
+ }
244
+ catch {
245
+ throw new ApiError(500, 'Deployment failed!', {
246
+ status: 'failure',
247
+ message: 'Deployment failed!'
248
+ });
249
+ }
250
+ };
251
+ /** Writes every member under `parentPath`, depth first. */
252
+ const writeMembers = async (store, members, parentPath) => {
253
+ for (const member of members) {
254
+ const path = `${parentPath}/${memberName(member)}`;
255
+ if (member.type === 'folder') {
256
+ await store.mkdirp(path);
257
+ await writeMembers(store, member.members, path);
258
+ continue;
259
+ }
260
+ await store.write(path, member.type === 'file' ? decodeBase64(member.code) : member.code);
261
+ }
262
+ };
@@ -37,6 +37,14 @@ export interface DriveService {
37
37
  rmdir(folderPath: string): Promise<DriveResult>;
38
38
  rename(oldPath: string, newPath: string): Promise<DriveResult>;
39
39
  tree(folderPath?: string): Promise<DriveTree>;
40
+ /**
41
+ * Writes a whole tree under `appLoc`, or refuses without writing.
42
+ *
43
+ * `tree` is `unknown` on purpose: the wire payload is the caller's JSON, and
44
+ * the shape is one of the things this decides - a mistyped tree earns the
45
+ * routes' `400` rather than a crash in a host that trusted it.
46
+ */
47
+ deploy(appLoc: string, tree: unknown): Promise<DriveResult>;
40
48
  }
41
49
  export declare const createDriveService: (store: DriveStore) => DriveService;
42
50
  //# sourceMappingURL=service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/drive/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAWvD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,SAAS,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;IAC7B,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE,QAAQ,CAAA;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IAChD,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC3E,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC7E,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9D,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;CAC9C;AAqBD,eAAO,MAAM,kBAAkB,GAAI,OAAO,UAAU,KAAG,YAoJtD,CAAA"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/drive/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAYvD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,SAAS,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;IAC7B,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE,QAAQ,CAAA;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IAChD,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC3E,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC7E,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9D,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IAC7C;;;;;;OAMG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;CAC5D;AAqBD,eAAO,MAAM,kBAAkB,GAAI,OAAO,UAAU,KAAG,YA0JtD,CAAA"}
@@ -1,4 +1,5 @@
1
1
  import { ApiError } from '../types.js';
2
+ import { deployTree } from './deploy.js';
2
3
  import { basename, checkFilePath, dirname, hasBlockedExtension, isRoot, normaliseDrivePath } from './paths.js';
3
4
  /** A path a file operation may act on, or the refusal the operation answers. */
4
5
  const filePath = (input, outside) => {
@@ -142,6 +143,10 @@ export const createDriveService = (store) => {
142
143
  if (!stat.directory)
143
144
  throw new ApiError(400, 'Not a Folder.');
144
145
  return { status: 'success', tree: await treeOf(path) };
146
+ },
147
+ async deploy(appLoc, tree) {
148
+ await deployTree(store, appLoc, tree);
149
+ return { status: 'success' };
145
150
  }
146
151
  };
147
152
  };
@@ -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,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAGnD,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,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;IACtD;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAA;CAC3B;AAsCD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CAqHrB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EAiBlC,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,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAInD,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,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;IACtD;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAA;CAC3B;AAsCD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CAsIrB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EAkBlC,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { routeById } from '../routes/index.js';
2
2
  import { ApiError } from '../types.js';
3
+ import { DEPLOY_SUCCESS } from '../drive/deploy.js';
3
4
  /**
4
5
  * The parameters the table marks required, checked before the handler runs.
5
6
  *
@@ -126,10 +127,21 @@ export const dispatch = async (services, routeId, request = {}) => {
126
127
  status: 200,
127
128
  body: await drive.rename(String(request.body?.oldPath || ''), String(request.body?.newPath || ''))
128
129
  };
130
+ case 'POST /SASjsApi/drive/deploy': {
131
+ // `streamWebFolder` is the host's: publishing an app stream is not a drive
132
+ // operation, and a host that has no app stream ignores it.
133
+ await drive.deploy(String(body.appLoc ?? ''), body.fileTree);
134
+ return { status: 200, body: DEPLOY_SUCCESS };
135
+ }
129
136
  case 'GET /SASjsApi/drive/filetree':
130
137
  return { status: 200, body: await drive.tree(query._folderPath) };
131
- default:
132
- throw new ApiError(501, `This package has no handler for ${routeId}.`);
138
+ default: {
139
+ // The reason the table carries travels with the refusal: a host developer
140
+ // reading a 501 wants to know what the operation waits on, not only that
141
+ // it is missing.
142
+ const reason = routeById(routeId)?.reason;
143
+ throw new ApiError(501, `This package has no handler for ${routeId}.${reason ? ` ${reason}` : ''}`);
144
+ }
133
145
  }
134
146
  };
135
147
  /** Whether a route is one `dispatch` can answer today. */
@@ -143,6 +155,7 @@ export const HANDLED_ROUTES = [
143
155
  'DELETE /SASjsApi/drive/folder',
144
156
  'POST /SASjsApi/drive/rename',
145
157
  'GET /SASjsApi/drive/filetree',
158
+ 'POST /SASjsApi/drive/deploy',
146
159
  'POST /SASjsApi/auth/token',
147
160
  'POST /SASjsApi/auth/refresh',
148
161
  'POST /SASjsApi/auth/logout',
@@ -16,6 +16,8 @@
16
16
  export * from './types.js';
17
17
  export { normaliseDrivePath, hasBlockedExtension, dirname, basename, isRoot } from './drive/paths.js';
18
18
  export { createDriveService } from './drive/service.js';
19
+ export { DEPLOY_LIMITS, DEPLOY_PAYLOAD_BYTES, DEPLOY_SUCCESS, checkDeployTree, decodeBase64, deployTree, isDeployTree, measureDeployTree } from './drive/deploy.js';
20
+ export type { DeployLimits, DeployMeasurements, DeployMember, DeployRefusal, DeployTree } from './drive/deploy.js';
19
21
  export type { DriveService, DriveResult, DriveListing, DriveFile, DriveTree } from './drive/service.js';
20
22
  export { MemoryDriveStore } from './drive/memory-store.js';
21
23
  export { ROUTES, routeById, findRoute } from './routes/index.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,MAAM,EACP,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,YAAY,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,EACT,SAAS,EACV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAChE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAC7D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,YAAY,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,cAAc,EACd,aAAa,EACb,YAAY,EACZ,OAAO,EACP,SAAS,EACV,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,MAAM,EACP,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,OAAO,EACL,aAAa,EACb,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,iBAAiB,EAClB,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EACV,YAAY,EACZ,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,UAAU,EACX,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,EACT,SAAS,EACV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAChE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAC7D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,YAAY,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,cAAc,EACd,aAAa,EACb,YAAY,EACZ,OAAO,EACP,SAAS,EACV,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA"}
@@ -16,6 +16,7 @@
16
16
  export * from './types.js';
17
17
  export { normaliseDrivePath, hasBlockedExtension, dirname, basename, isRoot } from './drive/paths.js';
18
18
  export { createDriveService } from './drive/service.js';
19
+ export { DEPLOY_LIMITS, DEPLOY_PAYLOAD_BYTES, DEPLOY_SUCCESS, checkDeployTree, decodeBase64, deployTree, isDeployTree, measureDeployTree } from './drive/deploy.js';
19
20
  export { MemoryDriveStore } from './drive/memory-store.js';
20
21
  export { ROUTES, routeById, findRoute } from './routes/index.js';
21
22
  export { OVERLAY, TAG_CAPABILITIES } from './routes/overlay.js';
@@ -1 +1 @@
1
- {"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../../src/routes/generated.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,gBAAgB,EAAE,cAAc,EAirD5C,CAAA"}
1
+ {"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../../src/routes/generated.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,gBAAgB,EAAE,cAAc,EAyrD5C,CAAA"}
@@ -469,6 +469,10 @@ export const GENERATED_ROUTES = [
469
469
  status: 400,
470
470
  description: 'Invalid Format'
471
471
  },
472
+ {
473
+ status: 413,
474
+ description: 'File tree exceeds the deploy limit'
475
+ },
472
476
  {
473
477
  status: 500,
474
478
  description: 'Execution Error'
@@ -499,6 +503,10 @@ export const GENERATED_ROUTES = [
499
503
  status: 400,
500
504
  description: 'Invalid Format'
501
505
  },
506
+ {
507
+ status: 413,
508
+ description: 'File tree exceeds the deploy limit'
509
+ },
502
510
  {
503
511
  status: 500,
504
512
  description: 'Execution Error'
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/routes/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAiB,MAAM,aAAa,CAAA;AAkDvD,eAAO,MAAM,MAAM,EAAE,KAAK,EAAgC,CAAA;AAW1D,4EAA4E;AAC5E,eAAO,MAAM,SAAS,GAAI,IAAI,MAAM,KAAG,KAAK,GAAG,SAAyB,CAAA;AAExE,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,KAAK,CAAA;IACZ,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAgCD;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GACpB,QAAQ,MAAM,EACd,MAAM,MAAM,KACX,UAAU,GAAG,SAuBf,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/routes/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAiB,MAAM,aAAa,CAAA;AA4DvD,eAAO,MAAM,MAAM,EAAE,KAAK,EAAgC,CAAA;AAW1D,4EAA4E;AAC5E,eAAO,MAAM,SAAS,GAAI,IAAI,MAAM,KAAG,KAAK,GAAG,SAAyB,CAAA;AAExE,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,KAAK,CAAA;IACZ,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAgCD;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GACpB,QAAQ,MAAM,EACd,MAAM,MAAM,KACX,UAAU,GAAG,SAuBf,CAAA"}
@@ -10,7 +10,12 @@
10
10
  import { GENERATED_ROUTES } from './generated.js';
11
11
  import { OVERLAY, TAG_CAPABILITIES } from './overlay.js';
12
12
  const merge = (generated) => {
13
- const overlay = OVERLAY[generated.id] || {};
13
+ const overlay = OVERLAY[generated.id];
14
+ // Every operation states its decision. A route with no entry is a route
15
+ // nobody has looked at, which reads exactly like a route that is about to be
16
+ // done - so it fails the build instead, and the message says what to add.
17
+ if (!overlay)
18
+ throw new Error(`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.`);
14
19
  const documented = new Map(generated.responses.map((r) => [r.status, r.description]));
15
20
  // The document states the statuses and the overlay only annotates the ones a
16
21
  // client keys a body on. Naming a status the document does not state is a
@@ -43,7 +48,8 @@ const merge = (generated) => {
43
48
  responses,
44
49
  hostResponses: overlay.hostResponses || [],
45
50
  capabilities: overlay.capabilities || TAG_CAPABILITIES[generated.tag] || [],
46
- implemented: overlay.implemented === true
51
+ implemented: overlay.implemented,
52
+ ...(overlay.implemented ? {} : { reason: overlay.reason })
47
53
  };
48
54
  };
49
55
  export const ROUTES = GENERATED_ROUTES.map(merge);
@@ -14,7 +14,16 @@
14
14
  * server does not send;
15
15
  * - which capability the route needs, so a host can answer 501 with a reason
16
16
  * instead of 404 with none;
17
- * - whether this package implements it yet, so "not done" is visible.
17
+ * - whether this package implements it and, when it does not, what the
18
+ * operation is waiting on.
19
+ *
20
+ * EVERY operation in the document has an entry, and the type carries the rule:
21
+ * `implemented: true`, or `implemented: false` with the `reason` it is not. An
22
+ * operation with no entry is a build failure (`src/routes/index.ts`), because
23
+ * silence is indistinguishable from "nobody has looked at this yet" - which is
24
+ * the state this file exists to remove. The reason is also what a 501 carries
25
+ * to whoever asked (`src/host/dispatch.ts`), so the answer names the dependency
26
+ * rather than the absence.
18
27
  *
19
28
  * The statuses come from the document, and an overlay body is attached to the
20
29
  * status it belongs to: naming a status the document does not state is a build
@@ -23,9 +32,9 @@
23
32
  * re-vendored here.
24
33
  */
25
34
  import type { Capability, HttpMethod } from '../types.js';
26
- export interface RouteOverlay {
35
+ /** The annotations both states of an entry can carry. */
36
+ interface OverlayFields {
27
37
  capabilities?: Capability[];
28
- implemented?: boolean;
29
38
  /**
30
39
  * Statuses the document states that this package cannot send because they are
31
40
  * the host's to send: an account lockout is the host's login limiter, a
@@ -41,9 +50,23 @@ export interface RouteOverlay {
41
50
  */
42
51
  responses?: Record<number, unknown>;
43
52
  }
53
+ /**
54
+ * One operation's decision: carried here, or carried by the host.
55
+ *
56
+ * A union rather than an optional field, so a route marked unimplemented
57
+ * without saying why does not compile. The reason is the point of the entry -
58
+ * "not done yet" on its own tells a reader nothing they can act on.
59
+ */
60
+ export type RouteOverlay = OverlayFields & ({
61
+ implemented: true;
62
+ } | {
63
+ implemented: false;
64
+ reason: string;
65
+ });
44
66
  /** The default capability for each tag, overridden per route where it differs. */
45
67
  export declare const TAG_CAPABILITIES: Record<string, Capability[]>;
46
68
  export declare const OVERLAY: Record<string, RouteOverlay>;
47
69
  /** Every method the table may state, in the order a diff reads best. */
48
70
  export declare const METHODS: HttpMethod[];
71
+ export {};
49
72
  //# sourceMappingURL=overlay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../../src/routes/overlay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAEzD,MAAM,WAAW,YAAY;IAC3B,YAAY,CAAC,EAAE,UAAU,EAAE,CAAA;IAC3B,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC;AAED,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAczD,CAAA;AAYD,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CA6FhD,CAAA;AAED,wEAAwE;AACxE,eAAO,MAAM,OAAO,EAAE,UAAU,EAA8C,CAAA"}
1
+ {"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../../src/routes/overlay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAGzD,yDAAyD;AACzD,UAAU,aAAa;IACrB,YAAY,CAAC,EAAE,UAAU,EAAE,CAAA;IAC3B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,aAAa,GACtC,CAAC;IAAE,WAAW,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,WAAW,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAElE,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAczD,CAAA;AAiDD,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAmOhD,CAAA;AAED,wEAAwE;AACxE,eAAO,MAAM,OAAO,EAAE,UAAU,EAA8C,CAAA"}