webspresso 0.0.98 → 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 (56) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/AUTH.md +169 -0
  4. package/.agents/ORM.md +88 -4
  5. package/.agents/PLUGINS.md +73 -5
  6. package/.agents/QUEUE.md +84 -0
  7. package/.agents/ROUTING.md +127 -7
  8. package/AGENTS.md +1 -0
  9. package/core/auth/index.js +60 -0
  10. package/core/auth/manager.js +134 -0
  11. package/core/auth/middleware.js +88 -0
  12. package/core/auth/oauth.js +517 -0
  13. package/core/auth/qrcode.js +499 -0
  14. package/core/auth/totp.js +504 -0
  15. package/core/orm/eager-loader.js +144 -10
  16. package/core/orm/index.js +8 -0
  17. package/core/orm/model.js +4 -0
  18. package/core/orm/query-builder.js +90 -4
  19. package/core/orm/repository.js +44 -3
  20. package/core/orm/search.js +213 -0
  21. package/core/queue/cron.js +331 -0
  22. package/core/queue/index.js +8 -1
  23. package/core/queue/manager.js +82 -1
  24. package/core/queue/scheduler.js +359 -0
  25. package/core/sse/index.js +19 -0
  26. package/core/sse/stream.js +430 -0
  27. package/index.d.ts +528 -0
  28. package/index.js +44 -0
  29. package/package.json +1 -1
  30. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  31. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  32. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  34. package/plugins/admin-panel/field-renderers/index.js +2 -0
  35. package/plugins/api-key/crypto.js +101 -0
  36. package/plugins/api-key/index.js +288 -0
  37. package/plugins/api-key/middleware.js +461 -0
  38. package/plugins/api-key/migration-template.js +48 -0
  39. package/plugins/api-key/model.js +50 -0
  40. package/plugins/api-key/scopes.js +125 -0
  41. package/plugins/audit-log/admin-component.js +487 -70
  42. package/plugins/audit-log/api-handlers.js +114 -0
  43. package/plugins/audit-log/index.js +24 -0
  44. package/plugins/file-manager/admin-component.js +20 -4
  45. package/plugins/file-manager/index.js +21 -4
  46. package/plugins/index.js +6 -0
  47. package/plugins/oauth/index.js +338 -0
  48. package/plugins/queue/index.js +4 -0
  49. package/src/api/api-loader.js +1 -0
  50. package/src/discovery/file-route-parser.js +44 -1
  51. package/src/discovery/scan-routes.js +2 -2
  52. package/src/file-router.js +137 -100
  53. package/src/helpers.js +9 -0
  54. package/src/routing/cascading.js +378 -0
  55. package/src/routing/middleware-resolver.js +92 -0
  56. package/src/server.js +6 -0
@@ -21,12 +21,34 @@ function createAuditLogHandlers(options) {
21
21
  if (query.action) {
22
22
  qb.where('action', String(query.action));
23
23
  }
24
+ const recordId = query.recordId || query.resource_id || query.resourceId;
25
+ if (recordId !== undefined && recordId !== null && String(recordId).trim() !== '') {
26
+ qb.where('resource_id', String(recordId));
27
+ }
28
+ const actorId = query.actorId || query.actor_id;
29
+ if (actorId !== undefined && actorId !== null && String(actorId).trim() !== '') {
30
+ qb.where('actor_id', Number(actorId));
31
+ }
32
+ const actorEmail = query.actorEmail || query.actor_email;
33
+ if (actorEmail) {
34
+ qb.where('actor_email', 'like', `%${String(actorEmail).trim()}%`);
35
+ }
24
36
  if (query.from) {
25
37
  qb.where('created_at', '>=', String(query.from));
26
38
  }
27
39
  if (query.to) {
28
40
  qb.where('created_at', '<=', String(query.to));
29
41
  }
42
+ const searchTerm = query.search || query.q;
43
+ if (searchTerm && String(searchTerm).trim() !== '') {
44
+ const term = `%${String(searchTerm).trim()}%`;
45
+ qb.where((sub) => {
46
+ sub.where('actor_email', 'like', term)
47
+ .orWhere('path', 'like', term)
48
+ .orWhere('resource_id', 'like', term)
49
+ .orWhere('resource_model', 'like', term);
50
+ });
51
+ }
30
52
  }
