wirejs-resources 0.1.1-alpha → 0.1.2-alpha

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.
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { FileService } from './services/file.js';
2
2
  export { AuthenticationService } from './services/authentication.js';
3
3
  export { CookieJar } from './adapters/cookie-jar.js';
4
- export { withContext, requiresContext, Context } from './adapters/context.js';
4
+ export { withContext, requiresContext, Context } from './adapters/context.js';
5
+ export { Resource } from './resource.js';
@@ -0,0 +1,32 @@
1
+ export class Resource {
2
+ /**
3
+ * @type {Resource | string}
4
+ */
5
+ scope;
6
+
7
+ /**
8
+ * @type {string}
9
+ */
10
+ id;
11
+
12
+ /**
13
+ *
14
+ * @param {Resource | string} scope
15
+ * @param {string} id
16
+ */
17
+ constructor(scope, id) {
18
+ this.scope = scope;
19
+ this.id = id;
20
+ }
21
+
22
+ get absoluteId() {
23
+ const sanitizedId = encodeURIComponent(this.id);
24
+ if (typeof this.scope === 'string') {
25
+ return `${encodeURIComponent(this.scope)}/${sanitizedId}`;
26
+ } else if (typeof this.scope?.id === 'string') {
27
+ return `${this.scope.absoluteId}/${sanitizedId}`;
28
+ } else {
29
+ throw new Error("Resources must defined within a scope. Provide either a namespace string or parent resource.");
30
+ }
31
+ }
32
+ }
@@ -1,48 +1,50 @@
1
- import process from 'process';
2
- import fs from 'fs';
3
- import path from 'path';
4
1
  import crypto from 'crypto';
2
+ import { Resource } from '../resource.js';
3
+ import { FileService } from '../services/file.js';
5
4
 
6
- const CWD = process.cwd();
5
+ const FILENAME = 'secret';
7
6
 
