@serve.zone/dcrouter 17.2.4 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) 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 +4 -1
  5. package/dist_ts/classes.dcrouter.js +42 -2
  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.gateway-client.doc.d.ts +25 -0
  13. package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
  14. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
  15. package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
  16. package/dist_ts/db/documents/index.d.ts +1 -0
  17. package/dist_ts/db/documents/index.js +2 -1
  18. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
  19. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
  20. package/dist_ts/dns/index.d.ts +1 -0
  21. package/dist_ts/dns/index.js +2 -1
  22. package/dist_ts/dns/manager.dns.d.ts +19 -0
  23. package/dist_ts/dns/manager.dns.js +25 -1
  24. package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
  25. package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
  26. package/dist_ts/monitoring/index.d.ts +1 -0
  27. package/dist_ts/monitoring/index.js +2 -1
  28. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  29. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  30. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
  31. package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
  32. package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
  33. package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
  34. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
  35. package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
  36. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
  37. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
  38. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  39. package/dist_ts/opsserver/handlers/index.js +2 -1
  40. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
  41. package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
  42. package/dist_ts/plugins.d.ts +2 -1
  43. package/dist_ts/plugins.js +3 -2
  44. package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
  45. package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
  46. package/dist_ts_interfaces/data/config-events.d.ts +34 -0
  47. package/dist_ts_interfaces/data/config-events.js +8 -0
  48. package/dist_ts_interfaces/data/index.d.ts +1 -0
  49. package/dist_ts_interfaces/data/index.js +2 -1
  50. package/dist_ts_interfaces/data/route-management.d.ts +28 -1
  51. package/dist_ts_interfaces/data/route-management.js +3 -1
  52. package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
  53. package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
  54. package/dist_ts_interfaces/requests/config-events.js +4 -0
  55. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  56. package/dist_ts_interfaces/requests/index.js +2 -1
  57. package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
  58. package/dist_ts_interfaces/requests/realtime.js +2 -1
  59. package/dist_ts_migrations/index.js +148 -9
  60. package/dist_ts_web/00_commitinfo_data.js +1 -1
  61. package/dist_ts_web/appstate/config-events.d.ts +19 -0
  62. package/dist_ts_web/appstate/config-events.js +68 -0
  63. package/dist_ts_web/appstate/runtime.js +7 -1
  64. package/dist_ts_web/appstate.d.ts +1 -0
  65. package/dist_ts_web/appstate.js +2 -1
  66. package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
  67. package/dist_ts_web/elements/ops-platform-events.js +361 -0
  68. package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
  69. package/dist_ts_web/elements/ops-view-logs.js +96 -9
  70. package/package.json +2 -2
  71. package/ts/00_commitinfo_data.ts +1 -1
  72. package/ts/classes.dcrouter.ts +48 -1
  73. package/ts/config/classes.api-token-manager.ts +237 -4
  74. package/ts/config/classes.gateway-client-manager.ts +463 -19
  75. package/ts/db/documents/classes.api-token.doc.ts +8 -1
  76. package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
  77. package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
  78. package/ts/db/documents/index.ts +1 -0
  79. package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
  80. package/ts/dns/index.ts +1 -0
  81. package/ts/dns/manager.dns.ts +36 -0
  82. package/ts/monitoring/classes.ops-event-manager.ts +99 -0
  83. package/ts/monitoring/index.ts +2 -1
  84. package/ts/opsserver/classes.opsserver.ts +2 -0
  85. package/ts/opsserver/handlers/certificate.handler.ts +51 -4
  86. package/ts/opsserver/handlers/config-event.handler.ts +62 -0
  87. package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
  88. package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
  89. package/ts/opsserver/handlers/index.ts +1 -0
  90. package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
  91. package/ts/plugins.ts +2 -0
  92. package/ts_apiclient/classes.gatewayclient.ts +45 -0
  93. package/ts_web/00_commitinfo_data.ts +1 -1
  94. package/ts_web/appstate/config-events.ts +98 -0
  95. package/ts_web/appstate/runtime.ts +8 -1
  96. package/ts_web/appstate.ts +1 -0
  97. package/ts_web/elements/ops-platform-events.ts +315 -0
  98. 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;
@@ -2,6 +2,21 @@ import * as plugins from '../../plugins.js';
2
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
3
3
  import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
