wirejs-resources 0.1.6-alpha → 0.1.8-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,98 +0,0 @@
1
- import { CookieJar } from "./cookie-jar.js";
2
-
3
- const __requiresContext = new Symbol('__requiresContext');
4
-
5
- /**
6
- * @typedef {(...args: any) => any} ApiMethod
7
- */
8
-
9
- /**
10
- * @typedef {{
11
- * [K in string]: ApiMethod | ApiNamespace
12
- * }} ApiNamespace
13
- */
14
-
15
- /**
16
- * @template T
17
- * @typedef {T extends ((...args: infer ARGS) => infer RT)
18
- * ? ((
19
- * context: Context | boolean,
20
- * ...args: ARGS
21
- * ) => RT extends Promise<any> ? RT : Promise<RT>) : never
22
- * } ContextfulApiMethod
23
- */
24
-
25
- /**
26
- * @template T
27
- * @typedef {{
28
- * [K in keyof T]: T[K] extends ApiMethod
29
- * ? ContextfulApiMethod<T[K]>
30
- * : ContextfulApiNamespace<T[K]>
31
- * }} ContextfulApiNamespace
32
- */
33
-
34
- /**
35
- * @template {ApiNamespace | ApiMethod} T
36
- * @typedef {T extends ApiMethod
37
- * ? ContextfulApiMethod<T>
38
- * : ContextfulApiNamespace<T>
39
- * } ContextWrapped
40
- */
41
-
42
- /**
43
- * @template {ApiMethod | ApiNamespace} T
44
- * @param {(context: Context) => T} contextWrapper
45
- * @param {string[]} [path]
46
- * @returns {ContextWrapped<T>}
47
- */
48
- export function withContext(contextWrapper, path = []) {
49
- // first param needs to be a function, which enables `Proxy` to implement `apply()`.
50
- const fnOrNs = new Proxy(function() {}, {
51
- apply(_target, _thisArg, args) {
52
- const [context, ...remainingArgs] = args;
53
- let functionOrNamespaceObject = contextWrapper(context);
54
- console.log({context, args, functionOrNamespaceObject, path});
55
- for (const k of path) {
56
- functionOrNamespaceObject = functionOrNamespaceObject[k];
57
- }
58
- return functionOrNamespaceObject(...remainingArgs);
59
- },
60
- get(_target, prop) {
61
- return withContext(contextWrapper, [...path, prop])
62
- }
63
- });
64
- fnOrNs[__requiresContext] = true;
65
- return fnOrNs;
66
- }
67
-
68
- /**
69
- *
70
- * @param {Object} fnOrNS
71
- * @returns {fnOrNS is (context: Context) => T}
72
- */
73
- export function requiresContext(fnOrNS) {
74
- return fnOrNS[__requiresContext] === true;
75
- }
76
-
77
- export class Context {
78
- /**
79
- * @type {CookieJar} cookies
80
- */
81
- cookies;
82
-
83
- /**
84
- * @type {URL} location
85
- */
86
- location;
87
-
88
- /**
89
- * @param {{
90
- * cookies: CookieJar;
91
- * location: URL;
92
- * }}
93
- */
94
- constructor({ cookies, location }) {
95
- this.cookies = cookies;
96
- this.location = location;
97
- }
98
- }
@@ -1,94 +0,0 @@
1
- /**
2
- * @typedef {Object} Cookie
3
- * @property {string} name
4
- * @property {string} value
5
- * @property {number} [maxAge] - The maximum age (TTL) in seconds
6
- * @property {boolean} [httpOnly] - Whether the cookie is only accessible to the client (not JS)
7
- * @property {boolean} [secure] - Whether the cookie should only be sent over HTTPS (or localhost)
8
- */
9
-
10
- export class CookieJar {
11
- /**
12
- * @type {Record<string, Cookie>}
13
- */
14
- #cookies = {};
15
-
16
- /**
17
- * The list of cookies that have been set with `set()` which need to be
18
- * sent to the client.
19
- *
20
- * @type {Set<string>}
21
- */
22
- #setCookies = new Set();
23
-
24
- /**
25
- * Initialize
26
- *
27
- * @param {string | undefined} cookie
28
- */
29
- constructor(cookie) {
30
- this.#cookies = Object.fromEntries(
31
- (cookie || '')
32
- .split(/;/g)
33
- .map(c => {
34
- const [k, v] = c.split('=').map(p => decodeURIComponent(p.trim()));
35
- return [k, {
36
- name: k,
37
- value: v
38
- }];
39
- })
40
- );
41
- }
42
-
43
- /**
44
- * @param {Cookie} cookie
45
- */
46
- set(cookie) {
47
- this.#cookies[cookie.name] = {...cookie};
48
- this.#setCookies.add(cookie.name);
49
- }
50
-
51
- /**
52
- *
53
- * @param {string} name
54
- * @returns {Cookie | undefined}
55
- */
56
- get(name) {
57
- return this.#cookies[name] ? { ...this.#cookies[name] } : undefined;
58
- }
59
-
60
- /**
61
- *
62
- * @param {string} name
63
- */
64
- delete(name) {
65
- if (this.#cookies[name]) {
66
- this.#cookies[name].value = '-- deleted --';
67
- this.#cookies[name].maxAge = 0;
68
- this.#setCookies.add(name);
69
- }
70
- }
71
-
72
- /**
73
- * Gets a copy of all cookies.
74
- *
75
- * Changes made to this copy are not reflected
76
- *
77
- * @returns {Record<string, string>}
78
- */
79
- getAll() {
80
- const all = {};
81
- for (const cookie of Object.values(this.#cookies)) {
82
- all[cookie.name] = cookie.value;
83
- }
84
- return all;
85
- }
86
-
87
- getSetCookies() {
88
- const all = [];
89
- for (const name of this.#setCookies) {
90
- all.push({...this.#cookies[name]});
91
- }
92
- return all;
93
- }
94
- }
package/lib/resource.js DELETED
@@ -1,32 +0,0 @@
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,54 +0,0 @@
1
- import crypto from 'crypto';
2
- import { Resource } from '../resource.js';
3
- import { FileService } from '../services/file.js';
4
- import { overrides } from '../overrides.js';
5
-
6
- const FILENAME = 'secret';
7
-
8
- export class Secret extends Resource {
9
- /**
10
- * @type {FileService}
11
- */
12
- #fileService;
13
-
14
- /**
15
- * @type {Promise<any>}
16
- */
17
- #initPromise;
18
-
19
- /**
20
- * @param {Resource | string}
21
- * @param {string} id
22
- */
23
- constructor(scope, id) {
24
- super(scope, id);
25
- this.#fileService = new (overrides.FileService || FileService)(this, 'files');
26
- }
27
-
28
- #initialize() {
29
- this.#initPromise = this.#initPromise || this.#fileService.write(
30
- FILENAME,
31
- JSON.stringify(crypto.randomBytes(64).toString('base64url')),
32
- { onlyIfNotExists: true }
33
- ).catch(error => {
34
- if (!this.#fileService.isAlreadyExistsError(error)) throw error;
35
- });
36
- return this.#initPromise;
37
- }
38
-
39
- /**
40
- * @returns {any}
41
- */
42
- async read() {
43
- await this.#initialize();
44
- return JSON.parse(await this.#fileService.read(FILENAME));
45
- }
46
-
47
- /**
48
- * @param {any} data
49
- */
50
- async write(data) {
51
- await this.#initialize();
52
- await this.#fileService.write(FILENAME, JSON.stringify(data));
53
- }
54
- }
@@ -1,439 +0,0 @@
1
- import { scrypt, randomBytes } from 'crypto';
2
-
3
- import * as jose from 'jose';
4
-
5
- import { Resource } from '../resource.js';
6
- import { FileService } from './file.js';
7
- import { Secret } from '../resources/secret.js';
8
- import { withContext } from '../adapters/context.js';
9
- import { overrides } from '../overrides.js';
10
-
11
-
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
- }
38
-
39
- // #region types
40
-
41
- /**
42
- * @typedef {{
43
- * id: string;
44
- * password: string;
45
- * }} User
46
- */
47
-
48
- /**
49
- * @typedef {{
50
- * label: string;
51
- * type: 'string' | 'password' | 'number' | 'boolean';
52
- * isRequired?: boolean;
53
- * }} AuthenticationInput
54
- */
55
-
56
- /**
57
- * @typedef {{
58
- * name: string;
59
- * title?: string;
60
- * description?: string;
61
- * message?: string;
62
- * inputs?: Record<string, AuthenticationInput>;
63
- * buttons?: string[];
64
- * }} Action
65
- */
66
-
67
- /**
68
- * @typedef {{
69
- * state: 'authenticated' | 'unauthenticated';
70
- * user: string | undefined;
71
- * }} AuthenticationBaseState
72
- */
73
-
74
- /**
75
- * @typedef {{
76
- * state: AuthenticationBaseState;
77
- * message?: string;
78
- * actions: Record<string, Action>;
79
- * }} AuthenticationState
80
- */
81
-
82
- /**
83
- * @typedef {{
84
- * key: string;
85
- * inputs: Record<string, string | number | boolean>;
86
- * verb: string;
87
- * }} PerformActionParameter
88
- */
89
-
90
- /**
91
- * @typedef {{
92
- * message: string;
93
- * field?: string;
94
- * }} AuthenticationError
95
- */
96
-
97
- /**
98
- * @typedef {Object} AuthenticationServiceOptions
99
- * @property {number} [duration] - The number of seconds the authentication session stays alive.
100
- * @property {boolean} [keepalive] - Whether to automatically extend (keep alive) an authentication session when used.
101
- * @property {string} [cookie] - The name of the cookie to use to store the authentication state JWT.
102
- */
103
-
104
- // #endregion
105
-
106
- const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
107
-
108
- export class AuthenticationService extends Resource {
109
- #duration;
110
- #keepalive;
111
- #cookieName;
112
-
113
- /**
114
- * @type {Secret}
115
- */
116
- #rawSigningSecret;
117
-
118
- /**
119
- * @type {Promise<Uint8Array<ArrayBufferLike>> | undefined}
120
- */
121
- #signingSecret;
122
-
123
- #users;
124
-
125
- /**
126
- *
127
- * @param {Resource | string} scope
128
- * @param {string} id
129
- * @param {AuthenticationServiceOptions} [options]
130
- */
131
- constructor(scope, id, { duration, keepalive, cookie } = {}) {
132
- super(scope, id);
133
-
134
- this.#duration = duration || ONE_WEEK;
135
- this.#keepalive = !!keepalive;
136
- this.#cookieName = cookie ?? 'identity';
137
-
138
- this.#rawSigningSecret = new (overrides.Secret || Secret)(this, 'jwt-signing-secret');
139
- const fileService = new (overrides.FileService || FileService)(this, 'files');
140
-
141
- this.#users = {
142
- id,
143
-
144
- /**
145
- *
146
- * @param {string} username
147
- */
148
- async get(username) {
149
- try {
150
- const data = await fileService.read(this.filenameFor(username));
151
- return JSON.parse(data);
152
- } catch {
153
- return undefined;
154
- }
155
- },
156
-
157
- /**
158
- * @param {string} username
159
- * @param {User} user
160
- */
161
- async set(username, details) {
162
- await fileService.write(this.filenameFor(username), JSON.stringify(details));
163
- },
164
-
165
- /**
166
- * @param {string} username
167
- */
168
- async has(username) {
169
- const user = await this.get(username);
170
- return !!user;
171
- },
172
-
173
- /**
174
- * @param {string} username
175
- * @returns
176
- */
177
- filenameFor(username) {
178
- return `${username}.json`;
179
- }
180
- }
181
- }
182
-
183
- async getSigningSecret() {
184
- const secretAsString = await this.#rawSigningSecret.read();
185
- return new TextEncoder().encode(secretAsString);
186
- }
187
-
188
- /**
189
- * @type {Promise<Uint8Array<ArrayBufferLike>>}
190
- */
191
- get signingSecret() {
192
- if (!this.#signingSecret) {
193
- this.#signingSecret = this.getSigningSecret();
194
- }
195
- return this.#signingSecret;
196
- }
197
-
198
- /**
199
- * @param {CookieJar} cookies
200
- * @returns {Promise<AuthenticationBaseState>}
201
- */
202
- async getBaseState(cookies) {
203
- let idCookie, idPayload, user;
204
-
205
- try {
206
- idCookie = cookies.get(this.#cookieName)?.value;
207
- idPayload = idCookie ? (
208
- await jose.jwtVerify(idCookie, await this.signingSecret)
209
- ) : undefined;
210
- user = idPayload ? idPayload.payload.sub : undefined;
211
- } catch (err) {
212
- // jose doesn't like our cookie.
213
- console.error(err);
214
- }
215
-
216
- if (user) {
217
- return {
218
- state: 'authenticated',
219
- user
220
- }
221
- } else {
222
- return {
223
- state: 'unauthenticated',
224
- user: undefined,
225
- }
226
- }
227
- }
228
-
229
- /**
230
- * @param {CookieJar} cookies
231
- * @returns {Promise<AuthenticationState>}
232
- */
233
- async getState(cookies) {
234
- const state = await this.getBaseState(cookies);
235
- if (state.state === 'authenticated') {
236
- if (this.#keepalive) this.setBaseState(state);
237
- return {
238
- state,
239
- actions: {
240
- changepassword: {
241
- name: "Change Password",
242
- inputs: {
243
- existingPassword: {
244
- label: 'Old Password',
245
- type: 'password',
246
- },
247
- newPassword: {
248
- label: 'New Password',
249
- type: 'password',
250
- }
251
- },
252
- buttons: ['Change Password']
253
- },
254
- signout: {
255
- name: "Sign out"
256
- },
257
- }
258
- }
259
- } else {
260
- return {
261
- state,
262
- actions: {
263
- signin: {
264
- name: "Sign In",
265
- inputs: {
266
- username: {
267
- label: 'Username',
268
- type: 'text',
269
- },
270
- password: {
271
- label: 'Password',
272
- type: 'password',
273
- },
274
- },
275
- buttons: ['Sign In']
276
- },
277
- signup: {
278
- name: "Sign Up",
279
- inputs: {
280
- username: {
281
- label: 'Username',
282
- type: 'text',
283
- },
284
- password: {
285
- label: 'Password',
286
- type: 'password',
287
- },
288
- },
289
- buttons: ['Sign Up']
290
- },
291
- }
292
- }
293
- }
294
- }
295
-
296
- /**
297
- *
298
- * @param {CookieJar} cookies
299
- * @param {string | undefined} [user]
300
- */
301
- async setBaseState(cookies, user) {
302
- if (!user) {
303
- cookies.delete(this.#cookieName);
304
- } else {
305
- const jwt = await new jose.SignJWT({})
306
- .setProtectedHeader({ alg: 'HS256' })
307
- .setIssuedAt()
308
- .setSubject(user)
309
- .setExpirationTime(`${this.#duration}s`)
310
- .sign(await this.signingSecret);
311
-
312
- cookies.set({
313
- name: this.#cookieName,
314
- value: jwt,
315
- httpOnly: true,
316
- secure: true,
317
- maxAge: this.#duration
318
- });
319
- }
320
- }
321
-
322
- /**
323
- *
324
- * @param {Record<string, string>} input
325
- * @param {string[]} fields
326
- * @returns {AuthenticationError[] | undefined}
327
- */
328
- missingFieldErrors(input, fields) {
329
- /**
330
- * @type {AuthenticationError[]}
331
- */
332
- const errors = [];
333
- for (const field of fields) {
334
- if (!input[field]) errors.push({
335
- field,
336
- message: "Field is required."
337
- });
338
- }
339
- return errors.length > 0 ? errors : undefined;
340
- }
341
-
342
- /**
343
- * @param {CookieJar} cookies
344
- * @param {PerformActionParameter} params
345
- * @returns {Promise<AuthenticationState | { errors: AuthenticationError[] }>}
346
- */
347
- async setState(cookies, { key, inputs, verb: _verb }) {
348
- if (key === 'signout') {
349
- await this.setBaseState(cookies, undefined);
350
- return this.getState(cookies);
351
- } else if (key === 'signup') {
352
- const errors = this.missingFieldErrors(inputs, ['username', 'password']);
353
- if (errors) {
354
- return { errors };
355
- } else if (await this.#users.has(inputs.username)) {
356
- return { errors:
357
- [{
358
- field: 'username',
359
- message: 'User already exists.'
360
- }]
361
- };
362
- } else {
363
- await this.#users.set(inputs.username, {
364
- id: inputs.username,
365
- password: await hash(inputs.password)
366
- });
367
- await this.setBaseState(cookies, inputs.username);
368
- return this.getState(cookies);
369
- }
370
- } else if (key === 'signin') {
371
- const user = await this.#users.get(inputs.username);
372
- if (!user) {
373
- return { errors:
374
- [{
375
- field: 'username',
376
- message: `User doesn't exist.`
377
- }]
378
- };
379
- } else if (await verifyHash(inputs.password, user.password)) {
380
- // a real authentication service will use password hashing.
381
- // this is an in-memory just-for-testing user pool.
382
- await this.setBaseState(cookies, inputs.username);
383
- return this.getState(cookies);
384
- } else {
385
- return { errors:
386
- [{
387
- field: 'password',
388
- message: "Incorrect password."
389
- }]
390
- };
391
- }
392
- } else if (key === 'changepassword') {
393
- const state = await this.getBaseState(cookies);
394
- const user = await this.#users.get(state.user);
395
- if (!user) {
396
- return { errors:
397
- [{
398
- field: 'username',
399
- message: `You're not signed in as a recognized user.`
400
- }]
401
- };
402
- } else if (await verifyHash(inputs.existingPassword, user.password)) {
403
- await this.#users.set(user.id, {
404
- ...user,
405
- password: await hash(inputs.newPassword)
406
- });
407
- return {
408
- message: "Password updated.",
409
- ...await this.getState(cookies)
410
- };
411
- } else {
412
- return { errors: [{
413
- field: 'existingPassword',
414
- message: "The provided existing password is incorrect."
415
- }]
416
- };
417
- }
418
- } else {
419
- return { errors:
420
- [{
421
- message: 'Unrecognized authentication action.'
422
- }]
423
- };
424
- }
425
- }
426
-
427
- buildApi() {
428
- return withContext(context => ({
429
- getState: () => this.getState(context.cookies),
430
-
431
- /**
432
- *
433
- * @param {Parameters<typeof this['setState']>[1]} options
434
- * @returns
435
- */
436
- setState: (options) => this.setState(context.cookies, options),
437
- }));
438
- }
439
- }