@sasjs/api 0.2.0 → 0.3.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 (54) hide show
  1. package/README.md +28 -8
  2. package/build/browser.js +810 -4
  3. package/build/cjs/corpus/drive.js +20 -0
  4. package/build/cjs/corpus/identity.js +268 -0
  5. package/build/cjs/corpus/runner.js +11 -2
  6. package/build/cjs/drive/service.js +8 -0
  7. package/build/cjs/host/dispatch.js +57 -2
  8. package/build/cjs/identity/fixture.js +118 -0
  9. package/build/cjs/identity/memory-store.js +179 -0
  10. package/build/cjs/identity/service.js +142 -0
  11. package/build/cjs/identity/types.js +11 -0
  12. package/build/cjs/index.js +11 -1
  13. package/build/cjs/routes/generated.js +72 -0
  14. package/build/cjs/routes/index.js +6 -0
  15. package/build/cjs/routes/overlay.js +38 -0
  16. package/build/esm/corpus/drive.d.ts +14 -0
  17. package/build/esm/corpus/drive.d.ts.map +1 -1
  18. package/build/esm/corpus/drive.js +20 -0
  19. package/build/esm/corpus/identity.d.ts +17 -0
  20. package/build/esm/corpus/identity.d.ts.map +1 -0
  21. package/build/esm/corpus/identity.js +265 -0
  22. package/build/esm/corpus/runner.d.ts +6 -1
  23. package/build/esm/corpus/runner.d.ts.map +1 -1
  24. package/build/esm/corpus/runner.js +11 -2
  25. package/build/esm/drive/service.d.ts.map +1 -1
  26. package/build/esm/drive/service.js +9 -1
  27. package/build/esm/host/dispatch.d.ts +16 -0
  28. package/build/esm/host/dispatch.d.ts.map +1 -1
  29. package/build/esm/host/dispatch.js +57 -2
  30. package/build/esm/identity/fixture.d.ts +84 -0
  31. package/build/esm/identity/fixture.d.ts.map +1 -0
  32. package/build/esm/identity/fixture.js +114 -0
  33. package/build/esm/identity/memory-store.d.ts +51 -0
  34. package/build/esm/identity/memory-store.d.ts.map +1 -0
  35. package/build/esm/identity/memory-store.js +175 -0
  36. package/build/esm/identity/service.d.ts +48 -0
  37. package/build/esm/identity/service.d.ts.map +1 -0
  38. package/build/esm/identity/service.js +137 -0
  39. package/build/esm/identity/types.d.ts +79 -0
  40. package/build/esm/identity/types.d.ts.map +1 -0
  41. package/build/esm/identity/types.js +10 -0
  42. package/build/esm/index.d.ts +7 -0
  43. package/build/esm/index.d.ts.map +1 -1
  44. package/build/esm/index.js +4 -0
  45. package/build/esm/routes/generated.d.ts.map +1 -1
  46. package/build/esm/routes/generated.js +72 -0
  47. package/build/esm/routes/index.d.ts.map +1 -1
  48. package/build/esm/routes/index.js +6 -0
  49. package/build/esm/routes/overlay.d.ts +9 -0
  50. package/build/esm/routes/overlay.d.ts.map +1 -1
  51. package/build/esm/routes/overlay.js +38 -0
  52. package/build/esm/types.d.ts +7 -0
  53. package/build/esm/types.d.ts.map +1 -1
  54. package/package.json +9 -3
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryIdentityStore = void 0;
4
+ const fixture_js_1 = require("./fixture.js");
5
+ const types_js_1 = require("../types.js");
6
+ const keyOf = (subject) => `${subject.userId}|${subject.clientId}`;
7
+ /**
8
+ * Opaque identity strings, from the platform's cryptographic source.
9
+ *
10
+ * These strings are credentials: whoever holds one is the caller it names. A
11
+ * counter or `Math.random` is a sequence a reader can reproduce, so it would
12
+ * let one caller name another - the store is small and demo-shaped, but a token
13
+ * that can be guessed is not a demo quirk, it is an authentication bypass.
14
+ *
15
+ * `globalThis.crypto` is present in every browser and in Node from 19, which is
16
+ * the same reach the rest of this package has (no node imports, so the browser
17
+ * build bundles it unchanged).
18
+ */
19
+ const randomHex = (bytes) => {
20
+ const source = globalThis.crypto;
21
+ if (!source)
22
+ throw new types_js_1.ApiError(500, 'This host has no Web Crypto (`globalThis.crypto`), so it cannot mint an unguessable identity string.');
23
+ const buffer = new Uint8Array(bytes);
24
+ source.getRandomValues(buffer);
25
+ return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('');
26
+ };
27
+ /**
28
+ * How long a code buys tokens, in seconds.
29
+ *
30
+ * The same lifetime `sasjs/server` signs into its auth code JWT, so a code a
31
+ * mock issues and one a server issues age out together.
32
+ */
33
+ const CODE_LIFETIME_SECONDS = 30;
34
+ const live = (issued, now) => issued && (issued.expiresAt === undefined || issued.expiresAt > now)
35
+ ? issued.subject
36
+ : undefined;
37
+ /**
38
+ * Whether a token minted for one subject names the same user and client.
39
+ *
40
+ * The fixture can declare a token that outlives the map it is seeded into, so
41
+ * the read paths compare rather than trust the reference.
42
+ */
43
+ class MemoryIdentityStore {
44
+ constructor(fixture = fixture_js_1.DEFAULT_IDENTITY) {
45
+ this.users = [];
46
+ this.passwords = new Map();
47
+ this.clients = new Map();
48
+ this.pairs = new Map();
49
+ this.access = new Map();
50
+ this.refresh = new Map();
51
+ this.codes = new Map();
52
+ this.claimed = new Set();
53
+ if (fixture.format !== fixture_js_1.IDENTITY_FORMAT)
54
+ throw new types_js_1.ApiError(500, `The identity fixture states format ${String(fixture.format)}; this store reads ${fixture_js_1.IDENTITY_FORMAT}.`);
55
+ for (const user of fixture.users) {
56
+ this.users.push({
57
+ uid: user.uid,
58
+ username: user.username,
59
+ displayName: user.displayName,
60
+ isAdmin: user.isAdmin,
61
+ isActive: user.isActive,
62
+ needsToUpdatePassword: user.needsToUpdatePassword
63
+ });
64
+ this.passwords.set(user.uid, user.password);
65
+ }
66
+ for (const client of fixture.clients)
67
+ this.clients.set(client.clientId, {
68
+ clientId: client.clientId,
69
+ accessTokenExpiration: client.accessTokenExpiration,
70
+ refreshTokenExpiration: client.refreshTokenExpiration
71
+ });
72
+ for (const token of fixture.tokens || []) {
73
+ const user = this.users.find((candidate) => candidate.username === token.username);
74
+ if (!user)
75
+ continue;
76
+ const subject = { userId: user.uid, clientId: token.clientId };
77
+ if (token.accessToken)
78
+ this.access.set(token.accessToken, { subject });
79
+ if (token.refreshToken)
80
+ this.refresh.set(token.refreshToken, { subject });
81
+ // a seeded token is the subject's live pair, which is what makes it
82
+ // authenticate: the read paths check the pair, not the token alone
83
+ if (token.accessToken && token.refreshToken)
84
+ this.pairs.set(keyOf(subject), {
85
+ accessToken: token.accessToken,
86
+ refreshToken: token.refreshToken
87
+ });
88
+ }
89
+ for (const code of fixture.codes || []) {
90
+ const user = this.users.find((candidate) => candidate.username === code.username);
91
+ if (!user)
92
+ continue;
93
+ this.codes.set(code.code, {
94
+ subject: { userId: user.uid, clientId: code.clientId }
95
+ });
96
+ }
97
+ }
98
+ async userByName(username) {
99
+ return this.users.find((user) => user.username === username);
100
+ }
101
+ async userById(uid) {
102
+ return this.users.find((user) => user.uid === uid);
103
+ }
104
+ /** A fixture holds the password in clear, so the check is an equality. */
105
+ async passwordMatches(user, password) {
106
+ return this.passwords.get(user.uid) === password;
107
+ }
108
+ async client(clientId) {
109
+ return this.clients.get(clientId);
110
+ }
111
+ async issue(subject, client) {
112
+ // The lifetimes are the client's, recorded against the instant of issue: a
113
+ // token's expiry is a fact about this pair, so the read paths can answer
114
+ // "invalid or expired" rather than carrying a number nothing compares.
115
+ const issuedAt = Date.now();
116
+ const pair = {
117
+ accessToken: `access-${randomHex(16)}`,
118
+ refreshToken: `refresh-${randomHex(16)}`
119
+ };
120
+ this.access.set(pair.accessToken, {
121
+ subject,
122
+ expiresAt: issuedAt + client.accessTokenExpiration * 1000
123
+ });
124
+ this.refresh.set(pair.refreshToken, {
125
+ subject,
126
+ expiresAt: issuedAt + client.refreshTokenExpiration * 1000
127
+ });
128
+ return pair;
129
+ }
130
+ /**
131
+ * A token names its subject only while that subject's CURRENT pair holds it.
132
+ * The server verifies a token against the pair saved for the user and client,
133
+ * so a logged-out or rotated token stops working there too; without the same
134
+ * check here, the browser host would honour a token the server had revoked.
135
+ */
136
+ async readAccess(token) {
137
+ return this.current(token, this.access, 'accessToken');
138
+ }
139
+ async readRefresh(token) {
140
+ return this.current(token, this.refresh, 'refreshToken');
141
+ }
142
+ current(token, issued, kind) {
143
+ const entry = issued.get(token);
144
+ if (!entry)
145
+ return undefined;
146
+ const subject = live(entry, Date.now());
147
+ if (!subject)
148
+ return undefined;
149
+ const pair = this.pairs.get(keyOf(subject));
150
+ return pair && pair[kind] === token ? subject : undefined;
151
+ }
152
+ async pairFor(subject) {
153
+ return this.pairs.get(keyOf(subject));
154
+ }
155
+ async savePair(subject, pair) {
156
+ this.pairs.set(keyOf(subject), pair);
157
+ }
158
+ async deletePair(subject) {
159
+ this.pairs.delete(keyOf(subject));
160
+ }
161
+ async issueCode(subject) {
162
+ const code = `code-${randomHex(16)}`;
163
+ this.codes.set(code, {
164
+ subject,
165
+ expiresAt: Date.now() + CODE_LIFETIME_SECONDS * 1000
166
+ });
167
+ return code;
168
+ }
169
+ async readCode(code) {
170
+ return live(this.codes.get(code), Date.now());
171
+ }
172
+ async claimCode(code) {
173
+ if (this.claimed.has(code) || !this.codes.has(code))
174
+ return false;
175
+ this.claimed.add(code);
176
+ return true;
177
+ }
178
+ }
179
+ exports.MemoryIdentityStore = MemoryIdentityStore;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asSession = exports.createIdentityService = void 0;
4
+ /**
5
+ * The sign-in flow's decisions: what each route answers, in which order, with
6
+ * which wording.
7
+ *
8
+ * The transport stays with the host - a session cookie and a CSRF token on a
9
+ * server, whatever the browser build holds - so nothing here reads a request.
10
+ * A host resolves the caller (a bearer token through `authenticate`, a session
11
+ * it already has) and passes the subject in; the service decides whether that
12
+ * is enough for the operation to proceed.
13
+ */
14
+ const types_js_1 = require("../types.js");
15
+ const unauthorized = () => new types_js_1.ApiError(401, 'Unauthorized');
16
+ /**
17
+ * The subject, provided the account behind it may still hold a token.
18
+ *
19
+ * `authenticate` reads a token on every request and refuses a deactivated
20
+ * account; an exchange or a refresh that skipped the same check would mint a
21
+ * fresh pair for exactly that account, so the check sits beside every path that
22
+ * reaches `store.issue`.
23
+ */
24
+ const liveSubject = async (store, subject) => {
25
+ const user = await store.userById(subject.userId);
26
+ if (!user || !user.isActive)
27
+ throw unauthorized();
28
+ return subject;
29
+ };
30
+ const createIdentityService = (store) => ({
31
+ async authenticate(token) {
32
+ if (token === undefined)
33
+ return undefined;
34
+ const subject = await store.readAccess(token);
35
+ if (!subject)
36
+ return undefined;
37
+ // A token names a user who may since have been deactivated. sasjs/server
38
+ // checks `isActive` on every request, so a token that outlived the account
39
+ // must stop resolving here too - otherwise the browser host honours a
40
+ // caller the server would refuse.
41
+ const user = await store.userById(subject.userId);
42
+ return user && user.isActive ? subject : undefined;
43
+ },
44
+ async login({ username, password }) {
45
+ const user = await store.userByName(username);
46
+ // An unknown username and a wrong password answer with different messages
47
+ // on purpose: the server's login form tells the caller which one it was,
48
+ // and the account lockout - the defence against guessing - is the host's.
49
+ if (!user)
50
+ throw new types_js_1.ApiError(401, 'Username is not found.');
51
+ if (!(await store.passwordMatches(user, password)))
52
+ throw new types_js_1.ApiError(401, 'Invalid Password.');
53
+ // Checked after the password, so the answer is not a way to learn that an
54
+ // account exists but is switched off without knowing its password.
55
+ if (!user.isActive)
56
+ throw new types_js_1.ApiError(401, 'This account is not active.');
57
+ return {
58
+ loggedIn: true,
59
+ user: {
60
+ uid: user.uid,
61
+ username: user.username,
62
+ displayName: user.displayName,
63
+ isAdmin: user.isAdmin,
64
+ needsToUpdatePassword: user.needsToUpdatePassword
65
+ }
66
+ };
67
+ },
68
+ async authorize(subject, clientId) {
69
+ // The same answer the server's middleware gives a caller with no token: the
70
+ // two hosts must not describe the same refusal differently.
71
+ if (!subject)
72
+ throw unauthorized();
73
+ if (!(await store.client(clientId)))
74
+ throw new types_js_1.ApiError(403, 'Invalid clientId.');
75
+ return store.issueCode({ userId: subject.userId, clientId });
76
+ },
77
+ async token({ clientId, code }) {
78
+ // Read before claiming: a code presented with another client is refused
79
+ // with the same answer as one that does not verify, and it is not spent, so
80
+ // the caller can still exchange it for the client it was issued for.
81
+ const subject = await store.readCode(code);
82
+ if (!subject || subject.clientId !== clientId)
83
+ throw new types_js_1.ApiError(401, 'Invalid Auth Code');
84
+ // The client is read before the code is spent and before an existing pair is
85
+ // handed back: both the pair and the tokens it would buy belong to the
86
+ // client, so a client deleted since the code was issued refuses the
87
+ // exchange - and refuses it without consuming the code.
88
+ const client = await store.client(clientId);
89
+ if (!client)
90
+ throw new types_js_1.ApiError(401, 'Invalid clientId.');
91
+ const named = await liveSubject(store, subject);
92
+ if (!(await store.claimCode(code)))
93
+ throw new types_js_1.ApiError(401, 'Invalid Auth Code');
94
+ // Re-exchanging a code for the same user and client while a valid pair
95
+ // exists returns that pair: another tab holding the old tokens must not be
96
+ // signed out by a second exchange.
97
+ const existing = await store.pairFor(named);
98
+ if (existing)
99
+ return existing;
100
+ const pair = await store.issue(named, client);
101
+ await store.savePair(named, pair);
102
+ return pair;
103
+ },
104
+ async refresh(token) {
105
+ const subject = token === undefined ? undefined : await store.readRefresh(token);
106
+ if (!subject)
107
+ throw unauthorized();
108
+ const client = await store.client(subject.clientId);
109
+ if (!client)
110
+ throw new types_js_1.ApiError(401, 'Invalid clientId.');
111
+ const named = await liveSubject(store, subject);
112
+ const pair = await store.issue(named, client);
113
+ await store.savePair(named, pair);
114
+ return pair;
115
+ },
116
+ async logout(subject) {
117
+ if (!subject)
118
+ throw unauthorized();
119
+ await store.deletePair(subject);
120
+ },
121
+ async logoutSession() {
122
+ return 'OK!';
123
+ },
124
+ async session(subject) {
125
+ if (!subject)
126
+ throw unauthorized();
127
+ const user = await store.userById(subject.userId);
128
+ if (!user)
129
+ throw unauthorized();
130
+ return (0, exports.asSession)(user);
131
+ }
132
+ });
133
+ exports.createIdentityService = createIdentityService;
134
+ /** A subject, as the session route reports it. */
135
+ const asSession = (user) => ({
136
+ uid: user.uid,
137
+ username: user.username,
138
+ displayName: user.displayName,
139
+ isAdmin: user.isAdmin,
140
+ needsToUpdatePassword: user.needsToUpdatePassword
141
+ });
142
+ exports.asSession = asSession;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * The identity the API works over, as a host supplies it.
4
+ *
5
+ * A server holds users in a database, verifies a bcrypt hash and signs JWTs; a
6
+ * browser build holds a seeded fixture, compares a plain-text demo password and
7
+ * mints opaque strings. Those differences are the host's, so they live behind
8
+ * this port - the statuses, the messages and the order of the checks are the
9
+ * service's, and both hosts run the same corpus over them.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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.DRIVE_CORPUS = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = exports.createDriveService = exports.isRoot = exports.basename = exports.dirname = exports.hasBlockedExtension = exports.normaliseDrivePath = void 0;
17
+ exports.servicesFor = exports.runCase = exports.IDENTITY_CORPUS = exports.DRIVE_CORPUS = exports.parseIdentity = exports.IDENTITY_FORMAT = exports.DEFAULT_IDENTITY = exports.MemoryIdentityStore = exports.createIdentityService = exports.HANDLED_ROUTES = exports.dispatch = exports.TAG_CAPABILITIES = exports.OVERLAY = exports.findRoute = exports.routeById = exports.ROUTES = exports.MemoryDriveStore = 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
  *
