@serve.zone/dcrouter 14.2.2 → 14.3.0

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.
@@ -31,7 +31,17 @@ const tlsCertOptions = [
31
31
  { key: 'custom', option: 'Custom certificate' },
32
32
  ];
33
33
  const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
34
- type TSzRouteSecurity = NonNullable<ISzSourceProfileOption['security']>;
34
+ type TSzRouteSecurityBase = NonNullable<ISzSourceProfileOption['security']>;
35
+ type TSzRouteSecurity = Omit<TSzRouteSecurityBase, 'rateLimit'> & {
36
+ rateLimit?: interfaces.data.IRouteSecurity['rateLimit'];
37
+ challenge?: interfaces.data.IRouteSecurity['challenge'];
38
+ };
39
+ type TSourceProfileOption = Omit<ISzSourceProfileOption, 'security'> & {
40
+ security?: TSzRouteSecurity;
41
+ };
42
+ type TSzRouteSourcePolicyPreset = Omit<ISzRouteSourcePolicyPreset, 'bindings'> & {
43
+ bindings: interfaces.data.IRouteSourceBinding[];
44
+ };
35
45
 
36
46
  function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
37
47
  return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
@@ -92,7 +102,7 @@ function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.dat
92
102
  ];
93
103
  }
94
104
 
