@serve.zone/dcrouter 13.44.0 → 13.45.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.
@@ -1172,7 +1172,7 @@ export class DcRouter {
1172
1172
  // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
1173
1173
  let routes: plugins.smartproxy.IRouteConfig[] = [
1174
1174
  ...this.seedConfigRoutes,
1175
- ...this.seedEmailRoutes,
1175
+ ...this.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
1176
1176
  ...this.runtimeDnsRoutes,
1177
1177
  ];
1178
1178
 
@@ -1715,6 +1715,115 @@ export class DcRouter {
1715
1715
  return dnsRoutes;
1716
1716
  }
1717
1717
 
1718
+ private getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
1719
+ return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
1720
+ }
1721
+
1722
+ private getCurrentGeneratedEmailRouteNames(): Set<string> {
1723
+ const sourceRoutes = this.seedEmailRoutes.length > 0
1724
+ ? this.seedEmailRoutes
1725
+ : this.options.emailConfig
1726
+ ? this.generateEmailRoutes(this.options.emailConfig)
1727
+ : [];
1728
+ return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
1729
+ }
1730
+
1731
+ private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
1732
+ if (storedRoute.origin !== 'email') {
1733
+ return false;
1734
+ }
1735
+ const routeName = storedRoute.route.name;
1736
+ if (!routeName || !this.getCurrentGeneratedEmailRouteNames().has(routeName)) {
1737
+ return false;
1738
+ }
1739
+ const expectedSystemKey = `email:${routeName}`;
1740
+ return !storedRoute.systemKey || storedRoute.systemKey === expectedSystemKey;
1741
+ }
1742
+
1743
+ private createServerFirstEmailRuntimeRoute(
1744
+ route: plugins.smartproxy.IRouteConfig,
1745
+ ): plugins.smartproxy.IRouteConfig | undefined {
1746
+ const action = route.action as any;
1747
+ if (action?.type !== 'forward') {
1748
+ return undefined;
1749
+ }
1750
+ const tlsMode = action.tls?.mode;
1751
+ if (tlsMode === 'terminate' || tlsMode === 'terminate-and-reencrypt') {
1752
+ return undefined;
1753
+ }
1754
+ const routePorts = plugins.smartproxy.expandPortRange(route.match?.ports as any) as number[];
1755
+ if (routePorts.length !== 1) {
1756
+ return undefined;
1757
+ }
1758
+
1759
+ const target = action.targets?.[0];
1760
+ if (!target || action.targets.length !== 1 || typeof target.port !== 'number') {
1761
+ return undefined;
1762
+ }
1763
+ if (typeof target.host !== 'string') {
1764
+ return undefined;
1765
+ }
1766
+
1767
+ const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host;
1768
+ return {
1769
+ ...route,
1770
+ action: {
1771
+ type: 'socket-handler' as any,
1772
+ socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
1773
+ } as any,
1774
+ };
1775
+ }
1776
+
1777
+ private createEmailSocketProxyHandler(
1778
+ targetHost: string,
1779
+ targetPort: number,
1780
+ ): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
1781
+ return (clientSocket) => {
1782
+ let backendSocket: plugins.net.Socket | undefined;
1783
+ let connectTimeout: ReturnType<typeof setTimeout> & { unref?: () => void };
1784
+ let cleanupDone = false;
1785
+
1786
+ const cleanup = () => {
1787
+ if (cleanupDone) return;
1788
+ cleanupDone = true;
1789
+ clearTimeout(connectTimeout);
1790
+ clientSocket.removeListener('timeout', cleanup);
1791
+ clientSocket.removeListener('error', cleanup);
1792
+ clientSocket.removeListener('end', cleanup);
1793
+ clientSocket.removeListener('close', cleanup);
1794
+ backendSocket?.removeListener('timeout', cleanup);
1795
+ backendSocket?.removeListener('error', cleanup);
1796
+ backendSocket?.removeListener('end', cleanup);
1797
+ backendSocket?.removeListener('close', cleanup);
1798
+ clientSocket.destroy();
1799
+ backendSocket?.destroy();
1800
+ };
1801
+
1802
+ connectTimeout = setTimeout(() => {
1803
+ cleanup();
1804
+ }, 30_000);
1805
+ connectTimeout.unref?.();
1806
+
1807
+ clientSocket.setTimeout(300_000);
1808
+ clientSocket.on('timeout', cleanup);
1809
+ clientSocket.on('error', cleanup);
1810
+ clientSocket.on('end', cleanup);
1811
+ clientSocket.on('close', cleanup);
1812
+
1813
+ backendSocket = plugins.net.connect(targetPort, targetHost, () => {
1814
+ clearTimeout(connectTimeout);
1815
+ backendSocket?.setTimeout(300_000);
1816
+ clientSocket.pipe(backendSocket!);
1817
+ backendSocket!.pipe(clientSocket);
1818
+ });
1819
+ backendSocket.setTimeout(30_000);
1820
+ backendSocket.on('timeout', cleanup);
1821
+ backendSocket.on('error', cleanup);
1822
+ backendSocket.on('end', cleanup);
1823
+ backendSocket.on('close', cleanup);
1824
+ };
1825
+ }
1826
+
1718
1827
  private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