@@ -51,8 +51,18 @@ Object.defineProperty(exports, "TAG_CAPABILITIES", { enumerable: true, get: func
51
51
  var dispatch_js_1 = require("./host/dispatch.js");
52
52
  Object.defineProperty(exports, "dispatch", { enumerable: true, get: function () { return dispatch_js_1.dispatch; } });
53
53
  Object.defineProperty(exports, "HANDLED_ROUTES", { enumerable: true, get: function () { return dispatch_js_1.HANDLED_ROUTES; } });
54
+ var service_js_2 = require("./identity/service.js");
55
+ Object.defineProperty(exports, "createIdentityService", { enumerable: true, get: function () { return service_js_2.createIdentityService; } });
56
+ var memory_store_js_2 = require("./identity/memory-store.js");
57
+ Object.defineProperty(exports, "MemoryIdentityStore", { enumerable: true, get: function () { return memory_store_js_2.MemoryIdentityStore; } });
58
+ var fixture_js_1 = require("./identity/fixture.js");
59
+ Object.defineProperty(exports, "DEFAULT_IDENTITY", { enumerable: true, get: function () { return fixture_js_1.DEFAULT_IDENTITY; } });
60
+ Object.defineProperty(exports, "IDENTITY_FORMAT", { enumerable: true, get: function () { return fixture_js_1.IDENTITY_FORMAT; } });
61
+ Object.defineProperty(exports, "parseIdentity", { enumerable: true, get: function () { return fixture_js_1.parseIdentity; } });
54
62
  var drive_js_1 = require("./corpus/drive.js");
55
63
  Object.defineProperty(exports, "DRIVE_CORPUS", { enumerable: true, get: function () { return drive_js_1.DRIVE_CORPUS; } });
64
+ var identity_js_1 = require("./corpus/identity.js");
65
+ Object.defineProperty(exports, "IDENTITY_CORPUS", { enumerable: true, get: function () { return identity_js_1.IDENTITY_CORPUS; } });
56
66
  var runner_js_1 = require("./corpus/runner.js");
57
67
  Object.defineProperty(exports, "runCase", { enumerable: true, get: function () { return runner_js_1.runCase; } });
58
68
  Object.defineProperty(exports, "servicesFor", { enumerable: true, get: function () { return runner_js_1.servicesFor; } });
@@ -87,6 +87,10 @@ exports.GENERATED_ROUTES = [
87
87
  {
88
88
  status: 204,
89
89
  description: 'No content'
90
+ },
91
+ {
92
+ status: 401,
93
+ description: 'No valid access token was supplied'
90
94
  }
91
95
  ]
92
96
  },
@@ -102,6 +106,10 @@ exports.GENERATED_ROUTES = [
102
106
  {
103
107
  status: 200,
104
108
  description: 'Ok'
109
+ },
110
+ {
111
+ status: 401,
112
+ description: 'The refresh token is missing, invalid or expired, or the client id is unknown'
105
113
  }
106
114
  ]
107
115
  },
