@serve.zone/dcrouter 17.7.1 → 17.8.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 (77) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +410 -410
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -0
  5. package/dist_ts/classes.dcrouter.js +23 -1
  6. package/dist_ts/config/classes.gateway-client-manager.js +3 -1
  7. package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
  8. package/dist_ts/db/documents/classes.cached.email.js +52 -2
  9. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  10. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  11. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  12. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  13. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  14. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  15. package/dist_ts/db/documents/index.d.ts +3 -0
  16. package/dist_ts/db/documents/index.js +5 -1
  17. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  18. package/dist_ts/email/classes.accepted-email-spool.js +28 -3
  19. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  20. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  21. package/dist_ts/index.d.ts +1 -0
  22. package/dist_ts/index.js +3 -1
  23. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  24. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  25. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +11 -5
  26. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  27. package/dist_ts/opsserver/handlers/index.js +3 -1
  28. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  29. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  30. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  31. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  32. package/dist_ts/plugins.d.ts +5 -2
  33. package/dist_ts/plugins.js +6 -3
  34. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  35. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  36. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  37. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  38. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  39. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  40. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  41. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  42. package/dist_ts/webpush/index.d.ts +4 -0
  43. package/dist_ts/webpush/index.js +5 -0
  44. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  45. package/dist_ts/webpush/webpush-indexes.js +123 -0
  46. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  47. package/dist_ts_interfaces/data/route-management.js +1 -1
  48. package/dist_ts_migrations/index.js +221 -11
  49. package/dist_ts_web/00_commitinfo_data.js +1 -1
  50. package/package.json +12 -8
  51. package/readme.md +27 -0
  52. package/third-party-notices.md +37 -0
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/classes.dcrouter.ts +29 -0
  55. package/ts/config/classes.gateway-client-manager.ts +2 -0
  56. package/ts/db/documents/classes.cached.email.ts +47 -0
  57. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  58. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  59. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  60. package/ts/db/documents/index.ts +5 -0
  61. package/ts/email/classes.accepted-email-spool.ts +41 -2
  62. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  63. package/ts/index.ts +3 -0
  64. package/ts/opsserver/classes.opsserver.ts +4 -0
  65. package/ts/opsserver/handlers/gatewayclient.handler.ts +10 -3
  66. package/ts/opsserver/handlers/index.ts +2 -0
  67. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  68. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  69. package/ts/plugins.ts +6 -0
  70. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  71. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  72. package/ts/webpush/classes.webpush-transport.ts +241 -0
  73. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  74. package/ts/webpush/index.ts +4 -0
  75. package/ts/webpush/webpush-indexes.ts +141 -0
  76. package/ts_web/00_commitinfo_data.ts +1 -1
  77. package/readme.plan.md +0 -22