4
4
 
5
+ export interface IGatewayClientPendingProvisioning {
6
+ type: TGatewayClientType;
7
+ name: string;
8
+ description: string | null;
9
+ hostnamePatterns: string[];
10
+ allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']>;
11
+ capabilities: NonNullable<IApiTokenPolicy['capabilities']>;
12
+ enabled: boolean;
13
+ policyGeneration: number;
14
+ policyDigest: string;
15
+ credentialSequence: number;
16
+ stagedAt: number;
17
+ stagedBy: string;
18
+ }
19
+
5
20
  const getDb = () => DcRouterDb.getInstance().getDb();
6
21
 
7
22
  @plugins.smartdata.Collection(() => getDb())
@@ -31,6 +46,23 @@ export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayCl
31
46
  @plugins.smartdata.svDb()
32
47
  public enabled: boolean = true;
33
48
 
49
+ @plugins.smartdata.svDb()
50
+ public policyGeneration: number = 1;
51
+
52
+ @plugins.smartdata.svDb()
53
+ public credentialSequence: number = 0;
54
+
55
+ /** Exact provisioned credential sequence authorized for the active policy. */
56
+ @plugins.smartdata.svDb()
57
+ public activeCredentialSequence: number = 0;
58
+
59
+ /** Monotonic compare-and-set authority for every mutable client field. */
60
+ @plugins.smartdata.svDb()
61
+ public mutationRevision: number = 0;
62
+
63
+ @plugins.smartdata.svDb()
64
+ public pendingProvisioning: IGatewayClientPendingProvisioning | null = null;
65
+
34
66
  @plugins.smartdata.svDb()
35
67
  public createdAt!: number;
36
68
 
@@ -48,6 +80,60 @@ export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayCl
48
80
  return await GatewayClientDoc.getInstance({ id });
49
81
  }
50
82
 
83
+ private static async getNativeCollection() {
84
+ const smartdataCollection = (GatewayClientDoc as typeof GatewayClientDoc & {
85
+ collection: plugins.smartdata.SmartdataCollection<GatewayClientDoc>;
86
+ }).collection;
87
+ await smartdataCollection.init();
88
+ return smartdataCollection.mongoDbCollection;
89
+ }
90
+
91
+ private static buildRevisionSelector(idArg: string, revisionArg: number): Record<string, unknown> {
92
+ if (revisionArg === 0) {
93
+ return {
94
+ id: idArg,
95
+ $or: [
96
+ { mutationRevision: 0 },
97
+ { mutationRevision: { $exists: false } },
98
+ ],
99
+ };
100
+ }
101
+ return { id: idArg, mutationRevision: revisionArg };
102
+ }
103
+
104
+ public static async compareAndSet(
105
+ idArg: string,
106
+ expectedRevisionArg: number,
107
+ setArg: Record<string, unknown>,
108
+ unsetArg: string[] = [],
109
+ ): Promise<GatewayClientDoc | null> {
110
+ const collection = await GatewayClientDoc.getNativeCollection();
111
+ const update: Record<string, unknown> = {
112
+ $set: {
113
+ ...setArg,
114
+ mutationRevision: expectedRevisionArg + 1,
115
+ _updatedAt: new Date().toISOString(),
116
+ },
117
+ };
118
+ if (unsetArg.length > 0) {
119
+ update.$unset = Object.fromEntries(unsetArg.map((fieldArg) => [fieldArg, '']));
120
+ }
121
+ const result = await collection.updateOne(
122
+ GatewayClientDoc.buildRevisionSelector(idArg, expectedRevisionArg),
123
+ update,
124
+ );
125
+ if (result.matchedCount !== 1) return null;
126
+ return await GatewayClientDoc.findById(idArg);
127
+ }
128
+
129
+ public static async deleteIfRevision(idArg: string, expectedRevisionArg: number): Promise<boolean> {
130
+ const collection = await GatewayClientDoc.getNativeCollection();
131
+ const result = await collection.deleteOne(
132
+ GatewayClientDoc.buildRevisionSelector(idArg, expectedRevisionArg),
133
+ );
134
+ return result.deletedCount === 1;
135
+ }
136
+
51
137
  public static async findAll(): Promise<GatewayClientDoc[]> {
52
138
  return await GatewayClientDoc.getInstances({});
53
139
  }