@@ -130,6 +138,14 @@ exports.GENERATED_ROUTES = [
130
138
  {
131
139
  status: 200,
132
140
  description: 'Ok'
141
+ },
142
+ {
143
+ status: 400,
144
+ description: 'The request body is missing `clientId` or `code`'
145
+ },
146
+ {
147
+ status: 401,
148
+ description: 'The authorization code is invalid, expired, already used, or was issued for another client, or the client id is unknown'
133
149
  }
134
150
  ]
135
151
  },
@@ -158,6 +174,26 @@ exports.GENERATED_ROUTES = [
158
174
  {
159
175
  status: 204,
160
176
  description: 'No content'
177
+ },
178
+ {
179
+ status: 400,
180
+ description: 'The request body is missing `currentPassword` or `newPassword`'
181
+ },
182
+ {
183
+ status: 401,
184
+ description: 'No valid access token was supplied'
185
+ },
186
+ {
187
+ status: 403,
188
+ description: 'The current password is incorrect'
189
+ },
190
+ {
191
+ status: 404,
192
+ description: 'The authenticated user no longer exists'
193
+ },
194
+ {
195
+ status: 405,
196
+ description: 'The account authenticates through an external provider, which holds its password'
161
197
  }
162
198
  ]
163
199
  },
@@ -1071,6 +1107,10 @@ exports.GENERATED_ROUTES = [
1071
1107
  {
1072
1108
  status: 200,
1073
1109
  description: 'Ok'
1110
+ },
1111
+ {
1112
+ status: 401,
1113
+ description: 'No valid access token or session was supplied'
1074
1114
  }
1075
1115
  ]
