millas 0.2.12-beta-1 → 0.2.13

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -7,85 +7,90 @@ const fields = require('../orm/fields/index').fields;
7
7
  * AuthUser
8
8
  *
9
9
  * Base model for authentication. Ships with Millas.
10
- * Covers the exact contract that Auth, AuthMiddleware, and AuthController expect:
11
- * - email (unique, required for login)
12
- * - password (hashed by Auth.register / Auth.hashPassword)
13
- * - role (read by RoleMiddleware)
10
+ * Covers the exact contract that Auth, AuthMiddleware, and AuthController expect.
11
+ *
12
+ * ── Fields ───────────────────────────────────────────────────────────────────
13
+ *
14
+ * Core:
15
+ * email — unique login identifier
16
+ * password — bcrypt hash (set by Auth.register / Auth.hashPassword)
17
+ * name — display name
18
+ * role — API-level role enum, read by RoleMiddleware ('admin'|'user')
19
+ *
20
+ * Django-style admin flags:
21
+ * is_active — false blocks login entirely (like Django's is_active)
22
+ * is_staff — true allows entry to the admin panel (like Django's is_staff)
23
+ * is_superuser — true bypasses all admin permission checks (like Django's is_superuser)
24
+ * last_login — updated by Auth.login() on each successful login
14
25
  *
15
26
  * ── Extending ────────────────────────────────────────────────────────────────
16
27
  *
17
- * Extend this instead of writing a User model from scratch.
18
- * Add your own fields on top all Auth behaviour is inherited.
28
+ * Because AuthUser is marked 'static abstract = true', its fields are
29
+ * automatically merged into any subclassno spread needed.
19
30
  *
20
31
  * class User extends AuthUser {
21
32
  * static table = 'users';
22
33
  * static fields = {
23
- * ...AuthUser.fields,
34
+ * // just declare what's new or overridden
24
35
  * phone: fields.string({ nullable: true }),
25
36
  * avatar_url: fields.string({ nullable: true }),
26
- * bio: fields.text({ nullable: true }),
37
+ * role: fields.enum(['tenant', 'landlord'], { default: 'tenant' }),
27
38
  * };
39
+ * // User.fields → id, name, email, password, is_active, is_staff,
40
+ * // is_superuser, last_login, created_at, updated_at,
41
+ * // phone, avatar_url, role (merged automatically)
28
42
  * }
29
43
  *
30
44
  * ── Customising the token payload ─────────────────────────────────────────
31
45
  *
32
- * Override tokenPayload() to add custom claims to the JWT:
33
- *
34
46
  * class User extends AuthUser {
35
47
  * tokenPayload() {
36
- * return {
37
- * ...super.tokenPayload(),
38
- * plan: this.plan,
39
- * orgId: this.org_id,
40
- * };
41
- * }
42
- * }
43
- *
44
- * ── Customising register / login hooks ────────────────────────────────────
45
- *
46
- * Override static hooks to run logic around auth operations:
47
- *
48
- * class User extends AuthUser {
49
- * static async afterCreate(instance) {
50
- * await emit('user.registered', { user: instance });
48
+ * return { ...super.tokenPayload(), plan: this.plan };
51
49
  * }
52
50
  * }
53
51
  */
54
52
  class AuthUser extends Model {
55
- static table = 'users';
53
+ // Abstract — no table. The app's User model owns the table.
54
+ // Equivalent to Django's AbstractUser with class Meta: abstract = True.
55
+ static abstract = true;
56
56
 
57
57
  static fields = {
58
- id: fields.id(),
59
- name: fields.string({ max: 100 }),
60
- email: fields.string({ unique: true }),
61
- password: fields.string(),
62
- role: fields.enum(['admin', 'user'], { default: 'user' }),
63
- created_at: fields.timestamp(),
64
- updated_at: fields.timestamp(),
58
+ id: fields.id(),
59
+ name: fields.string({ max: 100, nullable: true }),
60
+ email: fields.string({ unique: true }),
61
+ password: fields.string(),
62
+ role: fields.enum(['admin', 'user'], { default: 'user' }),
63
+
64
+ // ── Django-style admin access flags ──────────────────────────────────
65
+ is_active: fields.boolean({ default: true }),
66
+ is_staff: fields.boolean({ default: false }),
67
+ is_superuser: fields.boolean({ default: false }),
68
+
69
+ last_login: fields.timestamp(),
70
+ created_at: fields.timestamp(),
71
+ updated_at: fields.timestamp(),
65
72
  };
66
73
 
67
74
  // ── Auth contract helpers ──────────────────────────────────────────────────
68
75
 
69
76
  /**
70
- * The fields to include in the JWT payload.
77
+ * Fields included in the JWT payload.
71
78
  * Override to add custom claims.
72
- *
73
- * @returns {object}
74
79
  */
75
80
  tokenPayload() {
76
81
  return {
77
- id: this.id,
78
- sub: this.id,
79
- email: this.email,
80
- role: this.role || null,
82
+ id: this.id,
83
+ sub: this.id,
84
+ email: this.email,
85
+ role: this.role || null,
86
+ is_staff: this.is_staff ?? false,
87
+ is_superuser: this.is_superuser ?? false,
81
88
  };
82
89
  }
83
90
 
84
91
  /**
85
- * Safe representation — strips sensitive fields.
86
- * Used by AuthController._safeUser().
87
- *
88
- * @returns {object}
92
+ * Safe public representation — strips password and internal flags.
93
+ * Used by AuthController._safeUser() and API responses.
89
94
  */
90
95
  toSafeObject() {
91
96
  const data = { ...this };
@@ -93,6 +98,22 @@ class AuthUser extends Model {
93
98
  delete data.remember_token;
94
99
  return data;
95
100
  }
101
+
102
+ /**
103
+ * Returns true if this user can access the admin panel.
104
+ * Mirrors Django's User.has_module_perms / is_staff check.
105
+ */
106
+ get canAccessAdmin() {
107
+ return !!(this.is_active && this.is_staff);
108
+ }
109
+
110
+ /**
111
+ * Returns true if this user bypasses all permission checks.
112
+ * Mirrors Django's User.is_superuser.
113
+ */
114
+ get isSuperuser() {
115
+ return !!(this.is_active && this.is_superuser);
116
+ }
96
117
  }
97
118
 
98
119
  module.exports = AuthUser;
package/src/cli.js CHANGED
@@ -16,6 +16,8 @@ require('./commands/make')(program);
16
16
  require('./commands/migrate')(program);
17
17
  require('./commands/route')(program);
18
18
  require('./commands/queue')(program);
19
+ require('./commands/createsuperuser')(program);
20
+ require('./commands/lang')(program);
19
21
 
20
22
  // Unknown command handler
21
23
  program.on('command:*', ([cmd]) => {
@@ -24,4 +26,4 @@ program.on('command:*', ([cmd]) => {
24
26
  process.exit(1);
25
27
  });
26
28
 
27
- module.exports = { program };
29
+ module.exports = { program };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const readline = require('readline');
7
+
8
+ const Log = require("../logger/internal")
9
+
10
+ const TAG = 'Create SuperUser';
11
+
12
+ module.exports = function (program) {
13
+
14
+ // ── createsuperuser ────────────────────────────────────────────────────────
15
+ program
16
+ .command('createsuperuser')
17
+ .description('Create a superuser in the users table (interactive)')
18
+ .option('--email <email>', 'Email address (skip prompt)')
19
+ .option('--name <n>', 'Display name (skip prompt)')
20
+ .option('--noinput', 'Read password from ADMIN_PASSWORD env var, skip all prompts')
21
+ .action(async (options) => {
22
+ try {
23
+ const { User, Auth } = await resolveUserModel();
24
+
25
+ Log.i(TAG,chalk.cyan('Create Millas Superuser'));
26
+
27
+ // ── Email ────────────────────────────────────────────────
28
+ let email = options.email;
29
+ if (!email) email = await prompt(' Email address: ');
30
+ email = (email || '').trim().toLowerCase();
31
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
32
+ throw new Error('Enter a valid email address.');
33
+ }
34
+
35
+ const existing = await User.findBy('email', email);
36
+ if (existing) {
37
+ throw new Error(
38
+ `A user with email "${email}" already exists.\n` +
39
+ ` To grant staff access: update the record and set is_staff=true, is_superuser=true.\n` +
40
+ ` To change their password: millas changepassword --email ${email}`
41
+ );
42
+ }
43
+
44
+ // ── Name ─────────────────────────────────────────────────
45
+ let name = options.name;
46
+ if (!name && !options.noinput) {
47
+ name = await prompt(' Display name (optional, press Enter to skip): ');
48
+ }
49
+ name = (name || '').trim() || email.split('@')[0];
50
+
51
+ // ── Password ─────────────────────────────────────────────
52
+ let plainPassword;
53
+ if (options.noinput) {
54
+ plainPassword = process.env.ADMIN_PASSWORD;
55
+ if (!plainPassword) {
56
+ throw new Error('--noinput requires ADMIN_PASSWORD to be set in the environment.');
57
+ }
58
+ } else {
59
+ plainPassword = await promptPassword(' Password: ');
60
+ const confirm = await promptPassword(' Password (again): ');
61
+ if (plainPassword !== confirm) throw new Error('Passwords do not match.');
62
+ }
63
+
64
+ validatePassword(plainPassword);
65
+
66
+ // ── Create via Auth.register path but with staff flags ───
67
+ // Hash manually so we can pass the flags in the same create() call.
68
+ const Hasher = require('../auth/Hasher');
69
+ const hash = await Hasher.make(plainPassword);
70
+
71
+ await User.create({
72
+ email,
73
+ name,
74
+ password: hash,
75
+ is_active: true,
76
+ is_staff: true,
77
+ is_superuser: true,
78
+ });
79
+
80
+ Log.i(TAG,chalk.green(`✔ Superuser "${email}" created successfully.`));
81
+ Log.i(TAG,chalk.gray(` Run: millas serve then visit /admin`));
82
+ } catch (err) {
83
+ Log.e(TAG,chalk.red(`✖ ${err.message}`));
84
+ Log.e(TAG,err.stack);
85
+ process.exit(1);
86
+ }
87
+ });
88
+
89
+ // ── changepassword ─────────────────────────────────────────────────────────
90
+ program
91
+ .command('changepassword')
92
+ .description("Change a user's password in the users table")
93
+ .option('--email <email>', 'Email address of the user')
94
+ .action(async (options) => {
95
+ try {
96
+ const { User } = await resolveUserModel();
97
+
98
+ let email = options.email;
99
+ if (!email) email = await prompt('\n Email address: ');
100
+ email = (email || '').trim().toLowerCase();
101
+
102
+ const user = await User.findBy('email', email);
103
+ if (!user) throw new Error(`No user found with email "${email}".`);
104
+
105
+ Log.i(TAG,chalk.cyan(`Changing password for: ${user.email}`));
106
+
107
+ const plain = await promptPassword(' New password: ');
108
+ const confirm = await promptPassword(' New password (again): ');
109
+ if (plain !== confirm) throw new Error('Passwords do not match.');
110
+ validatePassword(plain);
111
+
112
+ const Hasher = require('../auth/Hasher');
113
+ const hash = await Hasher.make(plain);
114
+
115
+ await User.where('id', user.id).update({
116
+ password: hash,
117
+ updated_at: new Date().toISOString(),
118
+ });
119
+
120
+ Log.i(TAG, chalk.green(`✔ Password updated for "${email}".`));
121
+ } catch (err) {
122
+ Log.e(TAG, chalk.red(`✖ ${err.message}`));
123
+ process.exit(1);
124
+ }
125
+ });
126
+
127
+ // ── listadmins ─────────────────────────────────────────────────────────────
128
+ program
129
+ .command('listadmins')
130
+ .description('List all staff/superusers from the users table')
131
+ .action(async () => {
132
+ try {
133
+ const { User } = await resolveUserModel();
134
+
135
+ // Query for all staff users — is_staff=true
136
+ const users = await User.where('is_staff', true).orderBy('id').get();
137
+
138
+ if (!users.length) {
139
+ Log.i(TAG, chalk.yellow('No staff users found.\n Run: millas createsuperuser'));
140
+ return;
141
+ }
142
+
143
+ Log.i(TAG, chalk.cyan('Staff / Superusers'));
144
+ const colW = Math.max(...users.map(u => u.email.length)) + 2;
145
+ Log.i(TAG, chalk.gray(` ${'ID'.padEnd(5)} ${'Email'.padEnd(colW)} ${'Name'.padEnd(20)} Active Super`));
146
+ Log.i(TAG, chalk.gray(' ' + '─'.repeat(colW + 42)));
147
+ for (const u of users) {
148
+ const active = u.is_active ? chalk.green('Yes ') : chalk.red('No ');
149
+ const sup = u.is_superuser ? chalk.green('Yes') : chalk.gray('No');
150
+ Log.i(TAG, ` ${String(u.id).padEnd(5)} ${chalk.cyan(u.email.padEnd(colW))} ${(u.name || '—').padEnd(20)} ${active} ${sup}`);
151
+ }
152
+ } catch (err) {
153
+ Log.e(TAG, chalk.red(`✖ ${err.message}`));
154
+ process.exit(1);
155
+ }
156
+ });
157
+ };
158
+
159
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Resolve the User model using the same three-step priority as AuthServiceProvider:
163
+ *
164
+ * 1. config/app.js -> auth_user: 'ModelName'
165
+ * Looked up by name in app/models/index.js exports.
166
+ *
167
+ * 2. app/models/User.js (conventional default path)
168
+ *
169
+ * 3. Built-in AuthUser (abstract fallback)
170
+ *
171
+ * Also boots the DB connection and verifies the resolved model's table exists,
172
+ * giving a clear error if migrations haven't been run yet.
173
+ */
174
+
175
+ async function resolveUserModel() {
176
+ const cwd = process.cwd();
177
+ const configPath = path.join(cwd, 'config/database.js');
178
+ if (!fs.existsSync(configPath)) {
179
+ throw new Error('config/database.js not found. Are you inside a Millas project?');
180
+ }
181
+
182
+ // Always require DatabaseManager from the project-local node_modules.
183
+ // This ensures the same singleton is shared with the project's models,
184
+ // avoiding the "not configured" error when millas is installed globally.
185
+ const dbConfig = require(configPath);
186
+ let DatabaseManager;
187
+ try {
188
+ DatabaseManager = require(path.join(cwd, 'node_modules/millas/src/orm/drivers/DatabaseManager'));
189
+ } catch {
190
+ DatabaseManager = require('../orm/drivers/DatabaseManager');
191
+ }
192
+ DatabaseManager.configure(dbConfig);
193
+ const db = DatabaseManager.connection();
194
+
195
+ // -- Step 1: auth_user from config/app.js --
196
+ let User;
197
+ let authUserName = null;
198
+ try {
199
+ const appConfig = require(path.join(cwd, 'config/app'));
200
+ authUserName = appConfig.auth_user || null;
201
+ } catch { /* config/app.js missing or no auth_user key */ }
202
+
203
+ if (authUserName) {
204
+ try {
205
+ const modelsIndex = require(path.join(cwd, 'app/models/index'));
206
+ const resolved = modelsIndex[authUserName];
207
+ if (!resolved) {
208
+ throw new Error(
209
+ `auth_user: '${authUserName}' not found in app/models/index.js.\n` +
210
+ ` Available exports: ${Object.keys(modelsIndex).join(', ')}`
211
+ );
212
+ }
213
+ User = resolved;
214
+ } catch (err) {
215
+ if (err.message.includes('auth_user:')) throw err;
216
+ throw new Error(`Could not load app/models/index.js: ${err.message}`);
217
+ }
218
+ } else {
219
+ // -- Step 2: try app/models/User.js --
220
+ try {
221
+ User = require(path.join(cwd, 'app/models/User'));
222
+ } catch {
223
+ // -- Step 3: abstract AuthUser fallback --
224
+ User = require('../auth/AuthUser');
225
+ }
226
+ }
227
+
228
+ // -- Verify the model's table exists (uses the model's own table name) --
229
+ const table = User.table;
230
+ if (table) {
231
+ const tableExists = await db.schema.hasTable(table);
232
+ if (!tableExists) {
233
+ throw new Error(
234
+ `Table "${table}" does not exist.\n\n` +
235
+ ` Did you run migrations?\n` +
236
+ ` Run: millas migrate\n`
237
+ );
238
+ }
239
+ }
240
+
241
+ return { User, db };
242
+ }
243
+
244
+ function validatePassword(pw) {
245
+ if (!pw || pw.length < 8) {
246
+ throw new Error('This password is too short. It must contain at least 8 characters.');
247
+ }
248
+ }
249
+
250
+ function prompt(question) {
251
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
252
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); }));
253
+ }
254
+
255
+ function promptPassword(question) {
256
+ return new Promise(resolve => {
257
+ if (!process.stdin.isTTY) return prompt(question).then(resolve);
258
+
259
+ process.stdout.write(question);
260
+ const rl = readline.createInterface({
261
+ input: process.stdin,
262
+ output: new (require('stream').Writable)({ write(c, e, cb) { cb(); } }),
263
+ terminal: true,
264
+ });
265
+ rl.question('', ans => { rl.close(); process.stdout.write('\n'); resolve(ans); });
266
+ });
267
+ }