webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -0,0 +1,338 @@
1
+ /**
2
+ * OAuth 2.0 & Social Login Plugin for Webspresso
3
+ * Zero external dependencies: powered by native node:crypto and global fetch
4
+ * RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), signed anti-CSRF state tokens, and dual auth integration
5
+ * @module plugins/oauth
6
+ */
7
+
8
+ const { OAuthClient, OAuthError, createOAuthClient } = require('../../core/auth/oauth');
9
+
10
+ /**
11
+ * Resolve effective redirect URI for a provider
12
+ * Uses configured redirectUri or derives dynamically from request headers/host
13
+ *
14
+ * @param {import('express').Request} req
15
+ * @param {string} providerName
16
+ * @param {Object} providerConfig
17
+ * @param {string} pathPrefix
18
+ * @returns {string}
19
+ */
20
+ function resolveRedirectUri(req, providerName, providerConfig, pathPrefix) {
21
+ if (providerConfig.redirectUri) {
22
+ return providerConfig.redirectUri;
23
+ }
24
+
25
+ const proto = req.headers['x-forwarded-proto'] || (req.connection?.encrypted ? 'https' : req.protocol) || 'http';
26
+ const host = req.headers['x-forwarded-host'] || req.headers.host;
27
+ const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
28
+ return `${proto}://${host}${cleanPrefix}/${providerName}/callback`;
29
+ }
30
+
31
+ /**
32
+ * Webspresso OAuth Plugin Factory
33
+ *
34
+ * @param {Object} [options]
35
+ * @param {OAuthClient} [options.client] - Optional pre-instantiated OAuthClient instance
36
+ * @param {Record<string, Object>} [options.providers] - Provider configurations (google, github, etc.)
37
+ * @param {string} [options.secret] - State token signing secret
38
+ * @param {string} [options.pathPrefix='/auth'] - Base path prefix for OAuth routes
39
+ * @param {boolean} [options.routes=true] - Mount automatic /:provider and /:provider/callback routes
40
+ * @param {boolean} [options.pkce=true] - Enable RFC 7636 PKCE
41
+ * @param {number} [options.stateTtlMs=600000] - State TTL in milliseconds (default 10 mins)
42
+ * @param {string} [options.defaultReturnTo='/'] - Default redirect URL after successful login
43
+ * @param {string} [options.failureRedirect='/login'] - Default redirect URL on failure
44
+ * @param {Function} [options.findOrCreateUser] - (params: { provider, profile, tokens, stateData, req, res, db }) => Promise<User>
45
+ * @param {Function} [options.onSuccess] - (params: { provider, user, profile, tokens, stateData, req, res, client }) => Promise<void|any>
46
+ * @param {Function} [options.onError] - (params: { error, provider, req, res, client }) => Promise<void|any>
47
+ * @param {Object} [options.db] - Optional Database instance
48
+ * @returns {Object} Webspresso Plugin Definition
49
+ */
50
+ function oauthPlugin(options = {}) {
51
+ const {
52
+ pathPrefix = '/auth',
53
+ routes: mountRoutes = true,
54
+ pkce = true,
55
+ defaultReturnTo = '/',
56
+ failureRedirect = '/login',
57
+ stateTtlMs = 10 * 60 * 1000,
58
+ providers = {},
59
+ secret,
60
+ } = options;
61
+
62
+ const client = options.client || createOAuthClient({
63
+ secret: secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret',
64
+ stateTtlMs,
65
+ providers,
66
+ });
67
+
68
+ /**
69
+ * Handle OAuth failure response
70
+ */
71
+ async function handleFailure(err, providerName, req, res, next) {
72
+ if (typeof options.onError === 'function') {
73
+ try {
74
+ const hookResult = await options.onError({
75
+ error: err,
76
+ provider: providerName,
77
+ req,
78
+ res,
79
+ client,
80
+ });
81
+ if (res.headersSent || hookResult === false) {
82
+ return;
83
+ }
84
+ } catch (hookErr) {
85
+ console.error('[oauthPlugin] onError hook error:', hookErr);
86
+ }
87
+ }
88
+
89
+ const acceptsJson = req.xhr || (req.headers.accept && req.headers.accept.includes('application/json')) || req.query.format === 'json';
90
+ if (acceptsJson) {
91
+ return res.status(400).json({
92
+ error: err.code || 'OAUTH_ERROR',
93
+ message: err.message,
94
+ details: err.details || null,
95
+ });
96
+ }
97
+
98
+ const failureBase = options.failureRedirect || failureRedirect;
99
+ const url = new URL(failureBase, 'http://localhost');
100
+ url.searchParams.set('error', err.code || 'oauth_failed');
101
+ url.searchParams.set('message', err.message);
102
+
103
+ const redirectTarget = `${url.pathname}${url.search}`;
104
+ return res.redirect(redirectTarget);
105
+ }
106
+
107
+ /**
108
+ * Handle OAuth success response
109
+ */
110
+ async function handleSuccess(result, req, res, next) {
111
+ const { provider, user: profile, tokens, stateData } = result;
112
+
113
+ let appUser = null;
114
+ const db = req.db || options.db;
115
+ if (typeof options.findOrCreateUser === 'function') {
116
+ appUser = await options.findOrCreateUser({
117
+ provider,
118
+ profile,
119
+ tokens,
120
+ stateData,
121
+ req,
122
+ res,
123
+ db,
124
+ });
125
+ }
126
+
127
+ const effectiveUser = appUser || profile;
128
+
129
+ if (typeof options.onSuccess === 'function') {
130
+ const hookResult = await options.onSuccess({
131
+ provider,
132
+ user: effectiveUser,
133
+ profile,
134
+ tokens,
135
+ stateData,
136
+ req,
137
+ res,
138
+ client,
139
+ });
140
+
141
+ if (res.headersSent || hookResult === false) {
142
+ return;
143
+ }
144
+ }
145
+
146
+ // Default Session / Dual-auth login integration
147
+ if (effectiveUser) {
148
+ req.user = effectiveUser;
149
+ if (req.auth && typeof req.auth.login === 'function') {
150
+ try {
151
+ await req.auth.login(effectiveUser);
152
+ } catch (authErr) {
153
+ console.warn('[oauthPlugin] req.auth.login failed:', authErr.message);
154
+ }
155
+ }
156
+ }
157
+
158
+ // JSON response for API/mobile clients
159
+ const acceptsJson = req.xhr || (req.headers.accept && req.headers.accept.includes('application/json')) || req.query.format === 'json';
160
+ if (acceptsJson) {
161
+ let jwtToken = undefined;
162
+ if (req.auth && typeof req.auth.generateUserToken === 'function' && effectiveUser) {
163
+ try {
164
+ jwtToken = req.auth.generateUserToken(effectiveUser);
165
+ } catch {}
166
+ }
167
+
168
+ return res.json({
169
+ success: true,
170
+ provider,
171
+ user: effectiveUser,
172
+ token: jwtToken,
173
+ tokens,
174
+ });
175
+ }
176
+
177
+ const targetUrl = stateData?.returnTo || defaultReturnTo;
178
+ return res.redirect(targetUrl);
179
+ }
180
+
181
+ return {
182
+ name: 'oauth',
183
+ version: '1.0.0',
184
+ description: 'Zero-dependency OAuth 2.0 & Social Login plugin for Webspresso',
185
+
186
+ api: {
187
+ client,
188
+ getProvider: (name) => client.getProvider(name),
189
+ getProviderNames: () => client.getProviderNames(),
190
+ getAuthorizationUrl: (provider, opts) => client.getAuthorizationUrl(provider, opts),
191
+ exchangeCode: (provider, params) => client.exchangeCode(provider, params),
192
+ fetchUserProfile: (provider, tokens) => client.fetchUserProfile(provider, tokens),
193
+ handleCallback: (provider, params) => client.handleCallback(provider, params),
194
+ },
195
+
196
+ register(ctx) {
197
+ // 1. Attach req.oauth and helper methods to every incoming request
198
+ ctx.app.use((req, res, next) => {
199
+ req.oauth = client;
200
+
201
+ req.getOAuthAuthorizationUrl = (providerName, opts = {}) => {
202
+ const providerConfig = client.getProvider(providerName);
203
+ const redirectUri = opts.redirectUri || resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
204
+ return client.getAuthorizationUrl(providerName, {
205
+ redirectUri,
206
+ pkce,
207
+ ...opts,
208
+ });
209
+ };
210
+
211
+ req.handleOAuthCallback = (providerName, params = {}) => {
212
+ const providerConfig = client.getProvider(providerName);
213
+ const redirectUri = params.redirectUri || resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
214
+ return client.handleCallback(providerName, {
215
+ redirectUri,
216
+ ...params,
217
+ });
218
+ };
219
+
220
+ next();
221
+ });
222
+
223
+ // 2. Register named route middleware: middleware: ['oauth']
224
+ if (ctx.middlewares) {
225
+ ctx.middlewares.oauth = (providerName) => async (req, res, next) => {
226
+ req.oauthProvider = providerName;
227
+ next();
228
+ };
229
+ }
230
+
231
+ // 3. Register template helper: fsy.oauthUrl('google', { returnTo: '/profile' })
232
+ ctx.addHelper('oauthUrl', (providerName, helperOpts = {}) => {
233
+ const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
234
+ const base = `${cleanPrefix}/${providerName}`;
235
+ const params = new URLSearchParams();
236
+ if (helperOpts.returnTo) params.set('returnTo', helperOpts.returnTo);
237
+ if (helperOpts.scope) {
238
+ params.set('scope', Array.isArray(helperOpts.scope) ? helperOpts.scope.join(' ') : helperOpts.scope);
239
+ }
240
+ const qs = params.toString();
241
+ return qs ? `${base}?${qs}` : base;
242
+ });
243
+ },
244
+
245
+ onRoutesReady(ctx) {
246
+ if (!mountRoutes) return;
247
+
248
+ const cleanPrefix = pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`;
249
+
250
+ // Initiation Route: GET /auth/:provider
251
+ ctx.addRoute('get', `${cleanPrefix}/:provider`, async (req, res, next) => {
252
+ const providerName = req.params.provider;
253
+ if (!client.getProviderNames().includes(providerName)) {
254
+ return next();
255
+ }
256
+
257
+ try {
258
+ const providerConfig = client.getProvider(providerName);
259
+ const redirectUri = resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
260
+
261
+ const returnTo = req.query.returnTo || req.query.redirect || defaultReturnTo;
262
+ const extraState = req.query.state ? { customState: req.query.state } : {};
263
+ const stateData = {
264
+ returnTo,
265
+ ...extraState,
266
+ };
267
+
268
+ const scopes = req.query.scope ? req.query.scope.split(/[\s,]+/) : undefined;
269
+
270
+ const { url } = client.getAuthorizationUrl(providerName, {
271
+ redirectUri,
272
+ scopes,
273
+ stateData,
274
+ pkce,
275
+ });
276
+
277
+ return res.redirect(url);
278
+ } catch (err) {
279
+ return handleFailure(err, providerName, req, res, next);
280
+ }
281
+ });
282
+
283
+ // Callback Route: GET /auth/:provider/callback
284
+ ctx.addRoute('get', `${cleanPrefix}/:provider/callback`, async (req, res, next) => {
285
+ const providerName = req.params.provider;
286
+ if (!client.getProviderNames().includes(providerName)) {
287
+ return next();
288
+ }
289
+
290
+ // Provider reported error (e.g. user cancelled)
291
+ if (req.query.error) {
292
+ const errorMsg = req.query.error_description || req.query.error;
293
+ const oauthErr = new OAuthError(
294
+ `OAuth error from ${providerName}: ${errorMsg}`,
295
+ req.query.error,
296
+ req.query
297
+ );
298
+ return handleFailure(oauthErr, providerName, req, res, next);
299
+ }
300
+
301
+ const { code, state } = req.query;
302
+ if (!code || !state) {
303
+ const missingErr = new OAuthError(
304
+ 'Missing OAuth authorization code or state parameter',
305
+ 'INVALID_CALLBACK'
306
+ );
307
+ return handleFailure(missingErr, providerName, req, res, next);
308
+ }
309
+
310
+ try {
311
+ const providerConfig = client.getProvider(providerName);
312
+ const redirectUri = resolveRedirectUri(req, providerName, providerConfig, pathPrefix);
313
+
314
+ const result = await client.handleCallback(providerName, {
315
+ code,
316
+ state,
317
+ redirectUri,
318
+ });
319
+
320
+ return handleSuccess(result, req, res, next);
321
+ } catch (err) {
322
+ return handleFailure(err, providerName, req, res, next);
323
+ }
324
+ });
325
+ },
326
+ };
327
+ }
328
+
329
+ oauthPlugin.oauthPlugin = oauthPlugin;
330
+ oauthPlugin.OAuthClient = OAuthClient;
331
+ oauthPlugin.OAuthError = OAuthError;
332
+ oauthPlugin.createOAuthClient = createOAuthClient;
333
+
334
+ module.exports = oauthPlugin;
335
+ module.exports.oauthPlugin = oauthPlugin;
336
+ module.exports.OAuthClient = OAuthClient;
337
+ module.exports.OAuthError = OAuthError;
338
+ module.exports.createOAuthClient = createOAuthClient;
@@ -11,7 +11,8 @@ const { generateOrmCacheAdminComponent } = require('./admin-component');
11
11
  * @param {import('../../core/orm/types').DatabaseInstance} options.db - From createDatabase({ cache: true })
12
12
  */
13
13
  function ormCacheAdminPlugin(options = {}) {
14
- const { db } = options;
14
+ const { db, title, adminTitle } = options;
15
+ const pageTitle = title || adminTitle || 'ORM Cache';
15
16
 
16
17
  if (!db) {
17
18
  throw new Error('orm-cache-admin plugin requires `db` (database instance from createDatabase)');
@@ -41,9 +42,10 @@ function ormCacheAdminPlugin(options = {}) {
41
42
 
42
43
  adminApi.registerModule({
43
44
  id: 'orm-cache',
45
+ title: pageTitle,
44
46
  pages: [{
45
47
  id: 'orm-cache',
46
- title: 'ORM Cache',
48
+ title: pageTitle,
47
49
  path: '/orm-cache',
48
50
  icon: 'database',
49
51
  component: generateOrmCacheAdminComponent(),
@@ -72,6 +72,12 @@ function queuePlugin(options = {}) {
72
72
  pollInterval,
73
73
  jobsDir,
74
74
  autoStart,
75
+ retention: options.retention,
76
+ retentionDays: options.retentionDays,
77
+ retentionStatuses: options.retentionStatuses,
78
+ cleanupIntervalMs: options.cleanupIntervalMs,
79
+ autoCleanup: options.autoCleanup,
80
+ cleanupOnStart: options.cleanupOnStart,
75
81
  });
76
82
 
77
83
  // Expose to ctx & app
@@ -90,6 +96,15 @@ function queuePlugin(options = {}) {
90
96
  this.queue = queueManager;
91
97
  },
92
98
 
99
+ api: {
100
+ getQueueManager: () => queueManager,
101
+ clean: (opts) => queueManager?.clean(opts),
102
+ schedule: (expr, name, data, opts) => queueManager?.schedule(expr, name, data, opts),
103
+ cron: (expr, name, data, opts) => queueManager?.cron(expr, name, data, opts),
104
+ unschedule: (idOrName) => queueManager?.unschedule(idOrName),
105
+ getSchedules: () => queueManager?.getSchedules() || [],
106
+ },
107
+
93
108
  onRoutesReady(ctx) {
94
109
  if (jobsDir && fs.existsSync(jobsDir)) {
95
110
  queueManager.loadJobsFromDirectory(jobsDir);