@goodandready/dsh-cron 0.3.10 → 0.3.12

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/lib/http-utils.js CHANGED
@@ -77,65 +77,142 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
77
77
  }
78
78
 
79
79
  /**
80
- * Validate that an incoming request originates from a trusted local caller or
81
- * an authenticated client (Issue #86).
80
+ * Safe helper to get the DSH connection service from context or request (#357).
81
+ */
82
+ export function getConnection(ctx) {
83
+ if (!ctx) return null;
84
+ return Reflect.get(ctx, 'connection') || ctx.connection || null;
85
+ }
86
+
87
+ export function getDshConnection(req, options = {}) {
88
+ if (options.connection) return options.connection;
89
+ if (options.ctx) {
90
+ const conn = getConnection(options.ctx);
91
+ if (conn) return conn;
92
+ }
93
+ if (req) {
94
+ if (req.connectionService) return req.connectionService;
95
+ if (req.ctx) {
96
+ const conn = getConnection(req.ctx);
97
+ if (conn) return conn;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Determine if an incoming request should be rejected, and with which HTTP status (401 or 403).
105
+ * Returns undefined if the request is admitted, or 401 / 403 if rejected (#357).
82
106
  *
83
107
  * Rules:
84
- * 1. Non-loopback remote callers are rejected unless authenticated with a valid token.
85
- * 2. Host header must be present and non-empty.
86
- * 3. Origin header (if present) cannot be null and its host must match Host.
87
- * 4. Sec-Fetch-Site (if present) must be same-origin or none (cross-site/same-site rejected).
88
- * 5. Mutating requests (POST, PUT, PATCH, DELETE) must provide browser proof (same-origin / matching Origin)
89
- * or an explicit capability header (confirm header, token, or update header).
108
+ * 1. If DSH connection service is available, consult its admission first.
109
+ * - Connection service rejection 403 (untrusted Host / Origin / CSRF) is strictly rejected.
110
+ * - Connection service admission (undefined rejection) admits the request (browser session / Peer).
111
+ * 2. Token authentication: valid Bearer token matching options.apiToken, DSH_AUTH_TOKEN or DSH_TOKEN.
112
+ * 3. Local loopback caller: 127.0.0.1 or ::1 when connection service is not bound (standalone/CLI/test).
113
+ * 4. FAIL-CLOSED: if connection service is bound, request must have DSH admission or valid API token.
114
+ * If connection service is not bound, request must have valid API token or verified loopback IP.
115
+ * Requests with missing socket / missing remoteAddress and no token are rejected (401).
116
+ * 5. Origin and Sec-Fetch-Site: cannot be null, Origin host must match Host, Sec-Fetch-Site must be same-origin or none.
90
117
  */
91
- export function isTrustedRequest(req, options = {}) {
92
- if (!req) return false;
118
+ export function getRequestRejection(req, options = {}) {
119
+ if (!req) return 403;
120
+
121
+ // 1. Validate Origin header if present: cannot be null, must match Host (CSRF guard)
93
122
  const headers = req.headers || {};
123
+ const origin = headers.origin;
124
+ const host = headers.host || '';
125
+ if (origin !== undefined) {
126
+ if (origin === 'null' || !origin) return 403;
127
+ try {
128
+ const parsed = new URL(origin);
129
+ if (!parsed.host || !host || parsed.host !== host) return 403;
130
+ } catch {
131
+ return 403;
132
+ }
133
+ }
94
134
 
95
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
96
- const hasRemote = Boolean(remote && typeof remote === 'string');
97
- const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
135
+ // 2. Validate Sec-Fetch-Site header if present
136
+ const site = headers['sec-fetch-site'];
137
+ if (site !== undefined) {
138
+ if (site !== 'same-origin' && site !== 'none') {
139
+ return 403;
140
+ }
141
+ }
142
+
143
+ // 3. Check DSH connection service if available (#357)
144
+ const conn = getDshConnection(req, options);
145
+ let connRejection;
146
+ if (conn && typeof conn.requestRejection === 'function') {
147
+ connRejection = conn.requestRejection(req);
148
+ if (connRejection === 403) {
149
+ return 403;
150
+ }
151
+ }
98
152
 
153
+ // 4. Check token authentication
99
154
  const token = extractBearerToken(req);
100
155
  const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
101
156
  const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
102
157
 
103
- // 1. Non-loopback socket callers (or callers with missing remoteAddress) require explicit token authentication (#86, #295)
104
- if (req.socket || req.connection || hasRemote) {
105
- if (!isLoopback && !isTokenAuthenticated) {
106
- return false;
158
+ // If DSH connection service admitted the request (undefined rejection)
159
+ const isConnectionAdmitted = Boolean(conn && connRejection === undefined);
160
+
161
+ // Check loopback address
162
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
163
+ const hasRemote = Boolean(remote && typeof remote === 'string');
164
+ const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
165
+
166
+ // FAIL-CLOSED AUTHENTICATION CHECK (#357):
167
+ if (conn) {
168
+ if (!isConnectionAdmitted && !isTokenAuthenticated) {
169
+ return connRejection === 403 ? 403 : 401;
170
+ }
171
+ } else {
172
+ if (!isTokenAuthenticated && !isLoopback) {
173
+ if (token || !hasRemote) {
174
+ return 401;
175
+ }
176
+ return 403;
107
177
  }
108
178
  }
109
179
 
110
- // 2. Validate Origin header if present: cannot be null, must match Host
180
+ return undefined; // Admitted
181
+ }
182
+
183
+ /**
184
+ * Validate that an incoming request originates from a trusted local caller,
185
+ * an admitted DSH browser session, or an authenticated client (Issue #86, #357).
186
+ */
187
+ export function isTrustedRequest(req, options = {}) {
188
+ return getRequestRejection(req, options) === undefined;
189
+ }
190
+
191
+ /**
192
+ * Backward-compatible helper for checking cross-origin browser headers (Origin / Sec-Fetch-Site).
193
+ */
194
+ export function isCrossOrigin(req, options) {
195
+ if (!req) return true;
196
+ const headers = req.headers || {};
111
197
  const origin = headers.origin;
112
198
  const host = headers.host || '';
113
199
  if (origin !== undefined) {
114
- if (origin === 'null' || !origin) return false;
200
+ if (origin === 'null' || !origin) return true;
115
201
  try {
116
202
  const parsed = new URL(origin);
117
- if (!parsed.host || !host || parsed.host !== host) return false;
203
+ if (!parsed.host || !host || parsed.host !== host) return true;
118
204
  } catch {
119
- return false;
205
+ return true;
120
206
  }
121
207
  }
122
208
 
123
- // 3. Validate Sec-Fetch-Site header if present
124
209
  const site = headers['sec-fetch-site'];
125
210
  if (site !== undefined) {
126
211
  if (site !== 'same-origin' && site !== 'none') {
127
- return false;
212
+ return true;
128
213
  }
129
214
  }
130
-
131
- return true;
132
- }
133
-
134
- /**
135
- * Backward-compatible wrapper for checking cross-origin / untrusted requests.
136
- */
137
- export function isCrossOrigin(req, options) {
138
- return !isTrustedRequest(req, options);
215
+ return false;
139
216
  }
140
217
 
141
218
  /**
@@ -158,9 +235,13 @@ export function sendJson(res, statusCode, data) {
158
235
  res.end(JSON.stringify(data));
159
236
  }
160
237
 
161
- export function rejectCrossOrigin(req, res, options) {
162
- if (!isTrustedRequest(req, options)) {
163
- sendJson(res, 403, { ok: false, error: 'Forbidden: untrusted or cross-origin request' });
238
+ export function rejectCrossOrigin(req, res, options = {}) {
239
+ const rejection = getRequestRejection(req, options);
240
+ if (rejection !== undefined) {
241
+ const errorMsg = rejection === 401
242
+ ? 'Unauthorized: authentication required'
243
+ : 'Forbidden: untrusted or cross-origin request';
244
+ sendJson(res, rejection, { ok: false, error: errorMsg });
164
245
  return true;
165
246
  }
166
247
  return false;
@@ -224,6 +305,12 @@ export function redactTaskSecrets(task) {
224
305
  if (clone.httpBody) {
225
306
  clone.httpBody = REDACTED_SECRET;
226
307
  }
308
+ if (clone.webhookToken) {
309
+ clone.webhookToken = REDACTED_SECRET;
310
+ }
311
+ if (clone.webhookSecret) {
312
+ clone.webhookSecret = REDACTED_SECRET;
313
+ }
227
314
 
228
315
  return clone;
229
316
  }
@@ -285,5 +372,11 @@ export function restoreTaskSecrets(incoming, current) {
285
372
  restored.httpBody = current.httpBody;
286
373
  }
287
374
 
375
+ if (restored.webhookToken === REDACTED_SECRET && current.webhookToken !== undefined) {
376
+ restored.webhookToken = current.webhookToken;
377
+ }
378
+ if (restored.webhookSecret === REDACTED_SECRET && current.webhookSecret !== undefined) {
379
+ restored.webhookSecret = current.webhookSecret;
380
+ }
288
381
  return restored;
289
382
  }
package/lib/index.js CHANGED
@@ -307,7 +307,7 @@ export function apply(ctx, rawConfig) {
307
307
  try {
308
308
  const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
309
309
  const apiToken = settings.apiToken;
310
- if (rejectCrossOrigin(req, res, { apiToken })) return;
310
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
311
311
 
312
312
  if (req.method === 'GET') {
313
313
  const unwrapped = {
@@ -324,7 +324,7 @@ export function apply(ctx, rawConfig) {
324
324
  return;
325
325
  }
326
326
  if (req.method === 'POST') {
327
- if (rejectCrossOrigin(req, res)) return;
327
+ if (rejectCrossOrigin(req, res, { ctx })) return;
328
328
  const { body, error } = await readBody(req, res);
329
329
  if (error) return;
330
330
  const payload = sanitizeSettingsPayload(body);
@@ -0,0 +1,338 @@
1
+ import { Cron } from 'croner';
2
+
3
+ function pad(n) {
4
+ return String(n).padStart(2, '0');
5
+ }
6
+
7
+ /**
8
+ * Parse time string into { hour, minute }.
9
+ * Supports:
10
+ * - "9:00", "09:30", "18:45"
11
+ * - "9am", "9:30am", "6pm", "11:15pm"
12
+ * - "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
13
+ * - Plain hour "9", "14"
14
+ */
15
+ export function parseTimeString(raw) {
16
+ if (!raw || typeof raw !== 'string') return null;
17
+ const str = raw.trim().toLowerCase();
18
+
19
+ // 1. English 12h: "9am", "9:30am", "6pm", "6:15pm"
20
+ const ampmMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/);
21
+ if (ampmMatch) {
22
+ let hour = parseInt(ampmMatch[1], 10);
23
+ const minute = ampmMatch[2] ? parseInt(ampmMatch[2], 10) : 0;
24
+ const isPm = ampmMatch[3] === 'pm';
25
+ if (hour < 1 || hour > 12 || minute < 0 || minute > 59) return null;
26
+ if (isPm && hour !== 12) hour += 12;
27
+ if (!isPm && hour === 12) hour = 0;
28
+ return { hour, minute };
29
+ }
30
+
31
+ // 2. Russian qualifiers: "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
32
+ const ruMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(утра|дня|вечера|ночи)$/);
33
+ if (ruMatch) {
34
+ let hour = parseInt(ruMatch[1], 10);
35
+ const minute = ruMatch[2] ? parseInt(ruMatch[2], 10) : 0;
36
+ const period = ruMatch[3];
37
+ if (minute < 0 || minute > 59) return null;
38
+ if (period === 'вечера' && hour < 12) hour += 12;
39
+ if (period === 'дня' && hour >= 1 && hour <= 11) hour += 12;
40
+ if (period === 'ночи' && hour === 12) hour = 0;
41
+ if (hour < 0 || hour > 23) return null;
42
+ return { hour, minute };
43
+ }
44
+
45
+ // 3. 24h format: "14:30", "09:00", "9:00"
46
+ const colonMatch = str.match(/^(\d{1,2}):(\d{2})$/);
47
+ if (colonMatch) {
48
+ const hour = parseInt(colonMatch[1], 10);
49
+ const minute = parseInt(colonMatch[2], 10);
50
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
51
+ return { hour, minute };
52
+ }
53
+
54
+ // 4. Plain hour: "9", "14", "22"
55
+ const plainMatch = str.match(/^(\d{1,2})$/);
56
+ if (plainMatch) {
57
+ const hour = parseInt(plainMatch[1], 10);
58
+ if (hour >= 0 && hour <= 23) {
59
+ return { hour, minute: 0 };
60
+ }
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ const DAY_OF_WEEK_MAP = {
67
+ // Sunday (0)
68
+ 'воскресенье': 0, 'воскресеньям': 0, 'воскресенья': 0, 'вс': 0,
69
+ 'sunday': 0, 'sundays': 0, 'sun': 0,
70
+ // Monday (1)
71
+ 'понедельник': 1, 'понедельникам': 1, 'пн': 1,
72
+ 'monday': 1, 'mondays': 1, 'mon': 1,
73
+ // Tuesday (2)
74
+ 'вторник': 2, 'вторникам': 2, 'вт': 2,
75
+ 'tuesday': 2, 'tuesdays': 2, 'tue': 2,
76
+ // Wednesday (3)
77
+ 'среда': 3, 'среду': 3, 'средам': 3, 'ср': 3,
78
+ 'wednesday': 3, 'wednesdays': 3, 'wed': 3,
79
+ // Thursday (4)
80
+ 'четверг': 4, 'четвергам': 4, 'чт': 4,
81
+ 'thursday': 4, 'thursdays': 4, 'thu': 4,
82
+ // Friday (5)
83
+ 'пятница': 5, 'пятницу': 5, 'пятницам': 5, 'пт': 5,
84
+ 'friday': 5, 'fridays': 5, 'fri': 5,
85
+ // Saturday (6)
86
+ 'суббота': 6, 'субботу': 6, 'субботам': 6, 'сб': 6,
87
+ 'saturday': 6, 'saturdays': 6, 'sat': 6,
88
+ };
89
+
90
+ const DAY_NAMES_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
91
+
92
+ function withNextRun(res) {
93
+ if (res && res.cronPattern && !res.nextRun) {
94
+ try {
95
+ const job = new Cron(res.cronPattern, { sloppyRanges: true });
96
+ const next = job.nextRun();
97
+ res.nextRun = next ? next.getTime() : null;
98
+ } catch {
99
+ // ignore
100
+ }
101
+ }
102
+ return res;
103
+ }
104
+
105
+ /**
106
+ * Deterministically parse natural language schedule expression.
107
+ * Returns parsed schedule object compatible with parseScheduleExpression or null.
108
+ */
109
+ export function parseNaturalSchedule(input) {
110
+ if (!input || typeof input !== 'string') return null;
111
+ const raw = input.trim();
112
+ if (!raw) return null;
113
+
114
+ // Normalize: lower case, single spaces, strip surrounding punctuation
115
+ const s = raw.toLowerCase()
116
+ .replace(/[.,;!]+$/, '')
117
+ .replace(/\s+/g, ' ')
118
+ .trim();
119
+
120
+ // A. Relative delay in Russian: "через 15 минут", "через 2 часа"
121
+ const delayMatch = s.match(/^через\s+(\d+)\s*(секунд[ыа]?|сек|s|минут[ы]?|мин|m|часа?|часов|h|дней|дня|день|d)$/);
122
+ if (delayMatch) {
123
+ const num = parseInt(delayMatch[1], 10);
124
+ const u = delayMatch[2];
125
+ let unit = 'm';
126
+ if (u.startsWith('сек') || u === 's') unit = 's';
127
+ else if (u.startsWith('час') || u === 'h') unit = 'h';
128
+ else if (u.startsWith('дн') || u === 'день' || u === 'd') unit = 'd';
129
+ let delayMs = num * 60 * 1000;
130
+ let unitText = `${num} min`;
131
+ if (unit === 's') { delayMs = num * 1000; unitText = `${num} sec`; }
132
+ else if (unit === 'h') { delayMs = num * 3600 * 1000; unitText = `${num} h`; }
133
+ else if (unit === 'd') { delayMs = num * 86400 * 1000; unitText = `${num} d`; }
134
+ const targetMs = Date.now() + delayMs;
135
+ return {
136
+ cronPattern: null,
137
+ isOneShot: true,
138
+ targetTimestamp: targetMs,
139
+ humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
140
+ nextRun: targetMs,
141
+ isNatural: true,
142
+ };
143
+ }
144
+
145
+ // B. Periodic intervals in Russian: "каждые 15 минут", "каждые 2 часа", "каждые полчаса"
146
+ if (s === 'каждые полчаса' || s === 'каждые пол-часа' || s === 'every half hour') {
147
+ return withNextRun({
148
+ cronPattern: '*/30 * * * *',
149
+ humanText: 'Every 30 minutes',
150
+ isInterval: true,
151
+ isNatural: true,
152
+ });
153
+ }
154
+
155
+ const intervalRu = s.match(/^кажды[еяй]\s+(\d+)\s*(минут[ы]?|мин|часа?|часов|дней|дня|день)$/);
156
+ if (intervalRu) {
157
+ const num = parseInt(intervalRu[1], 10);
158
+ const u = intervalRu[2];
159
+ if (u.startsWith('мин')) {
160
+ if (num < 1) return null;
161
+ if (num < 60) {
162
+ return withNextRun({
163
+ cronPattern: `*/${num} * * * *`,
164
+ humanText: `Every ${num} minutes`,
165
+ isInterval: true,
166
+ isNatural: true,
167
+ });
168
+ }
169
+ } else if (u.startsWith('час')) {
170
+ if (num >= 1 && num < 24) {
171
+ return withNextRun({
172
+ cronPattern: `0 */${num} * * *`,
173
+ humanText: `Every ${num} hours`,
174
+ isInterval: true,
175
+ isNatural: true,
176
+ });
177
+ }
178
+ } else if (u.startsWith('дн') || u === 'день') {
179
+ if (num >= 1 && num <= 31) {
180
+ return withNextRun({
181
+ cronPattern: `0 0 */${num} * *`,
182
+ humanText: `Every ${num} days`,
183
+ isInterval: true,
184
+ isNatural: true,
185
+ });
186
+ }
187
+ }
188
+ }
189
+
190
+ // C. Hourly / Midnight / Noon
191
+ if (s === 'каждый час' || s === 'в начале каждого часа' || s === 'в начале часа' || s === 'every hour' || s === 'hourly') {
192
+ return withNextRun({
193
+ cronPattern: '0 * * * *',
194
+ humanText: 'Every hour',
195
+ isNatural: true,
196
+ });
197
+ }
198
+ if (s === 'в полночь' || s === 'at midnight' || s === 'midnight') {
199
+ return withNextRun({
200
+ cronPattern: '0 0 * * *',
201
+ humanText: 'Every day at 00:00',
202
+ isNatural: true,
203
+ });
204
+ }
205
+ if (s === 'в полдень' || s === 'at noon' || s === 'noon') {
206
+ return withNextRun({
207
+ cronPattern: '0 12 * * *',
208
+ humanText: 'Every day at 12:00',
209
+ isNatural: true,
210
+ });
211
+ }
212
+
213
+ // D. Daily at specific time:
214
+ const dailyMatch = s.match(/^(?:каждый\s+день|ежедневно|every\s+day|daily)\s+(?:в|at)\s+(.+)$/);
215
+ if (dailyMatch) {
216
+ const t = parseTimeString(dailyMatch[1]);
217
+ if (t) {
218
+ return withNextRun({
219
+ cronPattern: `${t.minute} ${t.hour} * * *`,
220
+ humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
221
+ isNatural: true,
222
+ });
223
+ }
224
+ }
225
+ // Reverse: "в 9:00 каждый день", "at 9am daily"
226
+ const dailyRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:каждый\s+день|ежедневно|every\s+day|daily)$/);
227
+ if (dailyRevMatch) {
228
+ const t = parseTimeString(dailyRevMatch[1]);
229
+ if (t) {
230
+ return withNextRun({
231
+ cronPattern: `${t.minute} ${t.hour} * * *`,
232
+ humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
233
+ isNatural: true,
234
+ });
235
+ }
236
+ }
237
+
238
+ // E. Weekdays at specific time:
239
+ const weekdayMatch = s.match(/^(?:по\s+будням|в\s+будние(?:\s+дни)?|с\s+понедельника\s+по\s+пятницу|every\s+weekday|weekdays|on\s+weekdays|mon-fri)\s+(?:в|at)\s+(.+)$/);
240
+ if (weekdayMatch) {
241
+ const t = parseTimeString(weekdayMatch[1]);
242
+ if (t) {
243
+ return withNextRun({
244
+ cronPattern: `${t.minute} ${t.hour} * * 1-5`,
245
+ humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
246
+ isNatural: true,
247
+ });
248
+ }
249
+ }
250
+ // Reverse: "в 10:00 по будням"
251
+ const weekdayRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:по\s+будням|в\s+будние(?:\s+дни)?|every\s+weekday|weekdays)$/);
252
+ if (weekdayRevMatch) {
253
+ const t = parseTimeString(weekdayRevMatch[1]);
254
+ if (t) {
255
+ return withNextRun({
256
+ cronPattern: `${t.minute} ${t.hour} * * 1-5`,
257
+ humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
258
+ isNatural: true,
259
+ });
260
+ }
261
+ }
262
+
263
+ // F. Weekends at specific time:
264
+ const weekendMatch = s.match(/^(?:по\s+выходным|в\s+выходные(?:\s+дни)?|по\s+субботам\s+и\s+воскресеньям|every\s+weekend|weekends|on\s+weekends)\s+(?:в|at)\s+(.+)$/);
265
+ if (weekendMatch) {
266
+ const t = parseTimeString(weekendMatch[1]);
267
+ if (t) {
268
+ return withNextRun({
269
+ cronPattern: `${t.minute} ${t.hour} * * 0,6`,
270
+ humanText: `Weekends at ${pad(t.hour)}:${pad(t.minute)}`,
271
+ isNatural: true,
272
+ });
273
+ }
274
+ }
275
+
276
+ // G. Specific day of week:
277
+ const dowMatch = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)?(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)\s+(?:в|at)\s+(.+)$/);
278
+ if (dowMatch) {
279
+ const dayWord = dowMatch[1];
280
+ const dow = DAY_OF_WEEK_MAP[dayWord];
281
+ if (dow !== undefined) {
282
+ const t = parseTimeString(dowMatch[2]);
283
+ if (t) {
284
+ return withNextRun({
285
+ cronPattern: `${t.minute} ${t.hour} * * ${dow}`,
286
+ humanText: `${DAY_NAMES_EN[dow]}s at ${pad(t.hour)}:${pad(t.minute)}`,
287
+ isNatural: true,
288
+ });
289
+ }
290
+ }
291
+ }
292
+
293
+ // H. Monthly on specific day of month:
294
+ const monthlyRu = s.match(/^(?:каждое\s+(\d+)\s+число|(\d+)\s+числа\s+каждого\s+месяца)\s+(?:в|at)\s+(.+)$/);
295
+ if (monthlyRu) {
296
+ const day = parseInt(monthlyRu[1] || monthlyRu[2], 10);
297
+ if (day >= 1 && day <= 31) {
298
+ const t = parseTimeString(monthlyRu[3]);
299
+ if (t) {
300
+ return withNextRun({
301
+ cronPattern: `${t.minute} ${t.hour} ${day} * *`,
302
+ humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
303
+ isNatural: true,
304
+ });
305
+ }
306
+ }
307
+ }
308
+
309
+ const monthlyEn = s.match(/^(?:on\s+the\s+(\d+)(?:st|nd|rd|th)?\s+of\s+every\s+month|every\s+month\s+on\s+the\s+(\d+)(?:st|nd|rd|th)?)\s+at\s+(.+)$/);
310
+ if (monthlyEn) {
311
+ const day = parseInt(monthlyEn[1] || monthlyEn[2], 10);
312
+ if (day >= 1 && day <= 31) {
313
+ const t = parseTimeString(monthlyEn[3]);
314
+ if (t) {
315
+ return withNextRun({
316
+ cronPattern: `${t.minute} ${t.hour} ${day} * *`,
317
+ humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
318
+ isNatural: true,
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ // I. Standalone day of week without time (defaults to 09:00):
325
+ const standaloneDow = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)$/);
326
+ if (standaloneDow) {
327
+ const dow = DAY_OF_WEEK_MAP[standaloneDow[1]];
328
+ if (dow !== undefined) {
329
+ return withNextRun({
330
+ cronPattern: `0 9 * * ${dow}`,
331
+ humanText: `${DAY_NAMES_EN[dow]}s at 09:00`,
332
+ isNatural: true,
333
+ });
334
+ }
335
+ }
336
+
337
+ return null;
338
+ }