@serve.zone/dcrouter 18.4.1 → 18.6.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.
Files changed (35) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +2 -1
  4. package/dist_ts/classes.dcrouter.js +4 -2
  5. package/dist_ts/db/documents/classes.gateway-mail-domain.doc.d.ts +16 -0
  6. package/dist_ts/db/documents/classes.gateway-mail-domain.doc.js +107 -0
  7. package/dist_ts/db/documents/index.d.ts +1 -0
  8. package/dist_ts/db/documents/index.js +2 -1
  9. package/dist_ts/email/classes.gateway-mail-domain.store.d.ts +11 -0
  10. package/dist_ts/email/classes.gateway-mail-domain.store.js +70 -0
  11. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -23
  12. package/dist_ts/email/classes.workapp-mail-manager.js +63 -35
  13. package/dist_ts/email/gateway-mail-domain-authority.d.ts +44 -0
  14. package/dist_ts/email/gateway-mail-domain-authority.js +160 -0
  15. package/dist_ts/email/index.d.ts +2 -0
  16. package/dist_ts/email/index.js +3 -1
  17. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -1
  18. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +2 -0
  19. package/dist_ts/opsserver/helpers/gateway-client-auth.js +5 -3
  20. package/dist_ts_migrations/index.js +196 -11
  21. package/dist_ts_web/00_commitinfo_data.js +1 -1
  22. package/package.json +2 -2
  23. package/readme.md +1 -1
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/classes.dcrouter.ts +3 -1
  26. package/ts/db/documents/classes.gateway-mail-domain.doc.ts +42 -0
  27. package/ts/db/documents/index.ts +1 -0
  28. package/ts/email/classes.gateway-mail-domain.store.ts +89 -0
  29. package/ts/email/classes.workapp-mail-manager.ts +90 -68
  30. package/ts/email/gateway-mail-domain-authority.ts +222 -0
  31. package/ts/email/index.ts +2 -0
  32. package/ts/opsserver/handlers/gatewayclient.handler.ts +43 -1
  33. package/ts/opsserver/helpers/gateway-client-auth.ts +6 -2
  34. package/ts_web/00_commitinfo_data.ts +1 -1
  35. package/readme.plan.md +0 -22