1076
1116
  },
@@ -1554,6 +1594,18 @@ exports.GENERATED_ROUTES = [
1554
1594
  {
1555
1595
  status: 200,
1556
1596
  description: 'Ok'
1597
+ },
1598
+ {
1599
+ status: 400,
1600
+ description: 'The request body is missing `clientId`, or the CSRF token is missing or invalid'
1601
+ },
1602
+ {
1603
+ status: 401,
1604
+ description: 'No signed-in session'
1605
+ },
1606
+ {
1607
+ status: 403,
1608
+ description: 'The client id is unknown'
1557
1609
  }
1558
1610
  ]
1559
1611
  },
@@ -1582,6 +1634,22 @@ exports.GENERATED_ROUTES = [
1582
1634
  {
1583
1635
  status: 200,
1584
1636
  description: 'Ok'
1637
+ },
1638
+ {
1639
+ status: 400,
1640
+ description: 'The request body is missing `username` or `password`'
1641
+ },
1642
+ {
1643
+ status: 401,
1644
+ description: 'The credentials are not valid, or password sign-in is disabled for local accounts'
1645
+ },
1646
+ {
1647
+ status: 429,
1648
+ description: 'Too many failed attempts for this account'
1649
+ },
1650
+ {
1651
+ status: 500,
1652
+ description: 'An unexpected error occurred while signing in'
1585
1653
  }
1586
1654
  ]
