@yunsoft/yuncms-api 0.1.5 → 0.1.7

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.
@@ -7,21 +7,16 @@ function service(req, name) {
7
7
  const Service = req.context.services[name];
8
8
  return new Service(serviceOptionsFromRequest(req));
9
9
  }
10
-
11
- function authService(req) {
12
- return service(req, 'AuthService');
13
- }
14
-
15
- function authTokensService(req) {
16
- return service(req, 'AuthTokensService');
17
- }
18
-
19
- function apiTokensService(req) {
20
- return service(req, 'ApiTokensService');
21
- }
22
-
23
- function usersService(req) {
24
- return service(req, 'UsersService');
10
+ function authService(req) { return service(req, 'AuthService'); }
11
+ function authTokensService(req) { return service(req, 'AuthTokensService'); }
12
+ function apiTokensService(req) { return service(req, 'ApiTokensService'); }
13
+ function usersService(req) { return service(req, 'UsersService'); }
14
+ function externalAuthService(req, registry) {
15
+ const Service = req.context.services.ExternalAuthService;
16
+ return new Service({
17
+ ...serviceOptionsFromRequest(req),
18
+ stateSecret: registry?.config?.stateSecret,
19
+ });
25
20
  }
26
21
 
27
22
  function requireSessionAuthentication(req) {
@@ -39,6 +34,13 @@ function requireMailer(mailer) {
39
34
  throw error;
40
35
  }
41
36
 
37
+ function requireExternalAuth(registry) {
38
+ if (registry?.config?.enabled) return registry;
39
+ const error = new Error('External authentication is not configured');
40
+ error.code = 'AUTH_PROVIDER_NOT_FOUND';
41
+ throw error;
42
+ }
43
+
42
44
  function actionUrl(config, action, token) {
43
45
  return `${config.auth.publicUrl}/?auth_action=${encodeURIComponent(action)}&token=${encodeURIComponent(token)}`;
44
46
  }
@@ -49,31 +51,126 @@ function noStore(req, res, next) {
49
51
  next();
50
52
  }
51
53
 
52
- export function createAuthRouter({ mailer = null, config = null, logger = console } = {}) {
54
+ async function reportExternalFailure(serviceInstance, providerId, error) {
55
+ try {
56
+ await serviceInstance.loginFailed(providerId, error?.code ?? 'external_auth_failed');
57
+ } catch {}
58
+ }
59
+
60
+ export function createAuthRouter({
61
+ mailer = null,
62
+ config = null,
63
+ logger = console,
64
+ rateLimitStore = null,
65
+ externalAuthRegistry = null,
66
+ } = {}) {
53
67
  const router = express.Router();
68
+ const samlBodyParser = express.urlencoded({ extended: false, limit: '1mb', parameterLimit: 20 });
54
69
  const limits = config?.auth?.rateLimit ?? {};
70
+ const sharedStore = limits.store === 'redis' ? rateLimitStore : null;
71
+ const common = { store: sharedStore, failureMode: limits.failureMode ?? 'best-effort', logger };
55
72
  const loginLimit = createFixedWindowRateLimit({
73
+ ...common,
74
+ scope: 'auth:login',
56
75
  windowMs: limits.loginWindowMs ?? 60_000,
57
76
  max: limits.loginMax ?? 10,
77
+ key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.email ?? req.body?.username ?? '').trim().toLowerCase()}`,
58
78
  });
59
79
  const refreshLimit = createFixedWindowRateLimit({
80
+ ...common,
81
+ scope: 'auth:refresh',
60
82
  windowMs: limits.refreshWindowMs ?? 60_000,
61
83
  max: limits.refreshMax ?? 30,
84
+ key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.refresh_token ?? '').slice(0, 24)}`,
62
85
  });
63
86
  const actionLimit = createFixedWindowRateLimit({
87
+ ...common,
88
+ scope: 'auth:action',
64
89
  windowMs: limits.actionWindowMs ?? 15 * 60_000,
65
90
  max: limits.actionMax ?? 5,
91
+ key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.email ?? req.body?.user ?? req.params?.provider ?? '').trim().toLowerCase()}`,
66
92
  });
67
93
 
68
94
  router.use(noStore);
69
95
 
96
+ router.get('/providers', (req, res) => {
97
+ res.json({ data: externalAuthRegistry?.publicProviders?.() ?? [] });
98
+ });
99
+
70
100
  router.post('/login', loginLimit, async (req, res) => {
71
- const result = await authService(req).login({
72
- email: req.body?.email,
73
- password: req.body?.password,
74
- ip: req.ip ?? null,
75
- userAgent: req.get('user-agent') ?? null,
76
- });
101
+ const result = await authService(req).login({ email: req.body?.email, password: req.body?.password, ip: req.ip ?? null, userAgent: req.get('user-agent') ?? null });
102
+ res.json({ data: result });
103
+ });
104
+
105
+ router.get('/login/:provider', actionLimit, async (req, res) => {
106
+ const registry = requireExternalAuth(externalAuthRegistry);
107
+ const auth = externalAuthService(req, registry);
108
+ try {
109
+ const result = await registry.begin(auth, req.params.provider, {
110
+ redirectTarget: req.query?.redirect ?? '/',
111
+ });
112
+ res.redirect(302, result.url.toString());
113
+ } catch (error) {
114
+ await reportExternalFailure(auth, req.params.provider, error);
115
+ throw error;
116
+ }
117
+ });
118
+
119
+ router.post('/login/:provider', loginLimit, async (req, res) => {
120
+ const registry = requireExternalAuth(externalAuthRegistry);
121
+ const auth = externalAuthService(req, registry);
122
+ try {
123
+ const result = await registry.loginLdap(auth, req.params.provider, {
124
+ username: req.body?.username,
125
+ password: req.body?.password,
126
+ ip: req.ip ?? null,
127
+ userAgent: req.get('user-agent') ?? null,
128
+ });
129
+ res.json({ data: result });
130
+ } catch (error) {
131
+ await reportExternalFailure(auth, req.params.provider, error);
132
+ throw error;
133
+ }
134
+ });
135
+
136
+ router.get('/callback/:provider', actionLimit, async (req, res) => {
137
+ const registry = requireExternalAuth(externalAuthRegistry);
138
+ const auth = externalAuthService(req, registry);
139
+ try {
140
+ const completed = await registry.completeBrowser(auth, req.params.provider, {
141
+ query: req.query,
142
+ ip: req.ip ?? null,
143
+ userAgent: req.get('user-agent') ?? null,
144
+ });
145
+ const target = await registry.createBrowserHandoff(auth, completed.result, completed.redirectTarget);
146
+ res.redirect(303, target);
147
+ } catch (error) {
148
+ await reportExternalFailure(auth, req.params.provider, error);
149
+ throw error;
150
+ }
151
+ });
152
+
153
+ router.post('/callback/:provider', actionLimit, samlBodyParser, async (req, res) => {
154
+ const registry = requireExternalAuth(externalAuthRegistry);
155
+ const auth = externalAuthService(req, registry);
156
+ try {
157
+ const completed = await registry.completeBrowser(auth, req.params.provider, {
158
+ body: req.body,
159
+ ip: req.ip ?? null,
160
+ userAgent: req.get('user-agent') ?? null,
161
+ });
162
+ const target = await registry.createBrowserHandoff(auth, completed.result, completed.redirectTarget);
163
+ res.redirect(303, target);
164
+ } catch (error) {
165
+ await reportExternalFailure(auth, req.params.provider, error);
166
+ throw error;
167
+ }
168
+ });
169
+
170
+ router.post('/exchange', actionLimit, async (req, res) => {
171
+ const registry = requireExternalAuth(externalAuthRegistry);
172
+ const auth = externalAuthService(req, registry);
173
+ const result = await registry.exchangeBrowserHandoff(auth, req.body?.auth_code);
77
174
  res.json({ data: result });
78
175
  });
79
176
 
@@ -81,23 +178,19 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
81
178
  const result = await authService(req).refresh(req.body?.refresh_token);
82
179
  res.json({ data: result });
83
180
  });
84
-
85
181
  router.post('/logout', async (req, res) => {
86
182
  requireSessionAuthentication(req);
87
183
  await authService(req).logout(req.authToken);
88
184
  res.status(204).end();
89
185
  });
90
-
91
186
  router.post('/logout-all', async (req, res) => {
92
187
  requireSessionAuthentication(req);
93
188
  await authService(req).logoutAll();
94
189
  res.status(204).end();
95
190
  });
96
-
97
191
  router.post('/password-reset/request', actionLimit, async (req, res) => {
98
192
  const transport = requireMailer(mailer);
99
193
  const result = await authTokensService(req).requestPasswordReset(req.body?.email);
100
-
101
194
  if (result) {
102
195
  try {
103
196
  const url = actionUrl(config, 'reset', result.token);
@@ -105,24 +198,17 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
105
198
  to: String(req.body.email).trim(),
106
199
  subject: 'Reset your YunCMS password',
107
200
  text: `A password reset was requested for your YunCMS account.\n\nOpen this link to choose a new password:\n${url}\n\nIf you did not request this, you can ignore this message.`,
108
- });
201
+ }, { accountability: req.accountability, requestId: req.id });
109
202
  } catch (error) {
110
- logger.error?.('YunCMS password reset mail delivery failed', {
111
- requestId: req.id,
112
- code: error?.code,
113
- message: error?.message,
114
- });
203
+ logger.error?.('YunCMS password reset mail delivery failed', { requestId: req.id, code: error?.code, message: error?.message });
115
204
  }
116
205
  }
117
-
118
206
  res.status(202).json({ data: { accepted: true } });
119
207
  });
120
-
121
208
  router.post('/password-reset/confirm', actionLimit, async (req, res) => {
122
209
  await authTokensService(req).resetPassword(req.body?.token, req.body?.password);
123
210
  res.status(204).end();
124
211
  });
125
-
126
212
  router.post('/email-verification/request', actionLimit, async (req, res) => {
127
213
  const transport = requireMailer(mailer);
128
214
  if (!req.accountability?.user) {
@@ -130,7 +216,6 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
130
216
  error.code = 'UNAUTHORIZED';
131
217
  throw error;
132
218
  }
133
-
134
219
  const userId = req.body?.user ?? req.accountability.user;
135
220
  const user = await usersService(req).readOne(userId);
136
221
  if (!user) {
@@ -140,29 +225,15 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
140
225
  }
141
226
  const result = await authTokensService(req).createEmailVerification(userId);
142
227
  const url = actionUrl(config, 'verify', result.token);
143
- await transport.send({
144
- to: user.email,
145
- subject: 'Verify your YunCMS email',
146
- text: `Verify your YunCMS email address by opening this link:\n${url}\n\nIf you did not request this, you can ignore this message.`,
147
- });
228
+ await transport.send({ to: user.email, subject: 'Verify your YunCMS email', text: `Verify your YunCMS email address by opening this link:\n${url}\n\nIf you did not request this, you can ignore this message.` }, { accountability: req.accountability, requestId: req.id });
148
229
  res.status(202).json({ data: { accepted: true } });
149
230
  });
150
-
151
231
  router.post('/email-verification/confirm', actionLimit, async (req, res) => {
152
232
  await authTokensService(req).verifyEmail(req.body?.token);
153
233
  res.status(204).end();
154
234
  });
155
-
156
- router.get('/tokens', async (req, res) => {
157
- const data = await apiTokensService(req).readMany();
158
- res.json({ data });
159
- });
160
-
161
- router.post('/tokens', async (req, res) => {
162
- const data = await apiTokensService(req).createOne(req.body ?? {});
163
- res.status(201).json({ data });
164
- });
165
-
235
+ router.get('/tokens', async (req, res) => { res.json({ data: await apiTokensService(req).readMany() }); });
236
+ router.post('/tokens', async (req, res) => { res.status(201).json({ data: await apiTokensService(req).createOne(req.body ?? {}) }); });
166
237
  router.delete('/tokens/:id', async (req, res) => {
167
238
  const deleted = await apiTokensService(req).deleteOne(req.params.id);
168
239
  if (!deleted) {
@@ -172,8 +243,14 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
172
243
  }
173
244
  res.status(204).end();
174
245
  });
175
-
176
246
  return router;
177
247
  }
178
248
 
179
- export { actionUrl, noStore, requireMailer, requireSessionAuthentication };
249
+ export {
250
+ actionUrl,
251
+ noStore,
252
+ reportExternalFailure,
253
+ requireExternalAuth,
254
+ requireMailer,
255
+ requireSessionAuthentication,
256
+ };
@@ -17,7 +17,39 @@ function destructiveRequested(req) {
17
17
  return String(req.query?.destructive ?? '').toLowerCase() === 'true';
18
18
  }
19
19
 
20
+ function stableSchemaEvent(action) {
21
+ if (action.startsWith('schema.relation.')) {
22
+ return action.endsWith('.delete') ? 'schema.relation.delete' : 'schema.relation.create';
23
+ }
24
+ if (action === 'schema.field.alter') return 'schema.field.update';
25
+ return action;
26
+ }
27
+
28
+ async function emitSchemaLifecycle(req, { action, collection = null, itemKey = null, payload = null }) {
29
+ const emitter = req.context.emitter;
30
+ if (!emitter) return;
31
+ const event = stableSchemaEvent(action);
32
+ const context = {
33
+ accountability: req.accountability,
34
+ requestId: req.id,
35
+ collection,
36
+ operation: event,
37
+ };
38
+ await emitter.action(event, {
39
+ key: itemKey,
40
+ collection,
41
+ ...payload,
42
+ }, context);
43
+ await emitter.action('schema.changed', {
44
+ event,
45
+ key: itemKey,
46
+ collection,
47
+ schemaVersion: payload?.after?.schemaVersion ?? payload?.result?.schemaVersion ?? null,
48
+ }, context);
49
+ }
50
+
20
51
  async function auditSchema(req, { action, collection = null, itemKey = null, payload = null }) {
52
+ await emitSchemaLifecycle(req, { action, collection, itemKey, payload });
21
53
  try {
22
54
  await service(req, 'AuditService').record({
23
55
  action,
@@ -88,7 +120,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
88
120
  router.delete('/collections/:collection', async (req, res) => {
89
121
  const collections = service(req, 'CollectionsService');
90
122
  const before = await collections.readOne(req.params.collection);
91
- await collections.deleteOne(req.params.collection, {
123
+ const result = await collections.deleteOne(req.params.collection, {
92
124
  destructive: destructiveRequested(req),
93
125
  });
94
126
  clearSchemaCache();
@@ -96,7 +128,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
96
128
  action: 'schema.collection.delete',
97
129
  collection: req.params.collection,
98
130
  itemKey: req.params.collection,
99
- payload: { before },
131
+ payload: { before, result },
100
132
  });
101
133
  res.status(204).end();
102
134
  });
@@ -172,7 +204,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
172
204
  router.delete('/collections/:collection/fields/:field', async (req, res) => {
173
205
  const fields = service(req, 'FieldsService');
174
206
  const before = await fields.readOne(req.params.collection, req.params.field);
175
- await fields.deleteOne(
207
+ const result = await fields.deleteOne(
176
208
  req.params.collection,
177
209
  req.params.field,
178
210
  { destructive: destructiveRequested(req) },
@@ -182,7 +214,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
182
214
  action: 'schema.field.delete',
183
215
  collection: req.params.collection,
184
216
  itemKey: req.params.field,
185
- payload: { before },
217
+ payload: { before, result },
186
218
  });
187
219
  res.status(204).end();
188
220
  });
@@ -225,7 +257,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
225
257
  router.delete('/relations/m2o/:manyCollection/:manyField', async (req, res) => {
226
258
  const relations = service(req, 'RelationsService');
227
259
  const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
228
- await relations.deleteM2O(
260
+ const result = await relations.deleteM2O(
229
261
  req.params.manyCollection,
230
262
  req.params.manyField,
231
263
  );
@@ -234,7 +266,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
234
266
  action: 'schema.relation.delete',
235
267
  collection: req.params.manyCollection,
236
268
  itemKey: req.params.manyField,
237
- payload: { before },
269
+ payload: { before, result },
238
270
  });
239
271
  res.status(204).end();
240
272
  });
@@ -258,7 +290,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
258
290
  router.delete('/relations/o2o/:manyCollection/:manyField', async (req, res) => {
259
291
  const relations = service(req, 'RelationsService');
260
292
  const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
261
- await deleteO2ORelation({
293
+ const result = await deleteO2ORelation({
262
294
  database: req.context.database,
263
295
  accountability: req.accountability,
264
296
  manyCollection: req.params.manyCollection,
@@ -269,7 +301,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
269
301
  action: 'schema.relation.o2o.delete',
270
302
  collection: req.params.manyCollection,
271
303
  itemKey: req.params.manyField,
272
- payload: { before },
304
+ payload: { before, result },
273
305
  });
274
306
  res.status(204).end();
275
307
  });
@@ -310,4 +342,9 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
310
342
  return router;
311
343
  }
312
344
 
313
- export { auditSchema, destructiveRequested };
345
+ export {
346
+ auditSchema,
347
+ destructiveRequested,
348
+ emitSchemaLifecycle,
349
+ stableSchemaEvent,
350
+ };
package/src/server.js CHANGED
@@ -12,25 +12,27 @@ import {
12
12
  loadEnvFileIfPresent,
13
13
  LocalStorageDriver,
14
14
  MemoryCacheStore,
15
+ RedisCacheStore,
16
+ RedisClient,
17
+ RedisFixedWindowStore,
15
18
  S3StorageDriver,
16
19
  SchemaCache,
17
20
  SmtpMailer,
18
21
  } from '@yunsoft/yuncms-core';
19
22
  import { createApp } from './app.js';
20
23
  import { INTERNAL_AUDIT_EVENTS } from './audit-events.js';
24
+ import { loadExternalAuthConfig } from './external-auth/config.js';
25
+ import { ExternalAuthProviderRegistry } from './external-auth/providers.js';
21
26
  import { loadExtensionRuntime } from './extensions/runtime.js';
27
+ import { createMcpRouter } from './mcp.js';
22
28
 
23
29
  loadEnvFileIfPresent();
24
- await assertMaintenanceStartupAllowed({
25
- cwd: process.cwd(),
26
- env: process.env,
27
- });
30
+ await assertMaintenanceStartupAllowed({ cwd: process.cwd(), env: process.env });
28
31
  const config = loadConfig();
32
+ const externalAuthConfig = loadExternalAuthConfig(process.env);
29
33
  const logger = createJsonLogger({ level: config.logging.level });
30
34
  const pool = createDatabasePool(config.database);
31
- const storageDrivers = {
32
- local: new LocalStorageDriver({ root: config.storage.localRoot }),
33
- };
35
+ const storageDrivers = { local: new LocalStorageDriver({ root: config.storage.localRoot }) };
34
36
  if (config.storage.s3.bucket) {
35
37
  storageDrivers.s3 = new S3StorageDriver({
36
38
  bucket: config.storage.s3.bucket,
@@ -42,46 +44,48 @@ if (config.storage.s3.bucket) {
42
44
  });
43
45
  }
44
46
  const storage = createStorageRegistry(storageDrivers);
45
- const permissionCache = config.cache.enabled
46
- ? new MemoryCacheStore({
47
- ttlMs: config.cache.ttlMs,
48
- maxEntries: config.cache.maxEntries,
49
- })
47
+
48
+ const redisNeeded = config.cache.store === 'redis'
49
+ || config.server.rateLimit.store === 'redis'
50
+ || config.auth.rateLimit.store === 'redis';
51
+ const redisClient = redisNeeded ? new RedisClient({
52
+ url: config.redis.url,
53
+ connectTimeoutMs: config.redis.connectTimeoutMs,
54
+ commandTimeoutMs: config.redis.commandTimeoutMs,
55
+ logger,
56
+ }) : null;
57
+ const permissionCache = !config.cache.enabled ? null
58
+ : config.cache.store === 'redis'
59
+ ? new RedisCacheStore({ client: redisClient, prefix: config.redis.prefix, namespace: 'permission', ttlMs: config.cache.ttlMs, logger })
60
+ : new MemoryCacheStore({ ttlMs: config.cache.ttlMs, maxEntries: config.cache.maxEntries });
61
+ const rateLimitStore = redisClient
62
+ ? new RedisFixedWindowStore({ client: redisClient, prefix: config.redis.prefix, logger })
50
63
  : null;
51
64
 
52
- const hasAnyMailConfig = Boolean(
53
- config.mail.host || config.mail.from || config.mail.user || config.mail.password,
54
- );
65
+ const hasAnyMailConfig = Boolean(config.mail.host || config.mail.from || config.mail.user || config.mail.password);
55
66
  if (hasAnyMailConfig && (!config.mail.host || !config.mail.from)) {
56
67
  throw new Error('SMTP_HOST and SMTP_FROM are both required when SMTP delivery is configured');
57
68
  }
58
- const mailer = config.mail.host
59
- ? new SmtpMailer({
60
- host: config.mail.host,
61
- port: config.mail.port,
62
- secure: config.mail.secure,
63
- user: config.mail.user,
64
- password: config.mail.password,
65
- from: config.mail.from,
66
- })
67
- : null;
69
+ const mailer = config.mail.host ? new SmtpMailer({
70
+ host: config.mail.host,
71
+ port: config.mail.port,
72
+ secure: config.mail.secure,
73
+ user: config.mail.user,
74
+ password: config.mail.password,
75
+ from: config.mail.from,
76
+ }) : null;
68
77
 
69
78
  let server = null;
79
+ let extensionRuntime = null;
70
80
  let shuttingDown = false;
71
81
 
72
82
  function registerInternalAudit({ emitter, services }) {
73
83
  const AuditService = services.AuditService;
74
84
  const systemAccountability = createSystemAccountability();
75
-
76
85
  for (const event of INTERNAL_AUDIT_EVENTS) {
77
86
  emitter.registerAction(event, async (payload, context) => {
78
87
  try {
79
- const audit = new AuditService({
80
- accountability: systemAccountability,
81
- database: pool,
82
- logger,
83
- requestId: context.requestId ?? null,
84
- });
88
+ const audit = new AuditService({ accountability: systemAccountability, database: pool, logger, requestId: context.requestId ?? null });
85
89
  await audit.record({
86
90
  user: context.accountability?.user ?? null,
87
91
  action: event,
@@ -91,27 +95,40 @@ function registerInternalAudit({ emitter, services }) {
91
95
  payload,
92
96
  });
93
97
  } catch (error) {
94
- logger.error('YunCMS audit write failed after committed mutation', {
95
- event,
96
- requestId: context.requestId ?? null,
97
- code: error?.code,
98
- error,
99
- });
98
+ logger.error('YunCMS audit write failed after committed mutation', { event, requestId: context.requestId ?? null, code: error?.code, error });
100
99
  }
101
- });
100
+ }, { extensionId: 'core.audit', priority: 1000 });
102
101
  }
103
102
  }
104
103
 
105
104
  async function start() {
106
105
  await assertDatabaseCompatible(pool);
106
+ if (redisClient) {
107
+ try {
108
+ await redisClient.connect();
109
+ if (!await redisClient.ping()) throw new Error('Unexpected Redis ping result');
110
+ logger.info('YunCMS shared Redis connected', { prefix: config.redis.prefix });
111
+ } catch (error) {
112
+ if (config.redis.required) throw error;
113
+ logger.warn('YunCMS Redis unavailable at startup; safe fallbacks remain active', { code: error?.code ?? null });
114
+ }
115
+ }
107
116
 
108
117
  const serviceRegistry = createCoreServiceRegistry();
109
118
  const services = serviceRegistry.toObject();
110
119
  const schemaCache = new SchemaCache();
111
- const emitter = new HookEmitter();
120
+ const emitter = new HookEmitter({ logger });
121
+ const externalAuthRegistry = new ExternalAuthProviderRegistry({
122
+ config: externalAuthConfig,
123
+ publicUrl: config.auth.publicUrl,
124
+ database: pool,
125
+ logger,
126
+ });
127
+ const mcpRouter = createMcpRouter({ config, logger });
128
+ mailer?.setEmitter(emitter);
112
129
  registerInternalAudit({ emitter, services });
113
130
 
114
- const extensionRuntime = await loadExtensionRuntime({
131
+ extensionRuntime = await loadExtensionRuntime({
115
132
  services,
116
133
  database: pool,
117
134
  schemaCache,
@@ -131,38 +148,46 @@ async function start() {
131
148
  emitter,
132
149
  storage,
133
150
  mailer,
151
+ rateLimitStore,
152
+ redisClient,
153
+ externalAuthRegistry,
134
154
  endpointExtensions: extensionRuntime.endpointExtensions,
155
+ mcpRouter,
135
156
  });
136
157
 
137
158
  await extensionRuntime.init('app.beforeStart');
138
159
  server = await new Promise((resolve, reject) => {
139
160
  const listeningServer = app.listen(config.server.port, config.server.host, () => {
140
- logger.info('YunCMS API listening', {
141
- host: config.server.host,
142
- port: config.server.port,
143
- });
161
+ logger.info('YunCMS API listening', { host: config.server.host, port: config.server.port });
144
162
  resolve(listeningServer);
145
163
  });
146
164
  listeningServer.once('error', reject);
147
165
  });
148
166
  await extensionRuntime.init('app.afterStart');
167
+ extensionRuntime.startSchedules();
149
168
  }
150
169
 
151
170
  async function shutdown(signal) {
152
171
  if (shuttingDown) return;
153
172
  shuttingDown = true;
154
173
  logger.info('YunCMS API shutting down', { signal });
155
-
156
174
  const forceExit = setTimeout(() => {
157
175
  logger.error('Graceful shutdown timed out', { signal });
158
176
  process.exit(1);
159
177
  }, 10_000);
160
178
  forceExit.unref();
161
179
 
162
- if (server) {
163
- await new Promise((resolve) => server.close(resolve));
180
+ const schedulesStopped = await extensionRuntime?.stopSchedules({ timeoutMs: 5_000 }).catch(() => false);
181
+ if (schedulesStopped === false) {
182
+ logger.warn('YunCMS extension jobs exceeded graceful shutdown budget', { signal });
164
183
  }
184
+ await extensionRuntime?.init('app.beforeStop').catch((error) => {
185
+ logger.error('YunCMS extension beforeStop hook failed', { code: error?.code ?? null });
186
+ });
187
+ if (server) await new Promise((resolve) => server.close(resolve));
188
+ await redisClient?.close().catch(() => {});
165
189
  await closeDatabasePool(pool);
190
+ await extensionRuntime?.init('app.afterStop').catch(() => {});
166
191
  clearTimeout(forceExit);
167
192
  logger.info('YunCMS API shutdown complete', { signal });
168
193
  }
@@ -179,10 +204,8 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
179
204
  }
180
205
 
181
206
  start().catch(async (error) => {
182
- logger.error('YunCMS API failed to start', {
183
- code: error?.code,
184
- error,
185
- });
207
+ logger.error('YunCMS API failed to start', { code: error?.code, error });
208
+ await redisClient?.close().catch(() => {});
186
209
  await closeDatabasePool(pool).catch(() => {});
187
210
  process.exit(1);
188
211
  });