@yunsoft/yuncms-api 0.1.3 → 0.1.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-api",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Express API runtime and bundled Studio server for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,13 @@
32
32
  "test": "node --test"
33
33
  },
34
34
  "dependencies": {
35
- "@yunsoft/yuncms-core": "0.1.3",
36
- "express": "5.2.1"
35
+ "@modelcontextprotocol/node": "2.0.0",
36
+ "@modelcontextprotocol/server": "2.0.0",
37
+ "@node-saml/node-saml": "5.1.0",
38
+ "@yunsoft/yuncms-core": "0.1.6",
39
+ "express": "5.2.1",
40
+ "ldapts": "9.0.0",
41
+ "openid-client": "6.8.7",
42
+ "zod": "4.4.3"
37
43
  }
38
44
  }
package/src/app.js CHANGED
@@ -7,6 +7,8 @@ import {
7
7
 
8
8
  import { createAuthenticationMiddleware } from './authentication.js';
9
9
  import { apiErrorHandler } from './error-response.js';
10
+ import { createPressureLimit } from './pressure-limit.js';
11
+ import { createFixedWindowRateLimit } from './rate-limit.js';
10
12
  import { createAuditRouter } from './routes/audit.js';
11
13
  import { createAuthRouter } from './routes/auth.js';
12
14
  import { createFilesRouter } from './routes/files.js';
@@ -20,6 +22,12 @@ import { createUsersRouter } from './routes/users.js';
20
22
  import { createStudioMiddleware } from './studio.js';
21
23
 
22
24
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
25
+ const CONTENT_SECURITY_POLICY = [
26
+ "default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
27
+ "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://yunsoft.com",
28
+ "font-src 'self' data:", "connect-src 'self'", "media-src 'self' blob:", "frame-src 'self' blob:",
29
+ "worker-src 'self' blob:", "form-action 'self'",
30
+ ].join('; ');
23
31
 
24
32
  function securityHeaders(req, res, next) {
25
33
  res.set('x-content-type-options', 'nosniff');
@@ -27,6 +35,10 @@ function securityHeaders(req, res, next) {
27
35
  res.set('referrer-policy', 'no-referrer');
28
36
  res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
29
37
  res.set('cross-origin-resource-policy', 'same-origin');
38
+ res.set('cross-origin-opener-policy', 'same-origin');
39
+ res.set('x-dns-prefetch-control', 'off');
40
+ res.set('content-security-policy', CONTENT_SECURITY_POLICY);
41
+ if (req.secure === true) res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
30
42
  next();
31
43
  }
32
44
 
@@ -34,14 +46,12 @@ function studioCors(config) {
34
46
  return (req, res, next) => {
35
47
  const origin = req.get('origin');
36
48
  const allowedOrigin = config?.server?.studioOrigin;
37
-
38
49
  if (origin && allowedOrigin && origin === allowedOrigin) {
39
50
  res.set('access-control-allow-origin', origin);
40
51
  res.set('vary', 'Origin');
41
- res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype');
52
+ res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype, mcp-protocol-version, mcp-method, mcp-name');
42
53
  res.set('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
43
54
  }
44
-
45
55
  if (req.method === 'OPTIONS') return res.sendStatus(204);
46
56
  return next();
47
57
  };
@@ -54,16 +64,70 @@ function requestIdentity(req, res, next) {
54
64
  next();
55
65
  }
56
66
 
67
+ function createApiRateLimit(config, rateLimitStore = null, logger = console) {
68
+ const limits = config?.server?.rateLimit;
69
+ if (!limits?.enabled) return null;
70
+ return createFixedWindowRateLimit({
71
+ windowMs: limits.windowMs,
72
+ max: limits.max,
73
+ maxBuckets: limits.maxBuckets,
74
+ store: limits.store === 'redis' ? rateLimitStore : null,
75
+ scope: 'api',
76
+ failureMode: limits.failureMode ?? 'best-effort',
77
+ logger,
78
+ });
79
+ }
80
+
81
+ function requestEventMetadata(req, res, startedAt) {
82
+ return {
83
+ requestId: req.id ?? null,
84
+ method: req.method,
85
+ route: req.route?.path ?? req.path,
86
+ status: res.statusCode,
87
+ durationMs: Math.max(0, Date.now() - startedAt),
88
+ accountability: req.accountability ? {
89
+ user: req.accountability.user ?? null,
90
+ role: req.accountability.role ?? null,
91
+ admin: req.accountability.admin === true,
92
+ system: req.accountability.system === true,
93
+ } : null,
94
+ ip: req.ip ?? null,
95
+ };
96
+ }
97
+
98
+ function createRequestEvents(emitter) {
99
+ if (!emitter) return null;
100
+ return (req, res, next) => {
101
+ const startedAt = Date.now();
102
+ emitter.action('request.received', requestEventMetadata(req, res, startedAt), {
103
+ accountability: req.accountability,
104
+ requestId: req.id,
105
+ }).catch(() => {});
106
+ res.once('finish', () => {
107
+ emitter.action('request.completed', requestEventMetadata(req, res, startedAt), {
108
+ accountability: req.accountability,
109
+ requestId: req.id,
110
+ }).catch(() => {});
111
+ });
112
+ next();
113
+ };
114
+ }
115
+
57
116
  export function createApp({
58
117
  pool,
59
118
  config,
60
119
  logger = console,
61
120
  serviceRegistry = createCoreServiceRegistry(),
62
121
  schemaCache = null,
122
+ permissionCache = null,
63
123
  emitter = null,
64
124
  storage = null,
65
125
  mailer = null,
126
+ rateLimitStore = null,
127
+ redisClient = null,
128
+ externalAuthRegistry = null,
66
129
  endpointExtensions = [],
130
+ mcpRouter = null,
67
131
  studioRoot = undefined,
68
132
  }) {
69
133
  if (!pool) throw new Error('Database pool is required');
@@ -84,33 +148,48 @@ export function createApp({
84
148
  });
85
149
 
86
150
  app.get('/ready', async (req, res) => {
151
+ const failures = [];
87
152
  try {
88
153
  const ready = await pingDatabase(pool);
89
- if (!ready) throw new Error('Database ping returned an unexpected result');
90
- res.json({ status: 'ready', request_id: req.id });
91
- } catch (error) {
92
- logger.warn?.('YunCMS readiness check failed', { requestId: req.id, error });
93
- res.status(503).json({
94
- status: 'not_ready',
95
- request_id: req.id,
96
- errors: [{ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' }],
97
- });
154
+ if (!ready) failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
155
+ } catch {
156
+ failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
157
+ }
158
+ if (config.redis?.required && redisClient) {
159
+ try {
160
+ if (!await redisClient.ping()) throw new Error('Unexpected Redis ping result');
161
+ } catch {
162
+ failures.push({ code: 'SHARED_STATE_UNAVAILABLE', message: 'Required shared state is not ready' });
163
+ }
98
164
  }
165
+ if (failures.length) {
166
+ logger.warn?.('YunCMS readiness check failed', { requestId: req.id, failures: failures.map((entry) => entry.code) });
167
+ return res.status(503).json({ status: 'not_ready', request_id: req.id, errors: failures });
168
+ }
169
+ return res.json({
170
+ status: 'ready',
171
+ request_id: req.id,
172
+ shared_state: {
173
+ cache: config.cache?.store ?? 'memory',
174
+ api_rate_limit: config.server?.rateLimit?.store ?? 'memory',
175
+ auth_rate_limit: config.auth?.rateLimit?.store ?? 'memory',
176
+ },
177
+ });
99
178
  });
100
179
 
101
180
  app.use(createStudioMiddleware({ root: studioRoot }));
181
+ const pressureLimit = createPressureLimit(config.server?.pressure);
182
+ if (pressureLimit) app.use(pressureLimit);
183
+ const apiRateLimit = createApiRateLimit(config, rateLimitStore, logger);
184
+ if (apiRateLimit) app.use(apiRateLimit);
102
185
 
103
- app.use(createAuthenticationMiddleware({
104
- pool,
105
- config,
106
- logger,
107
- services,
108
- schemaCache,
109
- emitter,
110
- storage,
111
- }));
186
+ app.use(createAuthenticationMiddleware({ pool, config, logger, services, schemaCache, permissionCache, emitter, storage }));
187
+ const requestEvents = createRequestEvents(emitter);
188
+ if (requestEvents) app.use(requestEvents);
189
+
190
+ if (mcpRouter) app.use('/mcp', mcpRouter);
112
191
  app.use('/studio-settings', createStudioSettingsRouter());
113
- app.use('/auth', createAuthRouter({ mailer, config, logger }));
192
+ app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
114
193
  app.use('/items', createItemsRouter());
115
194
  app.use('/schema', createSystemSchemaRouter({ schemaCache }));
116
195
  app.use('/schema', createSchemaRouter({ schemaCache }));
@@ -121,20 +200,31 @@ export function createApp({
121
200
  app.use('/audit', createAuditRouter());
122
201
 
123
202
  for (const extension of endpointExtensions) {
124
- if (!extension?.id || !extension?.router) {
125
- throw new Error('Invalid endpoint extension runtime entry');
126
- }
203
+ if (!extension?.id || !extension?.router) throw new Error('Invalid endpoint extension runtime entry');
127
204
  app.use(`/extensions/${encodeURIComponent(extension.id)}`, extension.router);
128
205
  }
129
206
 
130
207
  app.use((req, res) => {
131
- res.status(404).json({
132
- errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }],
133
- });
208
+ res.status(404).json({ errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }] });
209
+ });
210
+ app.use((error, req, res, next) => {
211
+ if (emitter) {
212
+ emitter.action('request.failed', {
213
+ ...requestEventMetadata(req, res, Date.now()),
214
+ error: { code: error?.code ?? 'INTERNAL_ERROR' },
215
+ }, { accountability: req.accountability, requestId: req.id }).catch(() => {});
216
+ }
217
+ next(error);
134
218
  });
135
-
136
219
  app.use(apiErrorHandler(logger));
137
220
  return app;
138
221
  }
139
222
 
140
- export { requestIdentity, securityHeaders, studioCors };
223
+ export {
224
+ CONTENT_SECURITY_POLICY,
225
+ createApiRateLimit,
226
+ createRequestEvents,
227
+ requestIdentity,
228
+ securityHeaders,
229
+ studioCors,
230
+ };
@@ -0,0 +1,18 @@
1
+ export const INTERNAL_AUDIT_EVENTS = Object.freeze([
2
+ 'items.create',
3
+ 'items.update',
4
+ 'items.delete',
5
+ 'files.create',
6
+ 'files.update',
7
+ 'files.delete',
8
+ 'users.create',
9
+ 'users.update',
10
+ 'users.delete',
11
+ 'users.password.update',
12
+ 'roles.create',
13
+ 'roles.update',
14
+ 'roles.delete',
15
+ 'permissions.create',
16
+ 'permissions.update',
17
+ 'permissions.delete',
18
+ ]);
@@ -25,6 +25,7 @@ export function createAuthenticationMiddleware({
25
25
  logger,
26
26
  services,
27
27
  schemaCache = null,
28
+ permissionCache = null,
28
29
  emitter = null,
29
30
  storage = null,
30
31
  }) {
@@ -79,6 +80,7 @@ export function createAuthenticationMiddleware({
79
80
  env: config,
80
81
  emitter,
81
82
  storage,
83
+ permissionCache,
82
84
  requestId: req.id,
83
85
  });
84
86
  next();
@@ -41,6 +41,7 @@ const STATUS_BY_CODE = new Map([
41
41
  ['INVALID_ON_DELETE', 400],
42
42
  ['INVALID_STORAGE_KEY', 400],
43
43
  ['INVALID_FILE_CONTENT', 400],
44
+ ['FILE_MIME_MISMATCH', 400],
44
45
  ['INVALID_MAIL_MESSAGE', 400],
45
46
  ['STORAGE_NOT_FOUND', 400],
46
47
  ['STORAGE_INVENTORY_UNSUPPORTED', 400],
@@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url';
2
2
  import express from 'express';
3
3
 
4
4
  import { discoverExtensions } from './discovery.js';
5
+ import { ExtensionScheduler } from './scheduler.js';
5
6
 
6
7
  function extensionError(code, message) {
7
8
  const error = new Error(message);
@@ -54,19 +55,39 @@ function createBaseContext({ services, database, schemaCache, emitter, storage,
54
55
  });
55
56
  }
56
57
 
57
- function hookRegistrationApi(emitter, baseContext) {
58
+ function hookRegistrationApi(emitter, baseContext, manifest, scheduler) {
59
+ const registration = Object.freeze({
60
+ extensionId: manifest.id,
61
+ priority: Number.isInteger(manifest.priority) ? manifest.priority : 0,
62
+ });
63
+
58
64
  return Object.freeze({
59
- filter(event, handler) {
65
+ filter(event, handler, options = {}) {
60
66
  return emitter.registerFilter(event, (payload, eventContext) =>
61
- handler(payload, { ...baseContext, ...eventContext }));
67
+ handler(payload, { ...baseContext, ...eventContext }), {
68
+ ...registration,
69
+ ...options,
70
+ extensionId: manifest.id,
71
+ });
62
72
  },
63
- action(event, handler) {
73
+ action(event, handler, options = {}) {
64
74
  return emitter.registerAction(event, (payload, eventContext) =>
65
- handler(payload, { ...baseContext, ...eventContext }));
75
+ handler(payload, { ...baseContext, ...eventContext }), {
76
+ ...registration,
77
+ ...options,
78
+ extensionId: manifest.id,
79
+ });
66
80
  },
67
- init(event, handler) {
81
+ init(event, handler, options = {}) {
68
82
  return emitter.registerInit(event, (eventContext) =>
69
- handler({ ...baseContext, ...eventContext }));
83
+ handler({ ...baseContext, ...eventContext }), {
84
+ ...registration,
85
+ ...options,
86
+ extensionId: manifest.id,
87
+ });
88
+ },
89
+ schedule(expression, handler, options = {}) {
90
+ return scheduler.register(manifest.id, expression, handler, options);
70
91
  },
71
92
  });
72
93
  }
@@ -89,14 +110,22 @@ export async function loadExtensionRuntime({
89
110
 
90
111
  const manifests = await discoverExtensions({ rootDir, localDirectory, includeDependencies });
91
112
  const baseContext = createBaseContext({ services, database, schemaCache, emitter, storage, logger, env });
113
+ const scheduler = new ExtensionScheduler({
114
+ database,
115
+ services,
116
+ schemaCache,
117
+ emitter,
118
+ storage,
119
+ logger,
120
+ env,
121
+ });
92
122
  const endpointExtensions = [];
93
- const hookApi = hookRegistrationApi(emitter, baseContext);
94
123
 
95
124
  for (const manifest of manifests) {
96
125
  const definition = await importExtension(manifest);
97
126
 
98
127
  if (manifest.type === 'hook') {
99
- await definition.register(hookApi, baseContext);
128
+ await definition.register(hookRegistrationApi(emitter, baseContext, manifest, scheduler), baseContext);
100
129
  logger.info?.(`Loaded YunCMS hook extension: ${manifest.id}`);
101
130
  continue;
102
131
  }
@@ -117,5 +146,11 @@ export async function loadExtensionRuntime({
117
146
  async init(event) {
118
147
  await emitter.init(event, baseContext);
119
148
  },
149
+ startSchedules() {
150
+ scheduler.start();
151
+ },
152
+ async stopSchedules(options = {}) {
153
+ return scheduler.stop(options);
154
+ },
120
155
  });
121
156
  }
@@ -0,0 +1,288 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ createSystemAccountability,
4
+ withAdvisoryLock,
5
+ } from '@yunsoft/yuncms-core';
6
+
7
+ const CRON_FIELD_RULES = Object.freeze([
8
+ { name: 'minute', min: 0, max: 59 },
9
+ { name: 'hour', min: 0, max: 23 },
10
+ { name: 'day', min: 1, max: 31 },
11
+ { name: 'month', min: 1, max: 12 },
12
+ { name: 'weekday', min: 0, max: 6 },
13
+ ]);
14
+ const JOB_ID_PATTERN = /^[A-Za-z0-9._:-]{1,100}$/;
15
+
16
+ function scheduleError(message) {
17
+ const error = new Error(message);
18
+ error.code = 'INVALID_EXTENSION_SCHEDULE';
19
+ return error;
20
+ }
21
+
22
+ function integerToken(value, rule, label) {
23
+ if (!/^\d+$/.test(value)) throw scheduleError(`Invalid ${label} cron token: ${value}`);
24
+ const number = Number(value);
25
+ if (!Number.isInteger(number) || number < rule.min || number > rule.max) {
26
+ throw scheduleError(`${label} must be between ${rule.min} and ${rule.max}`);
27
+ }
28
+ return number;
29
+ }
30
+
31
+ function rangeValues(start, end, step, rule, label) {
32
+ if (start > end) throw scheduleError(`Invalid ${label} cron range: ${start}-${end}`);
33
+ const values = [];
34
+ for (let value = start; value <= end; value += step) values.push(value);
35
+ return values;
36
+ }
37
+
38
+ function parseCronPart(part, rule) {
39
+ const [base, rawStep, extra] = part.split('/');
40
+ if (extra !== undefined) throw scheduleError(`Invalid ${rule.name} cron token: ${part}`);
41
+ const step = rawStep === undefined ? 1 : integerToken(rawStep, { min: 1, max: rule.max - rule.min + 1 }, `${rule.name} step`);
42
+
43
+ if (base === '*') return rangeValues(rule.min, rule.max, step, rule, rule.name);
44
+ if (base.includes('-')) {
45
+ const bits = base.split('-');
46
+ if (bits.length !== 2) throw scheduleError(`Invalid ${rule.name} cron range: ${base}`);
47
+ return rangeValues(
48
+ integerToken(bits[0], rule, rule.name),
49
+ integerToken(bits[1], rule, rule.name),
50
+ step,
51
+ rule,
52
+ rule.name,
53
+ );
54
+ }
55
+ if (rawStep !== undefined) throw scheduleError(`Cron step requires * or range for ${rule.name}`);
56
+ return [integerToken(base, rule, rule.name)];
57
+ }
58
+
59
+ function parseCronField(value, rule) {
60
+ if (!value) throw scheduleError(`Cron ${rule.name} field is required`);
61
+ const values = new Set();
62
+ for (const part of value.split(',')) {
63
+ if (!part) throw scheduleError(`Invalid ${rule.name} cron list`);
64
+ for (const number of parseCronPart(part, rule)) values.add(number);
65
+ }
66
+ return values;
67
+ }
68
+
69
+ export function parseCronExpression(expression) {
70
+ if (typeof expression !== 'string') throw scheduleError('Cron expression must be a string');
71
+ const fields = expression.trim().split(/\s+/);
72
+ if (fields.length !== 5) throw scheduleError('Cron expression must contain exactly 5 fields');
73
+ return Object.freeze(CRON_FIELD_RULES.map((rule, index) => parseCronField(fields[index], rule)));
74
+ }
75
+
76
+ export function cronMatches(parsed, date) {
77
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error('Valid date is required');
78
+ const values = [
79
+ date.getMinutes(),
80
+ date.getHours(),
81
+ date.getDate(),
82
+ date.getMonth() + 1,
83
+ date.getDay(),
84
+ ];
85
+ return parsed.every((allowed, index) => allowed.has(values[index]));
86
+ }
87
+
88
+ function normalizeJobOptions(options = {}) {
89
+ const id = String(options.id ?? '').trim();
90
+ if (!JOB_ID_PATTERN.test(id)) {
91
+ throw scheduleError('Scheduled extension job requires a stable id using letters, numbers, dot, underscore, colon or dash');
92
+ }
93
+ const mode = options.mode ?? 'per_process';
94
+ if (!['per_process', 'singleton'].includes(mode)) {
95
+ throw scheduleError('Scheduled extension job mode must be per_process or singleton');
96
+ }
97
+ if ((options.overlap ?? 'skip') !== 'skip') {
98
+ throw scheduleError('Scheduled extension job overlap currently supports skip only');
99
+ }
100
+ if (options.accountability !== 'system') {
101
+ throw scheduleError("Scheduled extension jobs must explicitly set accountability: 'system'");
102
+ }
103
+ return Object.freeze({ id, mode, overlap: 'skip', accountability: 'system' });
104
+ }
105
+
106
+ function minuteKey(date) {
107
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
108
+ }
109
+
110
+ export function scheduleLockName(identity) {
111
+ const digest = createHash('sha256').update(String(identity)).digest('hex').slice(0, 40);
112
+ return `yuncms:schedule:${digest}`;
113
+ }
114
+
115
+ export class ExtensionScheduler {
116
+ constructor({
117
+ database,
118
+ services,
119
+ schemaCache,
120
+ emitter,
121
+ storage = null,
122
+ logger = console,
123
+ env,
124
+ now = () => new Date(),
125
+ lockRunner = withAdvisoryLock,
126
+ } = {}) {
127
+ if (!database || !services || !schemaCache || !emitter) {
128
+ throw new Error('Extension scheduler requires database, services, schemaCache and emitter');
129
+ }
130
+ this.database = database;
131
+ this.services = services;
132
+ this.schemaCache = schemaCache;
133
+ this.emitter = emitter;
134
+ this.storage = storage;
135
+ this.logger = logger;
136
+ this.env = env;
137
+ this.now = now;
138
+ this.lockRunner = lockRunner;
139
+ this.jobs = new Map();
140
+ this.timer = null;
141
+ this.stopping = false;
142
+ this.runningPromises = new Set();
143
+ }
144
+
145
+ register(extensionId, expression, handler, options = {}) {
146
+ if (typeof handler !== 'function') throw scheduleError('Scheduled extension handler must be a function');
147
+ const parsed = parseCronExpression(expression);
148
+ const normalized = normalizeJobOptions(options);
149
+ const identity = `${extensionId}:${normalized.id}`;
150
+ if (this.jobs.has(identity)) throw scheduleError(`Duplicate scheduled extension job: ${identity}`);
151
+ const job = {
152
+ identity,
153
+ extensionId,
154
+ expression,
155
+ parsed,
156
+ handler,
157
+ options: normalized,
158
+ running: false,
159
+ lastMinute: null,
160
+ };
161
+ this.jobs.set(identity, job);
162
+ return () => this.jobs.delete(identity);
163
+ }
164
+
165
+ async #context(job, date) {
166
+ const accountability = createSystemAccountability();
167
+ const schema = await this.schemaCache.get(this.database);
168
+ return Object.freeze({
169
+ services: this.services,
170
+ database: this.database,
171
+ logger: this.logger,
172
+ env: this.env,
173
+ emitter: this.emitter,
174
+ storage: this.storage,
175
+ accountability,
176
+ requestId: `schedule:${job.identity}:${date.toISOString()}`,
177
+ getSchema: () => this.schemaCache.get(this.database),
178
+ serviceOptions: async () => ({
179
+ accountability,
180
+ database: this.database,
181
+ schema,
182
+ logger: this.logger,
183
+ emitter: this.emitter,
184
+ storage: this.storage,
185
+ permissionCache: null,
186
+ requestId: `schedule:${job.identity}:${date.toISOString()}`,
187
+ }),
188
+ schedule: Object.freeze({
189
+ id: job.options.id,
190
+ extensionId: job.extensionId,
191
+ expression: job.expression,
192
+ mode: job.options.mode,
193
+ scheduledAt: date,
194
+ }),
195
+ });
196
+ }
197
+
198
+ async #execute(job, date) {
199
+ if (job.running) {
200
+ this.logger?.warn?.('Skipping overlapping YunCMS extension job', { job: job.identity });
201
+ return false;
202
+ }
203
+ job.running = true;
204
+ const startedAt = Date.now();
205
+ this.logger?.info?.('Starting YunCMS extension job', { job: job.identity, mode: job.options.mode });
206
+ try {
207
+ const run = async () => job.handler(await this.#context(job, date));
208
+ if (job.options.mode === 'singleton') {
209
+ try {
210
+ await this.lockRunner(
211
+ this.database,
212
+ scheduleLockName(job.identity),
213
+ run,
214
+ { timeoutSeconds: 0 },
215
+ );
216
+ } catch (error) {
217
+ if (error?.code === 'SCHEMA_LOCK_UNAVAILABLE') {
218
+ this.logger?.info?.('Skipping YunCMS singleton extension job owned by another replica', { job: job.identity });
219
+ return false;
220
+ }
221
+ throw error;
222
+ }
223
+ } else {
224
+ await run();
225
+ }
226
+ this.logger?.info?.('Completed YunCMS extension job', {
227
+ job: job.identity,
228
+ durationMs: Date.now() - startedAt,
229
+ });
230
+ return true;
231
+ } catch (error) {
232
+ this.logger?.error?.('YunCMS extension job failed', {
233
+ job: job.identity,
234
+ durationMs: Date.now() - startedAt,
235
+ code: error?.code ?? null,
236
+ message: error?.message ?? String(error),
237
+ });
238
+ return false;
239
+ } finally {
240
+ job.running = false;
241
+ }
242
+ }
243
+
244
+ async runDue(date = this.now()) {
245
+ if (this.stopping) return [];
246
+ const executions = [];
247
+ const key = minuteKey(date);
248
+ for (const job of this.jobs.values()) {
249
+ if (job.lastMinute === key || !cronMatches(job.parsed, date)) continue;
250
+ job.lastMinute = key;
251
+ const execution = this.#execute(job, date);
252
+ this.runningPromises.add(execution);
253
+ execution.finally(() => this.runningPromises.delete(execution));
254
+ executions.push(execution);
255
+ }
256
+ return Promise.all(executions);
257
+ }
258
+
259
+ start() {
260
+ if (this.timer || this.stopping) return;
261
+ const tick = () => this.runDue().catch((error) => {
262
+ this.logger?.error?.('YunCMS extension scheduler tick failed', { code: error?.code ?? null });
263
+ });
264
+ tick();
265
+ this.timer = setInterval(tick, 15_000);
266
+ this.timer.unref?.();
267
+ }
268
+
269
+ async stop({ timeoutMs = 5_000 } = {}) {
270
+ this.stopping = true;
271
+ if (this.timer) clearInterval(this.timer);
272
+ this.timer = null;
273
+ const running = [...this.runningPromises];
274
+ if (running.length === 0) return true;
275
+ let timeout;
276
+ const completed = await Promise.race([
277
+ Promise.allSettled(running).then(() => true),
278
+ new Promise((resolve) => {
279
+ timeout = setTimeout(() => resolve(false), timeoutMs);
280
+ timeout.unref?.();
281
+ }),
282
+ ]);
283
+ if (timeout) clearTimeout(timeout);
284
+ return completed;
285
+ }
286
+ }
287
+
288
+ export { normalizeJobOptions };