1587
1655
  },
@@ -1597,6 +1665,10 @@ exports.GENERATED_ROUTES = [
1597
1665
  {
1598
1666
  status: 200,
1599
1667
  description: 'Ok'
1668
+ },
1669
+ {
1670
+ status: 500,
1671
+ description: 'The session store failed to replace the session'
1600
1672
  }
1601
1673
  ]
1602
1674
  }
@@ -36,9 +36,15 @@ const merge = (generated) => {
36
36
  ...(body === undefined ? {} : { body })
37
37
  };
38
38
  });
39
+ // A host-only status has to be one the document states: the annotation marks
40
+ // where a status is answered, it does not add one.
41
+ for (const status of overlay.hostResponses || [])
42
+ if (!documented.has(status))
43
+ throw new Error(`src/routes/overlay.ts marks status ${status} of ${generated.id} as host-only, and the OpenAPI document does not state it. Correct the document in sasjs/server and re-vendor spec/swagger.yaml, or drop the annotation.`);
39
44
  return {
40
45
  ...generated,
41
46
  responses,
47
+ hostResponses: overlay.hostResponses || [],
42
48
  capabilities: overlay.capabilities || overlay_js_1.TAG_CAPABILITIES[generated.tag] || [],
43
49
  implemented: overlay.implemented === true
44
50
  };