@@ -0,0 +1,222 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ IWorkAppMailInboundRoute,
4
+ IWorkAppMailOwnership,
5
+ } from '../../ts_interfaces/data/workhoster.js';
6
+ import type { TGatewayClientType } from '../../ts_interfaces/data/route-management.js';
7
+
8
+ export interface IStoredWorkAppMailIdentity {
9
+ id: string;
10
+ externalKey: string;
11
+ ownership: IWorkAppMailOwnership;
12
+ address: string;
13
+ localPart: string;
14
+ domain: string;
15
+ enabled: boolean;
16
+ displayName?: string;
17
+ inbound?: IWorkAppMailInboundRoute;
18
+ inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget;
19
+ smtp: {
20
+ enabled: boolean;
21
+ username: string;
22
+ };
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ createdBy: string;
26
+ smtpPassword: string;
27
+ smtpLastRotatedAt?: number;
28
+ }
29
+
30
+ export interface IGatewayMailDomainOwner {
31
+ gatewayClientType: TGatewayClientType;
32
+ gatewayClientId: string;
33
+ }
34
+
35
+ export interface IGatewayMailDomainGroup extends IGatewayMailDomainOwner {
36
+ id: string;
37
+ domain: string;
38
+ identities: IStoredWorkAppMailIdentity[];
39
+ }
40
+
41
+ const gatewayClientTypes = new Set<TGatewayClientType>(['onebox', 'cloudly', 'custom']);
42
+
43
+ const requireTrimmedString = (valueArg: unknown, fieldNameArg: string): string => {
44
+ const value = typeof valueArg === 'string' ? valueArg.trim() : '';
45
+ if (!value) throw new Error(`${fieldNameArg} is required`);
46
+ return value;
47
+ };
48
+
49
+ const requireTimestamp = (valueArg: unknown, fieldNameArg: string): number => {
50
+ if (!Number.isSafeInteger(valueArg) || Number(valueArg) < 0) {
51
+ throw new Error(`${fieldNameArg} must be a non-negative safe integer`);
52
+ }
53
+ return Number(valueArg);
54
+ };
55
+
56
+ export function normalizeGatewayMailDomain(domainArg: unknown): string {
57
+ const candidate = typeof domainArg === 'string'
58
+ ? domainArg.trim().toLowerCase().replace(/\.+$/, '')
59
+ : '';
60
+ if (!candidate || candidate.includes('@') || !candidate.includes('.')) {
61
+ throw new Error(`Invalid email domain: ${String(domainArg || '')}`);
62
+ }
63
+ const ascii = plugins.url.domainToASCII(candidate);
64
+ if (!ascii || ascii.includes('@') || !ascii.includes('.')) {
65
+ throw new Error(`Invalid email domain: ${String(domainArg || '')}`);
66
+ }
67
+ return ascii;
68
+ }
69
+
70
+ export function normalizeGatewayMailDomainOwner(
71
+ ownerArg: {
72
+ gatewayClientType?: unknown;
73
+ gatewayClientId?: unknown;
74
+ },
75
+ ): IGatewayMailDomainOwner {
76
+ const gatewayClientType = ownerArg?.gatewayClientType as TGatewayClientType;
77
+ if (!gatewayClientTypes.has(gatewayClientType)) {
78
+ throw new Error(`Invalid gateway client type: ${String(ownerArg?.gatewayClientType || '')}`);
79
+ }
80
+ return {
81
+ gatewayClientType,
82
+ gatewayClientId: requireTrimmedString(ownerArg.gatewayClientId, 'gatewayClientId'),
83
+ };
84
+ }
85
+
86
+ export function gatewayMailOwnerFromLegacyOwnership(
87
+ ownershipArg: IWorkAppMailOwnership,
88
+ ): IGatewayMailDomainOwner & { appInstanceId: string } {
89
+ const owner = normalizeGatewayMailDomainOwner({
90
+ gatewayClientType: ownershipArg?.workHosterType,
91
+ gatewayClientId: ownershipArg?.workHosterId,
92
+ });
93
+ return {
94
+ ...owner,
95
+ appInstanceId: requireTrimmedString(ownershipArg?.workAppId, 'appInstanceId'),
96
+ };
97
+ }
98
+
99
+ export function createGatewayMailDomainId(
100
+ ownerArg: IGatewayMailDomainOwner,
101
+ domainArg: string,
102
+ ): string {
103
+ const owner = normalizeGatewayMailDomainOwner(ownerArg);
104
+ const domain = normalizeGatewayMailDomain(domainArg);
105
+ const digest = plugins.crypto.createHash('sha256')
106
+ .update(JSON.stringify([owner.gatewayClientType, owner.gatewayClientId, domain]))
107
+ .digest('hex');
108
+ return `gateway-mail-domain-${digest}`;
109
+ }
110
+
111
+ export function normalizeStoredWorkAppMailIdentity(
112
+ identityArg: unknown,
113
+ ): IStoredWorkAppMailIdentity {
114
+ if (!identityArg || typeof identityArg !== 'object' || Array.isArray(identityArg)) {
115
+ throw new Error('Stored WorkApp mail identity must be an object');
116
+ }
117
+ const identity = identityArg as Record<string, any>;
118
+ const owner = gatewayMailOwnerFromLegacyOwnership(identity.ownership);
119
+ const domain = normalizeGatewayMailDomain(identity.domain);
120
+ const localPart = requireTrimmedString(identity.localPart, 'localPart').toLowerCase();
121
+ if (localPart.includes('@') || /\s/.test(localPart)) {
122
+ throw new Error(`Invalid email local part: ${identity.localPart}`);
123
+ }
124
+ const rawAddress = requireTrimmedString(identity.address, 'address').toLowerCase();
125
+ const [addressLocalPart, addressDomain, addressExtra] = rawAddress.split('@');
126
+ if (!addressLocalPart || !addressDomain || addressExtra) {
127
+ throw new Error(`Invalid stored mail identity address: ${identity.address}`);
128
+ }
129
+ const address = `${addressLocalPart}@${normalizeGatewayMailDomain(addressDomain)}`;
130
+ if (address !== `${localPart}@${domain}`) {
131
+ throw new Error('Stored mail identity address, localPart, and domain do not match');
132
+ }
133
+ if (!identity.smtp || typeof identity.smtp !== 'object' || Array.isArray(identity.smtp)) {
134
+ throw new Error('Stored mail identity smtp configuration is required');
135
+ }
136
+ if (typeof identity.enabled !== 'boolean' || typeof identity.smtp.enabled !== 'boolean') {
137
+ throw new Error('Stored mail identity enabled flags must be boolean');
138
+ }
139
+ const createdAt = requireTimestamp(identity.createdAt, 'createdAt');
140
+ const updatedAt = requireTimestamp(identity.updatedAt, 'updatedAt');
141
+ const smtpLastRotatedAt = identity.smtpLastRotatedAt === undefined
142
+ ? undefined
143
+ : requireTimestamp(identity.smtpLastRotatedAt, 'smtpLastRotatedAt');
144
+ const ownership: IWorkAppMailOwnership = {
145
+ workHosterType: owner.gatewayClientType,
146
+ workHosterId: owner.gatewayClientId,
147
+ workAppId: owner.appInstanceId,
148
+ };
149
+ const externalKey = [
150
+ owner.gatewayClientType,
151
+ owner.gatewayClientId,
152
+ owner.appInstanceId,
153
+ address,
154
+ ].join(':');
155
+
156
+ return {
157
+ id: requireTrimmedString(identity.id, 'id'),
158
+ externalKey,
159
+ ownership,
160
+ address,
161
+ localPart,
162
+ domain,
163
+ enabled: identity.enabled,
164
+ ...(identity.displayName === undefined
165
+ ? {}
166
+ : { displayName: String(identity.displayName) }),
167
+ ...(identity.inbound === undefined
168
+ ? {}
169
+ : { inbound: structuredClone(identity.inbound) }),
170
+ ...(identity.inboundTarget === undefined
171
+ ? {}
172
+ : { inboundTarget: structuredClone(identity.inboundTarget) }),
173
+ smtp: {
174
+ enabled: identity.smtp.enabled,
175
+ username: requireTrimmedString(identity.smtp.username, 'smtp.username'),
176
+ },
177
+ createdAt,
178
+ updatedAt,
179
+ createdBy: requireTrimmedString(identity.createdBy, 'createdBy'),
180
+ smtpPassword: requireTrimmedString(identity.smtpPassword, 'smtpPassword'),
181
+ ...(smtpLastRotatedAt === undefined ? {} : { smtpLastRotatedAt }),
182
+ };
183
+ }
184
+
185
+ export function groupGatewayMailIdentities(
186
+ identitiesArg: unknown[],
187
+ ): IGatewayMailDomainGroup[] {
188
+ const groups = new Map<string, IGatewayMailDomainGroup>();
189
+ const identityIds = new Set<string>();
190
+ const externalKeys = new Set<string>();
191
+
192
+ for (const identityArg of identitiesArg) {
193
+ const identity = normalizeStoredWorkAppMailIdentity(identityArg);
194
+ if (identityIds.has(identity.id)) {
195
+ throw new Error(`Duplicate stored mail identity id: ${identity.id}`);
196
+ }
197
+ if (externalKeys.has(identity.externalKey)) {
198
+ throw new Error(`Duplicate stored mail identity externalKey: ${identity.externalKey}`);
199
+ }
200
+ identityIds.add(identity.id);
201
+ externalKeys.add(identity.externalKey);
202
+ const owner = gatewayMailOwnerFromLegacyOwnership(identity.ownership);
203
+ const id = createGatewayMailDomainId(owner, identity.domain);
204
+ const group = groups.get(id) || {
205
+ id,
206
+ gatewayClientType: owner.gatewayClientType,
207
+ gatewayClientId: owner.gatewayClientId,
208
+ domain: identity.domain,
209
+ identities: [],
210
+ };
211
+ group.identities.push(identity);
212
+ groups.set(id, group);
213
+ }
214
+
215
+ return Array.from(groups.values())
216
+ .map((groupArg) => ({
217
+ ...groupArg,
218
+ identities: groupArg.identities
219
+ .sort((leftArg, rightArg) => leftArg.externalKey.localeCompare(rightArg.externalKey)),
220
+ }))
221
+ .sort((leftArg, rightArg) => leftArg.id.localeCompare(rightArg.id));
222
+ }
package/ts/email/index.ts CHANGED
@@ -8,12 +8,14 @@ export * from './reconcile-mail-dns-singleton.js';
8
8
  export * from './classes.mail-egress-coordinator.js';
