wirejs-resources 0.1.1-alpha → 0.1.3-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.
@@ -1,4 +1,4 @@
1
- import { CookieJar } from "./cookie-jar.js";
1
+ import { registry } from "../registry";
2
2
 
3
3
  const contextWrappers = new Set();
4
4
 
@@ -76,7 +76,7 @@ export function requiresContext(fnOrNS) {
76
76
 
77
77
  export class Context {
78
78
  /**
79
- * @type {CookieJar} cookies
79
+ * @type {typeof registry['CookieJar']} cookies
80
80
  */
81
81
  cookies;
82
82
 
@@ -87,7 +87,7 @@ export class Context {
87
87
 
88
88
  /**
89
89
  * @param {{
90
- * cookies: CookieJar;
90
+ * cookies: typeof registry['CookieJar'];
91
91
  * location: URL;
92
92
  * }}
93
93
  */
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
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';
6
+ export { registry } from './registry.js';
@@ -0,0 +1,13 @@
1
+ import { Context } from './adapters/context.js';
2
+ import { CookieJar } from './adapters/cookie-jar.js';
3
+ import { Secret } from './resources/secret.js';
4
+ import { AuthenticationService } from './services/authentication.js'
5
+ import { FileService } from './services/file.js';
6
+
7
+ export const registry = /** @type {const} **/ ({
8
+ Context,
9
+ CookieJar,
10
+ Secret,
11
+ AuthenticationService,
12
+ FileService
13
+ });
@@ -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 { registry } from '../registry.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 registry.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
 
8
- import { Secret } from '../resources/secret.js';
9
- import { CookieJar } from '../adapters/cookie-jar.js';
5
+ import { Resource } from '../resource.js';
10
6
  import { withContext } from '../adapters/context.js';
7
+ import { registry } from '../registry.js';
11
8
 
12
- const CWD = process.cwd();
13
- const SALT_ROUNDS = 10;
14
9
 
15
- const signingSecret = new Secret('wirejs-services/auth-jwt-signing-secret');
10
+ /**
11
+ * @param {string} password
12
+ * @param {string} [salt]
13
+ */
14
+ function hash(password, salt) {
15
+ return new Promise((resolve, reject) => {
16
+ const finalSalt = salt || randomBytes(16).toString('hex');
17
+ scrypt(password, finalSalt, 64, (err, key) => {
18
+ if (err) {
19
+ reject(err);
20
+ } else {
21
+ resolve(`${finalSalt}$${key.toString('hex')}`);
22
+ }
23
+ })
24
+ });
25
+ }
26
+
27
+ /**
28
+ * @param {string} password
29
+ * @param {string} passwordHash
30
+ */
31
+ async function verifyHash(password, passwordHash) {
32
+ const [saltPart, _hashPart] = passwordHash.split('$');
33
+ const rehashed = await hash(password, saltPart);
34
+ return rehashed === passwordHash;
35
+ }
36
+
37
+ // #region types
16
38
 
17
39
  /**
18
40
  * @typedef {{
@@ -77,33 +99,43 @@ 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();
102
+ // #endregion
84
103
 
85
104
  const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
86
105
 
87
- export class AuthenticationService {
88
- id;
106
+ export class AuthenticationService extends Resource {
89
107
  #duration;
90
108
  #keepalive;
91
109
  #cookieName;
110
+
111
+ /**
112
+ * @type {typeof registry['Secret']}
113
+ */
114
+ #rawSigningSecret;
115
+
116
+ /**
117
+ * @type {Promise<Uint8Array<ArrayBufferLike>> | undefined}
118
+ */
92
119
  #signingSecret;
93
120
 
94
121
  #users;
95
122
 
96
123
  /**
97
124
  *
125
+ * @param {Resource | string} scope
98
126
  * @param {string} id
99
127
  * @param {AuthenticationServiceOptions} [options]
100
128
  */
101
- constructor(id, { duration, keepalive, cookie } = {}) {
102
- this.id = id;
129
+ constructor(scope, id, { duration, keepalive, cookie } = {}) {
130
+ super(scope, id);
131
+
103
132
  this.#duration = duration || ONE_WEEK;
104
133
  this.#keepalive = !!keepalive;
105
134
  this.#cookieName = cookie ?? 'identity';
106
135
 
136
+ this.#rawSigningSecret = new registry.Secret(this, 'jwt-signing-secret');
137
+ const fileService = new registry.FileService(this, 'files');
138
+
107
139
  this.#users = {
108
140
  id,
109
141
 
@@ -113,7 +145,7 @@ export class AuthenticationService {
113
145
  */
114
146
  async get(username) {
115
147
  try {
116
- const data = await fs.promises.readFile(this.filenameFor(username));
148
+ const data = await fileService.read(this.filenameFor(username));
117
149
  return JSON.parse(data);
118
150
  } catch {
119
151
  return undefined;
@@ -125,14 +157,7 @@ export class AuthenticationService {
125
157
  * @param {User} user
126
158
  */
127
159
  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
- );
160
+ await fileService.write(this.filenameFor(username), JSON.stringify(details));
136
161
  },
137
162
 
138
163
  /**
@@ -144,29 +169,17 @@ export class AuthenticationService {
144
169
  },
145
170
 
146
171
  /**
147
- * @param {string} username
172
+ * @param {string} username
148
173
  * @returns
149
174
  */
150
175
  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
- );
176
+ return `${username}.json`;
160
177
  }
161
178
  }
162
-
163
- if (services.has(id)) {
164
- services.set(id, this);
165
- }
166
179
  }
167
180
 
168
181
  async getSigningSecret() {
169
- const secretAsString = await signingSecret.read();
182
+ const secretAsString = await this.#rawSigningSecret.read();
170
183
  return new TextEncoder().encode(secretAsString);
171
184
  }
172
185
 
@@ -181,7 +194,7 @@ export class AuthenticationService {
181
194
  }
182
195
 
183
196
  /**
184
- * @param {CookieJar} cookies
197
+ * @param {typeof registry['CookieJar']} cookies
185
198
  * @returns {Promise<AuthenticationBaseState>}
186
199
  */
187
200
  async getBaseState(cookies) {
@@ -212,7 +225,7 @@ export class AuthenticationService {
212
225
  }
213
226
 
214
227
  /**
215
- * @param {CookieJar} cookies
228
+ * @param {typeof registry['CookieJar']} cookies
216
229
  * @returns {Promise<AuthenticationState>}
217
230
  */
218
231
  async getState(cookies) {
@@ -280,7 +293,7 @@ export class AuthenticationService {
280
293
 
281
294
  /**
282
295
  *
283
- * @param {CookieJar} cookies
296
+ * @param {typeof registry['CookieJar']} cookies
284
297
  * @param {string | undefined} [user]
285
298
  */
286
299
  async setBaseState(cookies, user) {
@@ -325,7 +338,7 @@ export class AuthenticationService {
325
338
  }
326
339
 
327
340
  /**
328
- * @param {CookieJar} cookies
341
+ * @param {typeof registry['CookieJar']} cookies
329
342
  * @param {PerformActionParameter} params
330
343
  * @returns {Promise<AuthenticationState | { errors: AuthenticationError[] }>}
331
344
  */
@@ -347,7 +360,7 @@ export class AuthenticationService {
347
360
  } else {
348
361
  await this.#users.set(inputs.username, {
349
362
  id: inputs.username,
350
- password: await bcrypt.hash(inputs.password, SALT_ROUNDS)
363
+ password: await hash(inputs.password)
351
364
  });
352
365
  await this.setBaseState(cookies, inputs.username);
353
366
  return this.getState(cookies);
@@ -361,7 +374,7 @@ export class AuthenticationService {
361
374
  message: `User doesn't exist.`
362
375
  }]
363
376
  };
364
- } else if (await bcrypt.compare(inputs.password, user.password)) {
377
+ } else if (await verifyHash(inputs.password, user.password)) {
365
378
  // a real authentication service will use password hashing.
366
379
  // this is an in-memory just-for-testing user pool.
367
380
  await this.setBaseState(cookies, inputs.username);
@@ -384,10 +397,10 @@ export class AuthenticationService {
384
397
  message: `You're not signed in as a recognized user.`
385
398
  }]
386
399
  };
387
- } else if (await bcrypt.compare(inputs.existingPassword, user.password)) {
400
+ } else if (await verifyHash(inputs.existingPassword, user.password)) {
388
401
  await this.#users.set(user.id, {
389
402
  ...user,
390
- password: await bcrypt.hash(inputs.newPassword, SALT_ROUNDS)
403
+ password: await hash(inputs.newPassword)
391
404
  });
392
405
  return {
393
406
  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.3-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
- }