95
- function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): ISzRouteSourcePolicyPreset[] {
105
+ function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): TSzRouteSourcePolicyPreset[] {
96
106
  const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
97
107
  if (missingNames.length > 0) {
98
108
  return [];
@@ -136,25 +146,40 @@ function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile):
136
146
  function normalizeCatalogRateLimit(
137
147
  rateLimitValue: interfaces.data.IRouteSecurity['rateLimit'] | undefined,
138
148
  ): TSzRouteSecurity['rateLimit'] | undefined {
149
+ if (rateLimitValue === null) return null;
139
150
  if (!rateLimitValue) return undefined;
151
+ const keyBy = ['ip', 'path', 'header'].includes(String(rateLimitValue.keyBy))
152
+ ? rateLimitValue.keyBy as 'ip' | 'path' | 'header'
153
+ : undefined;
140
154
  return {
141
155
  enabled: Boolean(rateLimitValue.enabled),
142
156
  maxRequests: Number(rateLimitValue.maxRequests) || 0,
143
157
  window: Number(rateLimitValue.window) || 0,
144
- ...(rateLimitValue.keyBy ? { keyBy: String(rateLimitValue.keyBy) } : {}),
158
+ ...(keyBy ? { keyBy } : {}),
159
+ ...(rateLimitValue.onExceeded ? { onExceeded: rateLimitValue.onExceeded } : {}),
145
160
  };
146
161
  }
147
162
 
148
- function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): ISzSourceProfileOption[] {
163
+ function normalizeCatalogChallenge(
164
+ challengeValue: interfaces.data.IRouteSecurity['challenge'] | undefined,
165
+ ): TSzRouteSecurity['challenge'] | undefined {
166
+ if (challengeValue === null) return null;
167
+ if (!challengeValue) return undefined;
168
+ return structuredClone(challengeValue) as TSzRouteSecurity['challenge'];
169
+ }
170
+
171
+ function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): TSourceProfileOption[] {
149
172
  return profiles.map((profile) => {
150
173
  const ipAllowList = normalizeSecurityListEntries(profile.security?.ipAllowList);
151
174
  const ipBlockList = normalizeSecurityListEntries(profile.security?.ipBlockList);
152
175
  const rateLimitValue = normalizeCatalogRateLimit(profile.security?.rateLimit);
176
+ const challengeValue = normalizeCatalogChallenge(profile.security?.challenge);
153
177
  const security: TSzRouteSecurity = {
154
178
  ...(ipAllowList.length ? { ipAllowList } : {}),
155
179
  ...(ipBlockList.length ? { ipBlockList } : {}),
156
180
  ...(typeof profile.security?.maxConnections === 'number' ? { maxConnections: profile.security.maxConnections } : {}),
157
- ...(rateLimitValue ? { rateLimit: rateLimitValue } : {}),
181
+ ...(rateLimitValue !== undefined ? { rateLimit: rateLimitValue } : {}),
182
+ ...(challengeValue !== undefined ? { challenge: challengeValue } : {}),
158
183
  };
159
184
  return {
160
185
  id: profile.id,
@@ -21,22 +21,91 @@ function parseOptionalPositiveInteger(value: unknown): number | undefined {
21
21
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
22
22
  }
23
23
 
24
- function buildRateLimitFromFormData(data: Record<string, any>) {
25
- if (!Boolean(data.rateLimitEnabled)) {
24
+ const protectionModeOptions = [
25
+ { key: 'inherit', option: 'Inherit / unset' },
26
+ { key: 'none', option: 'None (clear inherited)' },
27
+ { key: 'custom', option: 'Custom' },
28
+ ];
29
+
30
+ const rateLimitExceededOptions = [
31
+ { key: '429', option: 'Return 429' },
32
+ { key: 'challenge', option: 'Browser challenge' },
33
+ ];
34
+
35
+ function getDropdownKey(value: unknown): string {
36
+ if (typeof value === 'string') {
37
+ return value;
38
+ }
39
+ if (value && typeof value === 'object' && 'key' in value) {
40
+ const key = (value as { key?: unknown }).key;
41
+ return typeof key === 'string' ? key : '';
42
+ }
43
+ return '';
44
+ }
45
+
46
+ function protectionModeForValue(value: unknown): 'inherit' | 'none' | 'custom' {
47
+ if (value === null) return 'none';
48
+ if (value === undefined) return 'inherit';
49
+ return 'custom';
50
+ }
51
+
52
+ function buildChallengeConfigFromFormData(data: Record<string, unknown>): NonNullable<interfaces.data.IRouteSecurity['challenge']> | undefined {
53
+ const providerId = String(data.challengeProviderId || 'smartchallenge').trim();
54
+ const challengeType = String(data.challengeType || 'wait').trim();
55
+ const ttlSeconds = parseOptionalPositiveInteger(data.challengeTtlSeconds) || 300;
56
+ if (!providerId || !challengeType) {
57
+ alert('Challenge requires Provider ID and Challenge Type.');
58
+ return undefined;
59
+ }
60
+ return {
61
+ providerId,
62
+ challengeType,
63
+ ...(challengeType === 'wait' ? { settings: { waitSeconds: 0, ttlSeconds } } : {}),
64
+ clearance: { ttlSeconds, bindToHost: true },
65
+ };
66
+ }
67
+
68
+ function buildRateLimitFromFormData(data: Record<string, unknown>): interfaces.data.IRouteSecurity['rateLimit'] | null | undefined {
69
+ const mode = getDropdownKey(data.rateLimitMode) || (Boolean(data.rateLimitEnabled) ? 'custom' : 'inherit');
70
+ if (mode === 'inherit') {
26
71
  return undefined;
27
72
  }
73
+ if (mode === 'none') {
74
+ return null;
75
+ }
28
76
  const maxRequests = parseOptionalPositiveInteger(data.rateLimitMaxRequests);
29
77
  const window = parseOptionalPositiveInteger(data.rateLimitWindow);
30
78
  if (!maxRequests || !window) {
31
79
  alert('Rate limit requires positive Max Requests and Window values.');
32
80
  return null;
33
81
  }
34
- return {
82
+ const rateLimit: NonNullable<interfaces.data.IRouteSecurity['rateLimit']> = {
35
83
  enabled: true,
36
84
  maxRequests,
37
85
  window,
38
86
  keyBy: 'ip' as const,
39
87
  };
88
+ if (getDropdownKey(data.rateLimitExceededMode) === 'challenge') {
89
+ const challenge = buildChallengeConfigFromFormData(data);
90
+ if (!challenge) return null;
91
+ rateLimit.onExceeded = {
92
+ type: 'challenge',
93
+ challenge,
94
+ clearanceEffect: 'bypass-rate-limit',
95
+ };
96
+ }
97
+ return rateLimit;
98
+ }
99
+
100
+ function buildChallengeFromFormData(data: Record<string, unknown>): interfaces.data.IRouteSecurity['challenge'] | null | undefined {
101
+ const mode = getDropdownKey(data.challengeMode) || 'inherit';
102
+ if (mode === 'inherit') {
103
+ return undefined;
104
+ }
105
+ if (mode === 'none') {
106
+ return null;
107
+ }
108
+ return buildChallengeConfigFromFormData(data) || null;
40
109
  }
41
110
 
42
111
  declare global {
@@ -105,7 +174,14 @@ export class OpsViewSourceProfiles extends DeesElement {
105
174
  'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
106
175
  'IP Block List': (profile.security?.ipBlockList || []).join(', ') || '-',
107
176
  'Max Connections': profile.security?.maxConnections ?? '-',
108
- 'Rate Limit': profile.security?.rateLimit?.enabled ? `${profile.security.rateLimit.maxRequests}/${profile.security.rateLimit.window}s` : '-',
177
+ 'Rate Limit': profile.security?.rateLimit === null
178
+ ? 'none'
179
+ : profile.security?.rateLimit?.enabled
180
+ ? `${profile.security.rateLimit.maxRequests}/${profile.security.rateLimit.window}s${profile.security.rateLimit.onExceeded?.type === 'challenge' ? ' -> challenge' : ''}`
181
+ : '-',
182
+ Challenge: profile.security?.challenge === null
183
+ ? 'none'
184
+ : profile.security?.challenge ? `${profile.security.challenge.providerId}/${profile.security.challenge.challengeType}` : '-',
109
185
  Extends: (profile.extendsProfiles || []).length > 0
110
186
  ? profile.extendsProfiles!.map(id => {
111
187
  const p = profiles.find(pp => pp.id === id);
@@ -165,9 +241,14 @@ export class OpsViewSourceProfiles extends DeesElement {
165
241
  <dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
166
242
  <dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
167
243
  <dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
168
- <dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${false}></dees-input-checkbox>
244
+ <dees-input-dropdown .key=${'rateLimitMode'} .label=${'Rate Limit'} .description=${'Default for route bindings. Use none to clear inherited parent defaults.'} .options=${protectionModeOptions} .selectedOption=${protectionModeOptions[0]}></dees-input-dropdown>
169
245
  <dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'}></dees-input-text>
170
246
  <dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'}></dees-input-text>
247
+ <dees-input-dropdown .key=${'rateLimitExceededMode'} .label=${'When Rate Limit Is Exceeded'} .description=${'Return 429 or issue the configured browser challenge.'} .options=${rateLimitExceededOptions} .selectedOption=${rateLimitExceededOptions[0]}></dees-input-dropdown>
248
+ <dees-input-dropdown .key=${'challengeMode'} .label=${'Browser Challenge'} .description=${'Default for HTTP-visible route bindings.'} .options=${protectionModeOptions} .selectedOption=${protectionModeOptions[0]}></dees-input-dropdown>
249
+ <dees-input-text .key=${'challengeProviderId'} .label=${'Challenge Provider ID'} .value=${'smartchallenge'}></dees-input-text>
250
+ <dees-input-text .key=${'challengeType'} .label=${'Challenge Type'} .value=${'wait'}></dees-input-text>
251
+ <dees-input-text .key=${'challengeTtlSeconds'} .label=${'Clearance TTL Seconds'} .value=${'300'}></dees-input-text>
171
252
  </dees-form>
172
253
  `,
173
254
  menuOptions: [
@@ -182,7 +263,9 @@ export class OpsViewSourceProfiles extends DeesElement {
182
263
  const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
183
264
  const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
184
265
  const rateLimit = buildRateLimitFromFormData(data);
185
- if (rateLimit === null) return;
266
+ if (rateLimit === null && getDropdownKey(data.rateLimitMode) === 'custom') return;
267
+ const challenge = buildChallengeFromFormData(data);
268
+ if (challenge === null && getDropdownKey(data.challengeMode) === 'custom') return;
186
269
 
187
270
  await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
188
271
  name: String(data.name),
@@ -191,7 +274,8 @@ export class OpsViewSourceProfiles extends DeesElement {
191
274
  ...(ipAllowList.length > 0 ? { ipAllowList } : {}),
192
275
  ...(ipBlockList.length > 0 ? { ipBlockList } : {}),
193
276
  ...(maxConnections ? { maxConnections } : {}),
194
- ...(rateLimit ? { rateLimit } : {}),
277
+ ...(rateLimit !== undefined ? { rateLimit } : {}),
278
+ ...(challenge !== undefined ? { challenge } : {}),
195
279
  },
196
280
  });
197
281
  modalArg.destroy();
@@ -203,6 +287,16 @@ export class OpsViewSourceProfiles extends DeesElement {
203
287
 
204
288
  private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) {
205
289
  const { DeesModal } = await import('@design.estate/dees-catalog');
290
+ const rateLimitMode = protectionModeForValue(profile.security?.rateLimit);
291
+ const rateLimitExceededMode = profile.security?.rateLimit && profile.security.rateLimit !== null && profile.security.rateLimit.onExceeded?.type === 'challenge'
292
+ ? 'challenge'
293
+ : '429';
294
+ const challengeMode = protectionModeForValue(profile.security?.challenge);
295
+ const challengeDefaults = profile.security?.rateLimit && profile.security.rateLimit !== null && profile.security.rateLimit.onExceeded?.type === 'challenge'
296
+ ? profile.security.rateLimit.onExceeded.challenge
297
+ : profile.security?.challenge && profile.security.challenge !== null
298
+ ? profile.security.challenge
299
+ : undefined;
206
300
  DeesModal.createAndShow({
207
301
  heading: `Edit Profile: ${profile.name}`,
208
302
  content: html`
@@ -212,9 +306,14 @@ export class OpsViewSourceProfiles extends DeesElement {
212
306
  <dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipAllowList || []}></dees-input-list>
213
307
  <dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipBlockList || []}></dees-input-list>
214
308
  <dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
215
- <dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${profile.security?.rateLimit?.enabled === true}></dees-input-checkbox>
309
+ <dees-input-dropdown .key=${'rateLimitMode'} .label=${'Rate Limit'} .description=${'Default for route bindings. Use none to clear inherited parent defaults.'} .options=${protectionModeOptions} .selectedOption=${protectionModeOptions.find((option) => option.key === rateLimitMode) || protectionModeOptions[0]}></dees-input-dropdown>
216
310
  <dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'} .value=${String(profile.security?.rateLimit?.maxRequests || '')}></dees-input-text>
217
311
  <dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'} .value=${String(profile.security?.rateLimit?.window || '')}></dees-input-text>
312
+ <dees-input-dropdown .key=${'rateLimitExceededMode'} .label=${'When Rate Limit Is Exceeded'} .description=${'Return 429 or issue the configured browser challenge.'} .options=${rateLimitExceededOptions} .selectedOption=${rateLimitExceededOptions.find((option) => option.key === rateLimitExceededMode) || rateLimitExceededOptions[0]}></dees-input-dropdown>
313
+ <dees-input-dropdown .key=${'challengeMode'} .label=${'Browser Challenge'} .description=${'Default for HTTP-visible route bindings.'} .options=${protectionModeOptions} .selectedOption=${protectionModeOptions.find((option) => option.key === challengeMode) || protectionModeOptions[0]}></dees-input-dropdown>
314
+ <dees-input-text .key=${'challengeProviderId'} .label=${'Challenge Provider ID'} .value=${challengeDefaults?.providerId || 'smartchallenge'}></dees-input-text>
315
+ <dees-input-text .key=${'challengeType'} .label=${'Challenge Type'} .value=${challengeDefaults?.challengeType || 'wait'}></dees-input-text>
316
+ <dees-input-text .key=${'challengeTtlSeconds'} .label=${'Clearance TTL Seconds'} .value=${String(challengeDefaults?.clearance?.ttlSeconds || 300)}></dees-input-text>
218
317
  </dees-form>
219
318
  `,
220
319
  menuOptions: [
@@ -229,7 +328,9 @@ export class OpsViewSourceProfiles extends DeesElement {
229
328
  const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
230
329
  const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
231
330
  const rateLimit = buildRateLimitFromFormData(data);
232
- if (rateLimit === null) return;
331
+ if (rateLimit === null && getDropdownKey(data.rateLimitMode) === 'custom') return;
332
+ const challenge = buildChallengeFromFormData(data);
333
+ if (challenge === null && getDropdownKey(data.challengeMode) === 'custom') return;
233
334
 
234
335
  await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
235
336
  id: profile.id,
@@ -239,8 +340,8 @@ export class OpsViewSourceProfiles extends DeesElement {
239
340
  ipAllowList,
240
341
  ipBlockList,
241
342
  ...(maxConnections ? { maxConnections } : {}),
242
- ...(rateLimit ? { rateLimit } : {}),
243
- ...(profile.security?.challenge ? { challenge: profile.security.challenge } : {}),
343
+ ...(rateLimit !== undefined ? { rateLimit } : {}),
344
+ ...(challenge !== undefined ? { challenge } : {}),
244
345
  },
245
346
  });
246
347
  modalArg.destroy();