@serve.zone/dcrouter 13.41.2 → 13.42.1

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 (31) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1025 -983
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/classes.db-seeder.js +29 -2
  5. package/dist_ts/config/classes.reference-resolver.d.ts +5 -0
  6. package/dist_ts/config/classes.reference-resolver.js +79 -15
  7. package/dist_ts/config/classes.route-config-manager.d.ts +5 -1
  8. package/dist_ts/config/classes.route-config-manager.js +136 -6
  9. package/dist_ts/config/classes.source-policy-compiler.d.ts +35 -0
  10. package/dist_ts/config/classes.source-policy-compiler.js +497 -0
  11. package/dist_ts/config/index.d.ts +1 -0
  12. package/dist_ts/config/index.js +2 -1
  13. package/dist_ts_interfaces/data/route-management.d.ts +39 -0
  14. package/dist_ts_interfaces/data/route-management.js +65 -1
  15. package/dist_ts_migrations/index.js +67 -1
  16. package/dist_ts_web/00_commitinfo_data.js +1 -1
  17. package/dist_ts_web/elements/network/ops-view-routes.d.ts +2 -0
  18. package/dist_ts_web/elements/network/ops-view-routes.js +237 -11
  19. package/dist_ts_web/elements/network/ops-view-sourceprofiles.js +42 -5
  20. package/package.json +4 -4
  21. package/readme.md +74 -0
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/config/classes.db-seeder.ts +28 -1
  24. package/ts/config/classes.reference-resolver.ts +94 -14
  25. package/ts/config/classes.route-config-manager.ts +162 -5
  26. package/ts/config/classes.source-policy-compiler.ts +614 -0
  27. package/ts/config/index.ts +1 -0
  28. package/ts/readme.md +1 -1
  29. package/ts_web/00_commitinfo_data.ts +1 -1
  30. package/ts_web/elements/network/ops-view-routes.ts +257 -10
  31. package/ts_web/elements/network/ops-view-sourceprofiles.ts +41 -4
@@ -24,11 +24,176 @@ const tlsCertOptions = [
24
24
  { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
25
25
  { key: 'custom', option: 'Custom certificate' },
26
26
  ];
27
+ const sourcePolicyPresetOptions = [
28
+ { key: 'manual', option: 'Manual source policy' },
29
+ { key: 'gitea', option: 'Gitea bot protection' },
30
+ ];
31
+ const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
32
+
33
+ function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
34
+ return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
35
+ }
27
36
 
28
37
  function getDropdownKey(value: any): string {
29
38
  return typeof value === 'string' ? value : value?.key || '';
30
39
  }
31
40
 
