wirejs-resources 0.1.1-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.
@@ -0,0 +1,98 @@
1
+ import { CookieJar } from "./cookie-jar.js";
2
+
3
+ const contextWrappers = new Set();
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
+ contextWrappers.add(fnOrNs);
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 contextWrappers.has(fnOrNS);
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
+ }
@@ -0,0 +1,94 @@
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/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { FileService } from './services/file.js';
2
+ export { AuthenticationService } from './services/authentication.js';
3
+ export { CookieJar } from './adapters/cookie-jar.js';
4
+ export { withContext, requiresContext, Context } from './adapters/context.js';
@@ -0,0 +1,48 @@
1
+ import process from 'process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+
6
+ const CWD = process.cwd();
7
+
8
+ /**
9
+ * @type {Map<string, Secret>}
10
+ */
11
+ const secrets = new Map();
12
+
13
+ export class Secret {
14
+ #id;
15
+
16
+ /**
17
+ * @param {string} id
18
+ * @param {string} [value]
19
+ */
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;
39
+ }
40
+
41
+ async read() {
42
+ return fs.promises.readFile(this.#filename(), 'utf8');
43
+ }
44
+
45
+ async write(value) {
46
+ fs.promises.writeFile(this.#filename(), value);
47
+ }
48
+ }
@@ -0,0 +1,424 @@
1
+ import process from 'process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import * as jose from 'jose';
6
+ import bcrypt from 'bcrypt';
7
+
8
+ import { Secret } from '../resources/secret.js';
9
+ import { CookieJar } from '../adapters/cookie-jar.js';
10
+ import { withContext } from '../adapters/context.js';
11
+
12
+ const CWD = process.cwd();
13
+ const SALT_ROUNDS = 10;
14
+
15
+ const signingSecret = new Secret('wirejs-services/auth-jwt-signing-secret');
16
+
17
+ /**
18
+ * @typedef {{
19
+ * id: string;
20
+ * password: string;
21
+ * }} User
22
+ */
23
+
24
+ /**
25
+ * @typedef {{
26
+ * label: string;
27
+ * type: 'string' | 'password' | 'number' | 'boolean';
28
+ * isRequired?: boolean;
29
+ * }} AuthenticationInput
30
+ */
31
+
32
+ /**
33
+ * @typedef {{
34
+ * name: string;
35
+ * title?: string;
36
+ * description?: string;
37
+ * message?: string;
38
+ * inputs?: Record<string, AuthenticationInput>;
39
+ * buttons?: string[];
40
+ * }} Action
41
+ */
42
+
43
+ /**
44
+ * @typedef {{
45
+ * state: 'authenticated' | 'unauthenticated';
46
+ * user: string | undefined;
47
+ * }} AuthenticationBaseState
48
+ */
49
+
50
+ /**
51
+ * @typedef {{
52
+ * state: AuthenticationBaseState;
53
+ * message?: string;
54
+ * actions: Record<string, Action>;
55
+ * }} AuthenticationState
56
+ */
57
+
58
+ /**
59
+ * @typedef {{
60
+ * key: string;
61
+ * inputs: Record<string, string | number | boolean>;
62
+ * verb: string;
63
+ * }} PerformActionParameter
64
+ */
65
+
66
+ /**
67
+ * @typedef {{
68
+ * message: string;
69
+ * field?: string;
70
+ * }} AuthenticationError
71
+ */
72
+
73
+ /**
74
+ * @typedef {Object} AuthenticationServiceOptions
75
+ * @property {number} [duration] - The number of seconds the authentication session stays alive.
76
+ * @property {boolean} [keepalive] - Whether to automatically extend (keep alive) an authentication session when used.
77
+ * @property {string} [cookie] - The name of the cookie to use to store the authentication state JWT.
78
+ */
79
+
80
+ /**
81
+ * @type {Map<string, AuthService>}
82
+ */
83
+ const services = new Map();
84
+
85
+ const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
86
+
87
+ export class AuthenticationService {
88
+ id;
89
+ #duration;
90
+ #keepalive;
91
+ #cookieName;
92
+ #signingSecret;
93
+
94
+ #users;
95
+
96
+ /**
97
+ *
98
+ * @param {string} id
99
+ * @param {AuthenticationServiceOptions} [options]
100
+ */
101
+ constructor(id, { duration, keepalive, cookie } = {}) {
102
+ this.id = id;
103
+ this.#duration = duration || ONE_WEEK;
104
+ this.#keepalive = !!keepalive;
105
+ this.#cookieName = cookie ?? 'identity';
106
+
107
+ this.#users = {
108
+ id,
109
+
110
+ /**
111
+ *
112
+ * @param {string} username
113
+ */
114
+ async get(username) {
115
+ try {
116
+ const data = await fs.promises.readFile(this.filenameFor(username));
117
+ return JSON.parse(data);
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ },
122
+
123
+ /**
124
+ * @param {string} username
125
+ * @param {User} user
126
+ */
127
+ 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
+ );
136
+ },
137
+
138
+ /**
139
+ * @param {string} username
140
+ */
141
+ async has(username) {
142
+ const user = await this.get(username);
143
+ return !!user;
144
+ },
145
+
146
+ /**
147
+ * @param {string} username
148
+ * @returns
149
+ */
150
+ 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
+ );
160
+ }
161
+ }
162
+
163
+ if (services.has(id)) {
164
+ services.set(id, this);
165
+ }
166
+ }
167
+
168
+ async getSigningSecret() {
169
+ const secretAsString = await signingSecret.read();
170
+ return new TextEncoder().encode(secretAsString);
171
+ }
172
+
173
+ /**
174
+ * @type {Promise<Uint8Array<ArrayBufferLike>>}
175
+ */
176
+ get signingSecret() {
177
+ if (!this.#signingSecret) {
178
+ this.#signingSecret = this.getSigningSecret();
179
+ }
180
+ return this.#signingSecret;
181
+ }
182
+
183
+ /**
184
+ * @param {CookieJar} cookies
185
+ * @returns {Promise<AuthenticationBaseState>}
186
+ */
187
+ async getBaseState(cookies) {
188
+ let idCookie, idPayload, user;
189
+
190
+ try {
191
+ idCookie = cookies.get(this.#cookieName)?.value;
192
+ idPayload = idCookie ? (
193
+ await jose.jwtVerify(idCookie, await this.signingSecret)
194
+ ) : undefined;
195
+ user = idPayload ? idPayload.payload.sub : undefined;
196
+ } catch (err) {
197
+ // jose doesn't like our cookie.
198
+ console.error(err);
199
+ }
200
+
201
+ if (user) {
202
+ return {
203
+ state: 'authenticated',
204
+ user
205
+ }
206
+ } else {
207
+ return {
208
+ state: 'unauthenticated',
209
+ user: undefined,
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * @param {CookieJar} cookies
216
+ * @returns {Promise<AuthenticationState>}
217
+ */
218
+ async getState(cookies) {
219
+ const state = await this.getBaseState(cookies);
220
+ if (state.state === 'authenticated') {
221
+ if (this.#keepalive) this.setBaseState(state);
222
+ return {
223
+ state,
224
+ actions: {
225
+ changepassword: {
226
+ name: "Change Password",
227
+ inputs: {
228
+ existingPassword: {
229
+ label: 'Old Password',
230
+ type: 'password',
231
+ },
232
+ newPassword: {
233
+ label: 'New Password',
234
+ type: 'password',
235
+ }
236
+ },
237
+ buttons: ['Change Password']
238
+ },
239
+ signout: {
240
+ name: "Sign out"
241
+ },
242
+ }
243
+ }
244
+ } else {
245
+ return {
246
+ state,
247
+ actions: {
248
+ signin: {
249
+ name: "Sign In",
250
+ inputs: {
251
+ username: {
252
+ label: 'Username',
253
+ type: 'text',
254
+ },
255
+ password: {
256
+ label: 'Password',
257
+ type: 'password',
258
+ },
259
+ },
260
+ buttons: ['Sign In']
261
+ },
262
+ signup: {
263
+ name: "Sign Up",
264
+ inputs: {
265
+ username: {
266
+ label: 'Username',
267
+ type: 'text',
268
+ },
269
+ password: {
270
+ label: 'Password',
271
+ type: 'password',
272
+ },
273
+ },
274
+ buttons: ['Sign Up']
275
+ },
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ *
283
+ * @param {CookieJar} cookies
284
+ * @param {string | undefined} [user]
285
+ */
286
+ async setBaseState(cookies, user) {
287
+ if (!user) {
288
+ cookies.delete(this.#cookieName);
289
+ } else {
290
+ const jwt = await new jose.SignJWT({})
291
+ .setProtectedHeader({ alg: 'HS256' })
292
+ .setIssuedAt()
293
+ .setSubject(user)
294
+ .setExpirationTime(`${this.#duration}s`)
295
+ .sign(await this.signingSecret);
296
+
297
+ cookies.set({
298
+ name: this.#cookieName,
299
+ value: jwt,
300
+ httpOnly: true,
301
+ secure: true,
302
+ maxAge: this.#duration
303
+ });
304
+ }
305
+ }
306
+
307
+ /**
308
+ *
309
+ * @param {Record<string, string>} input
310
+ * @param {string[]} fields
311
+ * @returns {AuthenticationError[] | undefined}
312
+ */
313
+ missingFieldErrors(input, fields) {
314
+ /**
315
+ * @type {AuthenticationError[]}
316
+ */
317
+ const errors = [];
318
+ for (const field of fields) {
319
+ if (!input[field]) errors.push({
320
+ field,
321
+ message: "Field is required."
322
+ });
323
+ }
324
+ return errors.length > 0 ? errors : undefined;
325
+ }
326
+
327
+ /**
328
+ * @param {CookieJar} cookies
329
+ * @param {PerformActionParameter} params
330
+ * @returns {Promise<AuthenticationState | { errors: AuthenticationError[] }>}
331
+ */
332
+ async setState(cookies, { key, inputs, verb: _verb }) {
333
+ if (key === 'signout') {
334
+ await this.setBaseState(cookies, undefined);
335
+ return this.getState(cookies);
336
+ } else if (key === 'signup') {
337
+ const errors = this.missingFieldErrors(inputs, ['username', 'password']);
338
+ if (errors) {
339
+ return { errors };
340
+ } else if (await this.#users.has(inputs.username)) {
341
+ return { errors:
342
+ [{
343
+ field: 'username',
344
+ message: 'User already exists.'
345
+ }]
346
+ };
347
+ } else {
348
+ await this.#users.set(inputs.username, {
349
+ id: inputs.username,
350
+ password: await bcrypt.hash(inputs.password, SALT_ROUNDS)
351
+ });
352
+ await this.setBaseState(cookies, inputs.username);
353
+ return this.getState(cookies);
354
+ }
355
+ } else if (key === 'signin') {
356
+ const user = await this.#users.get(inputs.username);
357
+ if (!user) {
358
+ return { errors:
359
+ [{
360
+ field: 'username',
361
+ message: `User doesn't exist.`
362
+ }]
363
+ };
364
+ } else if (await bcrypt.compare(inputs.password, user.password)) {
365
+ // a real authentication service will use password hashing.
366
+ // this is an in-memory just-for-testing user pool.
367
+ await this.setBaseState(cookies, inputs.username);
368
+ return this.getState(cookies);
369
+ } else {
370
+ return { errors:
371
+ [{
372
+ field: 'password',
373
+ message: "Incorrect password."
374
+ }]
375
+ };
376
+ }
377
+ } else if (key === 'changepassword') {
378
+ const state = await this.getBaseState(cookies);
379
+ const user = await this.#users.get(state.user);
380
+ if (!user) {
381
+ return { errors:
382
+ [{
383
+ field: 'username',
384
+ message: `You're not signed in as a recognized user.`
385
+ }]
386
+ };
387
+ } else if (await bcrypt.compare(inputs.existingPassword, user.password)) {
388
+ await this.#users.set(user.id, {
389
+ ...user,
390
+ password: await bcrypt.hash(inputs.newPassword, SALT_ROUNDS)
391
+ });
392
+ return {
393
+ message: "Password updated.",
394
+ ...await this.getState(cookies)
395
+ };
396
+ } else {
397
+ return { errors: [{
398
+ field: 'existingPassword',
399
+ message: "The provided existing password is incorrect."
400
+ }]
401
+ };
402
+ }
403
+ } else {
404
+ return { errors:
405
+ [{
406
+ message: 'Unrecognized authentication action.'
407
+ }]
408
+ };
409
+ }
410
+ }
411
+
412
+ buildApi() {
413
+ return withContext(context => ({
414
+ getState: () => this.getState(context.cookies),
415
+
416
+ /**
417
+ *
418
+ * @param {Parameters<typeof this['setState']>[1]} options
419
+ * @returns
420
+ */
421
+ setState: (options) => this.setState(context.cookies, options),
422
+ }));
423
+ }
424
+ }
@@ -0,0 +1,78 @@
1
+ import process from 'process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const CWD = process.cwd();
6
+
7
+ /**
8
+ * @type {Map<string, FileService>}
9
+ */
10
+ const services = new Map();
11
+
12
+ export class FileService {
13
+ id;
14
+
15
+ /**
16
+ *
17
+ * @param {{
18
+ * id: string
19
+ * }} options
20
+ */
21
+ constructor(id) {
22
+ this.id = id;
23
+ if (!services.has(id)) {
24
+ services.set(id, this);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @param {string} filename
30
+ * @returns
31
+ */
32
+ #fullNameFor(filename) {
33
+ const sanitizedId = this.id.replace('~', '-').replace(/\.+/g, '.');
34
+ const sanitizedName = filename.replace('~', '-').replace(/\.+/g, '.');
35
+ return path.join(CWD, 'temp', 'wirejs-services', 'files', sanitizedId, sanitizedName);
36
+ }
37
+
38
+ /**
39
+ * @param {string} filename
40
+ * @param {BufferEncoding} [encoding]
41
+ * @return {Promise<string>} file data as a string
42
+ */
43
+ async read(filename, encoding = 'utf8') {
44
+ return fs.promises.readFile(this.#fullNameFor(filename), { encoding });
45
+ }
46
+
47
+ /**
48
+ *
49
+ * @param {string} filename
50
+ * @param {string} data
51
+ */
52
+ async write(filename, data) {
53
+ const fullname = this.#fullNameFor(filename);
54
+ await fs.promises.mkdir(path.dirname(fullname), { recursive: true });
55
+ return fs.promises.writeFile(fullname, data);
56
+ }
57
+
58
+ /**
59
+ *
60
+ * @param {string} filename
61
+ */
62
+ async delete(filename) {
63
+ return fs.promises.unlink(this.#fullNameFor(filename));
64
+ }
65
+
66
+ /**
67
+ *
68
+ * @param {{
69
+ * prefix?: string
70
+ * }} [options]
71
+ */
72
+ async * list({ prefix = '' } = {}) {
73
+ const all = await fs.promises.readdir(CWD, { recursive: true });
74
+ for (const name of all) {
75
+ if (prefix === undefined || name.startsWith(prefix)) yield name;
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,12 @@
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
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type {
2
+ AuthenticationService as AuthenticationServiceBase,
3
+ withContext as withContextBase,
4
+ FileService as FileServiceBase,
5
+ Context as ContextBase
6
+ } from './index.js';
7
+
8
+ export declare class Context extends ContextBase {};
9
+ export declare class AuthenticationService extends AuthenticationServiceBase {};
10
+ export declare class FileService extends FileServiceBase {};
11
+ export declare function withContext(): typeof withContextBase;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "wirejs-resources",
3
+ "version": "0.1.1-alpha",
4
+ "description": "Basic services and server-side resources for wirejs apps",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/types.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types.ts",
11
+ "default": "./lib/index.js"
12
+ }
13
+ },
14
+ "scripts": {},
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/svidgen/create-wirejs-app.git"
18
+ },
19
+ "author": "Jon Wire",
20
+ "license": "AGPL-3.0-only",
21
+ "bugs": {
22
+ "url": "https://github.com/svidgen/create-wirejs-app/issues"
23
+ },
24
+ "homepage": "https://github.com/svidgen/create-wirejs-app#readme",
25
+ "dependencies": {
26
+ "bcrypt": "^5.1.1",
27
+ "jose": "^5.9.6"
28
+ }
29
+ }