@serve.zone/dcrouter 17.2.4 → 17.4.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 (113) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2024 -1758
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +11 -1
  5. package/dist_ts/classes.dcrouter.js +63 -3
  6. package/dist_ts/config/classes.api-token-manager.d.ts +20 -2
  7. package/dist_ts/config/classes.api-token-manager.js +201 -6
  8. package/dist_ts/config/classes.gateway-client-manager.d.ts +36 -2
  9. package/dist_ts/config/classes.gateway-client-manager.js +391 -27
  10. package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
  11. package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
  12. package/dist_ts/db/documents/classes.cached.email.d.ts +10 -1
  13. package/dist_ts/db/documents/classes.cached.email.js +27 -2
  14. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +25 -0
  15. package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
  16. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
  17. package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
  18. package/dist_ts/db/documents/index.d.ts +1 -0
  19. package/dist_ts/db/documents/index.js +2 -1
  20. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
  21. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
  22. package/dist_ts/dns/index.d.ts +1 -0
  23. package/dist_ts/dns/index.js +2 -1
  24. package/dist_ts/dns/manager.dns.d.ts +19 -0
  25. package/dist_ts/dns/manager.dns.js +25 -1
  26. package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
  27. package/dist_ts/email/classes.accepted-email-spool.js +93 -1
  28. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
  29. package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
  30. package/dist_ts/email/smartmta-storage-policy.js +3 -1
  31. package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
  32. package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
  33. package/dist_ts/monitoring/index.d.ts +1 -0
  34. package/dist_ts/monitoring/index.js +2 -1
  35. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  36. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  37. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
  39. package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
  40. package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
  41. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
  43. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
  44. package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
  45. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
  46. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
  47. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/index.js +2 -1
  49. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
  50. package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
  51. package/dist_ts/plugins.d.ts +2 -1
  52. package/dist_ts/plugins.js +3 -2
  53. package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
  54. package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
  55. package/dist_ts_interfaces/data/config-events.d.ts +34 -0
  56. package/dist_ts_interfaces/data/config-events.js +8 -0
  57. package/dist_ts_interfaces/data/index.d.ts +1 -0
  58. package/dist_ts_interfaces/data/index.js +2 -1
  59. package/dist_ts_interfaces/data/route-management.d.ts +28 -1
  60. package/dist_ts_interfaces/data/route-management.js +3 -1
  61. package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
  62. package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
  63. package/dist_ts_interfaces/requests/config-events.js +4 -0
  64. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  65. package/dist_ts_interfaces/requests/index.js +2 -1
  66. package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
  67. package/dist_ts_interfaces/requests/realtime.js +2 -1
  68. package/dist_ts_migrations/index.js +148 -9
  69. package/dist_ts_web/00_commitinfo_data.js +1 -1
  70. package/dist_ts_web/appstate/config-events.d.ts +19 -0
  71. package/dist_ts_web/appstate/config-events.js +68 -0
  72. package/dist_ts_web/appstate/runtime.js +7 -1
  73. package/dist_ts_web/appstate.d.ts +1 -0
  74. package/dist_ts_web/appstate.js +2 -1
  75. package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
  76. package/dist_ts_web/elements/ops-platform-events.js +361 -0
  77. package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
  78. package/dist_ts_web/elements/ops-view-logs.js +96 -9
  79. package/package.json +4 -4
  80. package/readme.plan.md +22 -0
  81. package/ts/00_commitinfo_data.ts +1 -1
  82. package/ts/classes.dcrouter.ts +72 -2
  83. package/ts/config/classes.api-token-manager.ts +237 -4
  84. package/ts/config/classes.gateway-client-manager.ts +463 -19
  85. package/ts/db/documents/classes.api-token.doc.ts +8 -1
  86. package/ts/db/documents/classes.cached.email.ts +21 -1
  87. package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
  88. package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
  89. package/ts/db/documents/index.ts +1 -0
  90. package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
  91. package/ts/dns/index.ts +1 -0
  92. package/ts/dns/manager.dns.ts +36 -0
  93. package/ts/email/classes.accepted-email-spool.ts +111 -0
  94. package/ts/email/classes.smartmta-storage-manager.ts +57 -1
  95. package/ts/email/smartmta-storage-policy.ts +2 -0
  96. package/ts/monitoring/classes.ops-event-manager.ts +99 -0
  97. package/ts/monitoring/index.ts +2 -1
  98. package/ts/opsserver/classes.opsserver.ts +2 -0
  99. package/ts/opsserver/handlers/certificate.handler.ts +51 -4
  100. package/ts/opsserver/handlers/config-event.handler.ts +62 -0
  101. package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
  102. package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
  103. package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
  104. package/ts/opsserver/handlers/index.ts +1 -0
  105. package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
  106. package/ts/plugins.ts +2 -0
  107. package/ts_apiclient/classes.gatewayclient.ts +45 -0
  108. package/ts_web/00_commitinfo_data.ts +1 -1
  109. package/ts_web/appstate/config-events.ts +98 -0
  110. package/ts_web/appstate/runtime.ts +8 -1
  111. package/ts_web/appstate.ts +1 -0
  112. package/ts_web/elements/ops-platform-events.ts +315 -0
  113. package/ts_web/elements/ops-view-logs.ts +84 -7