8
- /**
9
- * @type {Map<string, Secret>}
10
- */
11
- const secrets = new Map();
7
+ export class Secret extends Resource {
8
+ /**
9
+ * @type {FileService}
10
+ */
11
+ #fileService;
12
12
 
13
- export class Secret {
14
- #id;
13
+ /**
14
+ * @type {Promise<any>}
15
+ */
16
+ #initPromise;
15
17
 
16
18
  /**
19
+ * @param {Resource | string}
17
20
  * @param {string} id
18
- * @param {string} [value]
19
21
  */
20
- constructor(id, value) {
21
- this.#id = id;
22
- secrets.set(id, this);
23
- if (!fs.existsSync(this.#filename())) {
24
- fs.mkdirSync(path.dirname(this.#filename()), { recursive: true });
25
- fs.writeFileSync(
26
- this.#filename(),
27
- value ?? crypto.randomBytes(64).toString('base64url')
28
- );
29
- }
30
- }
31
-
32
- #filename() {
33
- const sanitizedId = this.id.replace('~', '-').replace(/\.+/g, '.');
34
- return path.join(CWD, 'temp', 'wirejs-services', 'secrets', sanitizedId);
35
- }
36
-
37
- get id() {
38
- return this.#id;
22
+ constructor(scope, id) {
23
+ super(scope, id);
24
+ this.#fileService = new FileService(this, 'files');
25
+
26
+ this.#initPromise = this.#fileService.write(
27
+ FILENAME,
28
+ JSON.stringify(crypto.randomBytes(64).toString('base64url')),
29
+ { onlyIfNotExists: true }
30
+ ).catch(error => {
31
+ if (!this.#fileService.isAlreadyExistsError(error)) throw error;
32
+ });
39
33
  }
40
34
 
35
+ /**
36
+ * @returns {any}
37
+ */
41
38
  async read() {
42
- return fs.promises.readFile(this.#filename(), 'utf8');
39
+ await this.#initPromise;
40
+ return JSON.parse(await this.#fileService.read(FILENAME));
43
41
  }
44
42
 
45
- async write(value) {
46
- fs.promises.writeFile(this.#filename(), value);
43
+ /**
44
+ * @param {any} data
45
+ */
46
+ async write(data) {
47
+ await this.#initPromise;
48
+ await this.#fileService.write(FILENAME, JSON.stringify(data));
47
49
  }
48
50
  }
@@ -1,18 +1,40 @@
1
- import process from 'process';
2
- import fs from 'fs';
3
- import path from 'path';
1
+ import { scrypt, randomBytes } from 'crypto';
4
2
 
5
3
  import * as jose from 'jose';
6
- import bcrypt from 'bcrypt';
7
4
 
5
+ import { Resource } from '../resource.js';
8
6
  import { Secret } from '../resources/secret.js';
7
+ import { FileService } from './file.js';
9
8
  import { CookieJar } from '../adapters/cookie-jar.js';
10
9
  import { withContext } from '../adapters/context.js';
11
10
 
12
- const CWD = process.cwd();
13
- const SALT_ROUNDS = 10;
14
11
 
15
- const signingSecret = new Secret('wirejs-services/auth-jwt-signing-secret');
12
+ /**
13
+ * @param {string} password
14
+ * @param {string} [salt]
15
+ */
16
+ function hash(password, salt) {
17
+ return new Promise((resolve, reject) => {
18
+ const finalSalt = salt || randomBytes(16).toString('hex');
19
+ scrypt(password, finalSalt, 64, (err, key) => {
20
+ if (err) {
21
+ reject(err);
22
+ } else {
23
+ resolve(`${finalSalt}$${key.toString('hex')}`);
24
+ }
25
+ })
26
+ });
27
+ }
28
+
29
+ /**
30
+ * @param {string} password
31
+ * @param {string} passwordHash
32
+ */
33
+ async function verifyHash(password, passwordHash) {
34
+ const [saltPart, _hashPart] = passwordHash.split('$');
35
+ const rehashed = await hash(password, saltPart);
36
+ return rehashed === passwordHash;
37
+ }
16
38
 
17
39
  /**
18
40
  * @typedef {{
@@ -77,33 +99,41 @@ const signingSecret = new Secret('wirejs-services/auth-jwt-signing-secret');
77
99
  * @property {string} [cookie] - The name of the cookie to use to store the authentication state JWT.
78
100
  */
79
101
 
80
- /**
81
- * @type {Map<string, AuthService>}
82
- */
83
- const services = new Map();
84
-
85
102
  const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
86
103
 
87
- export class AuthenticationService {
88
- id;
104
+ export class AuthenticationService extends Resource {
89
105
  #duration;
90
106
  #keepalive;
91
107
  #cookieName;
108
+
109
+ /**
110
+ * @type {Secret}
111
+ */
112
+ #rawSigningSecret;
113
+
114
+ /**
115
+ * @type {Promise<Uint8Array<ArrayBufferLike>> | undefined}
116
+ */
92
117
  #signingSecret;
93
118
 
94
119
  #users;
95
120
 
96
121
  /**
97
122
  *
123
+ * @param {Resource | string} scope
98
124
  * @param {string} id
99
125
  * @param {AuthenticationServiceOptions} [options]
100
126
  */
101
- constructor(id, { duration, keepalive, cookie } = {}) {
102
- this.id = id;
127
+ constructor(scope, id, { duration, keepalive, cookie } = {}) {
128
+ super(scope, id);
129
+
103
130
  this.#duration = duration || ONE_WEEK;
104
131
  this.#keepalive = !!keepalive;
105
132
  this.#cookieName = cookie ?? 'identity';
106
133
 
134
+ this.#rawSigningSecret = new Secret(this, 'jwt-signing-secret');
135
+ const fileService = new FileService(this, 'files');
136
+
107
137
  this.#users = {
108
138
  id,
109
139
 
@@ -113,7 +143,7 @@ export class AuthenticationService {
113
143
  */
114
144
  async get(username) {
115
145
  try {
116
- const data = await fs.promises.readFile(this.filenameFor(username));
146
+ const data = await fileService.read(this.filenameFor(username));
117
147
  return JSON.parse(data);
118
148
  } catch {
119
149
  return undefined;
@@ -125,14 +155,7 @@ export class AuthenticationService {
125
155
  * @param {User} user
126
156
  */
127
157
  async set(username, details) {
128
- await fs.promises.mkdir(
129
- path.dirname(this.filenameFor(username)),
130
- { recursive: true }
131
- );
132
- await fs.promises.writeFile(
133
- this.filenameFor(username),
134
- JSON.stringify(details)
135
- );
158
+ await fileService.write(this.filenameFor(username), JSON.stringify(details));
136
159
  },
137
160
 
138
161
  /**
@@ -144,29 +167,17 @@ export class AuthenticationService {
144
167
  },
145
168
 
146
169
  /**
147
- * @param {string} username
170
+ * @param {string} username
148
171
  * @returns
149
172
  */
150
173
  filenameFor(username) {
151
- const sanitizedId = this.id.replace('~', '-').replace(/\.+/g, '.');
152
- const sanitizedName = username.replace('~', '-').replace(/\.+/g, '.');
153
- return path.join(CWD,
154
- 'temp',
155
- 'wirejs-services',
156
- 'authentication',
157
- sanitizedId,
158
- `${sanitizedName}.json`
159
- );
174
+ return `${username}.json`;
160
175
  }
161
176
  }
162
-
163
- if (services.has(id)) {
164
- services.set(id, this);
165
- }
166
177
  }
167
178
 
168
179
  async getSigningSecret() {
169
- const secretAsString = await signingSecret.read();
180
+ const secretAsString = await this.#rawSigningSecret.read();
170
181
  return new TextEncoder().encode(secretAsString);
171
182
  }
172
183
 
@@ -347,7 +358,7 @@ export class AuthenticationService {
347
358
  } else {
348
359
  await this.#users.set(inputs.username, {
349
360
  id: inputs.username,
350
- password: await bcrypt.hash(inputs.password, SALT_ROUNDS)
361
+ password: await hash(inputs.password)
351
362
  });
352
363
  await this.setBaseState(cookies, inputs.username);
353
364
  return this.getState(cookies);
@@ -361,7 +372,7 @@ export class AuthenticationService {
361
372
  message: `User doesn't exist.`
362
373
  }]
363
374
  };
364
- } else if (await bcrypt.compare(inputs.password, user.password)) {
375
+ } else if (await verifyHash(inputs.password, user.password)) {
365
376
  // a real authentication service will use password hashing.
366
377
  // this is an in-memory just-for-testing user pool.
367
378
  await this.setBaseState(cookies, inputs.username);
@@ -384,10 +395,10 @@ export class AuthenticationService {
384
395
  message: `You're not signed in as a recognized user.`
385
396
  }]
386
397
  };
387
- } else if (await bcrypt.compare(inputs.existingPassword, user.password)) {
398
+ } else if (await verifyHash(inputs.existingPassword, user.password)) {
388
399
  await this.#users.set(user.id, {
389
400
  ...user,
390
- password: await bcrypt.hash(inputs.newPassword, SALT_ROUNDS)
401
+ password: await hash(inputs.newPassword)
391
402
  });
392
403
  return {
393
404
  message: "Password updated.",
@@ -2,27 +2,19 @@ import process from 'process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
 
5
- const CWD = process.cwd();
5
+ import { Resource } from '../resource.js';
6
6
 
7
- /**
8
- * @type {Map<string, FileService>}
9
- */
10
- const services = new Map();
7
+ const CWD = process.cwd();
11
8
 
12
- export class FileService {
13
- id;
9
+ const ALREADY_EXISTS_CODE = 'EEXIST';
14
10
 
11
+ export class FileService extends Resource {
15
12
  /**
16
- *
17
- * @param {{
18
- * id: string
19
- * }} options
13
+ * @param {Resource | string} scope
14
+ * @param {string} id
20
15
  */
21
- constructor(id) {
22
- this.id = id;
23
- if (!services.has(id)) {
24
- services.set(id, this);
25
- }
16
+ constructor(scope, id) {
17
+ super(scope, id);
26
18
  }
27
19
 
28
20
  /**
@@ -30,9 +22,9 @@ export class FileService {
30
22
  * @returns
31
23
  */
32
24
  #fullNameFor(filename) {
33
- const sanitizedId = this.id.replace('~', '-').replace(/\.+/g, '.');
25
+ const sanitizedId = this.absoluteId.replace('~', '-').replace(/\.+/g, '.');
34
26
  const sanitizedName = filename.replace('~', '-').replace(/\.+/g, '.');
35
- return path.join(CWD, 'temp', 'wirejs-services', 'files', sanitizedId, sanitizedName);
27
+ return path.join(CWD, 'temp', 'wirejs-services', sanitizedId, sanitizedName);
36
28
  }
37
29
 
38
30
  /**
@@ -47,12 +39,16 @@ export class FileService {
47
39
  /**
48
40
  *
49
41
  * @param {string} filename
50
- * @param {string} data
42
+ * @param {string} data
43
+ * @param {{
44
+ * onlyIfNotExists?: boolean;
45
+ * }} [options]
51
46
  */
52
- async write(filename, data) {
47
+ async write(filename, data, { onlyIfNotExists = false } = {}) {
53
48
  const fullname = this.#fullNameFor(filename);
49
+ const flag = onlyIfNotExists ? 'wx' : 'w';
54
50
  await fs.promises.mkdir(path.dirname(fullname), { recursive: true });
55
- return fs.promises.writeFile(fullname, data);
51
+ return fs.promises.writeFile(fullname, data, { flag });
56
52
  }
57
53
 
58
54
  /**
@@ -75,4 +71,8 @@ export class FileService {
75
71
  if (prefix === undefined || name.startsWith(prefix)) yield name;
76
72
  }
77
73
  }
74
+
75
+ isAlreadyExistsError(error) {
76
+ return error.code === ALREADY_EXISTS_CODE;
77
+ }
78
78
  }
package/lib/types.ts CHANGED
@@ -2,9 +2,11 @@ import type {
2
2
  AuthenticationService as AuthenticationServiceBase,
3
3
  withContext as withContextBase,
4
4
  FileService as FileServiceBase,
5
- Context as ContextBase
5
+ Context as ContextBase,
6
+ Resource as ResourceBase,
6
7
  } from './index.js';
7
8
 
9
+ export declare class Resource extends ResourceBase {};
8
10
  export declare class Context extends ContextBase {};
9
11
  export declare class AuthenticationService extends AuthenticationServiceBase {};
10
12
  export declare class FileService extends FileServiceBase {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wirejs-resources",
3
- "version": "0.1.1-alpha",
3
+ "version": "0.1.2-alpha",
4
4
  "description": "Basic services and server-side resources for wirejs apps",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -23,7 +23,6 @@
23
23
  },
24
24
  "homepage": "https://github.com/svidgen/create-wirejs-app#readme",
25
25
  "dependencies": {
26
- "bcrypt": "^5.1.1",
27
26
  "jose": "^5.9.6"
28
27
  }
29
28
  }
@@ -1,12 +0,0 @@
1
- /**
2
- * @type {Map<string, SendmailService>}
3
- */
4
- const services = new Map();
5
-
6
- class SendmailService {
7
- id;
8
-
9
- constructor({ id }) {
10
-
11
- }
12
- }