31
53
 
32
54
  async function listHandler(req, res) {
@@ -53,6 +75,79 @@ function createAuditLogHandlers(options) {
53
75
  }
54
76
  }
55
77
 
78
+ /**
79
+ * Dedicated Record Timeline Handler
80
+ * GET /audit-logs/timeline/:model/:id or GET /audit-logs/timeline?model=Post&id=1
81
+ */
82
+ async function timelineHandler(req, res) {
83
+ try {
84
+ const model = req.params?.model || req.query?.model;
85
+ const recordId = req.params?.id || req.query?.id || req.query?.recordId || req.query?.resource_id;
86
+
87
+ if (!model || recordId === undefined) {
88
+ return res.status(400).json({ error: 'model and id are required to fetch timeline' });
89
+ }
90
+
91
+ const rows = await knex(tableName)
92
+ .where({
93
+ resource_model: String(model),
94
+ resource_id: String(recordId),
95
+ })
96
+ .orderBy('created_at', 'asc');
97
+
98
+ const totalChanges = rows.length;
99
+ const createdEvent = rows.find((r) => r.action === 'create') || null;
100
+ const lastModifiedEvent = rows.length > 0 ? rows[rows.length - 1] : null;
101
+
102
+ const events = rows.map((r, index) => {
103
+ let parsedMetadata = r.metadata;
104
+ if (typeof parsedMetadata === 'string') {
105
+ try {
106
+ parsedMetadata = JSON.parse(parsedMetadata);
107
+ } catch {}
108
+ }
109
+
110
+ const changedFields = Array.isArray(parsedMetadata?.changedFields)
111
+ ? parsedMetadata.changedFields
112
+ : [];
113
+
114
+ return {
115
+ id: r.id,
116
+ action: r.action,
117
+ createdAt: r.created_at,
118
+ actor: {
119
+ id: r.actor_id,
120
+ email: r.actor_email,
121
+ },
122
+ resourceModel: r.resource_model,
123
+ resourceId: r.resource_id,
124
+ httpMethod: r.http_method,
125
+ path: r.path,
126
+ ip: r.ip,
127
+ userAgent: r.user_agent,
128
+ changedFields,
129
+ metadata: parsedMetadata,
130
+ index: index + 1,
131
+ };
132
+ });
133
+
134
+ res.json({
135
+ model: String(model),
136
+ recordId: String(recordId),
137
+ summary: {
138
+ totalChanges,
139
+ createdAt: createdEvent?.created_at || (events[0] ? events[0].createdAt : null),
140
+ createdBy: createdEvent?.actor_email || (events[0] ? events[0].actor?.email : null),
141
+ lastModifiedAt: lastModifiedEvent?.created_at || null,
142
+ lastModifiedBy: lastModifiedEvent?.actor_email || null,
143
+ },
144
+ timeline: events,
145
+ });
146
+ } catch (err) {
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ }
150
+
56
151
  async function purgeHandler(req, res) {
57
152
  try {
58
153
  const body = req.body || {};
@@ -89,8 +184,10 @@ function createAuditLogHandlers(options) {
89
184
  * @param {Object} [filters]
90
185
  * @param {string} [filters.model]
91
186
  * @param {string} [filters.action]
187
+ * @param {string} [filters.recordId]
92
188
  * @param {string} [filters.from]
93
189
  * @param {string} [filters.to]
190
+ * @param {string} [filters.search]
94
191
  * @param {number} [filters.limit=100]
95
192
  * @param {number} [filters.offset=0]
96
193
  */
@@ -105,10 +202,27 @@ function createAuditLogHandlers(options) {
105
202
  .limit(limit);
106
203
  }
107
204
 
205
+ /**
206
+ * Programmatic query for single record timeline
207
+ * @param {string} model
208
+ * @param {string|number} recordId
209
+ */
210
+ async function queryTimeline(model, recordId) {
211
+ if (!model || recordId === undefined) return [];
212
+ return knex(tableName)
213
+ .where({
214
+ resource_model: String(model),
215
+ resource_id: String(recordId),
216
+ })
217
+ .orderBy('created_at', 'asc');
218
+ }
219
+
108
220
  return {
109
221
  listHandler,
222
+ timelineHandler,
110
223
  purgeHandler,
111
224
  queryLogs,
225
+ queryTimeline,
112
226
  };
113
227
  }
114
228
 
@@ -36,6 +36,7 @@ function auditLogPlugin(options = {}) {
36
36
  tableName = 'audit_logs',
37
37
  includeDefaultPage = true,
38
38
  apiPrefix = '/audit-logs',
39
+ registerRecordAction = true,
39
40
  } = options;
40
41
 
41
42
  const pageTitle = title || adminTitle || customPageTitle || 'Audit log';
@@ -152,6 +153,8 @@ function auditLogPlugin(options = {}) {
152
153
  return { ...retentionConfig };
153
154
  },
154
155
  queryLogs: (filters) => handlers.queryLogs(filters),
156
+ queryTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
157
+ getRecordTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
155
158
  getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
156
159
  },
157
160
 
@@ -211,10 +214,31 @@ function auditLogPlugin(options = {}) {
211
214
  prefix: apiPrefix,
212
215
  routes: [
213
216
  { method: 'get', path: '', handler: handlers.listHandler },
217
+ { method: 'get', path: '/timeline', handler: handlers.timelineHandler },
218
+ { method: 'get', path: '/timeline/:model/:id', handler: handlers.timelineHandler },
214
219
  { method: 'post', path: '/purge', handler: handlers.purgeHandler },
215
220
  ],
216
221
  },
217
222
  });