1719
1828
  const routeName = storedRoute.route.name || '';
1720
1829
  const isDohRoute = storedRoute.origin === 'dns'
@@ -1722,6 +1831,9 @@ export class DcRouter {
1722
1831
  && routeName.startsWith('dns-over-https-');
1723
1832
 
1724
1833
  if (!isDohRoute) {
1834
+ if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
1835
+ return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
1836
+ }
1725
1837
  return undefined;
1726
1838
  }
1727
1839
 
@@ -1,7 +1,8 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import { CachedDocument, TTL } from '../classes.cached.document.js';
3
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
4
3
 
4
+ const TTL = plugins.smartdata.smartdataTtlValues;
5
+
5
6
  /**
6
7
  * Email status in the cache
7
8
  */
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
19
20
  * and maintaining email history for the configured TTL period.
20
21
  */
21
22
  @plugins.smartdata.Collection(() => getDb())
22
- export class CachedEmail extends CachedDocument<CachedEmail> {
23
- // TTL fields from base class (decorators required on concrete class)
24
- @plugins.smartdata.svDb()
25
- public createdAt: Date = new Date();
26
-
27
- @plugins.smartdata.svDb()
28
- public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
29
-
30
- @plugins.smartdata.svDb()
31
- public lastAccessedAt: Date = new Date();
32
-
23
+ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
33
24
  /**
34
25
  * Unique identifier for this email
35
26
  */
@@ -1,7 +1,8 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import { CachedDocument, TTL } from '../classes.cached.document.js';
3
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
4
3
 
4
+ const TTL = plugins.smartdata.smartdataTtlValues;
5
+
5
6
  /**
6
7
  * Helper to get the smartdata database instance
7
8
  */
@@ -29,17 +30,7 @@ export interface IIPReputationData {
29
30
  * external API calls. Default TTL is 24 hours.
30
31
  */
31
32
  @plugins.smartdata.Collection(() => getDb())
32
- export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
33
- // TTL fields from base class (decorators required on concrete class)
34
- @plugins.smartdata.svDb()
35
- public createdAt: Date = new Date();
36
-
37
- @plugins.smartdata.svDb()
38
- public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
39
-
40
- @plugins.smartdata.svDb()
41
- public lastAccessedAt: Date = new Date();
42
-
33
+ export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
43
34
  /**
44
35
  * IP address (unique identifier)
45
36
  */
package/ts/db/index.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  // Unified database manager
2
2
  export * from './classes.dcrouter-db.js';
3
3
 
4
- // TTL base class and constants
5
- export * from './classes.cached.document.js';
6
-
7
4
  // Cache cleaner
8
5
  export * from './classes.cache.cleaner.js';
9
6
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.44.0',
6
+ version: '13.45.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -2,6 +2,12 @@ import * as appstate from '../../appstate.js';
2
2
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
3
3
  import { viewHostCss } from '../shared/css.js';
4
4
  import { type IStatsTile } from '@design.estate/dees-catalog';
5
+ import type {
6
+ IRoutePathClassOption as ISzRoutePathClassOption,
7
+ IRouteSourcePolicyPreset as ISzRouteSourcePolicyPreset,
8
+ ISourceProfileOption as ISzSourceProfileOption,
9
+ SzInputRouteSourcePolicy,
10
+ } from '@serve.zone/catalog';
5
11
 
6
12
  import {
7
13
  DeesElement,
@@ -24,8 +30,8 @@ const tlsCertOptions = [
24
30
  { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
25
31
  { key: 'custom', option: 'Custom certificate' },
26
32
  ];
27
- const maxSourceBindingRows = 16;
28
33
  const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
34
+ type TSzRouteSecurity = NonNullable<ISzSourceProfileOption['security']>;
29
35
 
30
36
  function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
31
37
  return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
@@ -35,36 +41,6 @@ function getDropdownKey(value: any): string {
35
41
  return typeof value === 'string' ? value : value?.key || '';
36
42
  }
37
43
 
38
- function getSourceBindingRefsFromFormData(formData: Record<string, any>): string[] {
39
- const refs: string[] = [];
40
- for (let index = 0; index < maxSourceBindingRows; index++) {
41
- const ref = getDropdownKey(formData[`sourceBindingProfileRef${index}`]);
42
- if (ref && !refs.includes(ref)) {
43
- refs.push(ref);
44
- }
45
- }
46
- return refs;
47
- }
48
-
49
- function buildSourceBindingsMetadata(
50
- profileRefs: string[],
51
- existingSourceBindings?: interfaces.data.IRouteSourceBinding[],
52
- ): interfaces.data.IRouteSourceBinding[] {
53
- return profileRefs.map((sourceProfileRef) => {
54
- const existingBinding = existingSourceBindings?.find((binding) => binding.sourceProfileRef === sourceProfileRef);
55
- return existingBinding
56
- ? {
57
- ...existingBinding,
58
- sourceProfileRef,
59
- onExceeded: existingBinding.onExceeded || { type: '429' as const },
60
- }
61
- : {
62
- sourceProfileRef,
63
- onExceeded: { type: '429' as const },
64
- };
65
- });
66
- }
67
-
68
44
  function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
69
45
  refs: string[];
70
46
  missingNames: string[];
@@ -116,70 +92,111 @@ function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.dat
116
92
  ];
117
93
  }
118
94
 
119
- function getGiteaPresetSourceBindings(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourceBinding[] | null {
95
+ function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): ISzRouteSourcePolicyPreset[] {
120
96
  const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
121
97
  if (missingNames.length > 0) {
122
- alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
123
- return null;
124
- }
125
- if (!validateSourceBindingSelection(refs, profiles)) {
126
- return null;
98
+ return [];
127
99
  }
128
- return buildGiteaSourceBindingsMetadata(refs);
100
+ return [
101
+ {
102
+ key: 'gitea-bot-protection',
103
+ label: 'Gitea bot protection',
104
+ description: 'TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC with path-class rate limits.',
105
+ bindings: buildGiteaSourceBindingsMetadata(refs),
106
+ },
107
+ ];
129
108
  }
130
109
 
131
- function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
132
- return Boolean(metadata?.sourceBindings?.some((binding) => binding.pathPolicies?.length));
110
+ function normalizeSecurityListEntries(entries: unknown): string[] {
111
+ if (!Array.isArray(entries)) {
112
+ return [];
113
+ }
114
+ return entries
115
+ .map((entry) => {
116
+ if (typeof entry === 'string') return entry.trim();
117
+ if (entry && typeof entry === 'object' && 'ip' in entry) {
118
+ const ip = (entry as Record<string, unknown>).ip;
119
+ return typeof ip === 'string' ? ip.trim() : '';
120
+ }
121
+ return '';
122
+ })
123
+ .filter(Boolean);
133
124
  }
134
125
 
135
126
  function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
136
- return (profile.security?.ipAllowList || []).some((entry) => {
137
- const source = typeof entry === 'string' ? entry : entry.ip;
127
+ return normalizeSecurityListEntries(profile.security?.ipAllowList).some((source) => {
138
128
  return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
139
129
  });
140
130
  }
141
131
 
142
132
  function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
143
- return (profile.security?.ipAllowList || []).some((entry) => {
144
- const source = typeof entry === 'string' ? entry : entry.ip;
145
- return source.trim().length > 0;
146
- });
133
+ return normalizeSecurityListEntries(profile.security?.ipAllowList).length > 0;
147
134
  }
148
135
 
149
- function validateSourceBindingSelection(
150
- profileRefs: string[],
151
- profiles: interfaces.data.ISourceProfile[],
152
- ): boolean {
153
- if (profileRefs.length === 0) {
154
- return true;
155
- }
156
-
157
- const selectedProfiles = profileRefs
158
- .map((profileRef) => profiles.find((profile) => profile.id === profileRef))
159
- .filter(Boolean) as interfaces.data.ISourceProfile[];
136
+ function normalizeCatalogRateLimit(
137
+ rateLimitValue: interfaces.data.IRouteSecurity['rateLimit'] | undefined,
138
+ ): TSzRouteSecurity['rateLimit'] | undefined {
139
+ if (!rateLimitValue) return undefined;
140
+ return {
141
+ enabled: Boolean(rateLimitValue.enabled),
142
+ maxRequests: Number(rateLimitValue.maxRequests) || 0,
143
+ window: Number(rateLimitValue.window) || 0,
144
+ ...(rateLimitValue.keyBy ? { keyBy: String(rateLimitValue.keyBy) } : {}),
145
+ };
146
+ }
160
147
 
161
- if (selectedProfiles.length !== profileRefs.length) {
162
- alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
163
- return false;
164
- }
148
+ function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): ISzSourceProfileOption[] {
149
+ return profiles.map((profile) => {
150
+ const ipAllowList = normalizeSecurityListEntries(profile.security?.ipAllowList);
151
+ const ipBlockList = normalizeSecurityListEntries(profile.security?.ipBlockList);
152
+ const rateLimitValue = normalizeCatalogRateLimit(profile.security?.rateLimit);
153
+ const security: TSzRouteSecurity = {
154
+ ...(ipAllowList.length ? { ipAllowList } : {}),
155
+ ...(ipBlockList.length ? { ipBlockList } : {}),
156
+ ...(typeof profile.security?.maxConnections === 'number' ? { maxConnections: profile.security.maxConnections } : {}),
157
+ ...(rateLimitValue ? { rateLimit: rateLimitValue } : {}),
158
+ };
159
+ return {
160
+ id: profile.id,
161
+ name: profile.name,
162
+ description: profile.description,
163
+ security,
164
+ hasSourceMatches: sourceProfileHasSourceMatches(profile),
165
+ matchesAllSources: sourceProfileMatchesAll(profile),
166
+ };
167
+ });
168
+ }
165
169
 
166
- const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
167
- if (profilesWithoutMatches.length > 0) {
168
- alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
169
- return false;
170
- }
170
+ function getRoutePathClassOptions(): ISzRoutePathClassOption[] {
171
+ return interfaces.data.routePathClasses.map((pathClass) => ({
172
+ key: pathClass,
173
+ label: interfaces.data.giteaRoutePathClassLabels[pathClass],
174
+ defaultPatterns: interfaces.data.giteaRoutePathClassPatterns[pathClass],
175
+ }));
176
+ }
171
177
 
172
- if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
173
- alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
174
- return false;
175
- }
178
+ function getSourcePolicyInfoText(profiles: interfaces.data.ISourceProfile[]): string {
179
+ const { missingNames } = getGiteaPresetProfileRefs(profiles);
180
+ const presetText = missingNames.length > 0
181
+ ? `Gitea preset hidden until these source profiles exist: ${missingNames.join(', ')}.`
182
+ : 'Use the Gitea preset as a starting point, then edit the generated bindings before saving.';
183
+ return `First matching source profile wins. Leave empty for no route-level source access control. ${presetText}`;
184
+ }
176
185
 
177
- const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
178
- if (sourceProfileMatchesAll(fallbackProfile) && fallbackProfile.security?.rateLimit?.enabled !== true) {
179
- return confirm(`The wildcard profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
186
+ function validateSourcePolicyInput(form: Element): boolean {
187
+ const sourcePolicyInput = form.querySelector('sz-input-route-source-policy') as SzInputRouteSourcePolicy | null;
188
+ if (!sourcePolicyInput || sourcePolicyInput.isValid()) {
189
+ return true;
180
190
  }
191
+ alert(sourcePolicyInput.getValidationMessages().join('\n'));
192
+ return false;
193
+ }
181
194
 
182
- return true;
195
+ function getSourceBindingsFromFormData(formData: Record<string, unknown>): interfaces.data.IRouteSourceBinding[] {
196
+ const sourceBindings = formData.sourceBindings;
197
+ return Array.isArray(sourceBindings)
198
+ ? sourceBindings as interfaces.data.IRouteSourceBinding[]
199
+ : [];
183
200
  }
184
201
 
185
202
  function parseTargetPort(value: any): number | undefined {
@@ -620,13 +637,6 @@ export class OpsViewRoutes extends DeesElement {
620
637
  const profiles = this.profilesTargetsState.profiles;
621
638
  const targets = this.profilesTargetsState.targets;
622
639
 
623
- const profileOptions = [
624
- { key: '', option: '(none — inline security)' },
625
- ...profiles.map((p) => ({
626
- key: p.id,
627
- option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
628
- })),
629
- ];
630
640
  const targetOptions = [
631
641
  { key: '', option: '(none — inline target)' },
632
642
  ...targets.map((t) => ({
@@ -651,7 +661,10 @@ export class OpsViewRoutes extends DeesElement {
651
661
  const currentVpnOnly = route.vpnOnly === true;
652
662
  const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
653
663
  const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
654
- const currentSourceBindingRefs = this.getSourceBindingRefs(merged.metadata);
664
+ const sourceProfileOptions = getSourceProfileOptions(profiles);
665
+ const pathClassOptions = getRoutePathClassOptions();
666
+ const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
667
+ const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
655
668
 
656
669
  // Compute current TLS state for pre-population
657
670
  const currentTls = (route.action as any).tls;
@@ -672,24 +685,15 @@ export class OpsViewRoutes extends DeesElement {
672
685
  <dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
673
686
  <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
674
687
  <dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
675
- <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;">
676
- <strong>Source Bindings</strong>
677
- <small>First matching source profile wins. Leave all rows empty to remove route-level source access control.</small>
678
- <dees-input-checkbox
679
- .key=${'useGiteaTemplate'}
680
- .label=${'Apply Gitea bot protection template on save'}
681
- .description=${'Replaces these rows with TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
682
- .value=${false}
683
- ></dees-input-checkbox>
684
- ${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
685
- <dees-input-dropdown
686
- .key=${`sourceBindingProfileRef${index}`}
687
- .label=${`Binding ${index + 1}`}
688
- .options=${profileOptions}
689
- .selectedOption=${profileOptions.find((o) => o.key === (currentSourceBindingRefs[index] || '')) || profileOptions[0]}
690
- ></dees-input-dropdown>
691
- `)}
692
- </div>
688
+ <sz-input-route-source-policy
689
+ .key=${'sourceBindings'}
690
+ .label=${'Source Policy'}
691
+ .infoText=${sourcePolicyInfoText}
692
+ .sourceProfiles=${sourceProfileOptions}
693
+ .pathClassOptions=${pathClassOptions}
694
+ .presets=${sourcePolicyPresets}
695
+ .value=${merged.metadata?.sourceBindings || []}
696
+ ></sz-input-route-source-policy>
693
697
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
694
698
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
695
699
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
@@ -723,6 +727,7 @@ export class OpsViewRoutes extends DeesElement {
723
727
  if (!form) return;
724
728
  const formData = await form.collectFormData();
725
729
  if (!formData.name || !formData.ports) return;
730
+ if (!validateSourcePolicyInput(form)) return;
726
731
 
727
732
  const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
728
733
  const domains: string[] = Array.isArray(formData.domains)
@@ -730,11 +735,7 @@ export class OpsViewRoutes extends DeesElement {
730
735
  : [];
731
736
  const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
732
737
 
733
- const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
734
- const sourceBindingRefs = useGiteaTemplate
735
- ? []
736
- : getSourceBindingRefsFromFormData(formData);
737
- if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
738
+ const sourceBindings = getSourceBindingsFromFormData(formData);
738
739
  const targetKey = getDropdownKey(formData.networkTargetRef);
739
740
  const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
740
741
  const targetPort = preserveMatchPort
@@ -798,12 +799,8 @@ export class OpsViewRoutes extends DeesElement {
798
799
  }
799
800
 
800
801
  const metadata: any = {};
801
- if (useGiteaTemplate) {
802
- const sourceBindings = getGiteaPresetSourceBindings(profiles);
803
- if (!sourceBindings) return;
802
+ if (sourceBindings.length > 0) {
804
803
  metadata.sourceBindings = sourceBindings;
805
- } else if (sourceBindingRefs.length > 0) {
806
- metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs, merged.metadata?.sourceBindings);
807
804
  } else if (merged.metadata?.sourceBindings) {
808
805
  metadata.sourceBindings = [];
809
806
  }
@@ -841,14 +838,11 @@ export class OpsViewRoutes extends DeesElement {
841
838
  const profiles = this.profilesTargetsState.profiles;
842
839
  const targets = this.profilesTargetsState.targets;
843
840
 
844
- // Build dropdown options for profiles and targets
845
- const profileOptions = [
846
- { key: '', option: '(none — inline security)' },
847
- ...profiles.map((p) => ({
848
- key: p.id,
849
- option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
850
- })),
851
- ];
841
+ // Build dropdown options for targets and source policy metadata
842
+ const sourceProfileOptions = getSourceProfileOptions(profiles);
843
+ const pathClassOptions = getRoutePathClassOptions();
844
+ const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles);
845
+ const sourcePolicyInfoText = getSourcePolicyInfoText(profiles);
852
846
  const targetOptions = [
853
847
  { key: '', option: '(none — inline target)' },
854
848
  ...targets.map((t) => ({
@@ -865,24 +859,15 @@ export class OpsViewRoutes extends DeesElement {
865
859
  <dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
866
860
  <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
867
861
  <dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
868
- <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;">
869
- <strong>Source Bindings</strong>
870
- <small>First matching source profile wins. Leave all rows empty for no route-level source access control.</small>
871
- <dees-input-checkbox
872
- .key=${'useGiteaTemplate'}
873
- .label=${'Apply Gitea bot protection template on save'}
874
- .description=${'Writes TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
875
- .value=${false}
876
- ></dees-input-checkbox>
877
- ${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
878
- <dees-input-dropdown
879
- .key=${`sourceBindingProfileRef${index}`}
880
- .label=${`Binding ${index + 1}`}
881
- .options=${profileOptions}
882
- .selectedOption=${profileOptions[0]}
883
- ></dees-input-dropdown>
884
- `)}
885
- </div>
862
+ <sz-input-route-source-policy
863
+ .key=${'sourceBindings'}
864
+ .label=${'Source Policy'}
865
+ .infoText=${sourcePolicyInfoText}
866
+ .sourceProfiles=${sourceProfileOptions}
867
+ .pathClassOptions=${pathClassOptions}
868
+ .presets=${sourcePolicyPresets}
869
+ .value=${[]}
870
+ ></sz-input-route-source-policy>
886
871
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
887
872
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
888
873
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
@@ -916,6 +901,7 @@ export class OpsViewRoutes extends DeesElement {
916
901
  if (!form) return;
917
902
  const formData = await form.collectFormData();
918
903
  if (!formData.name || !formData.ports) return;
904
+ if (!validateSourcePolicyInput(form)) return;
919
905
 
920
906
  const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
921
907
  const domains: string[] = Array.isArray(formData.domains)
@@ -923,11 +909,7 @@ export class OpsViewRoutes extends DeesElement {
923
909
  : [];
924
910
  const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
925
911
 
926
- const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
927
- const sourceBindingRefs = useGiteaTemplate
928
- ? []
929
- : getSourceBindingRefsFromFormData(formData);
930
- if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
912
+ const sourceBindings = getSourceBindingsFromFormData(formData);
931
913
  const targetKey = getDropdownKey(formData.networkTargetRef);
932
914
  const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
933
915
  const targetPort = preserveMatchPort
@@ -992,12 +974,8 @@ export class OpsViewRoutes extends DeesElement {
992
974
 
993
975
  // Build metadata if profile/target selected
994
976
  const metadata: any = {};
995
- if (useGiteaTemplate) {
996
- const sourceBindings = getGiteaPresetSourceBindings(profiles);
997
- if (!sourceBindings) return;
977
+ if (sourceBindings.length > 0) {
998
978
  metadata.sourceBindings = sourceBindings;
999
- } else if (sourceBindingRefs.length > 0) {
1000
- metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs);
1001
979
  }
1002
980
  if (targetKey) {
1003
981
  metadata.networkTargetRef = targetKey;