@@ -0,0 +1,241 @@
1
+ import * as plugins from '../plugins.js';
2
+ import {
3
+ createPinnedWebPushAgent,
4
+ resolveWebPushEndpoint,
5
+ WebPushEndpointPolicyError,
6
+ type TWebPushDnsLookup,
7
+ } from './helpers.webpush-endpoint.js';
8
+
9
+ export interface IWebPushTransportRequest {
10
+ subscription: plugins.webpush.PushSubscription;
11
+ payload: string;
12
+ ttlSeconds: number;
13
+ urgency: plugins.webpush.Urgency;
14
+ topic?: string;
15
+ vapid: {
16
+ subject: string;
17
+ publicKey: string;
18
+ privateKey: string;
19
+ };
20
+ }
21
+
22
+ export interface IWebPushTransportResponse {
23
+ statusCode: number;
24
+ retryAfter?: string;
25
+ }
26
+
27
+ export interface IWebPushTransport {
28
+ send(requestArg: IWebPushTransportRequest): Promise<IWebPushTransportResponse>;
29
+ }
30
+
31
+ export type TWebPushTransportErrorCode =
32
+ | 'PUSH_DNS_TIMEOUT'
33
+ | 'PUSH_REQUEST_TIMEOUT'
34
+ | 'PUSH_RESPONSE_TOO_LARGE'
35
+ | 'PUSH_RESPONSE_INVALID'
36
+ | 'PUSH_NETWORK_ERROR';
37
+
38
+ export class WebPushTransportError extends Error {
39
+ public constructor(
40
+ public readonly code: TWebPushTransportErrorCode,
41
+ optionsArg: { cause?: unknown } = {},
42
+ ) {
43
+ super(code, optionsArg);
44
+ this.name = 'WebPushTransportError';
45
+ }
46
+ }
47
+
48
+ export interface IWebPushHttpTransportOptions {
49
+ dnsLookup?: TWebPushDnsLookup;
50
+ dnsTimeoutMs?: number;
51
+ connectTimeoutMs?: number;
52
+ requestTimeoutMs?: number;
53
+ maxResponseBytes?: number;
54
+ }
55
+
56
+ const normalizeRetryAfter = (valueArg: string | string[] | undefined): string | undefined => {
57
+ if (Array.isArray(valueArg)) return valueArg[0]?.slice(0, 128);
58
+ return typeof valueArg === 'string' ? valueArg.slice(0, 128) : undefined;
59
+ };
60
+
61
+ export class WebPushHttpTransport implements IWebPushTransport {
62
+ private readonly dnsLookup?: TWebPushDnsLookup;
63
+ private readonly dnsTimeoutMs: number;
64
+ private readonly connectTimeoutMs: number;
65
+ private readonly requestTimeoutMs: number;
66
+ private readonly maxResponseBytes: number;
67
+
68
+ public constructor(optionsArg: IWebPushHttpTransportOptions = {}) {
69
+ this.dnsLookup = optionsArg.dnsLookup;
70
+ this.dnsTimeoutMs = this.normalizePositiveInteger(
71
+ optionsArg.dnsTimeoutMs,
72
+ 5_000,
73
+ 'dnsTimeoutMs',
74
+ );
75
+ this.connectTimeoutMs = this.normalizePositiveInteger(
76
+ optionsArg.connectTimeoutMs,
77
+ 10_000,
78
+ 'connectTimeoutMs',
79
+ );
80
+ this.requestTimeoutMs = this.normalizePositiveInteger(
81
+ optionsArg.requestTimeoutMs,
82
+ 15_000,
83
+ 'requestTimeoutMs',
84
+ );
85
+ this.maxResponseBytes = this.normalizePositiveInteger(
86
+ optionsArg.maxResponseBytes,
87
+ 16_384,
88
+ 'maxResponseBytes',
89
+ );
90
+ }
91
+
92
+ public async send(requestArg: IWebPushTransportRequest): Promise<IWebPushTransportResponse> {
93
+ const sendDeadline = Date.now() + this.requestTimeoutMs;
94
+ let resolved: Awaited<ReturnType<typeof resolveWebPushEndpoint>>;
95
+ try {
96
+ resolved = await new Promise<Awaited<ReturnType<typeof resolveWebPushEndpoint>>>(
97
+ (resolve, reject) => {
98
+ let settled = false;
99
+ const settle = (
100
+ errorArg: unknown,
101
+ valueArg?: Awaited<ReturnType<typeof resolveWebPushEndpoint>>,
102
+ ) => {
103
+ if (settled) return;
104
+ settled = true;
105
+ clearTimeout(timer);
106
+ if (errorArg) reject(errorArg);
107
+ else resolve(valueArg!);
108
+ };
109
+ const timer = setTimeout(() => {
110
+ settle(new WebPushTransportError('PUSH_DNS_TIMEOUT'));
111
+ }, Math.min(this.dnsTimeoutMs, this.requestTimeoutMs));
112
+ resolveWebPushEndpoint(
113
+ requestArg.subscription.endpoint,
114
+ this.dnsLookup,
115
+ ).then(
116
+ (valueArg) => settle(null, valueArg),
117
+ (errorArg) => settle(errorArg),
118
+ );
119
+ },
120
+ );
121
+ } catch (error: unknown) {
122
+ if (error instanceof WebPushEndpointPolicyError) throw error;
123
+ if (error instanceof WebPushTransportError) throw error;
124
+ throw new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: error });
125
+ }
126
+ const remainingRequestTimeoutMs = sendDeadline - Date.now();
127
+ if (remainingRequestTimeoutMs <= 0) {
128
+ throw new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
129
+ }
130
+ const agent = createPinnedWebPushAgent(resolved);
131
+ try {
132
+ const requestDetails = plugins.webpush.generateRequestDetails(
133
+ requestArg.subscription,
134
+ requestArg.payload,
135
+ {
136
+ TTL: requestArg.ttlSeconds,
137
+ contentEncoding: 'aes128gcm',
138
+ urgency: requestArg.urgency,
139
+ ...(requestArg.topic ? { topic: requestArg.topic } : {}),
140
+ vapidDetails: requestArg.vapid,
141
+ },
142
+ );
143
+ if (
144
+ requestDetails.method !== 'POST'
145
+ || requestDetails.endpoint !== requestArg.subscription.endpoint
146
+ || !Buffer.isBuffer(requestDetails.body)
147
+ ) {
148
+ throw new WebPushTransportError('PUSH_RESPONSE_INVALID');
149
+ }
150
+ return await new Promise<IWebPushTransportResponse>((resolve, reject) => {
151
+ let settled = false;
152
+ let requestTimer: NodeJS.Timeout | undefined;
153
+ const settle = (
154
+ errorArg: Error | null,
155
+ responseArg?: IWebPushTransportResponse,
156
+ ) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ if (requestTimer) clearTimeout(requestTimer);
160
+ if (errorArg) reject(errorArg);
161
+ else resolve(responseArg!);
162
+ };
163
+ const pushRequest = plugins.https.request({
164
+ protocol: 'https:',
165
+ hostname: resolved.hostname,
166
+ servername: resolved.hostname,
167
+ port: 443,
168
+ path: `${resolved.url.pathname}${resolved.url.search}`,
169
+ method: 'POST',
170
+ headers: requestDetails.headers,
171
+ agent,
172
+ timeout: Math.min(this.connectTimeoutMs, remainingRequestTimeoutMs),
173
+ maxHeaderSize: 16_384,
174
+ setHost: true,
175
+ }, (pushResponse) => {
176
+ let responseBytes = 0;
177
+ pushResponse.on('data', (chunkArg: Buffer | string) => {
178
+ responseBytes += Buffer.byteLength(chunkArg);
179
+ if (responseBytes > this.maxResponseBytes) {
180
+ const error = new WebPushTransportError('PUSH_RESPONSE_TOO_LARGE');
181
+ settle(error);
182
+ pushResponse.destroy(error);
183
+ pushRequest.destroy(error);
184
+ }
185
+ });
186
+ pushResponse.once('end', () => {
187
+ const statusCode = pushResponse.statusCode;
188
+ if (!Number.isSafeInteger(statusCode) || statusCode! < 100 || statusCode! > 599) {
189
+ settle(new WebPushTransportError('PUSH_RESPONSE_INVALID'));
190
+ return;
191
+ }
192
+ settle(null, {
193
+ statusCode: statusCode!,
194
+ retryAfter: normalizeRetryAfter(pushResponse.headers['retry-after']),
195
+ });
196
+ });
197
+ pushResponse.once('error', (errorArg) => {
198
+ settle(
199
+ errorArg instanceof WebPushTransportError
200
+ ? errorArg
201
+ : new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: errorArg }),
202
+ );
203
+ });
204
+ });
205
+ pushRequest.once('timeout', () => {
206
+ const error = new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
207
+ settle(error);
208
+ pushRequest.destroy(error);
209
+ });
210
+ pushRequest.once('error', (errorArg) => {
211
+ settle(
212
+ errorArg instanceof WebPushTransportError
213
+ ? errorArg
214
+ : new WebPushTransportError('PUSH_NETWORK_ERROR', { cause: errorArg }),
215
+ );
216
+ });
217
+ requestTimer = setTimeout(() => {
218
+ const error = new WebPushTransportError('PUSH_REQUEST_TIMEOUT');
219
+ settle(error);
220
+ pushRequest.destroy(error);
221
+ }, remainingRequestTimeoutMs);
222
+ requestTimer.unref();
223
+ pushRequest.end(requestDetails.body);
224
+ });
225
+ } finally {
226
+ agent.destroy();
227
+ }
228
+ }
229
+
230
+ private normalizePositiveInteger(
231
+ valueArg: number | undefined,
232
+ defaultArg: number,
233
+ labelArg: string,
234
+ ): number {
235
+ const value = valueArg ?? defaultArg;
236
+ if (!Number.isSafeInteger(value) || value <= 0) {
237
+ throw new Error(`Web Push transport ${labelArg} must be a positive integer`);
238
+ }
239
+ return value;
240
+ }
241
+ }
@@ -0,0 +1,150 @@
1
+ import type { LookupFunction } from 'node:net';
2
+ import * as plugins from '../plugins.js';
3
+
4
+ export type TWebPushEndpointPolicyErrorCode =
5
+ | 'ENDPOINT_URL_INVALID'
6
+ | 'ENDPOINT_SCHEME_INVALID'
7
+ | 'ENDPOINT_PORT_INVALID'
8
+ | 'ENDPOINT_AUTHORITY_INVALID'
9
+ | 'ENDPOINT_FRAGMENT_INVALID'
10
+ | 'ENDPOINT_IP_LITERAL_FORBIDDEN'
11
+ | 'ENDPOINT_DNS_EMPTY'
12
+ | 'ENDPOINT_DNS_UNSAFE';
13
+
14
+ export class WebPushEndpointPolicyError extends Error {
15
+ public constructor(public readonly code: TWebPushEndpointPolicyErrorCode) {
16
+ super(code);
17
+ this.name = 'WebPushEndpointPolicyError';
18
+ }
19
+ }
20
+
21
+ export interface IWebPushDnsAddress {
22
+ address: string;
23
+ family: number;
24
+ }
25
+
26
+ export type TWebPushDnsLookup = (
27
+ hostnameArg: string,
28
+ ) => Promise<IWebPushDnsAddress[]>;
29
+
30
+ export interface IWebPushResolvedEndpoint {
31
+ url: URL;
32
+ hostname: string;
33
+ address: string;
34
+ family: 4 | 6;
35
+ }
36
+
37
+ const defaultDnsLookup: TWebPushDnsLookup = async (hostnameArg) => {
38
+ return await plugins.dns.promises.lookup(hostnameArg, {
39
+ all: true,
40
+ verbatim: true,
41
+ });
42
+ };
43
+
44
+ export function isGloballyRoutableWebPushAddress(addressArg: string): boolean {
45
+ const family = plugins.net.isIP(addressArg);
46
+ if (family !== 4 && family !== 6) return false;
47
+ try {
48
+ const parsed = plugins.ipaddr.parse(addressArg);
49
+ if (
50
+ parsed.kind() === 'ipv6'
51
+ && 'isIPv4MappedAddress' in parsed
52
+ && parsed.isIPv4MappedAddress()
53
+ ) return false;
54
+ return parsed.range() === 'unicast';
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function parseWebPushEndpoint(endpointArg: string): URL {
61
+ if (typeof endpointArg !== 'string' || endpointArg.length === 0 || endpointArg.length > 4096) {
62
+ throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID');
63
+ }
64
+ let endpoint: URL;
65
+ try {
66
+ endpoint = new URL(endpointArg);
67
+ } catch {
68
+ throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID');
69
+ }
70
+ if (endpoint.protocol !== 'https:') {
71
+ throw new WebPushEndpointPolicyError('ENDPOINT_SCHEME_INVALID');
72
+ }
73
+ if (endpoint.port && endpoint.port !== '443') {
74
+ throw new WebPushEndpointPolicyError('ENDPOINT_PORT_INVALID');
75
+ }
76
+ if (endpoint.username || endpoint.password || !endpoint.hostname) {
77
+ throw new WebPushEndpointPolicyError('ENDPOINT_AUTHORITY_INVALID');
78
+ }
79
+ if (endpoint.hash) {
80
+ throw new WebPushEndpointPolicyError('ENDPOINT_FRAGMENT_INVALID');
81
+ }
82
+ const hostname = endpoint.hostname.startsWith('[') && endpoint.hostname.endsWith(']')
83
+ ? endpoint.hostname.slice(1, -1)
84
+ : endpoint.hostname;
85
+ if (plugins.net.isIP(hostname) !== 0) {
86
+ throw new WebPushEndpointPolicyError('ENDPOINT_IP_LITERAL_FORBIDDEN');
87
+ }
88
+ return endpoint;
89
+ }
90
+
91
+ export async function resolveWebPushEndpoint(
92
+ endpointArg: string,
93
+ lookupArg: TWebPushDnsLookup = defaultDnsLookup,
94
+ ): Promise<IWebPushResolvedEndpoint> {
95
+ const url = parseWebPushEndpoint(endpointArg);
96
+ const hostname = url.hostname.toLowerCase();
97
+ const answers = await lookupArg(hostname);
98
+ if (!Array.isArray(answers) || answers.length === 0) {
99
+ throw new WebPushEndpointPolicyError('ENDPOINT_DNS_EMPTY');
100
+ }
101
+ const normalized = answers.map((answerArg) => ({
102
+ address: String(answerArg.address || '').trim(),
103
+ family: plugins.net.isIP(String(answerArg.address || '').trim()),
104
+ }));
105
+ if (
106
+ normalized.some((answerArg) => (
107
+ (answerArg.family !== 4 && answerArg.family !== 6)
108
+ || !isGloballyRoutableWebPushAddress(answerArg.address)
109
+ ))
110
+ ) {
111
+ throw new WebPushEndpointPolicyError('ENDPOINT_DNS_UNSAFE');
112
+ }
113
+ const unique = Array.from(
114
+ new Map(normalized.map((answerArg) => [`${answerArg.family}:${answerArg.address}`, answerArg])).values(),
115
+ );
116
+ const selected = unique[0] as { address: string; family: 4 | 6 };
117
+ return {
118
+ url,
119
+ hostname,
120
+ address: selected.address,
121
+ family: selected.family,
122
+ };
123
+ }
124
+
125
+ export function createPinnedWebPushAgent(
126
+ resolvedArg: IWebPushResolvedEndpoint,
127
+ ): InstanceType<typeof plugins.https.Agent> {
128
+ const lookup: LookupFunction = (hostnameArg, optionsArg, callbackArg) => {
129
+ if (hostnameArg.toLowerCase() !== resolvedArg.hostname) {
130
+ const error = new Error('Pinned Web Push agent hostname mismatch') as NodeJS.ErrnoException;
131
+ error.code = 'EHOSTUNREACH';
132
+ callbackArg(error, '', 0);
133
+ return;
134
+ }
135
+ if (optionsArg.all) {
136
+ callbackArg(null, [{
137
+ address: resolvedArg.address,
138
+ family: resolvedArg.family,
139
+ }]);
140
+ return;
141
+ }
142
+ callbackArg(null, resolvedArg.address, resolvedArg.family);
143
+ };
144
+ return new plugins.https.Agent({
145
+ keepAlive: false,
146
+ maxSockets: 1,
147
+ maxFreeSockets: 0,
148
+ lookup,
149
+ });
150
+ }
@@ -0,0 +1,4 @@
1
+ export * from './classes.webpush-crypto.js';
2
+ export * from './classes.webpush-manager.js';
3
+ export * from './classes.webpush-transport.js';
4
+ export * from './helpers.webpush-endpoint.js';
@@ -0,0 +1,141 @@
1
+ export type TWebPushIndexCollection =
2
+ | 'WebPushBindingDoc'
3
+ | 'WebPushAdmissionDoc'
4
+ | 'WebPushSpoolDoc';
5
+
6
+ export interface IWebPushIndexSpecification {
7
+ collection: TWebPushIndexCollection;
8
+ name: string;
9
+ key: Record<string, 1 | -1>;
10
+ unique?: true;
11
+ expireAfterSeconds?: number;
12
+ partialFilterExpression?: Record<string, unknown>;
13
+ }
14
+
15
+ export const webPushIndexSpecifications: IWebPushIndexSpecification[] = [
16
+ {
17
+ collection: 'WebPushBindingDoc',
18
+ name: 'web_push_binding_id_unique',
19
+ key: { id: 1 },
20
+ unique: true,
21
+ },
22
+ {
23
+ collection: 'WebPushBindingDoc',
24
+ name: 'web_push_binding_owner_unique',
25
+ key: {
26
+ ownerGatewayClientType: 1,
27
+ ownerGatewayClientId: 1,
28
+ ownerAppInstanceId: 1,
29
+ },
30
+ unique: true,
31
+ },
32
+ {
33
+ collection: 'WebPushBindingDoc',
34
+ name: 'web_push_binding_credential_unique',
35
+ key: { 'credential.id': 1 },
36
+ unique: true,
37
+ },
38
+ {
39
+ collection: 'WebPushBindingDoc',
40
+ name: 'web_push_binding_previous_credential_lookup',
41
+ key: { 'previousCredentials.id': 1 },
42
+ },
43
+ {
44
+ collection: 'WebPushBindingDoc',
45
+ name: 'web_push_binding_previous_credential_cleanup',
46
+ key: { 'previousCredentials.validUntil': 1 },
47
+ },
48
+ {
49
+ collection: 'WebPushBindingDoc',
50
+ name: 'web_push_binding_rotation_recovery_cleanup',
51
+ key: { 'credentialRotationRecovery.expiresAt': 1 },
52
+ },
53
+ {
54
+ collection: 'WebPushBindingDoc',
55
+ name: 'web_push_binding_vapid_status_cleanup',
56
+ key: { 'vapidKeys.status': 1 },
57
+ },
58
+ {
59
+ collection: 'WebPushBindingDoc',
60
+ name: 'web_push_binding_vapid_retire_after_cleanup',
61
+ key: { 'vapidKeys.retireAfter': 1 },
62
+ },
63
+ {
64
+ collection: 'WebPushBindingDoc',
65
+ name: 'web_push_binding_disabled_cleanup',
66
+ key: { enabled: 1, lifecycleGeneration: 1 },
67
+ },
68
+ {
69
+ collection: 'WebPushBindingDoc',
70
+ name: 'web_push_binding_deleted_cleanup',
71
+ key: { deletedAt: 1, lifecycleGeneration: 1 },
72
+ },
73
+ {
74
+ collection: 'WebPushAdmissionDoc',
75
+ name: 'web_push_admission_binding_unique',
76
+ key: { bindingId: 1, lifecycleGeneration: 1 },
77
+ unique: true,
78
+ },
79
+ {
80
+ collection: 'WebPushAdmissionDoc',
81
+ name: 'web_push_admission_ttl',
82
+ key: { purgeAt: 1 },
83
+ expireAfterSeconds: 0,
84
+ },
85
+ {
86
+ collection: 'WebPushSpoolDoc',
87
+ name: 'web_push_spool_id_unique',
88
+ key: { id: 1 },
89
+ unique: true,
90
+ },
91
+ {
92
+ collection: 'WebPushSpoolDoc',
93
+ name: 'web_push_spool_idempotency_unique',
94
+ key: {
95
+ bindingId: 1,
96
+ lifecycleGeneration: 1,
97
+ credentialId: 1,
98
+ idempotencyKeyDigest: 1,
99
+ },
100
+ unique: true,
101
+ },
102
+ {
103
+ collection: 'WebPushSpoolDoc',
104
+ name: 'web_push_spool_delivery_scan',
105
+ key: { state: 1, nextAttemptAt: 1, leaseExpiresAt: 1 },
106
+ },
107
+ {
108
+ collection: 'WebPushSpoolDoc',
109
+ name: 'web_push_spool_cancellation_scan',
110
+ key: { bindingId: 1, lifecycleGeneration: 1, subscriptionId: 1, state: 1 },
111
+ },
112
+ {
113
+ collection: 'WebPushSpoolDoc',
114
+ name: 'web_push_spool_cancelled_lease_scan',
115
+ key: { state: 1, cancelRequestedAt: 1, leaseExpiresAt: 1, id: 1 },
116
+ },
117
+ {
118
+ collection: 'WebPushSpoolDoc',
119
+ name: 'web_push_spool_lifecycle_drain_scan',
120
+ key: { bindingId: 1, lifecycleGeneration: 1, state: 1, leaseExpiresAt: 1 },
121
+ },
122
+ {
123
+ collection: 'WebPushSpoolDoc',
124
+ name: 'web_push_spool_vapid_retirement_scan',
125
+ key: { bindingId: 1, lifecycleGeneration: 1, vapidKeyId: 1, state: 1 },
126
+ },
127
+ {
128
+ collection: 'WebPushSpoolDoc',
129
+ name: 'web_push_spool_terminal_ttl',
130
+ key: { purgeAt: 1 },
131
+ expireAfterSeconds: 0,
132
+ },
133
+ ];
134
+
135
+ export function getWebPushIndexSpecifications(
136
+ collectionArg: TWebPushIndexCollection,
137
+ ): IWebPushIndexSpecification[] {
138
+ return webPushIndexSpecifications.filter(
139
+ (specificationArg) => specificationArg.collection === collectionArg,
140
+ );
141
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.7.1',
6
+ version: '17.8.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.