@@ -18,6 +18,13 @@ exports.TAG_CAPABILITIES = {
18
18
  Web: ['identity']
19
19
  };
20
20
  const success = { status: 'success' };
21
+ const user = {
22
+ uid: '',
23
+ username: '',
24
+ displayName: '',
25
+ isAdmin: false,
26
+ needsToUpdatePassword: false
27
+ };
21
28
  exports.OVERLAY = {
22
29
  // the route inventory reports what the CALLER may use, so it needs an identity
23
30
  // even though the rest of the Info tag needs nothing
@@ -69,6 +76,37 @@ exports.OVERLAY = {
69
76
  },
70
77
  'POST /SASjsApi/drive/deploy/upload': {
71
78
  implemented: false
79
+ },
80
+ // The lockout and the provider failure are answered before the package is
81
+ // consulted (routes/web/web.ts in sasjs/server).
82
+ 'POST /SASLogon/login': {
83
+ implemented: true,
84
+ hostResponses: [429, 500],
85
+ responses: { 200: { loggedIn: true, user } }
86
+ },
87
+ // The sign-in flow. The statuses are the document's; these entries state the
88
+ // body a client keys on, and that this package answers the operation.
89
+ 'POST /SASjsApi/auth/token': {
90
+ implemented: true,
91
+ responses: { 200: { accessToken: '', refreshToken: '' } }
92
+ },
93
+ 'POST /SASjsApi/auth/refresh': {
94
+ implemented: true,
95
+ responses: { 200: { accessToken: '', refreshToken: '' } }
96
+ },
97
+ 'POST /SASjsApi/auth/logout': { implemented: true },
98
+ 'POST /SASLogon/authorize': {
99
+ implemented: true,
100
+ responses: { 200: { code: '' } }
101
+ },
102
+ 'GET /SASLogon/logout': {
103
+ implemented: true,
104
+ hostResponses: [500],
105
+ responses: { 200: 'OK!' }
106
+ },
107
+ 'GET /SASjsApi/session': {
108
+ implemented: true,
109
+ responses: { 200: user }
72
110
  }
73
111
  };