41
+ function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[] {
42
+ const refs: string[] = [];
43
+ for (let index = 0; index < 4; index++) {
44
+ const ref = getDropdownKey(formData[`sourcePolicyProfileRef${index}`]);
45
+ if (ref && !refs.includes(ref)) {
46
+ refs.push(ref);
47
+ }
48
+ }
49
+ return refs;
50
+ }
51
+
52
+ function buildSourcePolicyMetadata(
53
+ profileRefs: string[],
54
+ existingSourcePolicy?: interfaces.data.IRouteSourcePolicy,
55
+ ): interfaces.data.IRouteSourcePolicy {
56
+ return {
57
+ bindings: profileRefs.map((sourceProfileRef) => {
58
+ const existingBinding = existingSourcePolicy?.bindings.find((binding) => binding.sourceProfileRef === sourceProfileRef);
59
+ return existingBinding
60
+ ? {
61
+ ...existingBinding,
62
+ sourceProfileRef,
63
+ onExceeded: existingBinding.onExceeded || { type: '429' as const },
64
+ }
65
+ : {
66
+ sourceProfileRef,
67
+ onExceeded: { type: '429' as const },
68
+ };
69
+ }),
70
+ };
71
+ }
72
+
73
+ function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
74
+ refs: string[];
75
+ missingNames: string[];
76
+ } {
77
+ const refs: string[] = [];
78
+ const missingNames: string[] = [];
79
+ for (const profileName of giteaSourcePolicyProfileNames) {
80
+ const profile = profiles.find((item) => item.name.trim().toUpperCase() === profileName);
81
+ if (profile) {
82
+ refs.push(profile.id);
83
+ } else {
84
+ missingNames.push(profileName);
85
+ }
86
+ }
87
+ return { refs, missingNames };
88
+ }
89
+
90
+ function buildGiteaSourcePolicyMetadata(profileRefs: string[]): interfaces.data.IRouteSourcePolicy {
91
+ const [trustedRef, aiRef, publicRef] = profileRefs;
92
+ return {
93
+ bindings: [
94
+ {
95
+ sourceProfileRef: trustedRef,
96
+ onExceeded: { type: '429' as const },
97
+ },
98
+ {
99
+ sourceProfileRef: aiRef,
100
+ onExceeded: { type: '429' as const },
101
+ pathPolicies: [
102
+ { pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
103
+ { pathClass: 'static', rateLimit: rateLimit(240) },
104
+ { pathClass: 'raw', rateLimit: rateLimit(20) },
105
+ { pathClass: 'archive', rateLimit: rateLimit(6) },
106
+ { pathClass: 'expensive-html', rateLimit: rateLimit(6) },
107
+ { pathClass: 'normal-html', rateLimit: rateLimit(20) },
108
+ ],
109
+ },
110
+ {
111
+ sourceProfileRef: publicRef,
112
+ onExceeded: { type: '429' as const },
113
+ pathPolicies: [
114
+ { pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
115
+ { pathClass: 'static', rateLimit: rateLimit(600) },
116
+ { pathClass: 'raw', rateLimit: rateLimit(120) },
117
+ { pathClass: 'archive', rateLimit: rateLimit(30) },
118
+ { pathClass: 'expensive-html', rateLimit: rateLimit(30) },
119
+ { pathClass: 'normal-html', rateLimit: rateLimit(120) },
120
+ ],
121
+ },
122
+ ],
123
+ };
124
+ }
125
+
126
+ function getGiteaPresetSourcePolicy(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourcePolicy | null {
127
+ const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
128
+ if (missingNames.length > 0) {
129
+ alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
130
+ return null;
131
+ }
132
+ if (!validateSourcePolicySelection(refs, profiles)) {
133
+ return null;
134
+ }
135
+ return buildGiteaSourcePolicyMetadata(refs);
136
+ }
137
+
138
+ function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
139
+ return Boolean(metadata?.sourcePolicy?.bindings.some((binding) => binding.pathPolicies?.length));
140
+ }
141
+
142
+ function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
143
+ return (profile.security?.ipAllowList || []).some((entry) => {
144
+ const source = typeof entry === 'string' ? entry : entry.ip;
145
+ return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
146
+ });
147
+ }
148
+
149
+ function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
150
+ return (profile.security?.ipAllowList || []).some((entry) => {
151
+ const source = typeof entry === 'string' ? entry : entry.ip;
152
+ return source.trim().length > 0;
153
+ });
154
+ }
155
+
156
+ function validateSourcePolicySelection(
157
+ profileRefs: string[],
158
+ profiles: interfaces.data.ISourceProfile[],
159
+ ): boolean {
160
+ if (profileRefs.length === 0) {
161
+ return true;
162
+ }
163
+
164
+ const selectedProfiles = profileRefs
165
+ .map((profileRef) => profiles.find((profile) => profile.id === profileRef))
166
+ .filter(Boolean) as interfaces.data.ISourceProfile[];
167
+
168
+ if (selectedProfiles.length !== profileRefs.length) {
169
+ alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
170
+ return false;
171
+ }
172
+
173
+ const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
174
+ if (profilesWithoutMatches.length > 0) {
175
+ alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
176
+ return false;
177
+ }
178
+
179
+ const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
180
+ if (!sourceProfileMatchesAll(fallbackProfile)) {
181
+ alert('Source policy needs an explicit public/wildcard fallback profile as the last binding. Add a profile with IP Allow List "*".');
182
+ return false;
183
+ }
184
+
185
+ if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
186
+ alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
187
+ return false;
188
+ }
189
+
190
+ if (fallbackProfile.security?.rateLimit?.enabled !== true) {
191
+ return confirm(`The fallback profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
192
+ }
193
+
194
+ return true;
195
+ }
196
+
32
197
  function parseTargetPort(value: any): number | undefined {
33
198
  const parsed = typeof value === 'number'
34
199
  ? value
@@ -355,6 +520,7 @@ export class OpsViewRoutes extends DeesElement {
355
520
 
356
521
  const meta = merged.metadata;
357
522
  const isSystemManaged = this.isSystemManagedRoute(merged);
523
+ const sourcePolicySummary = this.describeSourcePolicy(meta);
358
524
  await DeesModal.createAndShow({
359
525
  heading: `Route: ${merged.route.name}`,
360
526
  content: html`
@@ -364,7 +530,7 @@ export class OpsViewRoutes extends DeesElement {
364
530
  ${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
365
531
  <p>ID: <code style="color: #888;">${merged.id}</code></p>
366
532
  ${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
367
- ${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
533
+ ${sourcePolicySummary ? html`<p>Source Policy: <strong style="color: #a78bfa;">${sourcePolicySummary}</strong></p>` : ''}
368
534
  ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
369
535
  </div>
370
536
  `,
@@ -496,6 +662,8 @@ export class OpsViewRoutes extends DeesElement {
496
662
  const currentVpnOnly = route.vpnOnly === true;
497
663
  const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
498
664
  const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
665
+ const currentSourcePolicyRefs = this.getSourcePolicyRefs(merged.metadata);
666
+ const currentSourcePolicyPreset = metadataUsesPathPolicies(merged.metadata) ? 'gitea' : 'manual';
499
667
 
500
668
  // Compute current TLS state for pre-population
501
669
  const currentTls = (route.action as any).tls;
@@ -516,7 +684,25 @@ export class OpsViewRoutes extends DeesElement {
516
684
  <dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
517
685
  <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
518
686
  <dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
519
- <dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
687
+ <div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
688
+ <strong>Source Policy</strong>
689
+ <small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
690
+ <dees-input-dropdown
691
+ .key=${'sourcePolicyPreset'}
692
+ .label=${'Source Policy Preset'}
693
+ .options=${sourcePolicyPresetOptions}
694
+ .selectedOption=${sourcePolicyPresetOptions.find((o) => o.key === currentSourcePolicyPreset) || sourcePolicyPresetOptions[0]}
695
+ ></dees-input-dropdown>
696
+ <small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
697
+ ${[0, 1, 2, 3].map((index) => html`
698
+ <dees-input-dropdown
699
+ .key=${`sourcePolicyProfileRef${index}`}
700
+ .label=${`Source Profile ${index + 1}`}
701
+ .options=${profileOptions}
702
+ .selectedOption=${profileOptions.find((o) => o.key === (currentSourcePolicyRefs[index] || '')) || profileOptions[0]}
703
+ ></dees-input-dropdown>
704
+ `)}
705
+ </div>
520
706
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
521
707
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
522
708
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
@@ -557,7 +743,11 @@ export class OpsViewRoutes extends DeesElement {
557
743
  : [];
558
744
  const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
559
745
 
560
- const profileKey = getDropdownKey(formData.sourceProfileRef);
746
+ const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
747
+ const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
748
+ ? []
749
+ : getSourcePolicyRefsFromFormData(formData);
750
+ if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
561
751
  const targetKey = getDropdownKey(formData.networkTargetRef);
562
752
  const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
563
753
  const targetPort = preserveMatchPort
@@ -621,9 +811,18 @@ export class OpsViewRoutes extends DeesElement {
621
811
  }
622
812
 
623
813
  const metadata: any = {};
624
- if (profileKey) {
625
- metadata.sourceProfileRef = profileKey;
626
- } else if (merged.metadata?.sourceProfileRef) {
814
+ if (sourcePolicyPreset === 'gitea') {
815
+ const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
816
+ if (!sourcePolicy) return;
817
+ metadata.sourcePolicy = sourcePolicy;
818
+ metadata.sourceProfileRef = '';
819
+ metadata.sourceProfileName = '';
820
+ } else if (sourcePolicyRefs.length > 0) {
821
+ metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs, merged.metadata?.sourcePolicy);
822
+ metadata.sourceProfileRef = '';
823
+ metadata.sourceProfileName = '';
824
+ } else if (merged.metadata?.sourcePolicy || merged.metadata?.sourceProfileRef) {
825
+ metadata.sourcePolicy = { bindings: [] };
627
826
  metadata.sourceProfileRef = '';
628
827
  metadata.sourceProfileName = '';
629
828
  }
@@ -685,7 +884,25 @@ export class OpsViewRoutes extends DeesElement {
685
884
  <dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
686
885
  <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
687
886
  <dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
688
- <dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
887
+ <div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
888
+ <strong>Source Policy</strong>
889
+ <small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
890
+ <dees-input-dropdown
891
+ .key=${'sourcePolicyPreset'}
892
+ .label=${'Source Policy Preset'}
893
+ .options=${sourcePolicyPresetOptions}
894
+ .selectedOption=${sourcePolicyPresetOptions[0]}
895
+ ></dees-input-dropdown>
896
+ <small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
897
+ ${[0, 1, 2, 3].map((index) => html`
898
+ <dees-input-dropdown
899
+ .key=${`sourcePolicyProfileRef${index}`}
900
+ .label=${`Source Profile ${index + 1}`}
901
+ .options=${profileOptions}
902
+ .selectedOption=${profileOptions[0]}
903
+ ></dees-input-dropdown>
904
+ `)}
905
+ </div>
689
906
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
690
907
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
691
908
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
@@ -726,7 +943,11 @@ export class OpsViewRoutes extends DeesElement {
726
943
  : [];
727
944
  const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
728
945
 
729
- const profileKey = getDropdownKey(formData.sourceProfileRef);
946
+ const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
947
+ const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
948
+ ? []
949
+ : getSourcePolicyRefsFromFormData(formData);
950
+ if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
730
951
  const targetKey = getDropdownKey(formData.networkTargetRef);
731
952
  const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
732
953
  const targetPort = preserveMatchPort
@@ -791,8 +1012,12 @@ export class OpsViewRoutes extends DeesElement {
791
1012
 
792
1013
  // Build metadata if profile/target selected
793
1014
  const metadata: any = {};
794
- if (profileKey) {
795
- metadata.sourceProfileRef = profileKey;
1015
+ if (sourcePolicyPreset === 'gitea') {
1016
+ const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
1017
+ if (!sourcePolicy) return;
1018
+ metadata.sourcePolicy = sourcePolicy;
1019
+ } else if (sourcePolicyRefs.length > 0) {
1020
+ metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs);
796
1021
  }
797
1022
  if (targetKey) {
798
1023
  metadata.networkTargetRef = targetKey;
@@ -823,6 +1048,28 @@ export class OpsViewRoutes extends DeesElement {
823
1048
  appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
824
1049
  }
825
1050
 
1051
+ private getSourcePolicyRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
1052
+ const policyRefs = metadata?.sourcePolicy?.bindings
1053
+ ?.map((binding) => binding.sourceProfileRef)
1054
+ .filter(Boolean) || [];
1055
+ if (policyRefs.length > 0) {
1056
+ return policyRefs;
1057
+ }
1058
+ return metadata?.sourceProfileRef ? [metadata.sourceProfileRef] : [];
1059
+ }
1060
+
1061
+ private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
1062
+ const refs = this.getSourcePolicyRefs(metadata);
1063
+ if (refs.length === 0) {
1064
+ return '';
1065
+ }
1066
+ return refs.map((ref) => {
1067
+ const binding = metadata?.sourcePolicy?.bindings?.find((item) => item.sourceProfileRef === ref);
1068
+ const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref);
1069
+ return binding?.sourceProfileName || profile?.name || ref.slice(0, 8);
1070
+ }).join(' → ');
1071
+ }
1072
+
826
1073
  private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
827
1074
  if (clickedRoute.id) {
828
1075
  const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
@@ -12,6 +12,33 @@ import * as interfaces from '../../../dist_ts_interfaces/index.js';
12
12
  import { viewHostCss } from '../shared/css.js';
13
13
  import { type IStatsTile } from '@design.estate/dees-catalog';
14
14
 
15
+ function parseOptionalPositiveInteger(value: unknown): number | undefined {
16
+ const parsed = typeof value === 'number'
17
+ ? value
18
+ : typeof value === 'string'
19
+ ? parseInt(value.trim(), 10)
20
+ : Number.NaN;
21
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
22
+ }
23
+
24
+ function buildRateLimitFromFormData(data: Record<string, any>) {
25
+ if (!Boolean(data.rateLimitEnabled)) {
26
+ return undefined;
27
+ }
28
+ const maxRequests = parseOptionalPositiveInteger(data.rateLimitMaxRequests);
29
+ const window = parseOptionalPositiveInteger(data.rateLimitWindow);
30
+ if (!maxRequests || !window) {
31
+ alert('Rate limit requires positive Max Requests and Window values.');
32
+ return null;
33
+ }
34
+ return {
35
+ enabled: true,
36
+ maxRequests,
37
+ window,
38
+ keyBy: 'ip' as const,
39
+ };
40
+ }
41
+
15
42
  declare global {
16
43
  interface HTMLElementTagNameMap {
17
44
  'ops-view-sourceprofiles': OpsViewSourceProfiles;
@@ -138,6 +165,9 @@ export class OpsViewSourceProfiles extends DeesElement {
138
165
  <dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
139
166
  <dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
140
167
  <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>
169
+ <dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'}></dees-input-text>
170
+ <dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'}></dees-input-text>
141
171
  </dees-form>
142
172
  `,
143
173
  menuOptions: [
@@ -150,8 +180,9 @@ export class OpsViewSourceProfiles extends DeesElement {
150
180
  const data = await form.collectFormData();
151
181
  const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
152
182
  const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
153
- const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
154
- const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
183
+ const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
184
+ const rateLimit = buildRateLimitFromFormData(data);
185
+ if (rateLimit === null) return;
155
186
 
156
187
  await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
157
188
  name: String(data.name),
@@ -160,6 +191,7 @@ export class OpsViewSourceProfiles extends DeesElement {
160
191
  ...(ipAllowList.length > 0 ? { ipAllowList } : {}),
161
192
  ...(ipBlockList.length > 0 ? { ipBlockList } : {}),
162
193
  ...(maxConnections ? { maxConnections } : {}),
194
+ ...(rateLimit ? { rateLimit } : {}),
163
195
  },
164
196
  });
165
197
  modalArg.destroy();
@@ -180,6 +212,9 @@ export class OpsViewSourceProfiles extends DeesElement {
180
212
  <dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipAllowList || []}></dees-input-list>
181
213
  <dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipBlockList || []}></dees-input-list>
182
214
  <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>
216
+ <dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'} .value=${String(profile.security?.rateLimit?.maxRequests || '')}></dees-input-text>
217
+ <dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'} .value=${String(profile.security?.rateLimit?.window || '')}></dees-input-text>
183
218
  </dees-form>
184
219
  `,
185
220
  menuOptions: [
@@ -192,8 +227,9 @@ export class OpsViewSourceProfiles extends DeesElement {
192
227
  const data = await form.collectFormData();
193
228
  const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
194
229
  const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
195
- const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
196
- const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
230
+ const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
231
+ const rateLimit = buildRateLimitFromFormData(data);
232
+ if (rateLimit === null) return;
197
233
 
198
234
  await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
199
235
  id: profile.id,
@@ -203,6 +239,7 @@ export class OpsViewSourceProfiles extends DeesElement {
203
239
  ipAllowList,
204
240
  ipBlockList,
205
241
  ...(maxConnections ? { maxConnections } : {}),
242
+ ...(rateLimit ? { rateLimit } : {}),
206
243
  },
207
244
  });
208
245
  modalArg.destroy();