@@ -1,12 +1,37 @@
1
1
  import * as plugins from '../plugins.js';
2
- import { GatewayClientDoc } from '../db/index.js';
2
+ import {
3
+ GatewayClientDoc,
4
+ type IGatewayClientPendingProvisioning,
5
+ } from '../db/index.js';
3
6
  import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
7
+ import type { IGatewayCredentialLifecycle } from '../../ts_interfaces/data/route-management.js';
8
+
9
+ export interface IStagedGatewayClientProvisioning {
10
+ action: plugins.servezoneInterfaces.data.TGatewayClientProvisioningAction;
11
+ gatewayClient: IGatewayClient;
12
+ credentialSequence: number;
13
+ policyDigest: string;
14
+ }
15
+
16
+ interface INormalizedGatewayClientSpec {
17
+ type: IGatewayClient['type'];
18
+ name: string;
19
+ description: string | null;
20
+ hostnamePatterns: string[];
21
+ allowedRouteTargets: IGatewayClient['allowedRouteTargets'];
22
+ capabilities: IGatewayClient['capabilities'];
23
+ enabled: boolean;
24
+ }
4
25
 
5
26
  const defaultCapabilities: IGatewayClient['capabilities'] = {
6
27
  readDomains: true,
7
28
  readDnsRecords: true,
29
+ readRoutes: true,
8
30
  syncRoutes: true,
9
31
  syncDnsRecords: false,
32
+ readMail: false,
33
+ manageMail: false,
34
+ readCertificates: false,
10
35
  requestCertificates: false,
11
36
  };
12
37
 