223
+
224
+ if (registerRecordAction && typeof adminApi.registerAction === 'function') {
225
+ adminApi.registerAction('audit-timeline', {
226
+ id: 'audit-timeline',
227
+ label: 'Zaman Tüneli',
228
+ icon: 'clock',
229
+ color: 'blue',
230
+ models: '*',
231
+ handler: async (record, model) => {
232
+ const primaryKey = model?.primaryKey || 'id';
233
+ const id = record ? record[primaryKey] : null;
234
+ const modelName = model?.name || (typeof model === 'string' ? model : '');
235
+ return {
236
+ redirect: `/audit-log?model=${encodeURIComponent(modelName)}&recordId=${encodeURIComponent(id)}`,
237
+ message: `Zaman tüneli: ${modelName} #${id}`,
238
+ };
239
+ },
240
+ });
241
+ }
218
242
  },
219
243
  };
220
244
  }
@@ -569,30 +569,46 @@ function generateFileManagerComponent(options = {}) {
569
569
  var onSelect = opts.onSelect;
570
570
  var accept = opts.accept || '';
571
571
 
572
+ var existing = document.getElementById('file-manager-picker-modal-root');
573
+ if (existing && existing.parentNode) {
574
+ m.mount(existing, null);
575
+ existing.parentNode.removeChild(existing);
576
+ }
577
+
572
578
  var modalContainer = document.createElement('div');
573
579
  modalContainer.id = 'file-manager-picker-modal-root';
574
580
  document.body.appendChild(modalContainer);
575
581
 
576
582
  function cleanup() {
583
+ document.removeEventListener('keydown', onKeyDown);
577
584
  if (modalContainer && modalContainer.parentNode) {
578
585
  m.mount(modalContainer, null);
579
586
  modalContainer.parentNode.removeChild(modalContainer);
580
587
  }
581
588
  }
582
589
 
590
+ function onKeyDown(e) {
591
+ if (e.key === 'Escape') cleanup();
592
+ }
593
+ document.addEventListener('keydown', onKeyDown);
594
+
583
595
  var PickerModalComponent = {
584
596
  view: function() {
585
- return m('div.fixed.inset-0.bg-black/60.flex.items-center.justify-center.z-[9999].p-4.backdrop-blur-sm', [
597
+ return m('div.fixed.inset-0.bg-black/60.flex.items-center.justify-center.z-[9999].p-4.backdrop-blur-sm', {
598
+ onclick: function(e) {
599
+ if (e.target === e.currentTarget) cleanup();
600
+ }
601
+ }, [
586
602
  m('div.bg-white.dark:bg-slate-900.rounded-2xl.shadow-2xl.max-w-4xl.w-full.max-h-[90vh].flex.flex-col.overflow-hidden.border.border-gray-200.dark:border-slate-700', [
587
603
  m('div.flex.items-center.justify-between.p-4.border-b.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-800', [
588
- m('h2.text-base.font-bold.text-gray-900.dark:text-slate-100', opts.title || '📁 Ortam Kütüphanesinden Dosya Seç'),
604
+ m('h2.text-base.font-bold.text-gray-900.dark:text-slate-100', opts.title || 'Choose from Media Library'),
589
605
  m('button.text-gray-400.hover:text-gray-600.dark:hover:text-slate-200.text-xl', {
590
606
  onclick: cleanup
591
607
  }, '✕')
592
608
  ]),
593
609
  m('div.p-4.overflow-y-auto.flex-1', [
594
- m(FileManagerView(true, function(selectedUrl) {
595
- if (onSelect) onSelect(selectedUrl);
610
+ m(FileManagerView(true, function(selectedUrl, item) {
611
+ if (onSelect) onSelect(selectedUrl, item);
596
612
  cleanup();
597
613
  }, accept))
598
614
  ])
@@ -92,6 +92,26 @@ function fileManagerPlugin(options = {}) {
92
92
  return;
93
93
  }
94
94
 
95
+ const componentCode = generateFileManagerComponent({
96
+ apiPrefix,
97
+ publicBasePath,
98
+ });
99
+
100
+ // Always configure fileManager in admin settings and register client component
101
+ // so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
102
+ if (typeof adminApi.getRegistry === 'function') {
103
+ const registry = adminApi.getRegistry();
104
+ registry.configure({
105
+ fileManager: {
106
+ enabled: true,
107
+ apiPrefix,
108
+ publicBasePath,
109
+ pageTitle,
110
+ },
111
+ });
112
+ registry.registerClientComponent('files', componentCode);
113
+ }
114
+
95
115
  adminApi.registerModule({
96
116
  id: 'files',
97
117
  pages: includePage
@@ -100,10 +120,7 @@ function fileManagerPlugin(options = {}) {
100
120
  title: pageTitle,
101
121
  path: '/files',
102
122
  icon: 'folder',
103
- component: generateFileManagerComponent({
104
- apiPrefix,
105
- publicBasePath,
106
- }),
123
+ component: componentCode,
107
124
  }]
108
125
  : [],
109
126
 
package/plugins/index.js CHANGED
@@ -53,6 +53,12 @@ module.exports = {
53
53
  get xlsx() { return require('./xlsx').xlsx; },
54
54
  get csvPlugin() { return require('./csv').csvPlugin; },
55
55
  get csv() { return require('./csv').csv; },
56
+ get oauthPlugin() { return require('./oauth'); },
57
+ get OAuthClient() { return require('./oauth').OAuthClient; },
58
+ get OAuthError() { return require('./oauth').OAuthError; },
59
+ get createOAuthClient() { return require('./oauth').createOAuthClient; },
60
+ get apiKeyPlugin() { return require('./api-key').apiKeyPlugin; },
61
+ get createApiKeyMiddleware() { return require('./api-key').createApiKeyMiddleware; },
56
62
  };
57
63
 
58
64
 
@@ -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;
@@ -99,6 +99,10 @@ function queuePlugin(options = {}) {
99
99
  api: {
100
100
  getQueueManager: () => queueManager,
101
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() || [],
102
106
  },
103
107
 
104
108
  onRoutesReady(ctx) {
@@ -198,6 +198,7 @@ function createApiHandler(descriptor, context) {
198
198
  config: appOptions,
199
199
  logger: console,
200
200
  db,
201
+ sse: (sourceOrOptions, maybeOptions) => res.sse(sourceOrOptions, maybeOptions),
201
202
  };
202
203
 
203
204
  req.ctx = apiCtx;