9
9
  export * from './classes.mail-edge-eligibility.js';
10
10
  export * from './classes.email-settings.manager.js';
11
+ export * from './classes.gateway-mail-domain.store.js';
11
12
  export * from './classes.smartmta-blob-storage-manager.js';
12
13
  export * from './classes.smartmta-storage-manager.js';
13
14
  export * from './smartmta-storage-policy.js';
14
15
  export * from './classes.smtp-account.manager.js';
15
16
  export * from './smtp-scram.js';
16
17
  export * from './classes.workapp-mail-manager.js';
18
+ export * from './gateway-mail-domain-authority.js';
17
19
  export * from './email-dns-records.js';
18
20
  export * from './helpers.email-log-traffic.js';
19
21
  export * from './helpers.inbound-security.js';
@@ -32,7 +32,7 @@ export class GatewayClientHandler {
32
32
  private async requireAuth(
33
33
  request: { identity?: interfaces.data.IIdentity; apiToken?: string },
34
34
  requiredScope?: interfaces.data.TApiTokenScope,
35
- options: { allowCandidate?: boolean } = {},
35
+ options: { allowCandidate?: boolean; loadManagedZoneNames?: boolean } = {},
36
36
  ): Promise<TAuthContext> {
37
37
  return await requireGatewayMachineAuth(
38
38
  this.opsServerRef,
@@ -351,6 +351,48 @@ export class GatewayClientHandler {
351
351
  ),
352
352
  );
353
353
 
354
+ this.typedrouter.addTypedHandler(
355
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientMailDomainCount>(
356
+ 'getGatewayClientMailDomainCount',
357
+ async (dataArg) => {
358
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:read', {
359
+ loadManagedZoneNames: false,
360
+ });
361
+ this.assertCapability(auth, 'readMail');
362
+ const untypedRequest = dataArg as Record<string, unknown>;
363
+ for (const forbiddenKey of [
364
+ 'owner',
365
+ 'gatewayClientId',
366
+ 'gatewayClientType',
367
+ 'workHosterId',
368
+ 'workHosterType',
369
+ 'appInstanceId',
370
+ ]) {
371
+ if (Object.prototype.hasOwnProperty.call(untypedRequest, forbiddenKey)) {
372
+ throw new plugins.typedrequest.TypedResponseError(
373
+ 'mail-domain count ownership is derived from the gateway-client credential',
374
+ );
375
+ }
376
+ }
377
+ if (
378
+ auth.isAdmin
379
+ || auth.policy?.role !== 'gatewayClient'
380
+ || !auth.gatewayClient
381
+ || !auth.policy.gatewayClient
382
+ ) {
383
+ throw new plugins.typedrequest.TypedResponseError('gateway-client credential required');
384
+ }
385
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
386
+ return {
387
+ count: await manager.countMailDomains({
388
+ gatewayClientType: auth.gatewayClient.type,
389
+ gatewayClientId: auth.gatewayClient.id,
390
+ }),
391
+ };
392
+ },
393
+ ),
394
+ );
395
+
354
396
  this.typedrouter.addTypedHandler(
355
397
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientDomains>(
356
398
  'getGatewayClientDomains',
@@ -17,6 +17,8 @@ export type TGatewayCredentialState = 'candidate' | 'active' | 'manual';
17
17
 
18
18
  export interface IRequireGatewayMachineAuthOptions {
19
19
  allowCandidate?: boolean;
20
+ /** Skip managed DNS-domain loading for APIs that do not evaluate hostnames. */
21
+ loadManagedZoneNames?: boolean;
20
22
  }
21
23
 
22
24
  export function getGatewayCredentialState(
@@ -124,8 +126,10 @@ export async function requireGatewayMachineAuth(
124
126
  scope: scopeArg,
125
127
  requireAdminIdentity: false,
126
128
  });
127
- const managedZoneNames = (await opsServerRefArg.dcRouterRef.dnsManager?.listDomains() || [])
128
- .map((domainArg) => domainArg.name);
129
+ const managedZoneNames = optionsArg.loadManagedZoneNames === false
130
+ ? []
131
+ : (await opsServerRefArg.dcRouterRef.dnsManager?.listDomains() || [])
132
+ .map((domainArg) => domainArg.name);
129
133
 
130
134
  if (auth.isAdmin) {
131
135
  return {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.4.1',
6
+ version: '18.6.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
package/readme.plan.md DELETED
@@ -1,22 +0,0 @@
1
- # Platform refactoring program
2
-
3
- Status: approved 2026-07-21, derived from that day's incident ledger (deploy-verification crashes, unbounded scans, DNS/IP logic duplication, version-skew scares). Upstream foundations are RELEASED; consumer adoption and the dcrouter-internal refactors are sequenced below — do not start them mid-wave, fold them into the next natural release of each surface.
4
-
5
- ## Shipped upstream (adopt on next dependency bump)
6
-
7
- 1. **Typed-request error boundary — already existed.** @api.global/typedrequest ≥ 3.3.2 sanitizes every handler/middleware throw into a structured error envelope (no socket destruction). Action: ensure every deployed consumer (cloudly, dcrouter, coreflow, onebox) resolves ≥ 3.3.2 and remove any local try/catch layers that only re-implement it.
8
- 2. **Canonical IP helpers — @push.rocks/smartnetwork 4.9.0.** `isPublicIpAddress`, `toReverseDnsName` (non-throwing), `expandIpv6`, alongside the existing `getIpVersion`/`isValidIpv4`/`isValidIpv6`. Adoption: dcrouter ts/email/classes.mail-dns-sync.ts drops its private `toReverseDnsName`/`expandIpv6` (and the `Invalid IP address:` throw); cloudly's `isPublicDeploymentAddress` (classes.deploymentoperationmanager.ts) delegates to `isPublicIpAddress` (IPv4 semantics are byte-identical; IPv6 is 2000::/3 minus 2001:db8::/32).
9
- 3. **Cursor pagination as the org default — @push.rocks/smartdata 7.4.0.** `SmartDataDbDoc.getPagedInstances({ filter, sortField, sortDirection, uniqueField, limit ≤ 1000, cursor, withTotal })` — seek pagination with a unique tiebreaker, never materializes the full set. Adoption: OpsConfigEventDoc.findFiltered becomes a thin wrapper; every future list surface uses it. Review rule: a list API without a cursor is a defect.
10
- 4. **Binary provenance — @git.zone/tsrust 1.7.0.** Every built binary gets a JSON trailer (project, version, git commit, target, builtAt, tsrust version); `tsrust inspect <binary>` prints it. Adoption: bump tsrust across the Rust-bridge repos (smartdb, smartproxy, smartmta, smartnetwork, coretraffic, remoteingress) on their next releases; deployment verification can then assert binary identity instead of guessing (see the strings-on-x86 pitfall in smartdb readme.hints.md).
11
-
12
- ## Sequenced (do NOT start mid-wave)
13
-
14
- 5. **Ops-handler base class (dcrouter).** 25 handler files carry 11 copies of the requireAuth wrapper and 49 hand-written "manager not initialized" guards. Introduce a base with `registerMethod(name, scope, handlerFn)` doing auth, manager guard, error boundary, and realtime invalidation; migrate handlers opportunistically (new handlers must use it; old ones migrate when touched).
15
- 6. **Composition-root service table (dcrouter).** classes.dcrouter.ts (2,326 lines) has 20 near-identical addService blocks with drift (withRetry presence, error logging). Replace with a declarative registration table; behavior-preserving.
16
- 7. **Mail-detail contract dedup (interfaces + catalog + dcrouter).** IEmailDetail and friends exist twice by convention ("matches @serve.zone/catalog"). Move the contract into @serve.zone/interfaces; catalog and dcrouter consume it. Sequence AFTER the in-flight mail-detail wave (countdown, verdicts, transfer stats) fully lands — one migration wave, no compatibility layer.
17
- 8. **Version-skew hardening (conventions).** (a) Tolerant-reader parsing for persisted operation/evidence/state documents that cross release boundaries; (b) version handshake wherever a live process or socket is reused across upgrades (LocalSmartDb engine reuse — smartdb plan Track 6; gateway-client policyGeneration is the precedent).
18
-
19
- ## Non-goals
20
-
21
- - No big-bang rewrites: every item lands either additive-upstream or inside a surface's next natural release.
22
- - No compatibility layers (org rule): consumers move to the canonical helpers outright when their wave touches the code.