@@ -32,6 +57,7 @@ export class GatewayClientManager {
32
57
  allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
33
58
  capabilities?: IGatewayClient['capabilities'];
34
59
  createdBy: string;
60
+ applyCapabilityDefaults?: boolean;
35
61
  }): Promise<IGatewayClient> {
36
62
  const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
37
63
  if (!id) {
@@ -47,10 +73,17 @@ export class GatewayClientManager {
47
73
  doc.type = options.type;
48
74
  doc.name = options.name.trim();
49
75
  doc.description = options.description?.trim() || undefined;
50
- doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
76
+ doc.hostnamePatterns = this.normalizeHostnamePatterns(options.hostnamePatterns || []);
51
77
  doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
52
- doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
78
+ doc.capabilities = options.applyCapabilityDefaults === false
79
+ ? { ...(options.capabilities || {}) }
80
+ : { ...defaultCapabilities, ...(options.capabilities || {}) };
53
81
  doc.enabled = true;
82
+ doc.policyGeneration = 1;
83
+ doc.credentialSequence = 0;
84
+ doc.activeCredentialSequence = 0;
85
+ doc.mutationRevision = 1;
86
+ doc.pendingProvisioning = null;
54
87
  doc.createdAt = now;
55
88
  doc.updatedAt = now;
56
89
  doc.createdBy = options.createdBy;
@@ -61,42 +94,452 @@ export class GatewayClientManager {
61
94
  public async updateClient(
62
95
  id: string,
63
96
  patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
97
+ options: { applyCapabilityDefaults?: boolean } = {},
64
98
  ): Promise<IGatewayClient | null> {
65
99
  const doc = await GatewayClientDoc.findById(id);
66
100
  if (!doc) return null;
67
- if (patch.name !== undefined) doc.name = patch.name.trim();
68
- if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
69
- if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
70
- if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
71
- if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
72
- if (patch.enabled !== undefined) doc.enabled = patch.enabled;
73
- doc.updatedAt = Date.now();
74
- await doc.save();
75
- return this.toPublicClient(doc);
101
+ const expectedRevision = this.normalizeMutationRevision(doc.mutationRevision);
102
+ const priorPolicy = JSON.stringify({
103
+ hostnamePatterns: doc.hostnamePatterns || [],
104
+ allowedRouteTargets: doc.allowedRouteTargets || [],
105
+ capabilities: doc.capabilities || {},
106
+ enabled: doc.enabled,
107
+ });
108
+ const nextName = patch.name !== undefined ? patch.name.trim() : doc.name;
109
+ const nextDescription = patch.description !== undefined
110
+ ? patch.description.trim() || undefined
111
+ : doc.description;
112
+ const nextHostnamePatterns = patch.hostnamePatterns !== undefined
113
+ ? this.normalizeHostnamePatterns(patch.hostnamePatterns)
114
+ : doc.hostnamePatterns || [];
115
+ const nextAllowedRouteTargets = patch.allowedRouteTargets !== undefined
116
+ ? this.normalizeAllowedRouteTargets(patch.allowedRouteTargets)
117
+ : doc.allowedRouteTargets || [];
118
+ const nextCapabilities = patch.capabilities !== undefined
119
+ ? options.applyCapabilityDefaults === false
120
+ ? { ...patch.capabilities }
121
+ : { ...defaultCapabilities, ...patch.capabilities }
122
+ : doc.capabilities || {};
123
+ const nextEnabled = patch.enabled !== undefined ? patch.enabled : doc.enabled;
124
+ const nextPolicy = JSON.stringify({
125
+ hostnamePatterns: nextHostnamePatterns,
126
+ allowedRouteTargets: nextAllowedRouteTargets,
127
+ capabilities: nextCapabilities,
128
+ enabled: nextEnabled,
129
+ });
130
+ const policyGeneration = this.normalizePolicyGeneration(doc.policyGeneration)
131
+ + (priorPolicy === nextPolicy ? 0 : 1);
132
+ const updatedAt = Date.now();
133
+ const updated = await GatewayClientDoc.compareAndSet(id, expectedRevision, {
134
+ name: nextName,
135
+ hostnamePatterns: structuredClone(nextHostnamePatterns),
136
+ allowedRouteTargets: structuredClone(nextAllowedRouteTargets),
137
+ capabilities: structuredClone(nextCapabilities),
138
+ enabled: nextEnabled,
139
+ policyGeneration,
140
+ pendingProvisioning: null,
141
+ updatedAt,
142
+ ...(nextDescription ? { description: nextDescription } : {}),
143
+ }, nextDescription ? [] : ['description']);
144
+ if (!updated) {
145
+ if (await GatewayClientDoc.findById(id)) throw new Error('Gateway client changed concurrently; retry the update');
146
+ return null;
147
+ }
148
+ return this.toPublicClient(updated);
76
149
  }
77
150
 
78
151
  public async deleteClient(id: string): Promise<boolean> {
79
152
  const doc = await GatewayClientDoc.findById(id);
80
153
  if (!doc) return false;
81
- await doc.delete();
82
- return true;
154
+ const deleted = await GatewayClientDoc.deleteIfRevision(
155
+ id,
156
+ this.normalizeMutationRevision(doc.mutationRevision),
157
+ );
158
+ if (!deleted && await GatewayClientDoc.findById(id)) {
159
+ throw new Error('Gateway client changed concurrently; retry the deletion');
160
+ }
161
+ return deleted;
162
+ }
163
+
164
+ public async upsertClient(
165
+ spec: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec,
166
+ createdBy: string,
167
+ ): Promise<{
168
+ action: plugins.servezoneInterfaces.data.TGatewayClientProvisioningAction;
169
+ gatewayClient: IGatewayClient;
170
+ }> {
171
+ const id = this.normalizeId(spec.id);
172
+ if (!id) throw new Error('gateway client id is required');
173
+ const normalized = {
174
+ type: spec.type,
175
+ name: spec.name.trim(),
176
+ description: spec.description?.trim() || undefined,
177
+ hostnamePatterns: this.normalizeHostnamePatterns(spec.hostnamePatterns || []),
178
+ allowedRouteTargets: this.normalizeAllowedRouteTargets(spec.allowedRouteTargets || []),
179
+ capabilities: { ...(spec.capabilities || {}) },
180
+ enabled: spec.enabled ?? true,
181
+ };
182
+ if (!normalized.name) throw new Error('gateway client name is required');
183
+
184
+ const existing = await GatewayClientDoc.findById(id);
185
+ if (!existing) {
186
+ const gatewayClient = await this.createClient({
187
+ id,
188
+ type: normalized.type,
189
+ name: normalized.name,
190
+ description: normalized.description,
191
+ hostnamePatterns: normalized.hostnamePatterns,
192
+ allowedRouteTargets: normalized.allowedRouteTargets,
193
+ capabilities: normalized.capabilities,
194
+ createdBy,
195
+ applyCapabilityDefaults: false,
196
+ });
197
+ if (!normalized.enabled) {
198
+ return {
199
+ action: 'created',
200
+ gatewayClient: (await this.updateClient(id, { enabled: false }))!,
201
+ };
202
+ }
203
+ return { action: 'created', gatewayClient };
204
+ }
205
+ if (existing.type !== normalized.type) {
206
+ throw new Error('gateway client type cannot be changed');
207
+ }
208
+ const current = {
209
+ type: existing.type,
210
+ name: existing.name,
211
+ description: existing.description?.trim() || undefined,
212
+ hostnamePatterns: this.normalizeHostnamePatterns(existing.hostnamePatterns || []),
213
+ allowedRouteTargets: this.normalizeAllowedRouteTargets(existing.allowedRouteTargets || []),
214
+ capabilities: { ...(existing.capabilities || {}) },
215
+ enabled: existing.enabled,
216
+ };
217
+ if (JSON.stringify(current) === JSON.stringify(normalized)) {
218
+ return { action: 'unchanged', gatewayClient: this.toPublicClient(existing) };
219
+ }
220
+ const gatewayClient = await this.updateClient(id, normalized, { applyCapabilityDefaults: false });
221
+ if (!gatewayClient) throw new Error('gateway client disappeared during provisioning');
222
+ return { action: 'updated', gatewayClient };
223
+ }
224
+
225
+ /**
226
+ * Stage an idempotent provisioning policy without changing the live policy.
227
+ * Sequence gaps after a later credential write failure are intentional.
228
+ */
229
+ public async stageClientProvisioning(
230
+ spec: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec,
231
+ stagedBy: string,
232
+ ): Promise<IStagedGatewayClientProvisioning> {
233
+ const id = this.normalizeId(spec.id);
234
+ if (!id) throw new Error('gateway client id is required');
235
+ const normalized = this.normalizeProvisioningSpec(spec);
236
+ const existing = await GatewayClientDoc.findById(id);
237
+ const now = Date.now();
238
+
239
+ if (!existing) {
240
+ const doc = new GatewayClientDoc();
241
+ doc.id = id;
242
+ doc.type = normalized.type;
243
+ doc.name = normalized.name;
244
+ doc.description = normalized.description || undefined;
245
+ doc.hostnamePatterns = normalized.hostnamePatterns;
246
+ doc.allowedRouteTargets = normalized.allowedRouteTargets;
247
+ doc.capabilities = normalized.capabilities;
248
+ doc.enabled = normalized.enabled;
249
+ doc.policyGeneration = 1;
250
+ doc.credentialSequence = 1;
251
+ doc.activeCredentialSequence = 0;
252
+ doc.mutationRevision = 1;
253
+ doc.createdAt = now;
254
+ doc.updatedAt = now;
255
+ doc.createdBy = stagedBy;
256
+ const policyDigest = this.digestPolicy(id, normalized, 1);
257
+ doc.pendingProvisioning = {
258
+ ...structuredClone(normalized),
259
+ policyGeneration: 1,
260
+ policyDigest,
261
+ credentialSequence: 1,
262
+ stagedAt: now,
263
+ stagedBy,
264
+ };
265
+ await doc.save();
266
+ return {
267
+ action: 'created',
268
+ gatewayClient: this.toPublicPendingClient(doc, doc.pendingProvisioning),
269
+ credentialSequence: 1,
270
+ policyDigest,
271
+ };
272
+ }
273
+ if (existing.type !== normalized.type) {
274
+ throw new Error('gateway client type cannot be changed');
275
+ }
276
+
277
+ const active = this.activePolicyFromDoc(existing);
278
+ const activeGeneration = this.normalizePolicyGeneration(existing.policyGeneration);
279
+ const credentialSequence = this.normalizeCredentialSequence(existing.credentialSequence) + 1;
280
+ const policyUnchanged = this.digestPolicy(id, active, activeGeneration)
281
+ === this.digestPolicy(id, normalized, activeGeneration);
282
+ const metadataChanged = active.name !== normalized.name
283
+ || active.description !== normalized.description;
284
+ const policyGeneration = policyUnchanged ? activeGeneration : activeGeneration + 1;
285
+ const policyDigest = this.digestPolicy(id, normalized, policyGeneration);
286
+ const pendingProvisioning: IGatewayClientPendingProvisioning = {
287
+ ...structuredClone(normalized),
288
+ policyGeneration,
289
+ policyDigest,
290
+ credentialSequence,
291
+ stagedAt: now,
292
+ stagedBy,
293
+ };
294
+ const set: Record<string, unknown> = {
295
+ credentialSequence,
296
+ pendingProvisioning,
297
+ updatedAt: now,
298
+ };
299
+ const unset: string[] = [];
300
+ if (policyUnchanged) {
301
+ set.name = normalized.name;
302
+ if (normalized.description) {
303
+ set.description = normalized.description;
304
+ } else {
305
+ unset.push('description');
306
+ }
307
+ }
308
+ const updated = await GatewayClientDoc.compareAndSet(
309
+ id,
310
+ this.normalizeMutationRevision(existing.mutationRevision),
311
+ set,
312
+ unset,
313
+ );
314
+ if (!updated) throw new Error('Gateway client changed concurrently; retry provisioning');
315
+
316
+ return {
317
+ action: policyUnchanged && !metadataChanged ? 'unchanged' : 'updated',
318
+ gatewayClient: this.toPublicPendingClient(updated, updated.pendingProvisioning!),
319
+ credentialSequence,
320
+ policyDigest,
321
+ };
322
+ }
323
+
324
+ /** Resolve the exact durable policy a provisioned credential was issued for. */
325
+ public async getClientForProvisionedCredential(
326
+ idArg: string,
327
+ typeArg: IGatewayClient['type'],
328
+ lifecycleArg: IGatewayCredentialLifecycle,
329
+ ): Promise<IGatewayClient | null> {
330
+ const doc = await GatewayClientDoc.findById(idArg);
331
+ if (!doc || doc.type !== typeArg) return null;
332
+ const activeGeneration = this.normalizePolicyGeneration(doc.policyGeneration);
333
+ if (
334
+ lifecycleArg.state === 'active'
335
+ && lifecycleArg.sequence === this.normalizeCredentialSequence(doc.activeCredentialSequence)
336
+ && lifecycleArg.policyGeneration === activeGeneration
337
+ && lifecycleArg.policyDigest === this.digestPolicy(doc.id, this.activePolicyFromDoc(doc), activeGeneration)
338
+ ) {
339
+ return this.toPublicClient(doc);
340
+ }
341
+ const pending = doc.pendingProvisioning;
342
+ if (
343
+ lifecycleArg.state === 'candidate'
344
+ && pending
345
+ && this.normalizeCredentialSequence(doc.credentialSequence) === lifecycleArg.sequence
346
+ && pending.credentialSequence === lifecycleArg.sequence
347
+ && pending.policyGeneration === lifecycleArg.policyGeneration
348
+ && pending.policyDigest === lifecycleArg.policyDigest
349
+ ) {
350
+ return this.toPublicPendingClient(doc, pending);
351
+ }
352
+ return null;
353
+ }
354
+
355
+ /** Promote only the pending policy owned by this exact credential sequence. */
356
+ public async promoteProvisionedPolicy(
357
+ idArg: string,
358
+ typeArg: IGatewayClient['type'],
359
+ lifecycleArg: IGatewayCredentialLifecycle,
360
+ ): Promise<IGatewayClient> {
361
+ const doc = await GatewayClientDoc.findById(idArg);
362
+ if (!doc || doc.type !== typeArg) {
363
+ throw new Error('Gateway client is missing or has the wrong type');
364
+ }
365
+ const expectedRevision = this.normalizeMutationRevision(doc.mutationRevision);
366
+ const activeGeneration = this.normalizePolicyGeneration(doc.policyGeneration);
367
+ const activeDigest = this.digestPolicy(doc.id, this.activePolicyFromDoc(doc), activeGeneration);
368
+ if (
369
+ lifecycleArg.state === 'active'
370
+ && lifecycleArg.sequence === this.normalizeCredentialSequence(doc.activeCredentialSequence)
371
+ && lifecycleArg.policyGeneration === activeGeneration
372
+ && lifecycleArg.policyDigest === activeDigest
373
+ ) {
374
+ return this.toPublicClient(doc);
375
+ }
376
+ const pending = doc.pendingProvisioning;
377
+ if (
378
+ (lifecycleArg.state !== 'candidate' && lifecycleArg.state !== 'active')
379
+ || !pending
380
+ || this.normalizeCredentialSequence(doc.credentialSequence) !== lifecycleArg.sequence
381
+ || pending.credentialSequence !== lifecycleArg.sequence
382
+ || pending.policyGeneration !== lifecycleArg.policyGeneration
383
+ || pending.policyDigest !== lifecycleArg.policyDigest
384
+ ) {
385
+ throw new Error('Gateway credential policy was superseded before finalization');
386
+ }
387
+
388
+ const updated = await GatewayClientDoc.compareAndSet(idArg, expectedRevision, {
389
+ name: pending.name,
390
+ hostnamePatterns: structuredClone(pending.hostnamePatterns),
391
+ allowedRouteTargets: structuredClone(pending.allowedRouteTargets),
392
+ capabilities: structuredClone(pending.capabilities),
393
+ enabled: pending.enabled,
394
+ policyGeneration: pending.policyGeneration,
395
+ activeCredentialSequence: pending.credentialSequence,
396
+ pendingProvisioning: null,
397
+ updatedAt: Date.now(),
398
+ ...(pending.description ? { description: pending.description } : {}),
399
+ }, pending.description ? [] : ['description']);
400
+ if (!updated) {
401
+ throw new Error('Gateway client changed concurrently; retry credential finalization');
402
+ }
403
+ return this.toPublicClient(updated);
404
+ }
405
+
406
+ public digestPublicClientPolicy(clientArg: IGatewayClient): string {
407
+ return this.digestPolicy(
408
+ clientArg.id,
409
+ {
410
+ type: clientArg.type,
411
+ name: clientArg.name,
412
+ description: clientArg.description || null,
413
+ hostnamePatterns: structuredClone(clientArg.hostnamePatterns),
414
+ allowedRouteTargets: structuredClone(clientArg.allowedRouteTargets),
415
+ capabilities: structuredClone(clientArg.capabilities),
416
+ enabled: clientArg.enabled,
417
+ },
418
+ clientArg.policyGeneration,
419
+ );
83
420
  }
84
421
 
85
422
  private normalizeId(id: string): string {
86
423
  return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
87
424
  }
88
425
 
89
- private normalizeStringList(values: string[]): string[] {
90
- return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
426
+ private normalizeHostnamePatterns(values: string[]): string[] {
427
+ return Array.from(new Set(values.map((value) => this.normalizeHostnamePattern(value))));
428
+ }
429
+
430
+ private normalizeHostnamePattern(valueArg: string): string {
431
+ const value = valueArg.trim().toLowerCase().replace(/\.+$/, '');
432
+ if (value === '*') return value;
433
+ const wildcard = value.startsWith('*.');
434
+ const hostname = wildcard ? value.slice(2) : value;
435
+ const ascii = plugins.url.domainToASCII(hostname);
436
+ if (!ascii || ascii.includes('*')) throw new Error(`invalid hostname pattern: ${valueArg}`);
437
+ return wildcard ? `*.${ascii}` : ascii;
91
438
  }
92
439
 
93
440
  private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
94
441
  return targets
95
442
  .map((target) => ({
96
- host: target.host.trim().toLowerCase(),
97
- ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
443
+ host: this.normalizeTargetHost(target.host),
444
+ ports: Array.from(new Set(target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535))).sort((a, b) => a - b),
445
+ ...(target.allowAnyPort ? { allowAnyPort: true } : {}),
98
446
  }))
99
- .filter((target) => target.host && target.ports.length > 0);
447
+ .filter((target) => target.host && (target.allowAnyPort || target.ports.length > 0));
448
+ }
449
+
450
+ private normalizeTargetHost(valueArg: string): string {
451
+ const value = valueArg.trim().toLowerCase().replace(/\.+$/, '');
452
+ if (plugins.net.isIP(value)) return value;
453
+ const ascii = plugins.url.domainToASCII(value);
454
+ if (!ascii) throw new Error(`invalid route target host: ${valueArg}`);
455
+ return ascii;
456
+ }
457
+
458
+ private normalizeProvisioningSpec(
459
+ specArg: plugins.servezoneInterfaces.data.IGatewayClientProvisioningSpec,
460
+ ): INormalizedGatewayClientSpec {
461
+ const normalized: INormalizedGatewayClientSpec = {
462
+ type: specArg.type,
463
+ name: specArg.name.trim(),
464
+ description: specArg.description?.trim() || null,
465
+ hostnamePatterns: this.normalizeHostnamePatterns(specArg.hostnamePatterns || []),
466
+ allowedRouteTargets: this.normalizeAllowedRouteTargets(specArg.allowedRouteTargets || []),
467
+ capabilities: { ...(specArg.capabilities || {}) },
468
+ enabled: specArg.enabled ?? true,
469
+ };
470
+ if (!normalized.name) throw new Error('gateway client name is required');
471
+ return normalized;
472
+ }
473
+
474
+ private activePolicyFromDoc(docArg: GatewayClientDoc): INormalizedGatewayClientSpec {
475
+ return {
476
+ type: docArg.type,
477
+ name: docArg.name,
478
+ description: docArg.description?.trim() || null,
479
+ hostnamePatterns: this.normalizeHostnamePatterns(docArg.hostnamePatterns || []),
480
+ allowedRouteTargets: this.normalizeAllowedRouteTargets(docArg.allowedRouteTargets || []),
481
+ capabilities: { ...(docArg.capabilities || {}) },
482
+ enabled: docArg.enabled,
483
+ };
484
+ }
485
+
486
+ private digestPolicy(
487
+ idArg: string,
488
+ policyArg: INormalizedGatewayClientSpec,
489
+ generationArg: number,
490
+ ): string {
491
+ const canonicalize = (valueArg: unknown): unknown => {
492
+ if (Array.isArray(valueArg)) return valueArg.map((entryArg) => canonicalize(entryArg));
493
+ if (valueArg && typeof valueArg === 'object') {
494
+ return Object.fromEntries(Object.keys(valueArg as Record<string, unknown>)
495
+ .sort((left, right) => left.localeCompare(right))
496
+ .map((keyArg) => [keyArg, canonicalize((valueArg as Record<string, unknown>)[keyArg])]));
497
+ }
498
+ return valueArg;
499
+ };
500
+ return plugins.crypto.createHash('sha256').update(JSON.stringify(canonicalize({
501
+ id: idArg,
502
+ generation: generationArg,
503
+ policy: {
504
+ type: policyArg.type,
505
+ hostnamePatterns: policyArg.hostnamePatterns,
506
+ allowedRouteTargets: policyArg.allowedRouteTargets,
507
+ capabilities: policyArg.capabilities,
508
+ enabled: policyArg.enabled,
509
+ },
510
+ }))).digest('hex');
511
+ }
512
+
513
+ private normalizeCredentialSequence(valueArg: unknown): number {
514
+ return Number.isSafeInteger(valueArg) && Number(valueArg) >= 0 ? Number(valueArg) : 0;
515
+ }
516
+
517
+ private normalizePolicyGeneration(valueArg: unknown): number {
518
+ return Number.isSafeInteger(valueArg) && Number(valueArg) >= 1 ? Number(valueArg) : 1;
519
+ }
520
+
521
+ private normalizeMutationRevision(valueArg: unknown): number {
522
+ return Number.isSafeInteger(valueArg) && Number(valueArg) >= 0 ? Number(valueArg) : 0;
523
+ }
524
+
525
+ private toPublicPendingClient(
526
+ docArg: GatewayClientDoc,
527
+ pendingArg: IGatewayClientPendingProvisioning,
528
+ ): IGatewayClient {
529
+ return {
530
+ id: docArg.id,
531
+ type: pendingArg.type,
532
+ name: pendingArg.name,
533
+ description: pendingArg.description || undefined,
534
+ hostnamePatterns: structuredClone(pendingArg.hostnamePatterns),
535
+ allowedRouteTargets: structuredClone(pendingArg.allowedRouteTargets),
536
+ capabilities: structuredClone(pendingArg.capabilities),
537
+ enabled: pendingArg.enabled,
538
+ policyGeneration: pendingArg.policyGeneration,
539
+ createdAt: docArg.createdAt,
540
+ updatedAt: pendingArg.stagedAt,
541
+ createdBy: docArg.createdBy,
542
+ };
100
543
  }
101
544
 
102
545
  private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
@@ -109,6 +552,7 @@ export class GatewayClientManager {
109
552
  allowedRouteTargets: doc.allowedRouteTargets || [],
110
553
  capabilities: doc.capabilities || {},
111
554
  enabled: doc.enabled,
555
+ policyGeneration: this.normalizePolicyGeneration(doc.policyGeneration),
112
556
  createdAt: doc.createdAt,
113
557
  updatedAt: doc.updatedAt,
114
558
  createdBy: doc.createdBy,
@@ -1,6 +1,10 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
3
- import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
3
+ import type {
4
+ IApiTokenPolicy,
5
+ IGatewayCredentialLifecycle,
6
+ TApiTokenScope,
7
+ } from '../../../ts_interfaces/data/route-management.js';
4
8
 
5
9
  const getDb = () => DcRouterDb.getInstance().getDb();
6
10
 
@@ -21,6 +25,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
21
25
 
22
26
  @plugins.smartdata.svDb()
23
27
  public policy?: IApiTokenPolicy;
28
+ @plugins.smartdata.svDb()
29
+ public gatewayCredentialLifecycle: IGatewayCredentialLifecycle | null = null;
30
+
24
31
 
25
32
  @plugins.smartdata.svDb()
26
33
  public createdAt!: number;
@@ -6,7 +6,7 @@ const TTL = plugins.smartdata.smartdataTtlValues;
6
6
  /**
7
7
  * Email status in the cache
8
8
  */
9
- export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
9
+ export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred' | 'stored';
10
10
 
11
11
  /**
12
12
  * Direction of an accepted email: 'inbound' = received from a remote peer,
@@ -141,6 +141,14 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
141
141
  @plugins.smartdata.svDb()
142
142
  public status!: TCachedEmailStatus;
143
143
 
144
+ /** Attachment count derived from the parsed message at acceptance. */
145
+ @plugins.smartdata.svDb()
146
+ public attachmentCount?: number;
147
+
148
+ /** JSON-serialized inbound SPF/DKIM/DMARC verdicts from the SMTP session. */
149
+ @plugins.smartdata.svDb()
150
+ public inboundSecurityResults?: string;
151
+
144
152
  /**
145
153
  * Number of delivery attempts
146
154
  */
@@ -344,6 +352,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
344
352
  this.deliveredAt = new Date();
345
353
  }
346
354
 
355
+ /**
356
+ * Mark as terminally stored (catch-all inbound): visible in the email log,
357
+ * pruned after the retention window.
358
+ */
359
+ public markStored(retentionMs: number): void {
360
+ this.status = 'stored';
361
+ this.deliveredAt = new Date();
362
+ // Base-class TTL: the CacheCleaner deletes expired rows and their raw
363
+ // payloads via beforeDeleteCachedEmail.
364
+ this.setTTL(retentionMs);
365
+ }
366
+
347
367
  /**
348
368
  * Mark as failed with error
349
369
  */