74
112
  /** Every method the table may state, in the order a diff reads best. */
@@ -12,6 +12,7 @@
12
12
  * records each place the two disagree.
13
13
  */
14
14
  import type { ApiRequest } from '../host/dispatch.js';
15
+ import type { IdentityFixture } from '../identity/fixture.js';
15
16
  export interface CorpusCase {
16
17
  /** Stable name, used as the test's title. */
17
18
  id: string;
@@ -20,6 +21,8 @@ export interface CorpusCase {
20
21
  description: string;
21
22
  /** Files the case starts from. */
22
23
  seed?: Record<string, string | Uint8Array>;
24
+ /** The identity the case starts from; the default fixture when omitted. */
25
+ identity?: IdentityFixture;
23
26
  request: ApiRequest;
24
27
  expect: {
25
28
  status: number;
@@ -27,6 +30,17 @@ export interface CorpusCase {
27
30
  contents?: string | Uint8Array;
28
31
  /** The refusal's message, where the case expects one. */
29
32
  error?: string;
33
+ /**
34
+ * The properties a body must carry, where the host mints the rest - a user's
35
+ * `uid` is the host's identifier, so a case pinning one would be pinning a
36
+ * fixture value into the contract.
37
+ */
38
+ partial?: Record<string, unknown>;
39
+ /**
40
+ * The properties a body must carry, where the values are the host's - a
41
+ * minted token is not a value a case can state.
42
+ */
43
+ keys?: string[];
30
44
  };
31
45
  }
32
46
  export declare const DRIVE_CORPUS: CorpusCase[];
@@ -1 +1 @@
1
- {"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAErD,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,CAAA;CACF;AAWD,eAAO,MAAM,YAAY,EAAE,UAAU,EAyRpC,CAAA"}
1
+ {"version":3,"file":"drive.d.ts","sourceRoot":"","sources":["../../../src/corpus/drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAA;IACV,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAA;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,OAAO,EAAE,UAAU,CAAA;IACnB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;QAC9B,yDAAyD;QACzD,KAAK,CAAC,EAAE,MAAM,CAAA;QACd;;;;WAIG;QACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QACjC;;;WAGG;QACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;KAChB,CAAA;CACF;AAWD,eAAO,MAAM,YAAY,EAAE,UAAU,EA8SpC,CAAA"}
@@ -235,6 +235,26 @@ export const DRIVE_CORPUS = [
235
235
  request: { body: { oldPath: '/data/nope.csv', newPath: '/data/x.csv' } },
236
236
  expect: { status: 404, error: 'No file/folder found for provided path.' }
237
237
  },
238
+ {
239
+ id: 'rename-file-to-blocked-extension',
240
+ route: 'POST /SASjsApi/drive/rename',
241
+ description: 'refuses a rename that would produce a name the file routes refuse',
242
+ seed: { '/data/report.csv': 'a,b' },
243
+ request: {
244
+ body: { oldPath: '/data/report.csv', newPath: '/data/report.sh' }
245
+ },
246
+ expect: { status: 400, error: 'Invalid file extension' }
247
+ },
248
+ {
249
+ id: 'rename-folder-to-dotted-name',
250
+ route: 'POST /SASjsApi/drive/rename',
251
+ description: 'renames a folder whose new name carries a blocked suffix',
252
+ seed: { '/data/reports/a.csv': 'a' },
253
+ request: {
254
+ body: { oldPath: '/data/reports', newPath: '/data/reports.sh' }
255
+ },
256
+ expect: { status: 200, body: { status: 'success' } }
257
+ },
238
258
  {
239
259
  id: 'rename-folder-into-itself',
240
260
  route: 'POST /SASjsApi/drive/rename',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The sign-in flow's conformance corpus.
3
+ *
4
+ * A case names an operation, the request a client would make, the identity the
5
+ * host starts from, and the answer the caller expects. Where the answer carries
6
+ * a value the host mints - a token, a code - the case states the properties
7
+ * instead, because the value is the host's and asserting it would pin one host's
8
+ * cryptography into the contract.
9
+ */
10
+ import type { CorpusCase } from './drive.js';
11
+ import { type IdentityFixture } from '../identity/fixture.js';
12
+ /** The default fixture, plus a caller that is already signed in. */
13
+ export declare const SIGNED_IN: IdentityFixture;
14
+ /** The same identity, with the restricted user switched off. */
15
+ export declare const DEACTIVATED: IdentityFixture;
16
+ export declare const IDENTITY_CORPUS: CorpusCase[];
17
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../../src/corpus/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE/E,oEAAoE;AACpE,eAAO,MAAM,SAAS,EAAE,eA2BvB,CAAA;AAED,gEAAgE;AAChE,eAAO,MAAM,WAAW,EAAE,eAKzB,CAAA;AAUD,eAAO,MAAM,eAAe,EAAE,UAAU,EAkOvC,CAAA"}